- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml) - 部署模式: rsync Python 檔案至 188 → docker restart (volume mount) - Dockerfile/requirements 變動時自動重建 Docker image - 部署通知: Telegram (開始/成功/失敗) - 健康檢查: https://mo.wooo.work/health (最多 5 次重試) - 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(__NEW_LINE_0a563c996171804f__ gcloud compute ssh $GCP_INSTANCE --zone=$GCP_ZONE --command=\"\n cd ~/momo_pro_system\n \n echo ''重建 momo-app 服務...''\n sudo docker compose up -d --build momo-app 2>&1 | tail -50\n \n echo ''''\n echo ''等待容器啟動...''\n sleep 20\n \n echo ''''\n echo ''容器狀態:''\n sudo docker ps --filter ''name=momo'' --format ''table {{.Names}}\\\\t{{.Status}}''\n \n echo ''''\n echo ''健康檢查 \\(直接存取 5001 port\\):''\n curl -s -o /dev/null -w ''%{http_code}'' http://localhost:5001/ && echo '''' || echo ''無法連線''\n\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
119
.claude/skills/ai-self-learning-flow.py
Normal file
119
.claude/skills/ai-self-learning-flow.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
EwoooC — AI 自主學習流程 Skill
|
||||
skill: ai-self-learning-flow
|
||||
version: 1.0.0
|
||||
updated: 2026-04-18
|
||||
|
||||
用途:指導 Claude 在執行任何 AI 功能改動時,完整走過三層沉澱流程。
|
||||
遵循 ADR-001(三 Agent 分工)、ADR-007(雙寫規範)、Memory 規範。
|
||||
"""
|
||||
|
||||
SKILL_DESCRIPTION = """
|
||||
# EwoooC AI 自主學習流程 (ai-self-learning-flow)
|
||||
|
||||
## 觸發條件
|
||||
當統帥要求「新增 AI 功能」、「修改 AI 洞察寫入邏輯」、「新增 KM 記錄類型」時,
|
||||
Claude 應主動走過此 Skill 的三層 Checklist。
|
||||
|
||||
## Phase 1:規劃(5 分鐘)
|
||||
|
||||
### 1.1 確認 Agent 歸屬
|
||||
- [ ] 此功能屬於採集層(Hermes)?
|
||||
- [ ] 此功能屬於處理層(NemoTron)?
|
||||
- [ ] 此功能屬於應用層(OpenClaw)?
|
||||
- [ ] 有無跨層的資料傳遞?傳遞格式?
|
||||
|
||||
### 1.2 確認雙寫需求(ADR-007)
|
||||
- [ ] 是否有 AI 產出需要沉澱?(PPT 洞察、競品分析、對話)
|
||||
- [ ] DB sink:哪張表?哪些欄位?
|
||||
- [ ] KM sink:是否需要 embedding?哪個模型?維度?
|
||||
|
||||
### 1.3 確認成本影響
|
||||
- [ ] 是否增加 NemoTron 每日 tool call 次數?
|
||||
- [ ] 是否增加 OpenClaw API 費用?
|
||||
- [ ] 月成本目標:Hermes=$0, NemoTron=$0, OpenClaw<$5
|
||||
|
||||
## Phase 2:執行(主要開發)
|
||||
|
||||
### 2.1 Hermes 採集層(若適用)
|
||||
```python
|
||||
# services/hermes_analyst_service.py 或新服務
|
||||
# 任務:embedding、tag、去重、品質分數
|
||||
# 連線:http://192.168.0.111:11434/api/embeddings
|
||||
# 模型:bge-m3 (1024 dim)
|
||||
```
|
||||
|
||||
### 2.2 NemoTron 處理層(若適用)
|
||||
```python
|
||||
# services/nemoton_dispatcher_service.py
|
||||
# 任務:tool calling 寫入、分類派發
|
||||
# 注意:HTTP 429 → fallback 到 _hermes_rule_fallback() (ADR-004)
|
||||
```
|
||||
|
||||
### 2.3 OpenClaw 應用層(若適用)
|
||||
```python
|
||||
# services/gemini_service.py 或 ppt_generator.py
|
||||
# 任務:最終生成、RAG 引用歷史洞察
|
||||
# RAG 查詢需套用時間衰減(ADR-005)
|
||||
```
|
||||
|
||||
### 2.4 DB Migration(若需新欄位)
|
||||
```sql
|
||||
-- 放在 migrations/NNN_description.sql
|
||||
-- 新欄位遵循現有命名規範(snake_case)
|
||||
-- embedding 欄位:vector(1024) 搭配 HNSW 索引
|
||||
```
|
||||
|
||||
## Phase 3:驗證(完成後必做)
|
||||
|
||||
### 3.1 本地測試
|
||||
- [ ] 手動觸發一次完整流程(採集 → 處理 → 應用)
|
||||
- [ ] 確認 DB 有寫入資料
|
||||
- [ ] 確認 embedding 欄位不為 NULL
|
||||
- [ ] 確認 Telegram 告警(若有)格式正確
|
||||
|
||||
### 3.2 Fallback 測試(NemoTron 相關必做)
|
||||
- [ ] 模擬 NIM 回 429,確認 degraded 模式正常啟動
|
||||
- [ ] 確認降級訊息有 🟡 標記
|
||||
|
||||
### 3.3 三層沉澱(每次完成必做)
|
||||
- [ ] `docs/adr/ADR-XXX.md` — 有無需新建 ADR?(重大決策才建)
|
||||
- [ ] `docs/AI_INTELLIGENCE_MODULE_SOT.md` — 更新架構圖或表格
|
||||
- [ ] `memory/` — 有無新的 project/feedback memory 需要寫入?
|
||||
- [ ] `CLAUDE.md` — 有無新的憲法條款?
|
||||
- [ ] `TODO_NEXT_STEPS.txt` — 標記完成項目 ✅
|
||||
|
||||
## 常見問題
|
||||
|
||||
### Q: 這個 insight 該用哪個 `insight_type`?
|
||||
| 類型 | 適用場景 |
|
||||
|---|---|
|
||||
| `price_alert` | 競價告警分析 |
|
||||
| `ppt_insight` | PPT 生成洞察 |
|
||||
| `weekly_trend` | 週報 meta-analysis |
|
||||
| `conversation` | 用戶對話記錄 |
|
||||
| `structural` | 不變的結構知識(不套時間衰減) |
|
||||
| `constitutional` | 專案憲法類(不套時間衰減) |
|
||||
|
||||
### Q: 需要建新 ADR 的判斷標準?
|
||||
以下情況需建 ADR:
|
||||
- 引入新的外部依賴(新 API、新資料庫、新模型)
|
||||
- 改動資料庫 schema(不含純加欄位的 migration)
|
||||
- 改變 AI 路由邏輯(哪個 Agent 做什麼)
|
||||
- 改變成本結構(預計月費增加 > $1 USD)
|
||||
|
||||
以下不需建 ADR:
|
||||
- Bug fix
|
||||
- 效能優化(不改架構)
|
||||
- 新增 Telegram 告警模板
|
||||
- UI 改動
|
||||
|
||||
### Q: embedding 失敗時怎麼辦?
|
||||
1. DB 先寫入(`embedding = NULL`)
|
||||
2. 放入 retry queue(`embedding_retry_queue` 表)
|
||||
3. Hermes batch job 每天 03:00 重試所有 `embedding IS NULL` 的記錄
|
||||
"""
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(SKILL_DESCRIPTION)
|
||||
55
.env.example
Normal file
55
.env.example
Normal file
@@ -0,0 +1,55 @@
|
||||
# ==========================================
|
||||
# MOMO 監控系統 - 環境變數配置模板
|
||||
# ==========================================
|
||||
# 複製此檔案為 .env 並填入實際值
|
||||
# 注意:.env 檔案已加入 .gitignore,不會被提交到版本控制
|
||||
|
||||
# ==========================================
|
||||
# 安全設定
|
||||
# ==========================================
|
||||
LOGIN_PASSWORD=your_strong_password_here
|
||||
SECRET_KEY=your_flask_secret_key_here
|
||||
|
||||
# ==========================================
|
||||
# Telegram Bot 設定
|
||||
# ==========================================
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token
|
||||
TELEGRAM_CHAT_IDS=["chat_id_1","chat_id_2","chat_id_3"]
|
||||
|
||||
# ==========================================
|
||||
# Line Notify 設定
|
||||
# ==========================================
|
||||
LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token
|
||||
LINE_GROUP_ID=your_line_group_id
|
||||
|
||||
# ==========================================
|
||||
# Email (SMTP) 設定
|
||||
# ==========================================
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=your_email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your_email_app_password
|
||||
EMAIL_SENDER=your_email@gmail.com
|
||||
EMAIL_RECEIVER=receiver_email@gmail.com
|
||||
|
||||
# ==========================================
|
||||
# 網路設定
|
||||
# ==========================================
|
||||
PUBLIC_URL=http://your_server_ip:port
|
||||
NGROK_AUTH_TOKEN=your_ngrok_auth_token
|
||||
|
||||
# ==========================================
|
||||
# HTTPS 設定(生產環境)
|
||||
# ==========================================
|
||||
# 如果部署在 HTTPS 環境,設為 true
|
||||
USE_HTTPS=false
|
||||
|
||||
# ==========================================
|
||||
# Google Drive 自動匯入設定
|
||||
# ==========================================
|
||||
# 說明:系統會自動從 Google Drive 下載、匯入並刪除當日業績 Excel 檔案
|
||||
# 設定方式:請參考 GOOGLE_DRIVE_SETUP.md
|
||||
# 認證檔案位置:config/google_credentials.json
|
||||
# Token 檔案位置:config/google_token.pickle(首次認證後自動產生)
|
||||
GDRIVE_FOLDER_PATH=業績報表/當日業績
|
||||
GDRIVE_FILE_PATTERN=即時業績_當日
|
||||
195
.gitea/workflows/cd.yaml
Normal file
195
.gitea/workflows/cd.yaml
Normal file
@@ -0,0 +1,195 @@
|
||||
# =============================================================================
|
||||
# EwoooC CD Pipeline (Gitea Actions)
|
||||
# =============================================================================
|
||||
# 流程: Sync Files → 188 Docker Restart → Health Check
|
||||
# 部署架構: Docker Compose on ollama@192.168.0.188 (Volume Mount)
|
||||
# 加速措施: Python 檔案走 rsync,僅 Dockerfile/requirements 變動才重建 image
|
||||
# 參考: AWOOOI cd.yaml pattern (ADR-008 — Docker Compose 非 K8s)
|
||||
|
||||
name: CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
# 應用程式碼(volume-mounted)
|
||||
- 'app.py'
|
||||
- 'auth.py'
|
||||
- 'config.py'
|
||||
- 'scheduler.py'
|
||||
- 'services/**'
|
||||
- 'routes/**'
|
||||
- 'database/**'
|
||||
- 'templates/**'
|
||||
- 'static/**'
|
||||
# 需重建 image 的檔案
|
||||
- 'Dockerfile'
|
||||
- 'requirements.txt'
|
||||
- 'docker-compose.yml'
|
||||
# 工作流程本身
|
||||
- '.gitea/workflows/**'
|
||||
# docs/、memory/、ADR、k8s/ 等不觸發
|
||||
workflow_dispatch:
|
||||
# 手動觸發永遠可用(用於補跑、緊急部署)
|
||||
|
||||
# 新 push 立即取消舊 job,只部署最新版本
|
||||
concurrency:
|
||||
group: cd-deploy-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 取得 Commit 資訊
|
||||
id: commit
|
||||
run: |
|
||||
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
echo "message=$(git log -1 --pretty=%s | head -c 60)" >> $GITHUB_OUTPUT
|
||||
echo "start_time=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
echo "actor=${{ github.actor }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# 偵測是否需重建 Docker image(Dockerfile / requirements.txt / docker-compose.yml 變動)
|
||||
- name: 偵測部署類型
|
||||
id: deploy_type
|
||||
run: |
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
|
||||
if echo "$CHANGED" | grep -qE '^(Dockerfile|requirements\.txt|docker-compose\.yml)$'; then
|
||||
echo "type=rebuild" >> $GITHUB_OUTPUT
|
||||
echo "label=🔨 重建 Docker Image" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=sync" >> $GITHUB_OUTPUT
|
||||
echo "label=📁 同步 Python 檔案" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# 設定 SSH 金鑰(用於連線到 188)
|
||||
- name: 設定 SSH 金鑰
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
cat > ~/.ssh/config << 'EOF'
|
||||
Host 192.168.0.188
|
||||
HostName 192.168.0.188
|
||||
User ollama
|
||||
IdentityFile ~/.ssh/id_deploy
|
||||
StrictHostKeyChecking no
|
||||
ConnectTimeout 10
|
||||
EOF
|
||||
|
||||
- name: 通知部署開始
|
||||
run: |
|
||||
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
||||
MSG=$(printf '🚀 <b>EwoooC 部署開始</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n├ 👤 %s\n└ %s' \
|
||||
"${COMMIT_ESC}" \
|
||||
"${{ steps.commit.outputs.short_sha }}" \
|
||||
"${{ steps.commit.outputs.actor }}" \
|
||||
"${{ steps.deploy_type.outputs.label }}")
|
||||
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
|
||||
|
||||
# ── 模式 A:僅同步 Python 檔案(最常見,~10s) ────────────────────────
|
||||
- name: 同步 Python 檔案至 188
|
||||
if: steps.deploy_type.outputs.type == 'sync'
|
||||
run: |
|
||||
rsync -avz \
|
||||
-e "ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
|
||||
--exclude='.git/' \
|
||||
--exclude='.gitea/' \
|
||||
--exclude='data/' \
|
||||
--exclude='logs/' \
|
||||
--exclude='backups/' \
|
||||
--exclude='config/google_credentials.json' \
|
||||
--exclude='config/google_token.pickle' \
|
||||
--exclude='venv/' \
|
||||
--exclude='__pycache__/' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.db' \
|
||||
--exclude='*.db-journal' \
|
||||
--exclude='docs/' \
|
||||
--exclude='memory/' \
|
||||
--exclude='k8s/' \
|
||||
--exclude='n8n-workflows/' \
|
||||
--exclude='aiops-core/' \
|
||||
./ ollama@192.168.0.188:/home/ollama/momo-pro/
|
||||
|
||||
- name: 重啟容器(Sync 模式)
|
||||
if: steps.deploy_type.outputs.type == 'sync'
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no ollama@192.168.0.188 \
|
||||
"docker restart momo-pro-system && echo '✅ momo-pro-system 已重啟'"
|
||||
|
||||
# ── 模式 B:重建 Docker Image(Dockerfile / requirements.txt 變動) ──
|
||||
- name: 同步所有檔案並重建 Image
|
||||
if: steps.deploy_type.outputs.type == 'rebuild'
|
||||
run: |
|
||||
# 先同步全部檔案
|
||||
rsync -avz \
|
||||
-e "ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no" \
|
||||
--exclude='.git/' \
|
||||
--exclude='data/' \
|
||||
--exclude='logs/' \
|
||||
--exclude='backups/' \
|
||||
--exclude='config/google_credentials.json' \
|
||||
--exclude='config/google_token.pickle' \
|
||||
--exclude='venv/' \
|
||||
--exclude='__pycache__/' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.db' \
|
||||
./ ollama@192.168.0.188:/home/ollama/momo-pro/
|
||||
# 重建並重啟
|
||||
ssh -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no ollama@192.168.0.188 \
|
||||
"cd /home/ollama/momo-pro && docker compose build momo-app && docker compose up -d momo-app && echo '✅ Image 重建完成'"
|
||||
|
||||
# ── 健康檢查(最多重試 5 次,每次間隔 10s) ───────────────────────────
|
||||
- name: 健康檢查
|
||||
run: |
|
||||
echo "⏳ 等待服務啟動(15s)..."
|
||||
sleep 15
|
||||
for i in $(seq 1 5); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://mo.wooo.work/health --max-time 10 || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "✅ 健康檢查通過(HTTP $HTTP_CODE)"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ 嘗試 $i/5,HTTP $HTTP_CODE,等待 10s..."
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ 健康檢查失敗"
|
||||
exit 1
|
||||
|
||||
# ── 部署成功通知 ──────────────────────────────────────────────────────
|
||||
- name: 通知部署成功
|
||||
if: success()
|
||||
run: |
|
||||
END_TIME=$(date +%s)
|
||||
DURATION=$((END_TIME - ${{ steps.commit.outputs.start_time }}))
|
||||
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
||||
MSG=$(printf '✅ <b>EwoooC 部署成功</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n├ ⏱ 耗時 %ss\n└ 🌐 https://mo.wooo.work' \
|
||||
"${COMMIT_ESC}" \
|
||||
"${{ steps.commit.outputs.short_sha }}" \
|
||||
"${DURATION}")
|
||||
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
|
||||
|
||||
# ── 部署失敗通知 ──────────────────────────────────────────────────────
|
||||
- name: 通知部署失敗
|
||||
if: failure()
|
||||
run: |
|
||||
COMMIT_ESC=$(echo "${{ steps.commit.outputs.message }}" | sed 's/&/\&/g; s/</\</g; s/>/\>/g')
|
||||
MSG=$(printf '❌ <b>EwoooC 部署失敗</b>\n├ 📝 <code>%s</code>\n├ 🔖 <code>%s</code>\n└ 🔍 請查看 Gitea Actions 日誌' \
|
||||
"${COMMIT_ESC}" \
|
||||
"${{ steps.commit.outputs.short_sha }}")
|
||||
curl -fS -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg c "${{ secrets.TELEGRAM_CHAT_ID }}" --arg t "$MSG" '{chat_id:$c,text:$t,parse_mode:"HTML"}')"
|
||||
127
.gitignore
vendored
Normal file
127
.gitignore
vendored
Normal file
@@ -0,0 +1,127 @@
|
||||
# ==========================================
|
||||
# MOMO 監控系統 - Git 忽略清單
|
||||
# ==========================================
|
||||
|
||||
# 環境變數檔案(包含敏感資訊)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
# 例外:允許部署腳本庫
|
||||
!deploy/lib/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# 虛擬環境
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.venv
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# 日誌檔案
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 資料庫檔案
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
data/*.sqlite
|
||||
data/*.sqlite3
|
||||
database/*.db
|
||||
database/*.db-journal
|
||||
database/*.db-shm
|
||||
database/*.db-wal
|
||||
database/*.sqlite
|
||||
database/*.sqlite3
|
||||
|
||||
# 備份檔案
|
||||
backups/
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.bak
|
||||
|
||||
# Excel 匯出暫存檔
|
||||
data/excel_exports/
|
||||
*.xlsx~
|
||||
~$*.xlsx
|
||||
|
||||
# 上傳檔案
|
||||
web/static/uploads/
|
||||
web/static/screenshots/
|
||||
|
||||
# 測試與覆蓋率報告
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
docker-compose.override.yml
|
||||
.docker/
|
||||
|
||||
# SSL 憑證
|
||||
ssl/
|
||||
*.pem
|
||||
*.crt
|
||||
*.key
|
||||
|
||||
# Google Drive API 憑證
|
||||
config/google_credentials.json
|
||||
config/google_token.pickle
|
||||
config/*.json
|
||||
config/*.pickle
|
||||
data/momo_database.db-shm
|
||||
data/momo_database.db-wal
|
||||
k8s/03-secrets.yaml
|
||||
|
||||
# 雜項
|
||||
123
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
264
.gitlab-ci.yml
Normal file
264
.gitlab-ci.yml
Normal file
@@ -0,0 +1,264 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# GitLab CI/CD Pipeline
|
||||
# =============================================================================
|
||||
#
|
||||
# 流程:
|
||||
# 1. test: 執行 pytest 測試
|
||||
# 2. security: Bandit 安全掃描
|
||||
# 3. build: 構建 Docker 映像並推送到 Registry
|
||||
# 4. deploy-uat: 部署到 UAT 環境
|
||||
# 5. deploy-gcp: 部署到 GCP 環境 (手動觸發)
|
||||
#
|
||||
# Registry:
|
||||
# - URL: registry.wooo.work
|
||||
# - 帳號: GitLab CI/CD 變數 REGISTRY_USER
|
||||
# - 密碼: GitLab CI/CD 變數 REGISTRY_PASSWORD
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
# Docker Registry
|
||||
REGISTRY_URL: "registry.wooo.work"
|
||||
IMAGE_NAME: "wooo/momo-pro-system"
|
||||
IMAGE_PATH: "${REGISTRY_URL}/${IMAGE_NAME}"
|
||||
|
||||
# 目標環境
|
||||
UAT_HOST: "192.168.0.110"
|
||||
UAT_USER: "wooo"
|
||||
GCP_HOST: "35.194.233.141"
|
||||
GCP_USER: "ogt"
|
||||
|
||||
# =============================================================================
|
||||
# Test Stage
|
||||
# =============================================================================
|
||||
test:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- pip install --quiet -r requirements.txt
|
||||
- pip install --quiet pytest pytest-cov
|
||||
script:
|
||||
- python -m pytest tests/ -v --tb=short || echo "No tests or tests skipped"
|
||||
allow_failure: true
|
||||
tags:
|
||||
- docker
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "push"'
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
|
||||
# =============================================================================
|
||||
# Security Scan (Bandit)
|
||||
# =============================================================================
|
||||
security-scan:
|
||||
stage: test
|
||||
image: python:3.11-slim
|
||||
before_script:
|
||||
- pip install --quiet bandit
|
||||
script:
|
||||
- bandit -r . -x ./tests,./venv -f json -o bandit-report.json || true
|
||||
- |
|
||||
if [ -f bandit-report.json ]; then
|
||||
HIGH=$(cat bandit-report.json | python3 -c "import sys,json; print(len([r for r in json.load(sys.stdin).get('results',[]) if r.get('issue_severity')=='HIGH']))" 2>/dev/null || echo "0")
|
||||
echo "High severity issues: $HIGH"
|
||||
if [ "$HIGH" -gt "0" ]; then
|
||||
echo "⚠️ 發現高危安全問題"
|
||||
fi
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- bandit-report.json
|
||||
expire_in: 1 week
|
||||
allow_failure: true
|
||||
tags:
|
||||
- docker
|
||||
|
||||
# =============================================================================
|
||||
# Build & Push to Registry
|
||||
# =============================================================================
|
||||
build:
|
||||
stage: build
|
||||
image: docker:24.0.7
|
||||
services:
|
||||
- docker:24.0.7-dind
|
||||
variables:
|
||||
DOCKER_TLS_CERTDIR: "/certs"
|
||||
before_script:
|
||||
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin $REGISTRY_URL
|
||||
script:
|
||||
- echo "Building image..."
|
||||
- BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
- docker build --build-arg BUILD_DATE=$BUILD_DATE --build-arg GIT_SHA=$CI_COMMIT_SHA -t $IMAGE_PATH:$CI_COMMIT_SHORT_SHA -t $IMAGE_PATH:latest .
|
||||
- echo "Pushing to registry..."
|
||||
- docker push $IMAGE_PATH:$CI_COMMIT_SHORT_SHA
|
||||
- docker push $IMAGE_PATH:latest
|
||||
- echo "Image pushed - $IMAGE_PATH:latest"
|
||||
after_script:
|
||||
- if [ -n "$TELEGRAM_BOT_TOKEN" ] && [ "$CI_JOB_STATUS" = "success" ]; then curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" -d chat_id="${TELEGRAM_CHAT_ID}" -d parse_mode="HTML" -d text="Build Success - ${IMAGE_PATH}:${CI_COMMIT_SHORT_SHA}" > /dev/null 2>&1; fi
|
||||
tags:
|
||||
- docker
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
|
||||
# =============================================================================
|
||||
# Deploy to UAT
|
||||
# =============================================================================
|
||||
deploy-uat:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client curl
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$UAT_SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $UAT_HOST >> ~/.ssh/known_hosts 2>/dev/null
|
||||
script:
|
||||
- echo "🚀 部署到 UAT..."
|
||||
- |
|
||||
ssh -o StrictHostKeyChecking=no ${UAT_USER}@${UAT_HOST} << ENDSSH
|
||||
# 設定 kubectl 環境
|
||||
export KUBECONFIG=/home/wooo/.kube/config
|
||||
|
||||
# 同步程式碼
|
||||
cd /home/wooo/momo_pro_system
|
||||
git fetch gitlab main
|
||||
git reset --hard gitlab/main
|
||||
|
||||
# 更新 K8s registry secret
|
||||
kubectl delete secret registry-secret -n momo 2>/dev/null || true
|
||||
kubectl create secret docker-registry registry-secret \
|
||||
--docker-server=${REGISTRY_URL} \
|
||||
--docker-username=${REGISTRY_USER} \
|
||||
--docker-password=${REGISTRY_PASSWORD} \
|
||||
-n momo
|
||||
|
||||
# 重啟服務 (會自動拉取新映像)
|
||||
kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo
|
||||
|
||||
# 等待就緒
|
||||
kubectl rollout status deployment/momo-app -n momo --timeout=180s
|
||||
echo "✅ UAT 部署完成"
|
||||
ENDSSH
|
||||
- |
|
||||
# 健康檢查
|
||||
sleep 10
|
||||
if curl -s "https://mo.wooo.work/health" | grep -q "healthy"; then
|
||||
echo "✅ 健康檢查通過"
|
||||
else
|
||||
echo "⚠️ 健康檢查失敗"
|
||||
fi
|
||||
after_script:
|
||||
- |
|
||||
if [ -n "$TELEGRAM_BOT_TOKEN" ]; then
|
||||
if [ "$CI_JOB_STATUS" == "success" ]; then
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHAT_ID}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d text="✅ <b>UAT 部署成功</b>%0A%0A🌐 https://mo.wooo.work%0A📌 ${CI_COMMIT_SHORT_SHA}%0A👤 ${GITLAB_USER_NAME}" > /dev/null 2>&1
|
||||
else
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHAT_ID}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d text="❌ <b>UAT 部署失敗</b>%0A%0A🔗 ${CI_PIPELINE_URL}" > /dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
tags:
|
||||
- docker
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
needs:
|
||||
- build
|
||||
|
||||
# =============================================================================
|
||||
# Deploy to GCP (手動觸發)
|
||||
# =============================================================================
|
||||
deploy-gcp:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
before_script:
|
||||
- apk add --no-cache openssh-client curl docker
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$GCP_SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- ssh-keyscan -H $GCP_HOST >> ~/.ssh/known_hosts 2>/dev/null
|
||||
script:
|
||||
- echo "🚀 部署到 GCP..."
|
||||
- |
|
||||
# 從 Registry 拉取映像
|
||||
echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin $REGISTRY_URL
|
||||
docker pull $IMAGE_PATH:latest
|
||||
|
||||
# 匯出映像
|
||||
docker save $IMAGE_PATH:latest -o /tmp/momo-image.tar
|
||||
|
||||
# 傳輸到 GCP (使用用戶家目錄)
|
||||
scp -o StrictHostKeyChecking=no /tmp/momo-image.tar ${GCP_USER}@${GCP_HOST}:~/momo-image.tar
|
||||
|
||||
# 在 GCP 上匯入並部署
|
||||
ssh -o StrictHostKeyChecking=no ${GCP_USER}@${GCP_HOST} << ENDSSH
|
||||
# 匯入映像到 K3s
|
||||
sudo k3s ctr images import ~/momo-image.tar
|
||||
rm ~/momo-image.tar
|
||||
|
||||
# 更新 Deployment 使用新映像
|
||||
sudo kubectl set image deployment/momo-app momo-app=${IMAGE_PATH}:latest -n momo
|
||||
sudo kubectl set image deployment/momo-scheduler scheduler=${IMAGE_PATH}:latest -n momo
|
||||
|
||||
# 設定 imagePullPolicy 為 IfNotPresent(允許使用本地匯入的映像)
|
||||
sudo kubectl patch deployment momo-app -n momo -p '{"spec":{"template":{"spec":{"containers":[{"name":"momo-app","imagePullPolicy":"IfNotPresent"}]}}}}'
|
||||
sudo kubectl patch deployment momo-scheduler -n momo -p '{"spec":{"template":{"spec":{"containers":[{"name":"scheduler","imagePullPolicy":"IfNotPresent"}]}}}}'
|
||||
|
||||
# 等待部署完成
|
||||
sudo kubectl rollout status deployment/momo-app -n momo --timeout=180s
|
||||
|
||||
echo "✅ GCP 部署完成"
|
||||
ENDSSH
|
||||
- |
|
||||
# 健康檢查
|
||||
sleep 10
|
||||
if curl -s "https://momo.wooo.work/health" | grep -q "healthy"; then
|
||||
echo "✅ GCP 健康檢查通過"
|
||||
else
|
||||
echo "⚠️ GCP 健康檢查失敗"
|
||||
fi
|
||||
after_script:
|
||||
- |
|
||||
if [ -n "$TELEGRAM_BOT_TOKEN" ]; then
|
||||
if [ "$CI_JOB_STATUS" == "success" ]; then
|
||||
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||
-d chat_id="${TELEGRAM_CHAT_ID}" \
|
||||
-d parse_mode="HTML" \
|
||||
-d text="✅ <b>GCP 部署成功</b>%0A%0A🌐 https://momo.wooo.work%0A📌 ${CI_COMMIT_SHORT_SHA}" > /dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
tags:
|
||||
- docker
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
needs:
|
||||
- deploy-uat
|
||||
allow_failure: false
|
||||
|
||||
# =============================================================================
|
||||
# Health Check (手動)
|
||||
# =============================================================================
|
||||
health-check:
|
||||
stage: deploy
|
||||
image: alpine:latest
|
||||
before_script:
|
||||
- apk add --no-cache curl
|
||||
script:
|
||||
- echo "🏥 健康檢查..."
|
||||
- echo "UAT:" && curl -s "https://mo.wooo.work/health" || echo "無法連線"
|
||||
- echo "GCP:" && curl -s "https://momo.wooo.work/health" || echo "無法連線"
|
||||
- echo "Registry:" && curl -s -u "$REGISTRY_USER:$REGISTRY_PASSWORD" "https://$REGISTRY_URL/v2/_catalog" || echo "無法連線"
|
||||
tags:
|
||||
- docker
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "web"'
|
||||
when: manual
|
||||
407
AUTO_IMPORT_README.md
Normal file
407
AUTO_IMPORT_README.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Google Drive 自動匯入功能說明
|
||||
|
||||
## ✨ 功能概述
|
||||
|
||||
系統現在支援從 Google Drive 自動匯入當日業績 Excel 檔案!
|
||||
|
||||
### 主要特點
|
||||
|
||||
- 🔄 **自動化流程**:每 30 分鐘自動檢查 Google Drive
|
||||
- 📥 **自動下載**:發現新檔案立即下載到本地
|
||||
- 💾 **自動匯入**:解析 Excel 並匯入到資料庫
|
||||
- 🗑️ **自動清理**:匯入完成後刪除 Google Drive 原檔
|
||||
- 📊 **進度追蹤**:完整的任務狀態與進度顯示
|
||||
- 🌐 **網頁介面**:直觀的設定和監控介面
|
||||
|
||||
---
|
||||
|
||||
## 📁 新增檔案清單
|
||||
|
||||
### 後端服務
|
||||
- `services/google_drive_service.py` - Google Drive API 服務模組
|
||||
- `services/import_service.py` - 自動匯入服務邏輯
|
||||
- `database/import_models.py` - 匯入任務與配置資料表模型
|
||||
- `auto_import_routes.py` - API 路由(Blueprint)
|
||||
|
||||
### 前端介面
|
||||
- `web/auto_import_index.html` - 自動匯入管理頁面
|
||||
|
||||
### 配置與文檔
|
||||
- `config/` - 存放 Google Drive API 憑證(需自行設定)
|
||||
- `GOOGLE_DRIVE_SETUP.md` - 詳細的設定指南
|
||||
- `AUTO_IMPORT_README.md` - 本檔案
|
||||
- `test_google_drive.py` - 測試腳本
|
||||
|
||||
### 資料庫
|
||||
- 新增資料表:`import_jobs` - 匯入任務記錄
|
||||
- 新增資料表:`import_config` - 匯入配置
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速開始
|
||||
|
||||
### 1. 安裝依賴
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
新增的套件:
|
||||
- `google-auth`
|
||||
- `google-auth-oauthlib`
|
||||
- `google-auth-httplib2`
|
||||
- `google-api-python-client`
|
||||
|
||||
### 2. 設定 Google Drive API
|
||||
|
||||
請參考:[GOOGLE_DRIVE_SETUP.md](GOOGLE_DRIVE_SETUP.md)
|
||||
|
||||
簡要步驟:
|
||||
1. 建立 Google Cloud 專案
|
||||
2. 啟用 Google Drive API
|
||||
3. 建立 OAuth 2.0 憑證
|
||||
4. 下載憑證檔案並放到 `config/google_credentials.json`
|
||||
5. 執行首次認證
|
||||
|
||||
### 3. 配置資料夾路徑
|
||||
|
||||
1. 在 Google Drive 建立資料夾結構:
|
||||
```
|
||||
我的雲端硬碟/
|
||||
└── 業績報表/
|
||||
└── 當日業績/
|
||||
```
|
||||
|
||||
2. 開啟網頁介面:http://localhost:5888/auto_import
|
||||
|
||||
3. 設定:
|
||||
- **Google Drive 資料夾路徑**:`業績報表/當日業績`
|
||||
- **檔案名稱模式**:`即時業績_當日`
|
||||
|
||||
4. 點擊「儲存配置」
|
||||
|
||||
### 4. 測試連接
|
||||
|
||||
執行測試腳本:
|
||||
|
||||
```bash
|
||||
python3 test_google_drive.py
|
||||
```
|
||||
|
||||
或在網頁介面點擊「測試連接」
|
||||
|
||||
### 5. 上傳測試檔案
|
||||
|
||||
將當日業績 Excel 檔案上傳到 Google Drive 的 `業績報表/當日業績` 資料夾
|
||||
|
||||
### 6. 手動測試匯入
|
||||
|
||||
在網頁介面點擊「立即匯入」,觀察匯入進度
|
||||
|
||||
---
|
||||
|
||||
## 🎯 使用方式
|
||||
|
||||
### 自動模式(推薦)
|
||||
|
||||
系統每 30 分鐘自動執行:
|
||||
1. 檢查 Google Drive 指定資料夾
|
||||
2. 發現新的 Excel 檔案
|
||||
3. 自動下載到本地 `data/temp/`
|
||||
4. 自動匯入到資料庫
|
||||
5. 自動刪除 Google Drive 原檔
|
||||
6. 清理本地暫存檔
|
||||
|
||||
### 手動模式
|
||||
|
||||
在網頁介面點擊「立即匯入」可手動觸發匯入流程。
|
||||
|
||||
### 監控進度
|
||||
|
||||
開啟:http://localhost:5888/auto_import
|
||||
|
||||
可以看到:
|
||||
- ✅ 匯入配置
|
||||
- 📋 檔案清單
|
||||
- 📊 匯入任務歷史
|
||||
- 🔄 即時進度更新(每 10 秒自動刷新)
|
||||
|
||||
---
|
||||
|
||||
## 📊 資料表結構
|
||||
|
||||
### import_jobs(匯入任務)
|
||||
|
||||
| 欄位 | 類型 | 說明 |
|
||||
|-----|-----|-----|
|
||||
| id | INTEGER | 任務 ID |
|
||||
| job_type | VARCHAR | 任務類型(daily_sales, vendor_stockout) |
|
||||
| status | VARCHAR | 狀態(pending, downloading, importing, completed, failed) |
|
||||
| drive_file_id | VARCHAR | Google Drive 檔案 ID |
|
||||
| drive_file_name | VARCHAR | 檔案名稱 |
|
||||
| drive_file_size | INTEGER | 檔案大小(bytes) |
|
||||
| local_file_path | VARCHAR | 本地檔案路徑 |
|
||||
| progress_percent | FLOAT | 進度百分比 (0-100) |
|
||||
| current_step | VARCHAR | 當前步驟描述 |
|
||||
| total_rows | INTEGER | 總行數 |
|
||||
| processed_rows | INTEGER | 已處理行數 |
|
||||
| success_rows | INTEGER | 成功匯入行數 |
|
||||
| error_rows | INTEGER | 錯誤行數 |
|
||||
| created_at | DATETIME | 建立時間 |
|
||||
| started_at | DATETIME | 開始時間 |
|
||||
| completed_at | DATETIME | 完成時間 |
|
||||
| error_message | TEXT | 錯誤訊息 |
|
||||
| import_summary | TEXT | 匯入摘要(JSON) |
|
||||
|
||||
### import_config(匯入配置)
|
||||
|
||||
| 欄位 | 類型 | 說明 |
|
||||
|-----|-----|-----|
|
||||
| id | INTEGER | 配置 ID |
|
||||
| config_key | VARCHAR | 配置鍵 |
|
||||
| config_value | TEXT | 配置值 |
|
||||
| config_type | VARCHAR | 配置類型(string, int, bool, json) |
|
||||
| description | VARCHAR | 配置說明 |
|
||||
| created_at | DATETIME | 建立時間 |
|
||||
| updated_at | DATETIME | 更新時間 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 API 端點
|
||||
|
||||
### 查詢任務清單
|
||||
|
||||
```
|
||||
GET /api/import_jobs?limit=20
|
||||
```
|
||||
|
||||
### 查詢單一任務
|
||||
|
||||
```
|
||||
GET /api/import_jobs/{job_id}
|
||||
```
|
||||
|
||||
### 取得配置
|
||||
|
||||
```
|
||||
GET /api/import_config
|
||||
```
|
||||
|
||||
### 設定配置
|
||||
|
||||
```
|
||||
POST /api/import_config
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"folder_path": "業績報表/當日業績",
|
||||
"file_pattern": "即時業績_當日"
|
||||
}
|
||||
```
|
||||
|
||||
### 測試連接
|
||||
|
||||
```
|
||||
POST /api/test_drive_connection
|
||||
```
|
||||
|
||||
### 列出檔案
|
||||
|
||||
```
|
||||
POST /api/list_drive_files
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"folder_path": "業績報表/當日業績",
|
||||
"file_pattern": "即時業績_當日"
|
||||
}
|
||||
```
|
||||
|
||||
### 手動觸發匯入
|
||||
|
||||
```
|
||||
POST /api/manual_import
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 排程設定
|
||||
|
||||
在 `app.py` 中已註冊排程:
|
||||
|
||||
```python
|
||||
# 每半小時執行一次 Google Drive 自動匯入任務
|
||||
schedule.every(30).minutes.do(run_auto_import_task)
|
||||
```
|
||||
|
||||
### 修改檢查頻率
|
||||
|
||||
編輯 `app.py`:
|
||||
|
||||
```python
|
||||
# 每 15 分鐘
|
||||
schedule.every(15).minutes.do(run_auto_import_task)
|
||||
|
||||
# 每小時
|
||||
schedule.every(1).hours.do(run_auto_import_task)
|
||||
|
||||
# 每天早上 8 點
|
||||
schedule.every().day.at("08:00").do(run_auto_import_task)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全性
|
||||
|
||||
### 敏感檔案保護
|
||||
|
||||
以下檔案已加入 `.gitignore`:
|
||||
- `config/google_credentials.json` - OAuth 2.0 憑證
|
||||
- `config/google_token.pickle` - 存取權杖
|
||||
- `config/*.json` - 所有 JSON 配置
|
||||
- `config/*.pickle` - 所有 pickle 檔案
|
||||
|
||||
### OAuth 權限範圍
|
||||
|
||||
目前使用:`https://www.googleapis.com/auth/drive`(完整 Drive 存取)
|
||||
|
||||
如需更嚴格控制,可修改 `services/google_drive_service.py` 中的 `SCOPES`:
|
||||
|
||||
```python
|
||||
# 僅存取應用程式建立的檔案
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive.file']
|
||||
|
||||
# 僅讀取檔案
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
|
||||
```
|
||||
|
||||
### 授權管理
|
||||
|
||||
- 檢查授權:https://myaccount.google.com/permissions
|
||||
- 撤銷授權:刪除 `config/google_token.pickle` 並重新認證
|
||||
|
||||
---
|
||||
|
||||
## 📝 日誌
|
||||
|
||||
### 查看自動匯入日誌
|
||||
|
||||
```bash
|
||||
tail -f logs/system.log | grep AutoImport
|
||||
```
|
||||
|
||||
### 查看排程統計
|
||||
|
||||
```bash
|
||||
cat data/scheduler_stats.json | jq '.auto_import_task'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 故障排除
|
||||
|
||||
### 問題 1:認證失敗
|
||||
|
||||
**解決方法**:
|
||||
1. 確認 `config/google_credentials.json` 存在
|
||||
2. 刪除 `config/google_token.pickle` 並重新認證
|
||||
3. 執行:`python3 test_google_drive.py`
|
||||
|
||||
### 問題 2:找不到資料夾
|
||||
|
||||
**解決方法**:
|
||||
1. 確認資料夾路徑正確(區分大小寫)
|
||||
2. 確認使用正確的 Google 帳號
|
||||
3. 在網頁介面點擊「列出檔案」測試
|
||||
|
||||
### 問題 3:檔案沒有被刪除
|
||||
|
||||
**解決方法**:
|
||||
1. 檢查任務狀態是否為「已完成」
|
||||
2. 查看日誌:`tail -f logs/system.log`
|
||||
3. 確認 Google Drive API 權限正確
|
||||
|
||||
### 問題 4:匯入失敗
|
||||
|
||||
**解決方法**:
|
||||
1. 檢查 Excel 檔案格式是否正確
|
||||
2. 查看任務的錯誤訊息
|
||||
3. 手動測試:在網頁介面點擊「立即匯入」
|
||||
|
||||
---
|
||||
|
||||
## 🔄 工作流程
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Google Drive │
|
||||
│ 新檔案上傳 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 排程任務執行 │
|
||||
│ (每 30 分鐘) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 檢查檔案清單 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 建立匯入任務 │
|
||||
│ (import_jobs) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 下載到本地 │
|
||||
│ data/temp/ │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 解析 Excel │
|
||||
│ 匯入資料庫 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 刪除 Drive 原檔 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 清理本地檔案 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ 任務完成 │
|
||||
│ 更新狀態記錄 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相關文檔
|
||||
|
||||
- [Google Drive API 設定指南](GOOGLE_DRIVE_SETUP.md)
|
||||
- [Docker 部署指南](DEPLOY_README.md)
|
||||
- [GCP Cloud Run 部署](deploy_docker_guide.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎉 完成!
|
||||
|
||||
您現在擁有一個完全自動化的當日業績匯入系統!
|
||||
|
||||
**使用流程:**
|
||||
1. 將 Excel 檔案上傳到 Google Drive
|
||||
2. 系統每 30 分鐘自動檢查
|
||||
3. 自動下載、匯入、刪除
|
||||
4. 在網頁介面監控進度
|
||||
|
||||
**無需手動操作!** 🚀
|
||||
430
CONSTITUTION.md
Normal file
430
CONSTITUTION.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# EwoooC(原 MOMO Pro System)- 專案憲法 (CONSTITUTION)
|
||||
|
||||
> 本文件定義專案開發的核心準則與不可違反的規範
|
||||
> **建立日期**: 2026-01-12
|
||||
> **當前版本**: V10.1
|
||||
> **最後更新**: 2026-04-18(加入第十三、十四章 AI 架構與 Claude Code 官方規範)
|
||||
|
||||
---
|
||||
|
||||
## 📜 憲法總綱
|
||||
|
||||
本專案憲法旨在確保系統的**穩定性**、**一致性**與**可維護性**。所有開發者(包括 AI 助手)在進行任何程式碼修改前,**必須**詳細閱讀並嚴格遵守本憲法的所有條款。
|
||||
|
||||
---
|
||||
|
||||
## 第一章:資料庫與模型層規範
|
||||
|
||||
### 第 1 條:商品 ID 命名規範(絕對禁止違反)
|
||||
- ✅ **正確**: 使用 `product.i_code` 作為商品唯一識別碼
|
||||
- ❌ **禁止**: 使用 `product.momo_id`(此屬性不存在)
|
||||
- **理由**: Product 模型定義為 `i_code = Column(String(50), unique=True)`
|
||||
- **影響範圍**: 所有查詢、API、前端顯示
|
||||
|
||||
### 第 2 條:時間戳處理規範(絕對禁止違反)
|
||||
- ✅ **正確**: 使用 `datetime.now(TAIPEI_TZ).replace(tzinfo=None)`
|
||||
- ❌ **禁止**: 直接使用 `datetime.now()` 或保留 tzinfo
|
||||
- **理由**: SQLite 不支援時區感知的 datetime,資料庫存儲必須為 naive datetime
|
||||
- **影響範圍**: 所有涉及時間比對的查詢(價格變動、統計計算)
|
||||
|
||||
### 第 3 條:價格變動邏輯(絕對禁止違反)
|
||||
- ✅ **正確**: 比對「今日最新價格」與「今日之前的最後一筆價格」
|
||||
- ❌ **禁止**: 僅查詢「昨天 00:00-23:59」的價格記錄
|
||||
- **理由**: 爬蟲可能跳過某些日期,必須容錯處理
|
||||
- **實作方式**:
|
||||
```python
|
||||
# 取得今日之前的最後一筆價格
|
||||
yesterday_prices_subq = session.query(
|
||||
PriceRecord.product_id,
|
||||
func.max(PriceRecord.id).label('max_id')
|
||||
).filter(
|
||||
PriceRecord.product_id.in_(product_ids),
|
||||
PriceRecord.timestamp < today_start # 不限定昨天
|
||||
).group_by(PriceRecord.product_id).subquery()
|
||||
```
|
||||
|
||||
### 第 4 條:查詢效能優化(強制要求)
|
||||
- ✅ **正確**: 使用批次查詢(如 `yesterday_prices_map`)
|
||||
- ❌ **禁止**: N+1 查詢模式(在迴圈內執行單筆查詢)
|
||||
- **理由**: 商品數量可達數千筆,N+1 查詢會導致嚴重效能問題
|
||||
- **影響範圍**: 儀表板、API、統計計算
|
||||
|
||||
---
|
||||
|
||||
## 第二章:爬蟲與資料採集規範
|
||||
|
||||
### 第 5 條:商品圖片 URL 構造(絕對禁止違反)
|
||||
- ✅ **正確**: 使用 CDN 直接構造
|
||||
```python
|
||||
image_url = f"https://m.momoshop.com.tw/moscdn/goods/{i_code}_m.webp"
|
||||
```
|
||||
- ❌ **禁止**: 使用複雜的 DOM 查詢(8+ CSS 選擇器)
|
||||
- **理由**: CDN URL 格式固定且穩定,DOM 查詢速度慢且易失效
|
||||
- **效能提升**: 速度提升 20-30%
|
||||
|
||||
### 第 6 條:爬蟲頻率與禮貌性(強制要求)
|
||||
- ✅ **正確**: 每次請求間隔至少 1 秒
|
||||
- ❌ **禁止**: 高頻率無間隔爬取
|
||||
- **理由**: 避免被 MOMO 封鎖 IP
|
||||
|
||||
### 第 7 條:錯誤處理與重試機制(強制要求)
|
||||
- ✅ **正確**: 所有爬蟲函式必須使用 try-except 包裹
|
||||
- ✅ **正確**: 記錄失敗原因至日誌系統
|
||||
- ❌ **禁止**: 靜默失敗(吞掉例外)
|
||||
|
||||
---
|
||||
|
||||
## 第三章:API 設計規範
|
||||
|
||||
### 第 8 條:API 邏輯一致性(絕對禁止違反)
|
||||
- ✅ **正確**: API 的資料處理邏輯必須與儀表板**完全一致**
|
||||
- ❌ **禁止**: API 與前端使用不同的計算邏輯
|
||||
- **理由**: 避免前後端資料不一致,造成使用者困惑
|
||||
- **實例**: `/api/price_change_details` 必須使用與 `get_consolidated_data()` 相同的價格比對邏輯
|
||||
|
||||
### 第 9 條:API 錯誤處理(強制要求)
|
||||
- ✅ **正確**: 所有 API 必須使用 try-except-finally 結構
|
||||
- ✅ **正確**: 錯誤時返回 JSON 格式: `{'products': []}, 500`
|
||||
- ✅ **正確**: 記錄詳細錯誤日誌: `sys_log.error()`
|
||||
- ❌ **禁止**: 返回 HTML 錯誤頁面或純文字錯誤
|
||||
|
||||
### 第 10 條:API 效能優化(強制要求)
|
||||
- ✅ **正確**: 使用批次查詢與預先計算
|
||||
- ❌ **禁止**: 在迴圈中執行資料庫查詢
|
||||
- **實例**: 建立 `yesterday_prices_map` 一次性查詢所有商品的歷史價格
|
||||
|
||||
---
|
||||
|
||||
## 第四章:前端 UI/UX 規範
|
||||
|
||||
### 第 11 條:設計系統色彩(絕對禁止違反)
|
||||
- ✅ **主題色**: 紫色漸變 `#667eea` → `#764ba2`
|
||||
- ✅ **漲價**: 紅色 `#dc3545` / `#ff6b6b`
|
||||
- ✅ **降價**: 綠色 `#28a745` / `#51cf66`
|
||||
- ❌ **禁止**: 使用其他顏色作為主題色(除非整體改版)
|
||||
- **理由**: 保持與 Daily Sales 頁面視覺一致性
|
||||
|
||||
### 第 12 條:響應式設計(強制要求)
|
||||
- ✅ **正確**: 所有頁面必須支援手機版(< 768px)
|
||||
- ✅ **正確**: 使用 Bootstrap 5.3.3 的響應式網格系統
|
||||
- ✅ **正確**: 表格與圖表必須支援橫向滾動(手機版)
|
||||
- ❌ **禁止**: 僅針對桌面版設計
|
||||
|
||||
### 第 13 條:互動體驗(強制要求)
|
||||
- ✅ **正確**: 所有按鈕必須有 hover 效果(陰影、位移、顏色變化)
|
||||
- ✅ **正確**: 所有卡片必須有 hover 動畫
|
||||
- ✅ **正確**: 使用 `transition: all 0.3s ease` 實現平滑過渡
|
||||
- ❌ **禁止**: 靜態無互動的 UI 元素
|
||||
|
||||
### 第 14 條:字體與可讀性(強制要求)
|
||||
- ✅ **正確**: 主要文字顏色 `#2c3e50`(深灰)
|
||||
- ✅ **正確**: 表格表頭使用白色文字 `#fff`
|
||||
- ❌ **禁止**: 使用純黑色 `#000`(刺眼)
|
||||
- ❌ **禁止**: 使用低對比度顏色組合
|
||||
|
||||
---
|
||||
|
||||
## 第五章:系統架構規範
|
||||
|
||||
### 第 15 條:服務端口(絕對禁止違反)
|
||||
- ✅ **正確**: Flask 使用 **Port 80**
|
||||
- ❌ **禁止**: 使用 5888 或其他端口(已廢棄)
|
||||
- **配置位置**: `config.py` 的 `PUBLIC_URL`
|
||||
|
||||
### 第 16 條:資料庫路徑(絕對禁止違反)
|
||||
- ✅ **正確**: `data/momo_database.db`
|
||||
- ❌ **禁止**: 更改資料庫位置或名稱
|
||||
- **配置位置**: `config.py` 的 `DATABASE_PATH`
|
||||
|
||||
### 第 17 條:時區設定(絕對禁止違反)
|
||||
- ✅ **正確**: 使用 `TAIPEI_TZ = pytz.timezone('Asia/Taipei')`
|
||||
- ❌ **禁止**: 使用 UTC 或其他時區
|
||||
- **理由**: 所有業務邏輯基於台北時間
|
||||
|
||||
### 第 18 條:日誌系統(強制要求)
|
||||
- ✅ **正確**: 使用 `sys_log.info()` / `sys_log.error()` 記錄關鍵操作
|
||||
- ✅ **正確**: 日誌格式必須包含 `[模組] [功能] 狀態 | 詳細資訊`
|
||||
- ❌ **禁止**: 使用 `print()` 輸出日誌
|
||||
|
||||
---
|
||||
|
||||
## 第六章:版本管理規範
|
||||
|
||||
### 第 19 條:版本號更新(強制要求)
|
||||
- ✅ **正確**: 每次功能更新必須修改 `app.py` 的 `SYSTEM_VERSION`
|
||||
- ✅ **格式**: `V主版本.次版本` (例如: V9.4)
|
||||
- ❌ **禁止**: 修改功能但不更新版本號
|
||||
|
||||
### 第 20 條:備份系統(強制要求)
|
||||
- ✅ **正確**: 重大更新前必須執行 `python backup_system.py`
|
||||
- ✅ **排除目錄**: `['backups', '__pycache__', '.git', '.idea', '.vscode', 'bin', 'bin 2']`
|
||||
- ❌ **禁止**: 跳過備份直接上線
|
||||
|
||||
### 第 21 條:TODO 文件維護(強制要求)
|
||||
- ✅ **正確**: 完成功能後更新 `TODO_NEXT_STEPS.txt` 標記 ✅
|
||||
- ✅ **正確**: 記錄修改的檔案與行數
|
||||
- ❌ **禁止**: 完成任務但不更新文件
|
||||
|
||||
---
|
||||
|
||||
## 第七章:程式碼品質規範
|
||||
|
||||
### 第 22 條:命名規範(強制要求)
|
||||
- ✅ **函式名稱**: 使用 `snake_case` (例如: `get_price_change_details`)
|
||||
- ✅ **類別名稱**: 使用 `PascalCase` (例如: `PriceRecord`)
|
||||
- ✅ **變數名稱**: 使用有意義的描述性名稱
|
||||
- ❌ **禁止**: 使用 `a`, `b`, `temp`, `data` 等無意義名稱
|
||||
|
||||
### 第 23 條:註解規範(強制要求)
|
||||
- ✅ **正確**: 在複雜邏輯前加上中文註解說明「為什麼」
|
||||
- ✅ **正確**: 修復 Bug 時標註 `V-Fix:`
|
||||
- ✅ **正確**: 新增功能時標註 `V-New:`
|
||||
- ❌ **禁止**: 完全不寫註解或寫無意義註解
|
||||
|
||||
### 第 24 條:DRY 原則(強制要求)
|
||||
- ✅ **正確**: 重複邏輯必須抽取為函式
|
||||
- ❌ **禁止**: 複製貼上相同程式碼超過 2 次
|
||||
- **實例**: `get_consolidated_data()` 封裝了通用的資料查詢邏輯
|
||||
|
||||
---
|
||||
|
||||
## 第八章:測試與除錯規範
|
||||
|
||||
### 第 25 條:修改前測試(強制要求)
|
||||
- ✅ **正確**: 修改程式碼前,先了解現有功能的行為
|
||||
- ✅ **正確**: 修改後必須測試相關功能是否正常
|
||||
- ❌ **禁止**: 修改後直接提交,未經測試
|
||||
|
||||
### 第 26 條:錯誤修復流程(強制要求)
|
||||
1. 閱讀錯誤日誌 (`logs/system.log`)
|
||||
2. 定位問題程式碼位置
|
||||
3. 理解根本原因(不是表面症狀)
|
||||
4. 修復問題並添加註解
|
||||
5. 測試修復是否有效
|
||||
6. 檢查是否有相同問題的其他地方
|
||||
|
||||
### 第 27 條:日誌檢查(強制要求)
|
||||
- ✅ **正確**: 修改 API 後檢查日誌是否有錯誤
|
||||
- ✅ **正確**: 使用 `tail -f logs/system.log` 即時監控
|
||||
- ❌ **禁止**: 盲目修改不看日誌
|
||||
|
||||
---
|
||||
|
||||
## 第九章:效能優化規範
|
||||
|
||||
### 第 28 條:資料庫查詢優化(強制要求)
|
||||
- ✅ **正確**: 使用 subquery 與 JOIN 減少查詢次數
|
||||
- ✅ **正確**: 使用 `options(joinedload())` 預載關聯資料
|
||||
- ❌ **禁止**: 在迴圈中執行查詢(N+1 問題)
|
||||
|
||||
### 第 29 條:前端效能優化(強制要求)
|
||||
- ✅ **正確**: 圖表使用 CDN 載入 Chart.js
|
||||
- ✅ **正確**: 大表格使用分頁或虛擬滾動
|
||||
- ❌ **禁止**: 一次渲染超過 1000 筆資料
|
||||
|
||||
### 第 30 條:快取策略(建議執行)
|
||||
- ✅ **建議**: 對不常變動的資料實作快取(5-10 分鐘)
|
||||
- ✅ **建議**: 使用 `get_consolidated_data()` 的結果快取
|
||||
- ⚠️ **注意**: 快取必須有過期機制
|
||||
|
||||
---
|
||||
|
||||
## 第十章:安全性規範
|
||||
|
||||
### 第 31 條:敏感資訊保護(絕對禁止違反)
|
||||
- ✅ **正確**: 所有 API Token、密碼存放於 `config.py`
|
||||
- ❌ **禁止**: 硬編碼敏感資訊於程式碼中
|
||||
- ❌ **禁止**: 提交 `config.py` 至公開 Git 倉庫
|
||||
|
||||
### 第 32 條:SQL 注入防護(絕對禁止違反)
|
||||
- ✅ **正確**: 使用 SQLAlchemy ORM 的參數化查詢
|
||||
- ❌ **禁止**: 使用字串拼接建構 SQL 查詢
|
||||
- **範例**:
|
||||
```python
|
||||
# ✅ 正確
|
||||
session.query(Product).filter(Product.i_code == user_input)
|
||||
|
||||
# ❌ 禁止
|
||||
session.execute(f"SELECT * FROM products WHERE i_code = '{user_input}'")
|
||||
```
|
||||
|
||||
### 第 33 條:XSS 防護(強制要求)
|
||||
- ✅ **正確**: Jinja2 模板自動跳脫 HTML
|
||||
- ✅ **正確**: JavaScript 中使用 `textContent` 而非 `innerHTML`
|
||||
- ❌ **禁止**: 直接插入未驗證的使用者輸入
|
||||
|
||||
---
|
||||
|
||||
## 第十一章:部署與維運規範
|
||||
|
||||
### 第 34 條:伺服器啟動(強制要求)
|
||||
- ✅ **正確**: 使用 `nohup python3 app.py > /dev/null 2>&1 &` 背景執行
|
||||
- ✅ **正確**: 部署前檢查 Port 80 是否已被佔用
|
||||
- ❌ **禁止**: 直接執行 `python3 app.py`(關閉終端機會停止服務)
|
||||
|
||||
### 第 35 條:伺服器重啟(強制要求)
|
||||
- ✅ **正確步驟**:
|
||||
1. `pkill -9 -f "python3 app.py"` (停止舊程序)
|
||||
2. `sleep 2` (等待端口釋放)
|
||||
3. `nohup python3 app.py > /dev/null 2>&1 &` (啟動新程序)
|
||||
4. `ps aux | grep "[p]ython3 app.py"` (確認運行)
|
||||
|
||||
### 第 36 條:日誌輪替(建議執行)
|
||||
- ✅ **建議**: 定期清理過大的日誌檔案 (> 100MB)
|
||||
- ✅ **建議**: 使用 logrotate 自動管理日誌
|
||||
|
||||
---
|
||||
|
||||
## 第十二章:協作開發規範
|
||||
|
||||
### 第 37 條:Git Commit 規範(強制要求)
|
||||
- ✅ **正確格式**: `[版本號] 功能描述 | 修改的檔案`
|
||||
- **範例**: `[V9.4] 修正彈窗無資料問題 | app.py, dashboard.html`
|
||||
- ❌ **禁止**: `fix`, `update`, `修改` 等無意義訊息
|
||||
|
||||
### 第 38 條:程式碼審查(強制要求)
|
||||
- ✅ **正確**: 重大修改前與 AI 助手討論方案
|
||||
- ✅ **正確**: 提供清晰的需求描述與預期結果
|
||||
- ❌ **禁止**: 直接要求 AI 「修改成...」而不解釋原因
|
||||
|
||||
### 第 39 條:憲法修訂(特別規定)
|
||||
- ✅ **修訂權限**: 僅限專案負責人(統帥)
|
||||
- ✅ **修訂流程**: 提出修訂 → 討論評估 → 更新文件 → 通知全員
|
||||
- ⚠️ **重要**: 本憲法不可輕易修改,除非有重大架構變更
|
||||
|
||||
---
|
||||
|
||||
## 第十三章:AI 三 Agent 自主學習架構規範(2026-04-18 加入)
|
||||
|
||||
### 第 40 條:三 Agent 分工架構(絕對禁止違反)
|
||||
- **Hermes(採集層)**: `192.168.0.111` Ollama,負責 embedding、去重、品質分數計算。成本 = $0
|
||||
- **NemoTron(處理層)**: NVIDIA NIM Llama 3.1 8B,負責 tool calling 邏輯路由與 DB 寫入。限額 80 次/天
|
||||
- **OpenClaw / Gemini(應用層)**: 負責最終 PPT 生成、洞察報告對外輸出。成本最高,最後動用
|
||||
- ❌ **禁止**:讓 OpenClaw 做 Hermes 層的苦力工作(高算力浪費)
|
||||
- ❌ **禁止**:讓 Hermes 直接生成對外報告(品質不足)
|
||||
|
||||
### 第 41 條:AI 學習數據雙寫(絕對禁止違反)
|
||||
- ✅ **正確**:所有 AI 產出(PPT 洞察、競品分析、對話記錄)必須**雙寫** PostgreSQL `ai_insights` + pgvector embedding
|
||||
- ❌ **禁止**:只寫 DB 不寫 KM(RAG 無法語意搜尋)
|
||||
- ❌ **禁止**:只寫 KM 不寫 DB(精確 period/sku 查詢無法命中)
|
||||
- **理由**:DB 是精準命中,KM 是語意搜尋,兩者互補缺一不可(ADR-007)
|
||||
- **入口**:NemoTron `store_insight` tool call → 同步寫 DB → 異步排隊給 Hermes 做 embedding
|
||||
|
||||
### 第 42 條:KM 向量庫技術選型(絕對禁止違反)
|
||||
- ✅ **唯一選擇**:pgvector(與現有 PostgreSQL `192.168.0.188` 同一 DB)
|
||||
- ❌ **禁止**:引入 ChromaDB、Qdrant 等獨立向量庫
|
||||
- **理由**:混合查詢(`WHERE` 結構化 + `ORDER BY embedding <->` 語意)只有 pgvector 能一條 SQL 搞定(ADR-002)
|
||||
|
||||
### 第 43 條:Embedding 本地化(強制要求)
|
||||
- ✅ **正確**:使用 `bge-m3`(或 `nomic-embed-text`)掛載在 Hermes 主機 `192.168.0.111` Ollama
|
||||
- ❌ **禁止**:呼叫外部 Embedding API(成本與隱私雙重問題)
|
||||
- **維度**:1024 dim(`vector(1024)` 欄位)(ADR-003)
|
||||
|
||||
### 第 44 條:NemoTron 配額 Fallback 機制(強制要求)
|
||||
- ✅ **正確**:當 NIM 回傳 HTTP 429 時,立刻 fallback 至 `_hermes_rule_fallback()` rule-based 派發
|
||||
- ❌ **禁止**:配額耗盡時讓告警管線中斷
|
||||
- **標記**:降級模式告警須帶 `🟡` 前綴,讓統帥識別(ADR-004)
|
||||
|
||||
### 第 45 條:KM 品質分數時間衰減(強制要求)
|
||||
- ✅ **公式**:`effective_score = base_score × e^(-decay_rate × days_passed)`
|
||||
- **decay_rate**:`0.005`(預設);`decay_exempt=True` 用於結構性/憲法類知識
|
||||
- **理由**:確保 RAG 優先抓取最新、最適用的洞察,避免歷史偏誤(ADR-005)
|
||||
|
||||
---
|
||||
|
||||
## 第十四章:Claude Code 官方遊戲規則(2026-04-18 加入)
|
||||
|
||||
### 第 46 條:記憶架構四層必須完整(絕對禁止違反)
|
||||
- **Memory**:`~/.claude/projects/.../memory/*.md` — 跨 Session 持久記憶
|
||||
- **ADR**:`docs/adr/ADR-XXX-*.md` — 架構決策,只增不刪
|
||||
- **SOT**:`docs/AI_INTELLIGENCE_MODULE_SOT.md` — 當前架構事實
|
||||
- **Skills**:`.claude/skills/*.py` — 執行 SOP checklist
|
||||
- ❌ **禁止**:任何一層缺失,都會導致後續 Session 失憶或決策矛盾
|
||||
|
||||
### 第 47 條:Session 開始 SOP(強制要求)
|
||||
```
|
||||
每次 Claude Session 開始必做:
|
||||
1. 讀 CLAUDE.md 第零章(元憲法)
|
||||
2. 讀 memory/MEMORY.md 索引
|
||||
3. 讀 docs/adr/README.md 索引
|
||||
4. 才開始執行任務
|
||||
```
|
||||
|
||||
### 第 48 條:Session 結束沉澱 SOP(強制要求)
|
||||
```
|
||||
每次 Session 結束前必做 checklist:
|
||||
□ 有架構決策?→ 新建 ADR-XXX.md + 更新索引
|
||||
□ 有統帥偏好?→ 更新 memory/user_profile.md
|
||||
□ 有技術債?→ 更新 memory/project_tech_debt_backlog.md
|
||||
□ 有 SOT 變更?→ 更新 AI_INTELLIGENCE_MODULE_SOT.md
|
||||
□ 有憲法新條款?→ 更新 CONSTITUTION.md + CLAUDE.md
|
||||
□ 更新 memory/MEMORY.md 索引
|
||||
```
|
||||
|
||||
### 第 49 條:專案範圍邊界(絕對禁止違反)
|
||||
- ✅ **本憲法範圍**:`momo-pro-system`(EwoooC)**唯一**
|
||||
- ❌ **禁止**:將 AWOOOI / WOOO AIOps SaaS 的決策混入本文件
|
||||
- ❌ **禁止**:跨專案邊界做架構決策
|
||||
|
||||
---
|
||||
|
||||
## 附錄 A:常見錯誤與解決方案
|
||||
|
||||
### 錯誤 1: 'Product' object has no attribute 'momo_id'
|
||||
- **原因**: 使用錯誤的屬性名稱
|
||||
- **解決**: 改用 `product.i_code`
|
||||
|
||||
### 錯誤 2: API 返回空資料
|
||||
- **原因**: 價格比對邏輯錯誤(只查昨天)
|
||||
- **解決**: 使用「今日之前最後一筆」邏輯
|
||||
|
||||
### 錯誤 3: 時間戳比對失敗
|
||||
- **原因**: 時區感知 datetime 與 naive datetime 混用
|
||||
- **解決**: 使用 `.replace(tzinfo=None)` 統一為 naive
|
||||
|
||||
### 錯誤 4: 彈窗顯示「無資料」
|
||||
- **原因**: API 邏輯與前端不一致
|
||||
- **解決**: 確保使用相同的 `yesterday_prices_map` 查詢
|
||||
|
||||
---
|
||||
|
||||
## 附錄 B:關鍵檔案索引
|
||||
|
||||
| 檔案路徑 | 用途 | 禁止修改項目 |
|
||||
|---------|------|------------|
|
||||
| `config.py` | 系統配置 | `DATABASE_PATH`, `PUBLIC_URL` |
|
||||
| `database/models.py` | 資料模型 | `Product.i_code` 定義 |
|
||||
| `app.py` | 主程式 | `SYSTEM_VERSION`, `TAIPEI_TZ` |
|
||||
| `dashboard.html` | 商品看板 | 主題色系、響應式設計 |
|
||||
| `daily_sales.html` | 業績看板 | 行事曆邏輯、圖表配置 |
|
||||
| `scheduler.py` | 排程爬蟲 | 商品圖 CDN URL 構造 |
|
||||
| `backup_system.py` | 備份系統 | 排除目錄清單 |
|
||||
|
||||
---
|
||||
|
||||
## 附錄 C:版本更新檢查清單
|
||||
|
||||
每次發布新版本前,必須確認以下項目:
|
||||
|
||||
- [ ] `SYSTEM_VERSION` 已更新
|
||||
- [ ] 相關功能已測試正常運作
|
||||
- [ ] 日誌無錯誤訊息
|
||||
- [ ] `TODO_NEXT_STEPS.txt` 已更新
|
||||
- [ ] 已執行系統備份
|
||||
- [ ] API 邏輯與前端一致
|
||||
- [ ] 響應式設計正常(手機版測試)
|
||||
- [ ] 資料庫查詢效能正常(無 N+1 問題)
|
||||
|
||||
---
|
||||
|
||||
## 結語
|
||||
|
||||
本憲法的目的是維護專案的**長期穩定性**與**開發效率**。所有開發者(包括 AI 助手)在遇到與憲法條款衝突的需求時,應優先遵守憲法,並與專案負責人討論是否需要修訂憲法。
|
||||
|
||||
**記住**: 一個穩定運行的系統,遠比一個功能豐富但 Bug 頻出的系統更有價值。
|
||||
|
||||
---
|
||||
|
||||
**專案憲法版本**: 2.0
|
||||
**生效日期**: 2026-01-12
|
||||
**最後審核**: 2026-04-18(統帥批准,加入第十三、十四章)
|
||||
537
DEPLOYMENT_WORKFLOW.md
Normal file
537
DEPLOYMENT_WORKFLOW.md
Normal file
@@ -0,0 +1,537 @@
|
||||
# MOMO 監控系統 - 開發測試部署流程
|
||||
|
||||
**版本:** 1.0
|
||||
**制定日期:** 2026-01-13
|
||||
**最後更新:** 2026-01-13
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 環境架構
|
||||
|
||||
### 環境分層
|
||||
|
||||
| 環境 | 位置 | 用途 | 網址 | 運行方式 |
|
||||
|------|------|------|------|----------|
|
||||
| **開發環境 (Dev)** | `/Users/ogt/momo_pro_system` | 本地開發、程式碼修改 | `http://127.0.0.1:80` | 直接運行 `python app.py` |
|
||||
| **測試環境 (Test)** | 同開發環境 | 功能測試、安全測試 | 同開發環境 | 執行測試腳本 |
|
||||
|
||||
### 環境特性
|
||||
|
||||
#### 開發環境 (macOS Local)
|
||||
- **系統**:macOS (Darwin)
|
||||
- **Python**:pyenv 管理的 Python 3.11.7
|
||||
- **資料庫**:本地 SQLite
|
||||
- **用途**:
|
||||
- 程式碼開發
|
||||
- 快速測試
|
||||
- UI/UX 調整
|
||||
- 新功能實驗
|
||||
|
||||
#### 正式環境 (GCP VM)
|
||||
- **系統**:Ubuntu 22.04 LTS
|
||||
- **Python**:系統 Python 或 venv
|
||||
- **服務管理**:systemd (`momo.service`)
|
||||
- **反向代理**:可能使用 nginx
|
||||
- **域名**:DuckDNS (momo.wooo.work)
|
||||
- **用途**:
|
||||
- 生產服務
|
||||
- 24/7 運行
|
||||
- 爬蟲任務排程
|
||||
- 使用者訪問
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整開發部署流程
|
||||
|
||||
### 階段 1:需求與規劃
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[需求確認] --> B[功能設計]
|
||||
B --> C[技術評估]
|
||||
C --> D[風險評估]
|
||||
D --> E[制定計劃]
|
||||
```
|
||||
|
||||
**檢查清單**:
|
||||
- [ ] 需求文檔完整
|
||||
- [ ] 技術方案可行
|
||||
- [ ] 評估對現有功能的影響
|
||||
- [ ] 確認是否需要爬蟲修改(若是,參照憲法第四章)
|
||||
- [ ] 確認是否影響安全(若是,參照憲法第二章)
|
||||
|
||||
---
|
||||
|
||||
### 階段 2:本地開發 (Dev)
|
||||
|
||||
#### 2.1 環境準備
|
||||
|
||||
```bash
|
||||
# 確保在開發環境
|
||||
cd /Users/ogt/momo_pro_system
|
||||
|
||||
# 確認虛擬環境
|
||||
source venv/bin/activate
|
||||
|
||||
# 確認依賴最新
|
||||
pip list
|
||||
```
|
||||
|
||||
#### 2.2 程式碼開發
|
||||
|
||||
```bash
|
||||
# 1. 創建功能分支(重大修改)
|
||||
git checkout -b feature/功能名稱
|
||||
|
||||
# 2. 開發程式碼
|
||||
# - 遵守憲法第三章程式碼規範
|
||||
# - 使用繁體中文註解
|
||||
# - 添加錯誤處理
|
||||
# - 記錄詳細日誌
|
||||
|
||||
# 3. 本地測試
|
||||
python app.py
|
||||
# 訪問 http://127.0.0.1:80 測試功能
|
||||
```
|
||||
|
||||
**開發規範**:
|
||||
- ✅ 所有註解使用繁體中文
|
||||
- ✅ 所有輸入經過驗證
|
||||
- ✅ 使用 `safe_join()` 處理路徑
|
||||
- ✅ POST 請求包含 CSRF token
|
||||
- ✅ 敏感資訊使用環境變數
|
||||
- ✅ 完整的錯誤處理和日誌
|
||||
|
||||
#### 2.3 Git 提交
|
||||
|
||||
```bash
|
||||
# 查看修改
|
||||
git status
|
||||
git diff
|
||||
|
||||
# 提交修改
|
||||
git add 相關檔案
|
||||
git commit -m "[模組] 繁體中文描述"
|
||||
|
||||
# 範例:
|
||||
# git commit -m "[UI] 為廠商缺貨系統所有子頁面添加返回主頁按鈕"
|
||||
# git commit -m "[Crawler] [MOMO] 修復商品價格選擇器失效"
|
||||
# git commit -m "[Security] 強化檔案上傳驗證"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 階段 3:本地測試 (Test)
|
||||
|
||||
#### 3.1 功能測試
|
||||
|
||||
```bash
|
||||
# 手動測試
|
||||
python app.py
|
||||
# 1. 測試新功能正常運作
|
||||
# 2. 測試現有功能未受影響
|
||||
# 3. 測試邊界情況
|
||||
# 4. 測試錯誤處理
|
||||
```
|
||||
|
||||
#### 3.2 安全測試(如涉及安全)
|
||||
|
||||
```bash
|
||||
# 執行完整安全測試套件
|
||||
./run_security_tests.sh
|
||||
|
||||
# 或個別測試
|
||||
python test_sql_security.py
|
||||
python test_path_traversal.py
|
||||
python test_file_upload.py
|
||||
```
|
||||
|
||||
**必須通過**:
|
||||
- ✅ 所有安全測試通過
|
||||
- ✅ 無新增安全漏洞
|
||||
- ✅ 日誌記錄完整
|
||||
|
||||
#### 3.3 爬蟲測試(如涉及爬蟲)
|
||||
|
||||
```bash
|
||||
# 執行爬蟲測試
|
||||
python test_crawler_specific.py
|
||||
|
||||
# 測試項目:
|
||||
# 1. 選擇器有效性
|
||||
# 2. 資料完整性
|
||||
# 3. 錯誤處理
|
||||
# 4. 效能測試
|
||||
```
|
||||
|
||||
**必須通過**:
|
||||
- ✅ 選擇器正確抓取資料
|
||||
- ✅ 資料格式正確
|
||||
- ✅ 錯誤處理完整
|
||||
- ✅ 執行時間合理
|
||||
|
||||
#### 3.4 回歸測試
|
||||
|
||||
```bash
|
||||
# 確認現有功能未受影響
|
||||
# 1. 測試主要功能流程
|
||||
# 2. 測試業績分析
|
||||
# 3. 測試爬蟲任務
|
||||
# 4. 測試廠商缺貨系統
|
||||
# 5. 測試 Google Drive 自動匯入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 階段 4:部署前準備
|
||||
|
||||
#### 4.1 程式碼檢查
|
||||
|
||||
**檢查清單**:
|
||||
- [ ] 所有測試通過
|
||||
- [ ] 程式碼遵守規範
|
||||
- [ ] 註解完整清晰
|
||||
- [ ] 無硬編碼敏感資訊
|
||||
- [ ] 日誌記錄完整
|
||||
- [ ] Git commit 完成
|
||||
- [ ] 文檔更新完成
|
||||
|
||||
#### 4.2 確認修改檔案清單
|
||||
|
||||
```bash
|
||||
# 查看本次修改的所有檔案
|
||||
git status
|
||||
git diff --name-only
|
||||
|
||||
# 列出修改清單,確認需要部署的檔案
|
||||
# 範例:
|
||||
# - sales_analysis.html
|
||||
# - vendor_stockout_list.html
|
||||
# - vendor_stockout_import.html
|
||||
# - vendor_stockout_send_email.html
|
||||
# - vendor_stockout_history.html
|
||||
```
|
||||
|
||||
#### 4.3 備份正式環境
|
||||
|
||||
```bash
|
||||
# 連接到 GCP VM
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
|
||||
# 在 VM 上執行備份
|
||||
cd /home/ogt/momo_pro_system
|
||||
./deploy_scripts/backup.sh
|
||||
# 或手動備份
|
||||
tar -czf backup_$(date +%Y%m%d_%H%M%S).tar.gz \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.log' \
|
||||
.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 階段 5:部署到正式環境
|
||||
|
||||
#### 方法 A:完整部署(推薦用於大改動)
|
||||
|
||||
```bash
|
||||
# 在本地執行
|
||||
cd /Users/ogt/momo_pro_system
|
||||
|
||||
# 使用部署腳本自動上傳
|
||||
|
||||
# 腳本會:
|
||||
# 1. 檢查 gcloud 登入狀態
|
||||
# 2. 檢查 VM 狀態
|
||||
# 3. 上傳所有檔案(排除 venv、__pycache__、logs)
|
||||
# 4. 提示後續步驟
|
||||
```
|
||||
|
||||
**然後連接到 VM**:
|
||||
|
||||
```bash
|
||||
# 連接到 VM
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
|
||||
# 在 VM 上執行
|
||||
cd ~/momo_pro_system
|
||||
|
||||
# 安裝/更新依賴(如有新依賴)
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 重啟服務
|
||||
sudo systemctl restart momo.service
|
||||
|
||||
# 檢查服務狀態
|
||||
sudo systemctl status momo.service
|
||||
|
||||
# 查看日誌
|
||||
sudo journalctl -u momo.service -f
|
||||
```
|
||||
|
||||
#### 方法 B:快速更新(用於小改動,僅 HTML/CSS/JS)
|
||||
|
||||
```bash
|
||||
# 在本地執行
|
||||
cd /Users/ogt/momo_pro_system
|
||||
|
||||
# 僅上傳修改的檔案
|
||||
gcloud compute scp --zone=asia-east1-a \
|
||||
sales_analysis.html \
|
||||
vendor_stockout_list.html \
|
||||
vendor_stockout_import.html \
|
||||
vendor_stockout_send_email.html \
|
||||
vendor_stockout_history.html \
|
||||
momo-server:~/momo_pro_system/
|
||||
|
||||
# HTML 模板檔案不需要重啟服務
|
||||
# Flask 會自動重新載入模板
|
||||
```
|
||||
|
||||
**注意**:
|
||||
- HTML/CSS/JS 檔案:不需重啟服務
|
||||
- Python 檔案(.py):需要重啟服務
|
||||
- 配置檔案(.env):需要重啟服務
|
||||
- 依賴更新(requirements.txt):需要重新安裝並重啟
|
||||
|
||||
---
|
||||
|
||||
### 階段 6:部署後驗證
|
||||
|
||||
#### 6.1 服務狀態檢查
|
||||
|
||||
```bash
|
||||
# 在 GCP VM 上執行
|
||||
sudo systemctl status momo.service
|
||||
|
||||
# 查看最近的日誌
|
||||
sudo journalctl -u momo.service -n 100
|
||||
|
||||
# 檢查是否有錯誤
|
||||
sudo journalctl -u momo.service -p err -n 50
|
||||
```
|
||||
|
||||
#### 6.2 功能驗證
|
||||
|
||||
```bash
|
||||
# 測試網站訪問
|
||||
curl -I https://momo.wooo.work
|
||||
|
||||
# 測試特定功能頁面
|
||||
curl -I https://momo.wooo.work/vendor-stockout/send-email
|
||||
curl -I https://momo.wooo.work/vendor-stockout/history
|
||||
```
|
||||
|
||||
**手動驗證**:
|
||||
- [ ] 訪問 https://momo.wooo.work
|
||||
- [ ] 測試修改的功能
|
||||
- [ ] 確認新功能正常運作
|
||||
- [ ] 確認現有功能未受影響
|
||||
- [ ] 檢查是否有 JavaScript 錯誤(瀏覽器 Console)
|
||||
- [ ] 測試在不同瀏覽器(Chrome, Safari)
|
||||
|
||||
#### 6.3 監控檢查
|
||||
|
||||
```bash
|
||||
# 監控系統日誌(持續 10 分鐘)
|
||||
tail -f /home/ogt/momo_pro_system/logs/system.log
|
||||
|
||||
# 檢查是否有錯誤或異常
|
||||
```
|
||||
|
||||
**監控項目**:
|
||||
- [ ] 無錯誤訊息
|
||||
- [ ] 爬蟲任務正常執行
|
||||
- [ ] 資料庫操作正常
|
||||
- [ ] 記憶體使用正常
|
||||
- [ ] CPU 使用正常
|
||||
|
||||
#### 6.4 24 小時監控
|
||||
|
||||
**重要**:部署後必須監控 24 小時
|
||||
- 定期檢查日誌
|
||||
- 確認排程任務正常執行
|
||||
- 確認通知正常發送
|
||||
- 監控系統資源使用
|
||||
|
||||
---
|
||||
|
||||
## 🔄 回滾流程
|
||||
|
||||
### 何時需要回滾
|
||||
|
||||
- ❌ 部署後發現嚴重錯誤
|
||||
- ❌ 核心功能失效
|
||||
- ❌ 爬蟲完全失敗
|
||||
- ❌ 系統崩潰或無法啟動
|
||||
- ❌ 資料庫損壞
|
||||
|
||||
### 回滾步驟
|
||||
|
||||
```bash
|
||||
# 1. 連接到 GCP VM
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
|
||||
# 2. 停止服務
|
||||
sudo systemctl stop momo.service
|
||||
|
||||
# 3. 還原備份
|
||||
cd /home/ogt/momo_pro_system
|
||||
mv momo_pro_system momo_pro_system_failed
|
||||
tar -xzf backup_20260113_HHMMSS.tar.gz
|
||||
|
||||
# 4. 重啟服務
|
||||
sudo systemctl start momo.service
|
||||
|
||||
# 5. 驗證服務恢復
|
||||
sudo systemctl status momo.service
|
||||
curl -I https://momo.wooo.work
|
||||
|
||||
# 6. 記錄問題
|
||||
# 在本地環境修復問題,重新測試後再次部署
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 部署檢查清單總表
|
||||
|
||||
### 部署前檢查(Pre-Deployment)
|
||||
|
||||
- [ ] 功能開發完成
|
||||
- [ ] 本地測試通過
|
||||
- [ ] 安全測試通過(如涉及)
|
||||
- [ ] 爬蟲測試通過(如涉及)
|
||||
- [ ] 回歸測試通過
|
||||
- [ ] 程式碼遵守規範
|
||||
- [ ] Git commit 完成
|
||||
- [ ] 文檔更新完成
|
||||
- [ ] 正式環境已備份
|
||||
|
||||
### 部署中檢查(During Deployment)
|
||||
|
||||
- [ ] 檔案上傳成功
|
||||
- [ ] 依賴安裝成功(如需要)
|
||||
- [ ] 服務重啟成功(如需要)
|
||||
- [ ] 無錯誤訊息
|
||||
|
||||
### 部署後檢查(Post-Deployment)
|
||||
|
||||
- [ ] 服務狀態正常
|
||||
- [ ] 網站可以訪問
|
||||
- [ ] 新功能正常運作
|
||||
- [ ] 現有功能未受影響
|
||||
- [ ] 日誌無異常
|
||||
- [ ] 排程任務正常
|
||||
- [ ] 監控 24 小時
|
||||
- [ ] 更新部署日誌
|
||||
|
||||
---
|
||||
|
||||
## 🔧 常用命令速查
|
||||
|
||||
### 本地開發
|
||||
|
||||
```bash
|
||||
# 啟動開發環境
|
||||
cd /Users/ogt/momo_pro_system
|
||||
source venv/bin/activate
|
||||
python app.py
|
||||
|
||||
# 執行測試
|
||||
./run_security_tests.sh
|
||||
python test_specific.py
|
||||
|
||||
# Git 操作
|
||||
git status
|
||||
git add .
|
||||
git commit -m "[模組] 描述"
|
||||
git push
|
||||
```
|
||||
|
||||
## 📊 環境差異注意事項
|
||||
|
||||
### 路徑差異
|
||||
|
||||
| 項目 | 開發環境 | 正式環境 |
|
||||
|------|----------|----------|
|
||||
| 專案路徑 | `/Users/ogt/momo_pro_system` | `/home/ogt/momo_pro_system` |
|
||||
| Python | `/Users/ogt/.pyenv/versions/3.11.7/bin/python` | `/home/ogt/momo_pro_system/venv/bin/python` |
|
||||
| 資料庫 | `data/momo_database.db` | `data/momo_database.db` |
|
||||
| 日誌 | `logs/` | `logs/` |
|
||||
|
||||
### 服務運行差異
|
||||
|
||||
| 項目 | 開發環境 | 正式環境 |
|
||||
|------|----------|----------|
|
||||
| 運行方式 | 直接 `python app.py` | systemd service |
|
||||
| Port | 80 | 80 |
|
||||
| 網址 | `http://127.0.0.1:80` | `https://momo.wooo.work` |
|
||||
| 自動重啟 | 否 | 是(systemd) |
|
||||
| 日誌管理 | 檔案 | systemd journal + 檔案 |
|
||||
|
||||
### 注意事項
|
||||
|
||||
1. **程式碼差異**:
|
||||
- 開發環境和正式環境的程式碼必須保持同步
|
||||
- 避免在正式環境直接修改程式碼
|
||||
- 所有修改必須經過開發→測試→部署流程
|
||||
|
||||
2. **資料庫差異**:
|
||||
- 開發環境和正式環境使用不同的資料庫
|
||||
- 不要混用資料庫檔案
|
||||
- 測試時使用測試資料,避免污染正式資料
|
||||
|
||||
3. **環境變數**:
|
||||
- `.env` 檔案在兩個環境中可能不同
|
||||
- 確保正式環境的憑證正確配置
|
||||
- 不要將 `.env` 上傳到 Git
|
||||
|
||||
---
|
||||
|
||||
## 🎯 最佳實踐
|
||||
|
||||
### DO ✅
|
||||
|
||||
- ✅ 小步提交,頻繁部署
|
||||
- ✅ 每次部署前備份
|
||||
- ✅ 完整測試後再部署
|
||||
- ✅ 部署後立即驗證
|
||||
- ✅ 監控 24 小時
|
||||
- ✅ 記錄部署日誌
|
||||
- ✅ 遵守憲法規範
|
||||
|
||||
### DON'T ❌
|
||||
|
||||
- ❌ 直接在正式環境改程式碼
|
||||
- ❌ 跳過測試直接部署
|
||||
- ❌ 部署後不驗證
|
||||
- ❌ 不備份就部署
|
||||
- ❌ 在高峰時段部署重大更新
|
||||
- ❌ 同時部署多個大改動
|
||||
|
||||
---
|
||||
|
||||
## 📈 持續改進
|
||||
|
||||
### 定期檢視
|
||||
|
||||
- 每月檢視部署流程效率
|
||||
- 收集部署中遇到的問題
|
||||
- 優化自動化腳本
|
||||
- 更新文檔
|
||||
|
||||
### 自動化目標
|
||||
|
||||
未來可考慮的自動化:
|
||||
- [ ] CI/CD 流程(GitHub Actions)
|
||||
- [ ] 自動化測試流程
|
||||
- [ ] 自動化部署腳本
|
||||
- [ ] 自動化回滾機制
|
||||
- [ ] 部署通知(Telegram/Line)
|
||||
|
||||
---
|
||||
|
||||
**版本歷史**:
|
||||
- v1.0 (2026-01-13): 初版發布,定義完整開發測試部署流程
|
||||
359
DEPLOY_README.md
Normal file
359
DEPLOY_README.md
Normal file
@@ -0,0 +1,359 @@
|
||||
# Momo Pro System - 部署快速指南
|
||||
|
||||
## 🚀 推薦方案對比
|
||||
|
||||
| 方案 | 成本/月 | 部署難度 | 維護成本 | 擴展性 | 推薦度 |
|
||||
|-----|--------|---------|---------|--------|-------|
|
||||
| **Cloud Run** | $10-30 | ⭐ 極易 | ⭐ 極低 | ⭐⭐⭐ 高 | ⭐⭐⭐⭐⭐ |
|
||||
| Docker (本機) | $0 | ⭐⭐ 容易 | ⭐⭐ 低 | ⭐ 低 | ⭐⭐⭐⭐ |
|
||||
| VM (Compute Engine) | $32 | ⭐⭐⭐ 中等 | ⭐⭐⭐ 中 | ⭐⭐ 中 | ⭐⭐⭐ |
|
||||
| GKE (Kubernetes) | $123+ | ⭐⭐⭐⭐⭐ 困難 | ⭐⭐⭐⭐ 高 | ⭐⭐⭐ 高 | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 方案一:Cloud Run(最推薦)
|
||||
|
||||
**特點:**
|
||||
- ✅ 完全託管,自動擴展
|
||||
- ✅ 按使用付費,沒流量不收費
|
||||
- ✅ 自動 HTTPS
|
||||
- ✅ 5 分鐘部署完成
|
||||
|
||||
### 快速部署
|
||||
|
||||
```bash
|
||||
# 一鍵部署
|
||||
./deploy_scripts/deploy_cloudrun.sh
|
||||
```
|
||||
|
||||
或手動部署:
|
||||
|
||||
```bash
|
||||
# 登入 GCP
|
||||
gcloud auth login
|
||||
|
||||
# 設定專案
|
||||
gcloud config set project YOUR_PROJECT_ID
|
||||
|
||||
# 部署(從原始碼自動建立)
|
||||
gcloud run deploy momo-pro-system \
|
||||
--source . \
|
||||
--region=asia-east1 \
|
||||
--allow-unauthenticated \
|
||||
--port=5000
|
||||
```
|
||||
|
||||
**詳細文檔:** [deploy_docker_guide.md](deploy_docker_guide.md)
|
||||
|
||||
---
|
||||
|
||||
## 🏠 方案二:本機 Docker 測試
|
||||
|
||||
**適合:**
|
||||
- 本機開發測試
|
||||
- 不需要公開訪問
|
||||
- 學習 Docker
|
||||
|
||||
### 快速啟動
|
||||
|
||||
```bash
|
||||
# 一鍵測試
|
||||
./deploy_scripts/test_docker_local.sh
|
||||
```
|
||||
|
||||
或手動啟動:
|
||||
|
||||
```bash
|
||||
# 啟動容器
|
||||
docker-compose up -d
|
||||
|
||||
# 訪問
|
||||
open http://localhost
|
||||
|
||||
# 查看日誌
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 方案三:VM 部署(傳統方式)
|
||||
|
||||
**適合:**
|
||||
- 需要完全控制
|
||||
- 已有 VM 資源
|
||||
- 自定義環境需求
|
||||
|
||||
### Docker 方式(推薦)
|
||||
|
||||
```bash
|
||||
# 1. 建立 VM
|
||||
gcloud compute instances create momo-server \
|
||||
--zone=asia-east1-a \
|
||||
--machine-type=e2-medium \
|
||||
--image-family=cos-stable \
|
||||
--image-project=cos-cloud
|
||||
|
||||
# 2. 上傳程式碼
|
||||
gcloud compute scp --recurse . momo-server:~/momo_pro_system --zone=asia-east1-a
|
||||
|
||||
# 3. SSH 到 VM
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
|
||||
# 4. 在 VM 上啟動
|
||||
cd ~/momo_pro_system
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 傳統方式
|
||||
|
||||
```bash
|
||||
# 使用傳統腳本部署
|
||||
|
||||
# 在 VM 上執行
|
||||
./deploy_scripts/setup_vm.sh
|
||||
./deploy_scripts/setup_service.sh
|
||||
./deploy_scripts/setup_nginx.sh
|
||||
```
|
||||
|
||||
**詳細文檔:** [deploy_gcp_guide.md](deploy_gcp_guide.md)
|
||||
|
||||
---
|
||||
|
||||
## 📁 檔案結構
|
||||
|
||||
```
|
||||
momo_pro_system/
|
||||
├── Dockerfile # Docker 映像定義
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
├── nginx.conf # Nginx 配置
|
||||
├── .dockerignore # Docker 忽略檔案
|
||||
├── DEPLOY_README.md # 本檔案
|
||||
├── deploy_docker_guide.md # Docker 詳細指南
|
||||
├── deploy_gcp_guide.md # 傳統 VM 詳細指南
|
||||
└── deploy_scripts/
|
||||
├── deploy_cloudrun.sh # Cloud Run 一鍵部署 ⭐
|
||||
├── test_docker_local.sh # 本機 Docker 測試 ⭐
|
||||
├── setup_vm.sh # VM 環境設定
|
||||
├── setup_service.sh # 系統服務設定
|
||||
├── setup_nginx.sh # Nginx 設定
|
||||
└── backup.sh # 備份腳本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 環境變數設定
|
||||
|
||||
### 建立 .env 檔案
|
||||
|
||||
```bash
|
||||
# Flask 設定
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Email 設定
|
||||
EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASSWORD=your-app-password
|
||||
SMTP_SERVER=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
|
||||
# 資料庫
|
||||
DATABASE_PATH=data/momo_database.db
|
||||
```
|
||||
|
||||
### Gmail App Password 設定
|
||||
|
||||
1. 開啟 Google 帳戶設定
|
||||
2. 安全性 → 兩步驟驗證
|
||||
3. 應用程式密碼 → 選擇"郵件"
|
||||
4. 複製 16 位密碼到 `EMAIL_PASSWORD`
|
||||
|
||||
---
|
||||
|
||||
## 📊 監控和維護
|
||||
|
||||
### Cloud Run
|
||||
|
||||
```bash
|
||||
# 查看日誌
|
||||
gcloud logging read "resource.type=cloud_run_revision" --limit=50
|
||||
|
||||
# 查看指標
|
||||
gcloud run services describe momo-pro-system --region=asia-east1
|
||||
|
||||
# 更新服務
|
||||
./deploy_scripts/deploy_cloudrun.sh
|
||||
```
|
||||
|
||||
### Docker (本機/VM)
|
||||
|
||||
```bash
|
||||
# 查看日誌
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看狀態
|
||||
docker-compose ps
|
||||
|
||||
# 重啟服務
|
||||
docker-compose restart
|
||||
|
||||
# 更新服務
|
||||
docker-compose down
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 備份策略
|
||||
|
||||
### 自動備份(推薦)
|
||||
|
||||
```bash
|
||||
# 設定每日自動備份到 Cloud Storage
|
||||
# 1. 建立 bucket
|
||||
gsutil mb -l asia-east1 gs://momo-backups
|
||||
|
||||
# 2. 設定 cron job
|
||||
0 2 * * * cd /app && ./deploy_scripts/backup.sh && gsutil cp backups/*.zip gs://momo-backups/
|
||||
```
|
||||
|
||||
### 手動備份
|
||||
|
||||
```bash
|
||||
# 執行備份腳本
|
||||
./deploy_scripts/backup.sh
|
||||
|
||||
# 備份檔案位置
|
||||
ls -lh ~/backups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 故障排除
|
||||
|
||||
### 問題 1:容器無法啟動
|
||||
|
||||
```bash
|
||||
# 查看詳細日誌
|
||||
docker-compose logs momo-app
|
||||
|
||||
# 檢查配置
|
||||
docker-compose config
|
||||
```
|
||||
|
||||
### 問題 2:無法連接資料庫
|
||||
|
||||
```bash
|
||||
# 檢查資料庫檔案
|
||||
ls -lh data/momo_database.db
|
||||
|
||||
# 測試資料庫連接
|
||||
docker exec momo-pro-system python -c "import sqlite3; print(sqlite3.connect('/app/data/momo_database.db'))"
|
||||
```
|
||||
|
||||
### 問題 3:Email 無法發送
|
||||
|
||||
```bash
|
||||
# 檢查環境變數
|
||||
docker exec momo-pro-system env | grep EMAIL
|
||||
|
||||
# 測試 SMTP 連接
|
||||
docker exec momo-pro-system python -c "
|
||||
import smtplib
|
||||
import os
|
||||
smtp = smtplib.SMTP(os.getenv('SMTP_SERVER'), int(os.getenv('SMTP_PORT')))
|
||||
smtp.starttls()
|
||||
print('SMTP 連接成功')
|
||||
"
|
||||
```
|
||||
|
||||
### 問題 4:Cloud Run 超時
|
||||
|
||||
```bash
|
||||
# 增加超時時間
|
||||
gcloud run services update momo-pro-system \
|
||||
--timeout=900 \
|
||||
--region=asia-east1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💰 成本優化
|
||||
|
||||
### Cloud Run 優化
|
||||
|
||||
```bash
|
||||
# 設定最小實例為 0(沒流量時不收費)
|
||||
gcloud run services update momo-pro-system \
|
||||
--min-instances=0 \
|
||||
--region=asia-east1
|
||||
|
||||
# 減少記憶體配置
|
||||
gcloud run services update momo-pro-system \
|
||||
--memory=1Gi \
|
||||
--region=asia-east1
|
||||
```
|
||||
|
||||
### VM 優化
|
||||
|
||||
```bash
|
||||
# 使用 Preemptible VM(可節省 80% 成本)
|
||||
gcloud compute instances create momo-server \
|
||||
--preemptible \
|
||||
--machine-type=e2-small
|
||||
|
||||
# 使用 Spot VM(新版 Preemptible)
|
||||
gcloud compute instances create momo-server \
|
||||
--provisioning-model=SPOT \
|
||||
--machine-type=e2-small
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全性建議
|
||||
|
||||
1. **使用 Secret Manager** 儲存敏感資訊
|
||||
2. **啟用 HTTPS**(Cloud Run 自動,VM 需設定)
|
||||
3. **限制訪問權限**(使用 IAM)
|
||||
4. **定期更新依賴**
|
||||
5. **啟用日誌監控**
|
||||
|
||||
---
|
||||
|
||||
## 📞 支援
|
||||
|
||||
- **Docker 詳細指南:** [deploy_docker_guide.md](deploy_docker_guide.md)
|
||||
- **傳統 VM 指南:** [deploy_gcp_guide.md](deploy_gcp_guide.md)
|
||||
- **GCP 文檔:** https://cloud.google.com/docs
|
||||
- **Docker 文檔:** https://docs.docker.com
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速決策指南
|
||||
|
||||
**選擇 Cloud Run,如果:**
|
||||
- ✅ 想要最簡單的部署
|
||||
- ✅ 流量不穩定(自動擴展)
|
||||
- ✅ 預算有限(按用量付費)
|
||||
|
||||
**選擇本機 Docker,如果:**
|
||||
- ✅ 只是開發測試
|
||||
- ✅ 不需要公開訪問
|
||||
- ✅ 想學習 Docker
|
||||
|
||||
**選擇 VM,如果:**
|
||||
- ✅ 需要完全控制權
|
||||
- ✅ 有特殊環境需求
|
||||
- ✅ 已有 VM 資源
|
||||
|
||||
**不要選擇 GKE,除非:**
|
||||
- ✅ 需要複雜的微服務架構
|
||||
- ✅ 有專業 DevOps 團隊
|
||||
- ✅ 預算充足
|
||||
|
||||
---
|
||||
|
||||
**推薦:使用 Cloud Run 開始,需要時再遷移到其他方案。**
|
||||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
@@ -0,0 +1,67 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 設定工作目錄
|
||||
WORKDIR /app
|
||||
|
||||
# 安裝系統依賴 (包含 PostgreSQL 客戶端庫 + Chrome/Selenium 依賴)
|
||||
# 注意:Debian Trixie 已移除 libgconf-2-4,改用 libglib2.0-0
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
curl \
|
||||
libpq-dev \
|
||||
# Chrome/Selenium 依賴
|
||||
wget \
|
||||
gnupg \
|
||||
unzip \
|
||||
libnss3 \
|
||||
libglib2.0-0 \
|
||||
libfontconfig1 \
|
||||
libx11-xcb1 \
|
||||
libasound2t64 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
fonts-liberation \
|
||||
libappindicator3-1 || true \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安裝 Chrome (使用新版 GPG 金鑰管理方式,apt-key 已被移除)
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& wget -q -O /etc/apt/keyrings/google-chrome.asc https://dl.google.com/linux/linux_signing_key.pub \
|
||||
&& echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.asc] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y google-chrome-stable \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 複製 requirements
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安裝 Python 依賴
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 複製應用程式
|
||||
COPY . .
|
||||
|
||||
# 建立必要的目錄
|
||||
RUN mkdir -p data logs backups
|
||||
|
||||
# 確保 components symlink 正確(根目錄頁面需要此路徑)
|
||||
RUN rm -rf /app/components && ln -sf /app/templates/components /app/components
|
||||
|
||||
# 設定環境變數
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_APP=app.py
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 啟動應用
|
||||
CMD ["python", "app.py"]
|
||||
316
GOOGLE_DRIVE_SETUP.md
Normal file
316
GOOGLE_DRIVE_SETUP.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Google Drive API 設定指南
|
||||
|
||||
本指南說明如何設定 Google Drive API,讓系統能夠自動從 Google Drive 下載、匯入並刪除當日業績 Excel 檔案。
|
||||
|
||||
## 功能說明
|
||||
|
||||
系統會:
|
||||
1. **每 30 分鐘**自動檢查 Google Drive 指定資料夾
|
||||
2. **自動下載** Excel 檔案到本地
|
||||
3. **自動匯入**資料到資料庫
|
||||
4. **自動刪除** Google Drive 上的原始檔案
|
||||
5. **追蹤進度**並提供網頁介面查看匯入狀態
|
||||
|
||||
---
|
||||
|
||||
## 步驟 1:建立 Google Cloud 專案
|
||||
|
||||
### 1.1 前往 Google Cloud Console
|
||||
|
||||
開啟 [Google Cloud Console](https://console.cloud.google.com/)
|
||||
|
||||
### 1.2 建立新專案
|
||||
|
||||
1. 點擊上方的專案選擇器
|
||||
2. 點擊「新增專案」
|
||||
3. 輸入專案名稱(例如:`momo-auto-import`)
|
||||
4. 點擊「建立」
|
||||
|
||||
---
|
||||
|
||||
## 步驟 2:啟用 Google Drive API
|
||||
|
||||
### 2.1 啟用 API
|
||||
|
||||
1. 在 Google Cloud Console 中,前往「API 和服務」→「程式庫」
|
||||
2. 搜尋「Google Drive API」
|
||||
3. 點擊「Google Drive API」
|
||||
4. 點擊「啟用」
|
||||
|
||||
---
|
||||
|
||||
## 步驟 3:建立 OAuth 2.0 憑證
|
||||
|
||||
### 3.1 建立 OAuth 同意畫面
|
||||
|
||||
1. 前往「API 和服務」→「OAuth 同意畫面」
|
||||
2. 選擇「外部」(如果只有自己使用,選擇「內部」需要 Google Workspace)
|
||||
3. 點擊「建立」
|
||||
4. 填寫必要資訊:
|
||||
- **應用程式名稱**:`Momo Pro System`
|
||||
- **使用者支援電子郵件**:您的 Gmail 地址
|
||||
- **開發人員聯絡資訊**:您的 Gmail 地址
|
||||
5. 點擊「儲存並繼續」
|
||||
6. **範圍(Scopes)**:點擊「新增或移除範圍」
|
||||
- 搜尋並勾選:`https://www.googleapis.com/auth/drive`(完整 Drive 存取權限)
|
||||
- 點擊「更新」
|
||||
7. 點擊「儲存並繼續」
|
||||
8. **測試使用者**:新增您的 Gmail 地址
|
||||
9. 點擊「儲存並繼續」
|
||||
|
||||
### 3.2 建立 OAuth 2.0 用戶端 ID
|
||||
|
||||
1. 前往「API 和服務」→「憑證」
|
||||
2. 點擊「建立憑證」→「OAuth 用戶端 ID」
|
||||
3. 應用程式類型選擇:**桌面應用程式**
|
||||
4. 名稱輸入:`Momo Desktop Client`
|
||||
5. 點擊「建立」
|
||||
6. 會顯示「用戶端 ID」和「用戶端密鑰」
|
||||
7. 點擊「下載 JSON」
|
||||
|
||||
### 3.3 放置憑證檔案
|
||||
|
||||
1. 將下載的 JSON 檔案重新命名為:`google_credentials.json`
|
||||
2. 放置到專案的 `config/` 目錄中:
|
||||
```
|
||||
/Users/ogt/momo_pro_system/config/google_credentials.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步驟 4:首次認證
|
||||
|
||||
### 4.1 執行認證流程
|
||||
|
||||
首次使用時需要進行認證:
|
||||
|
||||
```bash
|
||||
cd /Users/ogt/momo_pro_system
|
||||
|
||||
# 執行認證腳本
|
||||
python3 -c "
|
||||
from services.google_drive_service import drive_service
|
||||
drive_service.authenticate()
|
||||
"
|
||||
```
|
||||
|
||||
### 4.2 完成授權
|
||||
|
||||
1. 瀏覽器會自動開啟 Google 授權頁面
|
||||
2. 選擇您的 Google 帳號
|
||||
3. 系統會顯示:「Google 尚未驗證此應用程式」
|
||||
- 點擊「繼續」(因為這是您自己的應用程式)
|
||||
4. 點擊「允許」授予 Google Drive 存取權限
|
||||
5. 看到「授權流程已完成」即表示成功
|
||||
|
||||
### 4.3 Token 檔案
|
||||
|
||||
授權完成後,系統會自動建立 `config/google_token.pickle`,此檔案包含您的存取權杖,下次使用時不需重新授權。
|
||||
|
||||
**安全提示**:此檔案包含敏感資訊,請勿分享或上傳到公開儲存庫。
|
||||
|
||||
---
|
||||
|
||||
## 步驟 5:設定 Google Drive 資料夾
|
||||
|
||||
### 5.1 在 Google Drive 建立資料夾結構
|
||||
|
||||
建議的資料夾結構:
|
||||
|
||||
```
|
||||
我的雲端硬碟/
|
||||
└── 業績報表/
|
||||
└── 當日業績/
|
||||
└── 即時業績_當日_20260113.xlsx
|
||||
```
|
||||
|
||||
### 5.2 在系統中配置路徑
|
||||
|
||||
1. 開啟瀏覽器,前往:http://localhost:5888/auto_import
|
||||
2. 在「匯入配置」區域設定:
|
||||
- **Google Drive 資料夾路徑**:`業績報表/當日業績`
|
||||
- **檔案名稱模式**:`即時業績_當日`(選填,用於過濾檔案)
|
||||
3. 點擊「儲存配置」
|
||||
4. 點擊「測試連接」確認連接正常
|
||||
5. 點擊「列出檔案」查看該資料夾中的檔案
|
||||
|
||||
---
|
||||
|
||||
## 步驟 6:測試自動匯入
|
||||
|
||||
### 6.1 上傳測試檔案
|
||||
|
||||
1. 將當日業績 Excel 檔案上傳到 Google Drive 的指定資料夾
|
||||
2. 檔案名稱範例:`即時業績_當日_20260113.xlsx`
|
||||
|
||||
### 6.2 手動測試
|
||||
|
||||
1. 在自動匯入頁面點擊「立即匯入」
|
||||
2. 觀察「匯入任務歷史」區域,應該會看到新的任務記錄
|
||||
3. 任務狀態會從「等待中」→「下載中」→「匯入中」→「已完成」
|
||||
4. 完成後,Google Drive 上的檔案會被自動刪除
|
||||
|
||||
### 6.3 驗證匯入結果
|
||||
|
||||
```bash
|
||||
# 查詢匯入的資料
|
||||
python3 -c "
|
||||
from services.import_service import import_service
|
||||
jobs = import_service.get_recent_jobs(limit=1)
|
||||
for job in jobs:
|
||||
print(f'任務 ID: {job[\"id\"]}')
|
||||
print(f'狀態: {job[\"status\"]}')
|
||||
print(f'檔案: {job[\"drive_file_name\"]}')
|
||||
print(f'成功: {job[\"success_rows\"]} 筆')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 步驟 7:自動排程
|
||||
|
||||
系統已自動設定每 30 分鐘檢查一次 Google Drive:
|
||||
|
||||
- 排程任務會在背景自動執行
|
||||
- 無需手動觸發
|
||||
- 可在「匯入任務歷史」查看執行記錄
|
||||
- 每次檢查到新檔案就會自動匯入
|
||||
|
||||
---
|
||||
|
||||
## 常見問題
|
||||
|
||||
### Q1: 認證失敗,顯示「找不到認證檔案」
|
||||
|
||||
**A:** 確認 `config/google_credentials.json` 檔案存在且路徑正確。
|
||||
|
||||
### Q2: 授權時顯示「應用程式未經驗證」
|
||||
|
||||
**A:** 這是正常的,因為這是您自己的應用程式。點擊「繼續」即可。
|
||||
|
||||
### Q3: 找不到 Google Drive 資料夾
|
||||
|
||||
**A:** 確認:
|
||||
1. 資料夾路徑正確(區分大小寫)
|
||||
2. 資料夾確實存在於「我的雲端硬碟」中
|
||||
3. 使用的 Google 帳號有該資料夾的存取權限
|
||||
|
||||
### Q4: 檔案沒有被自動刪除
|
||||
|
||||
**A:** 確認:
|
||||
1. 匯入任務狀態為「已完成」(不是「失敗」)
|
||||
2. 檢查日誌檔案:`logs/system.log`
|
||||
|
||||
### Q5: 如何重新授權?
|
||||
|
||||
**A:** 刪除 `config/google_token.pickle` 檔案,然後重新執行步驟 4。
|
||||
|
||||
### Q6: 可以使用 Service Account 嗎?
|
||||
|
||||
**A:** 可以,但需要修改程式碼。Service Account 適合在伺服器上無人值守運行,但需要手動分享 Drive 資料夾給 Service Account 的電子郵件地址。
|
||||
|
||||
---
|
||||
|
||||
## 安全建議
|
||||
|
||||
1. **不要分享憑證檔案**
|
||||
- `google_credentials.json`
|
||||
- `google_token.pickle`
|
||||
- 這些檔案已加入 `.gitignore`
|
||||
|
||||
2. **定期檢查授權**
|
||||
- 前往 [Google 帳戶安全性](https://myaccount.google.com/permissions)
|
||||
- 檢查「Momo Pro System」的授權狀態
|
||||
|
||||
3. **限制 API 存取範圍**
|
||||
- 目前使用完整 Drive 存取權限
|
||||
- 如需更嚴格控制,可修改為 `drive.file` scope(僅存取應用程式建立的檔案)
|
||||
|
||||
4. **備份重要資料**
|
||||
- 在刪除 Google Drive 檔案前,系統會先匯入到本地資料庫
|
||||
- 建議定期備份資料庫
|
||||
|
||||
---
|
||||
|
||||
## 進階設定
|
||||
|
||||
### 修改檢查頻率
|
||||
|
||||
編輯 `app.py`,找到:
|
||||
|
||||
```python
|
||||
schedule.every(30).minutes.do(run_auto_import_task)
|
||||
```
|
||||
|
||||
修改為您想要的頻率:
|
||||
- 每 15 分鐘:`schedule.every(15).minutes.do(...)`
|
||||
- 每小時:`schedule.every(1).hours.do(...)`
|
||||
- 每天早上 8 點:`schedule.every().day.at("08:00").do(...)`
|
||||
|
||||
### 保留 Google Drive 檔案
|
||||
|
||||
如果不想自動刪除 Google Drive 檔案,修改 `services/import_service.py` 中的 `auto_import_from_drive` 方法,註解掉刪除部分:
|
||||
|
||||
```python
|
||||
# if drive_service.delete_file(file_id):
|
||||
# logger.info(f"已刪除 Google Drive 檔案: {file_name}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 監控和日誌
|
||||
|
||||
### 查看即時日誌
|
||||
|
||||
```bash
|
||||
tail -f logs/system.log | grep AutoImport
|
||||
```
|
||||
|
||||
### 查看匯入統計
|
||||
|
||||
前往網頁介面:http://localhost:5888/auto_import
|
||||
|
||||
### 查詢排程統計
|
||||
|
||||
```bash
|
||||
cat data/scheduler_stats.json | grep auto_import_task
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 檢查服務狀態
|
||||
|
||||
```python
|
||||
python3 -c "
|
||||
from services.google_drive_service import drive_service
|
||||
from services.import_service import import_service
|
||||
|
||||
# 測試 Google Drive 連接
|
||||
if drive_service.authenticate():
|
||||
print('✅ Google Drive 連接正常')
|
||||
else:
|
||||
print('❌ Google Drive 連接失敗')
|
||||
|
||||
# 查詢最近的任務
|
||||
jobs = import_service.get_recent_jobs(limit=5)
|
||||
print(f'\\n📋 最近 {len(jobs)} 個任務:')
|
||||
for job in jobs:
|
||||
print(f' - 任務 {job[\"id\"]}: {job[\"status\"]} ({job[\"progress_percent\"]}%)')
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 參考資料
|
||||
|
||||
- [Google Drive API 文檔](https://developers.google.com/drive/api/guides/about-sdk)
|
||||
- [Python Quickstart](https://developers.google.com/drive/api/quickstart/python)
|
||||
- [OAuth 2.0 說明](https://developers.google.com/identity/protocols/oauth2)
|
||||
|
||||
---
|
||||
|
||||
**完成!** 🎉
|
||||
|
||||
您的 Google Drive 自動匯入功能已設定完成,系統會每 30 分鐘自動檢查並匯入新的業績檔案。
|
||||
599
PROJECT_CONSTITUTION.md
Normal file
599
PROJECT_CONSTITUTION.md
Normal file
@@ -0,0 +1,599 @@
|
||||
# MOMO 監控系統 - 專案憲法
|
||||
|
||||
**版本:** 1.3
|
||||
**制定日期:** 2026-01-12
|
||||
**最後更新:** 2026-01-14
|
||||
|
||||
---
|
||||
|
||||
## 📜 專案基本原則
|
||||
|
||||
本憲法定義了 MOMO 監控系統的開發規範、溝通原則、安全政策及技術標準。所有參與者(開發人員、AI 助手、維護人員)必須遵守以下規範。
|
||||
|
||||
---
|
||||
|
||||
## 🗣️ 第一章:溝通規範
|
||||
|
||||
### 第 1 條:語言使用
|
||||
- **所有溝通一律使用繁體中文**
|
||||
- 包含但不限於:
|
||||
- 程式碼註解
|
||||
- 文檔說明
|
||||
- Commit 訊息
|
||||
- 錯誤訊息
|
||||
- 日誌輸出
|
||||
- 使用者介面文字
|
||||
- README 和文檔
|
||||
|
||||
### 第 2 條:文檔規範
|
||||
- 所有文檔檔案使用 Markdown 格式(`.md`)
|
||||
- 檔案名稱使用英文大寫加底線(例:`PROJECT_CONSTITUTION.md`)
|
||||
- 文檔內容必須包含版本號和最後更新日期
|
||||
- 重要變更需記錄在 CHANGELOG 中
|
||||
|
||||
### 第 3 條:註解規範
|
||||
- Python 函數必須包含繁體中文 docstring
|
||||
- 複雜邏輯必須添加行內註解說明
|
||||
- 註解應說明「為什麼」而非「做什麼」
|
||||
|
||||
---
|
||||
|
||||
## 🔒 第二章:安全政策
|
||||
|
||||
### 第 4 條:敏感資訊管理
|
||||
- **禁止在程式碼中硬編碼任何敏感資訊**
|
||||
- 所有憑證、API 金鑰、密碼必須使用環境變數(`.env`)
|
||||
- `.env` 檔案必須列入 `.gitignore`
|
||||
- 提供 `.env.example` 作為範本
|
||||
|
||||
### 第 5 條:密碼安全
|
||||
- 所有密碼必須使用 `pbkdf2:sha256` 雜湊儲存
|
||||
- 禁止使用明文密碼(僅過渡期允許,需發出警告)
|
||||
- 密碼長度至少 8 個字元,包含英文字母和數字
|
||||
- 登入失敗 5 次後鎖定帳號 5 分鐘
|
||||
|
||||
### 第 6 條:輸入驗證
|
||||
- **所有使用者輸入必須經過驗證**
|
||||
- SQL 查詢必須使用參數化查詢或白名單驗證
|
||||
- 檔案上傳必須驗證副檔名和檔案大小
|
||||
- 路徑操作必須使用 `safe_join()` 防止路徑遍歷
|
||||
|
||||
### 第 7 條:CSRF 防護
|
||||
- 所有 POST/PUT/DELETE/PATCH 請求必須包含 CSRF token
|
||||
- HTML 表單使用 hidden input: `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>`
|
||||
- AJAX 請求使用 header: `'X-CSRFToken': getCSRFToken()`
|
||||
|
||||
### 第 8 條:Session 安全
|
||||
- Session cookie 必須設定 `HttpOnly=True`
|
||||
- Session cookie 必須設定 `SameSite=Lax`
|
||||
- 生產環境必須設定 `Secure=True`(HTTPS)
|
||||
- Session 有效期設定為 2 小時
|
||||
|
||||
---
|
||||
|
||||
## 💻 第三章:程式碼規範
|
||||
|
||||
### 第 9 條:檔案上傳
|
||||
- 僅允許上傳:`.xlsx`, `.xls`, `.csv`
|
||||
- 檔案大小限制:10 MB
|
||||
- 使用 `secure_filename_unicode()` 清理檔名(支援中文)
|
||||
- 檢查路徑遍歷攻擊(`..`, `/`, `\`)
|
||||
|
||||
### 第 10 條:SQL 安全
|
||||
- 表名驗證:僅允許英文字母、數字、底線
|
||||
- 欄位名驗證:允許中文、英文字母、數字、底線
|
||||
- 時間戳驗證:嚴格遵守 `YYYY-MM-DD HH:MM:SS` 格式
|
||||
- 使用 `safe_read_sql()` 進行安全的 SQL 查詢
|
||||
|
||||
### 第 11 條:路徑安全
|
||||
- 所有路徑拼接使用 `safe_join(base, *paths)`
|
||||
- 檢查 Windows 反斜線、連續點、雙點模式
|
||||
- 驗證最終路徑在基礎目錄內
|
||||
- 偵測到攻擊時記錄安全日誌
|
||||
|
||||
### 第 12 條:日誌規範
|
||||
- 使用結構化日誌格式:`[模組] [級別] 訊息 | 詳細資訊`
|
||||
- 安全事件使用 `[Security]` 標籤
|
||||
- 記錄所有失敗的驗證嘗試
|
||||
- 日誌級別:
|
||||
- `ERROR`: 系統錯誤
|
||||
- `WARNING`: 安全警告、失敗的攻擊嘗試
|
||||
- `INFO`: 重要操作成功
|
||||
- `DEBUG`: 詳細除錯資訊
|
||||
|
||||
---
|
||||
|
||||
## 🕷️ 第四章:數據爬取規範
|
||||
|
||||
### 第 13 條:爬蟲程式碼穩定性原則
|
||||
- **爬蟲程式碼屬於核心業務邏輯,修改時必須格外謹慎**
|
||||
- 任何修改必須經過完整測試,確認不影響現有爬取功能
|
||||
- 修改前必須備份現有可運作的版本
|
||||
- 修改後必須驗證所有爬蟲任務正常執行
|
||||
|
||||
### 第 14 條:爬蟲選擇器維護
|
||||
- **CSS 選擇器和 XPath 是脆弱的依賴**
|
||||
- 修改選擇器前必須:
|
||||
1. 記錄修改原因(網站改版、元素變更等)
|
||||
2. 測試新選擇器是否正確抓取目標資料
|
||||
3. 保留舊選擇器作為註解備份
|
||||
4. 記錄網站結構變更日期
|
||||
- 建議使用多層次選擇器備援(主選擇器 + 備用選擇器)
|
||||
|
||||
### 第 15 條:爬蟲錯誤處理
|
||||
- **所有爬蟲函數必須包含完整的錯誤處理**
|
||||
- 必須處理的情況:
|
||||
1. 網路連線失敗
|
||||
2. 頁面載入超時
|
||||
3. 元素找不到(選擇器失效)
|
||||
4. 資料格式異常
|
||||
5. 反爬蟲機制觸發
|
||||
- 錯誤發生時:
|
||||
- 記錄詳細錯誤日誌(包含 URL、選擇器、錯誤訊息)
|
||||
- 發送通知給管理員
|
||||
- 不中斷其他爬蟲任務
|
||||
- 保存最後成功的資料作為備援
|
||||
|
||||
### 第 16 條:爬蟲測試要求
|
||||
- **修改爬蟲程式碼後必須執行完整測試**
|
||||
- 測試項目:
|
||||
1. 單一商品資料爬取
|
||||
2. 列表頁面分頁爬取
|
||||
3. 多執行緒/並發爬取
|
||||
4. 錯誤處理機制
|
||||
5. 資料儲存完整性
|
||||
- 測試環境應模擬生產環境(網路延遲、並發請求)
|
||||
- 使用測試資料集驗證爬取結果準確性
|
||||
|
||||
### 第 17 條:爬蟲依賴管理
|
||||
- **爬蟲依賴的套件版本必須固定**
|
||||
- `requirements.txt` 中爬蟲相關套件必須指定版本號:
|
||||
- `selenium==4.x.x` (具體版本)
|
||||
- `requests==2.x.x`
|
||||
- `beautifulsoup4==4.x.x`
|
||||
- 升級套件前必須:
|
||||
1. 在測試環境驗證相容性
|
||||
2. 檢查 changelog 確認無破壞性變更
|
||||
3. 執行完整爬蟲測試套件
|
||||
4. 記錄升級原因和影響
|
||||
|
||||
### 第 18 條:網站結構變更應對
|
||||
- **定期檢查目標網站結構是否變更**
|
||||
- 建立網站結構監控機制:
|
||||
1. 記錄關鍵元素的 HTML 結構
|
||||
2. 定期比對結構變化
|
||||
3. 發現變更時立即通知
|
||||
4. 建立選擇器失效告警
|
||||
- 保存網站結構快照(HTML samples)供除錯使用
|
||||
|
||||
### 第 19 條:爬蟲效能與禮節
|
||||
- **遵守網站的 robots.txt 規範**
|
||||
- 設定合理的請求間隔(建議 1-3 秒)
|
||||
- 使用 User-Agent 識別身份
|
||||
- 避免在網站高峰時段進行大量爬取
|
||||
- 實作請求失敗的退避重試機制(Exponential Backoff)
|
||||
|
||||
### 第 20 條:資料驗證與清洗
|
||||
- **爬取的資料必須經過驗證**
|
||||
- 驗證項目:
|
||||
1. 價格範圍合理性(不可為 0 或異常大)
|
||||
2. 日期格式正確性
|
||||
3. 必填欄位完整性
|
||||
4. 資料型別正確性
|
||||
- 發現異常資料時:
|
||||
- 記錄到錯誤日誌
|
||||
- 標記為「需人工審核」
|
||||
- 不自動儲存到資料庫
|
||||
- 發送通知給管理員
|
||||
|
||||
### 第 21 條:爬蟲版本控制
|
||||
- **爬蟲程式碼每次修改必須建立 Git commit**
|
||||
- Commit 訊息格式:
|
||||
- `[Crawler] [網站名稱] 修改描述`
|
||||
- 例:`[Crawler] [MOMO] 修復商品價格選擇器失效問題`
|
||||
- 重大修改應建立分支,測試通過後才合併
|
||||
- 保留至少最近 3 個可運作版本的備份
|
||||
|
||||
### 第 22 條:爬蟲文檔要求
|
||||
- **每個爬蟲模組必須包含詳細文檔**
|
||||
- 必須記錄:
|
||||
1. 爬取目標(網站 URL、資料類型)
|
||||
2. 執行頻率(每小時/每日)
|
||||
3. 關鍵選擇器說明
|
||||
4. 已知問題和限制
|
||||
5. 最後修改日期和原因
|
||||
6. 聯絡人/負責人
|
||||
- 文檔應隨程式碼更新
|
||||
|
||||
---
|
||||
|
||||
## 🧪 第五章:測試與品質保證
|
||||
|
||||
### 第 23 條:測試覆蓋
|
||||
- 所有安全功能必須有對應的測試
|
||||
- 測試必須包含正常情況和攻擊情境
|
||||
- 使用 `test_*.py` 命名測試檔案
|
||||
- 執行 `./run_security_tests.sh` 必須全部通過
|
||||
|
||||
### 第 24 條:安全測試項目
|
||||
必須測試以下項目:
|
||||
1. 環境變數與憑證管理
|
||||
2. SQL 注入防護
|
||||
3. 路徑遍歷防護
|
||||
4. 檔案上傳驗證
|
||||
5. CSRF 防護
|
||||
6. 登入驗證強化
|
||||
7. Flask 安全配置
|
||||
|
||||
### 第 25 條:爬蟲測試項目
|
||||
必須測試以下項目:
|
||||
1. 選擇器有效性測試
|
||||
2. 資料完整性測試
|
||||
3. 錯誤處理測試
|
||||
4. 並發爬取測試
|
||||
5. 效能壓力測試
|
||||
|
||||
### 第 26 條:程式碼審查
|
||||
- 所有涉及安全的程式碼變更必須經過審查
|
||||
- 所有涉及爬蟲的程式碼變更必須經過審查
|
||||
- 檢查是否符合本憲法規範
|
||||
- 驗證是否通過完整測試
|
||||
- 確認日誌和錯誤處理完整
|
||||
|
||||
---
|
||||
|
||||
## 📦 第六章:部署與維運
|
||||
|
||||
### 第 27 條:環境管理規範
|
||||
|
||||
#### 27.1 環境分層
|
||||
本系統採用三層環境架構:
|
||||
|
||||
1. **開發環境 (Development)**
|
||||
- 位置:`/Users/ogt/momo_pro_system` (macOS Local)
|
||||
- 用途:程式碼開發、快速測試、UI/UX 調整
|
||||
- 運行方式:直接執行 `python app.py`
|
||||
- 特性:即時修改、快速迭代
|
||||
|
||||
2. **測試環境 (Testing)**
|
||||
- 位置:同開發環境
|
||||
- 用途:功能測試、安全測試、回歸測試
|
||||
- 運行方式:執行測試腳本
|
||||
|
||||
3. **正式環境 (Production)**
|
||||
- 位置:`/home/ogt/momo_pro_system` (GCP VM)
|
||||
- 用途:生產服務、24/7 運行
|
||||
- 運行方式:systemd service
|
||||
- 網址:`https://momo.wooo.work`
|
||||
|
||||
#### 27.2 環境同步原則
|
||||
|
||||
**嚴格禁止**:
|
||||
- ❌ 直接在正式環境修改程式碼
|
||||
- ❌ 跳過測試直接部署到正式環境
|
||||
- ❌ 混用不同環境的資料庫
|
||||
- ❌ 將 `.env` 檔案上傳到 Git
|
||||
|
||||
**必須遵守**:
|
||||
- ✅ 所有修改必須在開發環境完成
|
||||
- ✅ 完整測試通過後才能部署
|
||||
- ✅ 部署前必須備份正式環境
|
||||
- ✅ 部署後必須驗證功能正常
|
||||
- ✅ 監控 24 小時確保穩定
|
||||
|
||||
#### 27.3 標準部署流程
|
||||
|
||||
參照 `DEPLOYMENT_WORKFLOW.md` 文檔,嚴格遵守以下流程:
|
||||
|
||||
```
|
||||
開發 → 測試 → 備份 → 部署 → 驗證 → 監控
|
||||
```
|
||||
|
||||
**階段性檢查**:
|
||||
1. **開發階段**:程式碼符合規範、Git commit 完成
|
||||
2. **測試階段**:功能測試、安全測試、爬蟲測試通過
|
||||
3. **部署前**:備份正式環境、確認修改檔案清單
|
||||
4. **部署中**:使用標準部署腳本或手動部署
|
||||
5. **部署後**:服務狀態正常、功能驗證通過
|
||||
6. **監控期**:持續監控 24 小時
|
||||
|
||||
#### 27.4 部署方法選擇
|
||||
|
||||
**方法 A:完整部署**(推薦用於大改動)
|
||||
```bash
|
||||
cd /Users/ogt/momo_pro_system
|
||||
```
|
||||
適用於:
|
||||
- Python 程式碼修改
|
||||
- 依賴套件更新
|
||||
- 配置檔案變更
|
||||
- 資料庫結構變更
|
||||
|
||||
**方法 B:快速更新**(用於小改動)
|
||||
```bash
|
||||
gcloud compute scp --zone=asia-east1-a 修改的檔案 momo-server:~/momo_pro_system/
|
||||
```
|
||||
適用於:
|
||||
- HTML/CSS/JS 檔案修改
|
||||
- 模板檔案更新
|
||||
- 靜態資源更新
|
||||
|
||||
**重要**:
|
||||
- HTML/CSS/JS 修改:不需重啟服務(Flask 自動重載模板)
|
||||
- Python 檔案修改:必須重啟服務
|
||||
- 配置檔案修改:必須重啟服務
|
||||
|
||||
### 第 28 條:變更前強制備份原則 ⚠️
|
||||
|
||||
**重要性:最高優先級**
|
||||
|
||||
**核心原則**:
|
||||
- **所有涉及嚴重影響的變更、修改操作,變更前必須先進行完整備份**
|
||||
- **備份完成並確認無誤後,才可進行變更**
|
||||
- 違反此原則的變更操作視為嚴重違規
|
||||
|
||||
**適用範圍**:
|
||||
以下操作在執行前必須完整備份:
|
||||
|
||||
1. **資料庫相關**
|
||||
- 資料庫結構變更(ALTER TABLE, DROP, CREATE)
|
||||
- 大量資料修改或刪除(UPDATE, DELETE 影響 >100 筆)
|
||||
- 資料庫升級或遷移
|
||||
- 索引重建或優化
|
||||
|
||||
2. **系統配置**
|
||||
- 系統配置檔案修改(config.py, .env)
|
||||
- Nginx/Apache 配置變更
|
||||
- Systemd service 配置修改
|
||||
- 排程任務(crontab, scheduler)變更
|
||||
|
||||
3. **核心程式碼**
|
||||
- 爬蟲核心邏輯修改
|
||||
- 資料庫連線和 ORM 修改
|
||||
- 認證和安全模組修改
|
||||
- API 端點的破壞性變更
|
||||
|
||||
4. **部署操作**
|
||||
- 生產環境程式碼更新
|
||||
- Python 依賴套件升級
|
||||
- 系統套件升級(Python, Node.js 等)
|
||||
- 伺服器遷移或重啟
|
||||
|
||||
5. **資料處理**
|
||||
- Excel 匯入覆蓋現有資料
|
||||
- 批次資料清理或轉換
|
||||
- 歷史資料歸檔或刪除
|
||||
|
||||
**備份要求**:
|
||||
|
||||
**必須備份的內容**:
|
||||
- 完整資料庫檔案(momo.db 或 PostgreSQL dump)
|
||||
- 所有程式碼檔案(Git commit + 檔案副本)
|
||||
- 配置檔案(config.py, .env, nginx.conf 等)
|
||||
- 重要的資料檔案(Excel, CSV 等)
|
||||
|
||||
**備份驗證**:
|
||||
- 檢查備份檔案完整性(檔案大小、MD5 校驗)
|
||||
- 確認備份可讀取(嘗試開啟資料庫)
|
||||
- 記錄備份時間和檔案位置
|
||||
- 確保備份檔案有足夠的磁碟空間
|
||||
|
||||
**備份命名規範**:
|
||||
```
|
||||
資料庫:momo_backup_YYYYMMDD_HHMMSS.db
|
||||
程式碼:momo_code_backup_YYYYMMDD_HHMMSS.tar.gz
|
||||
配置:config_backup_YYYYMMDD_HHMMSS.tar.gz
|
||||
```
|
||||
|
||||
**復原計畫**:
|
||||
- 每次重大變更必須準備復原步驟文件
|
||||
- 測試復原流程的可行性
|
||||
- 記錄復原所需時間
|
||||
- 確保有回滾機制
|
||||
|
||||
**違規處理**:
|
||||
- 未備份就執行重大變更:視為一級違規
|
||||
- 備份不完整或無法復原:視為二級違規
|
||||
- 必須立即停止變更,進行損害評估
|
||||
- 記錄事件並更新操作規範
|
||||
|
||||
**例外情況**:
|
||||
僅以下情況可豁免備份要求:
|
||||
- 純前端 HTML/CSS/JS 修改(不影響資料)
|
||||
- 日誌檔案查看(唯讀操作)
|
||||
- 系統監控和狀態查詢
|
||||
- 測試環境的實驗性變更
|
||||
|
||||
### 第 29 條:環境配置
|
||||
- 開發環境使用 `.env`
|
||||
- 生產環境使用環境變數注入
|
||||
- 不同環境使用不同的 `SECRET_KEY`
|
||||
- 定期輪換敏感憑證
|
||||
|
||||
### 第 30 條:備份策略
|
||||
- 資料庫每日自動備份
|
||||
- 備份檔案加密保存
|
||||
- 保留最近 7 天備份
|
||||
- 使用 `safe_join()` 處理備份路徑
|
||||
|
||||
### 第 31 條:更新流程
|
||||
1. **評估變更影響**(是否需要備份,參照第 28 條)
|
||||
2. **完整備份**(若屬於重大變更,必須先備份)
|
||||
3. 更新程式碼
|
||||
4. 執行完整測試套件(安全測試 + 爬蟲測試)
|
||||
5. 檢查安全日誌
|
||||
6. 重啟服務
|
||||
7. 驗證功能正常(包含爬蟲任務)
|
||||
8. 監控 24 小時確保穩定
|
||||
9. 確認備份可刪除或歸檔
|
||||
|
||||
---
|
||||
|
||||
## 🚨 第七章:事件處理
|
||||
|
||||
### 第 32 條:安全事件
|
||||
發現安全漏洞時:
|
||||
1. 立即記錄詳細資訊
|
||||
2. 評估風險等級(Critical/High/Medium/Low)
|
||||
3. 優先處理 Critical 和 High 級別
|
||||
4. 修復後執行完整測試
|
||||
5. 更新 `SECURITY_FIX_SUMMARY.md`
|
||||
|
||||
### 第 33 條:爬蟲異常事件
|
||||
發現爬蟲異常時:
|
||||
1. 記錄詳細錯誤資訊(URL、選擇器、錯誤訊息)
|
||||
2. 檢查是否為網站結構變更
|
||||
3. 若為選擇器失效,立即修復並測試
|
||||
4. 發送通知給管理員
|
||||
5. 記錄在爬蟲維護日誌中
|
||||
|
||||
### 第 34 條:錯誤處理
|
||||
- 所有錯誤必須妥善處理,不得暴露敏感資訊
|
||||
- 使用者看到的錯誤訊息應簡潔明確
|
||||
- 詳細錯誤資訊記錄在日誌中
|
||||
- 開發環境可顯示詳細錯誤,生產環境僅顯示通用訊息
|
||||
|
||||
---
|
||||
|
||||
## 🔧 第八章:開發工具與依賴
|
||||
|
||||
### 第 35 條:Python 依賴
|
||||
核心依賴套件(見 `requirements.txt`):
|
||||
- Flask (Web 框架)
|
||||
- Flask-WTF (CSRF 防護)
|
||||
- SQLAlchemy (ORM)
|
||||
- pandas (資料處理)
|
||||
- selenium (網頁自動化)
|
||||
- werkzeug (安全工具)
|
||||
|
||||
### 第 36 條:版本控制
|
||||
- 使用 Git 進行版本控制
|
||||
- Commit 訊息使用繁體中文
|
||||
- Commit 格式:`[模組] 簡短描述`
|
||||
- 例:`[Security] 修復路徑遍歷漏洞`
|
||||
- 例:`[Crawler] [MOMO] 更新商品價格選擇器`
|
||||
|
||||
### 第 37 條:開發環境
|
||||
- Python 3.8+
|
||||
- 使用虛擬環境 (venv)
|
||||
- IDE 建議:VSCode, PyCharm
|
||||
- 測試環境與生產環境分離
|
||||
- 爬蟲測試使用獨立環境
|
||||
|
||||
---
|
||||
|
||||
## 📊 第九章:監控與維護
|
||||
|
||||
### 第 38 條:系統監控
|
||||
- 定期檢查安全日誌
|
||||
- 監控登入失敗次數
|
||||
- 追蹤異常 API 請求
|
||||
- 定期執行安全測試
|
||||
|
||||
### 第 39 條:爬蟲監控
|
||||
- 監控爬蟲執行成功率
|
||||
- 追蹤選擇器失效次數
|
||||
- 檢查資料品質(異常值、缺失值)
|
||||
- 監控爬取耗時變化
|
||||
- 定期檢查目標網站結構
|
||||
|
||||
### 第 40 條:效能監控
|
||||
- 資料庫查詢效能
|
||||
- API 回應時間
|
||||
- 記憶體使用量
|
||||
- 磁碟空間
|
||||
- 爬蟲執行效率
|
||||
|
||||
---
|
||||
|
||||
## 📋 第十章:憲法修訂
|
||||
|
||||
### 第 41 條:修訂流程
|
||||
- 本憲法可隨專案需求修訂
|
||||
- 修訂需說明原因和影響範圍
|
||||
- 更新版本號和修訂日期
|
||||
- 記錄在文檔歷史中
|
||||
|
||||
### 第 42 條:解釋權
|
||||
- 本憲法條款如有疑義,以最新版本為準
|
||||
- 技術決策以穩定性和安全性優先
|
||||
- 爬蟲修改以不影響現有功能為原則
|
||||
- 使用者體驗和效能次之
|
||||
|
||||
---
|
||||
|
||||
## 📚 附錄:快速檢查清單
|
||||
|
||||
### ✅ 新功能開發檢查
|
||||
- [ ] 程式碼和註解使用繁體中文
|
||||
- [ ] 無硬編碼敏感資訊
|
||||
- [ ] 所有輸入經過驗證
|
||||
- [ ] POST 請求包含 CSRF token
|
||||
- [ ] 路徑操作使用 `safe_join()`
|
||||
- [ ] 檔案上傳經過驗證
|
||||
- [ ] 錯誤處理完整
|
||||
- [ ] 日誌記錄完整
|
||||
- [ ] 通過安全測試
|
||||
- [ ] 更新相關文檔
|
||||
|
||||
### ⚠️ 重大變更前備份檢查
|
||||
- [ ] 評估變更影響範圍(資料庫/配置/核心程式碼/部署)
|
||||
- [ ] 確認符合第 28 條適用範圍
|
||||
- [ ] 完整備份資料庫檔案
|
||||
- [ ] 備份所有程式碼(Git commit + 檔案副本)
|
||||
- [ ] 備份配置檔案
|
||||
- [ ] 驗證備份檔案完整性(檔案大小、可讀取)
|
||||
- [ ] 記錄備份時間和位置
|
||||
- [ ] 準備復原計畫文件
|
||||
- [ ] 測試復原流程可行性
|
||||
- [ ] 確保有回滾機制
|
||||
|
||||
### 🔒 安全審查檢查
|
||||
- [ ] SQL 查詢使用參數化或白名單
|
||||
- [ ] 無明文密碼
|
||||
- [ ] Session 配置正確
|
||||
- [ ] CSRF 防護啟用
|
||||
- [ ] 路徑遍歷防護
|
||||
- [ ] 檔案上傳限制
|
||||
- [ ] 登入失敗鎖定
|
||||
- [ ] 敏感操作有日誌
|
||||
|
||||
### 🕷️ 爬蟲修改檢查
|
||||
- [ ] 備份現有可運作版本
|
||||
- [ ] 記錄修改原因和網站變更資訊
|
||||
- [ ] 保留舊選擇器作為註解
|
||||
- [ ] 測試新選擇器正確性
|
||||
- [ ] 執行單一商品爬取測試
|
||||
- [ ] 執行列表頁面爬取測試
|
||||
- [ ] 驗證資料完整性和格式
|
||||
- [ ] 檢查錯誤處理機制
|
||||
- [ ] 更新爬蟲文檔
|
||||
- [ ] 記錄在維護日誌中
|
||||
- [ ] 建立 Git commit
|
||||
- [ ] 監控 24 小時確保穩定
|
||||
|
||||
---
|
||||
|
||||
## 🎯 結語
|
||||
|
||||
本憲法旨在確保 MOMO 監控系統的安全性、穩定性、可維護性和一致性。所有參與者應:
|
||||
|
||||
1. **遵守規範**:嚴格遵守本憲法所有條款
|
||||
2. **持續改進**:隨著專案發展適時修訂
|
||||
3. **穩定優先**:爬蟲修改以不影響現有功能為原則
|
||||
4. **安全第一**:任何決策以安全為最高優先
|
||||
5. **謹慎測試**:修改後必須完整測試並監控
|
||||
6. **文檔完整**:保持文檔與程式碼同步更新
|
||||
|
||||
**核心原則:**
|
||||
- **安全不是功能,而是基礎**
|
||||
- **爬蟲是核心業務,修改需格外謹慎**
|
||||
- **測試是品質的保證,不可省略**
|
||||
|
||||
---
|
||||
|
||||
**版本歷史:**
|
||||
- v1.3 (2026-01-14): 新增第六章第 28 條「變更前強制備份原則」⚠️,明確規定所有涉及嚴重影響的變更操作前必須完整備份,定義適用範圍、備份要求、驗證流程、復原計畫及違規處理機制
|
||||
- v1.2 (2026-01-13): 擴充第六章第 27 條「環境管理規範」,明確定義開發/測試/正式三層環境架構、環境同步原則、標準部署流程,並新增 `DEPLOYMENT_WORKFLOW.md` 完整部署文檔
|
||||
- v1.1 (2026-01-12): 新增第四章「數據爬取規範」(第 13-22 條),定義爬蟲程式碼穩定性、選擇器維護、錯誤處理、測試要求等 10 項規範
|
||||
- v1.0 (2026-01-12): 初版發布,定義核心規範
|
||||
601
SECURITY_FIX_SUMMARY.md
Normal file
601
SECURITY_FIX_SUMMARY.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# MOMO 監控系統 - 安全修復摘要
|
||||
|
||||
**修復日期:** 2026-01-12
|
||||
**系統版本:** V9.4
|
||||
**修復人員:** Claude Code (Sonnet 4.5)
|
||||
|
||||
---
|
||||
|
||||
## 📋 修復概覽
|
||||
|
||||
本次安全稽核共發現 **17 個安全漏洞**,按風險等級分類:
|
||||
- 🔴 **Critical(重大)**:3 個 ✅ **已修復**
|
||||
- 🟠 **High(高風險)**:4 個 ✅ **已全部修復**
|
||||
- 🟡 **Medium(中風險)**:7 個 ⏳ **待處理**
|
||||
- 💡 **建議事項**:3 個 ⏳ **待處理**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成修復(Critical + High)
|
||||
|
||||
### 🔴 Critical #24: 移除硬編碼敏感資訊
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `config.py` - 改用環境變數
|
||||
- `.env` - 新建敏感資訊配置檔
|
||||
- `.env.example` - 新建配置模板
|
||||
- `.gitignore` - 防止敏感檔案被提交
|
||||
- `requirements.txt` - 添加 python-dotenv
|
||||
|
||||
**防護機制:**
|
||||
- 所有敏感資訊改用 `os.getenv()` 從環境變數讀取
|
||||
- `.env` 檔案已加入 `.gitignore`
|
||||
- 提供 `.env.example` 作為配置模板
|
||||
|
||||
**⚠️ 重要後續步驟(請立即執行):**
|
||||
```bash
|
||||
# 1. 立即更換所有已外洩的憑證
|
||||
# 當前已外洩的憑證包括:
|
||||
# - LOGIN_PASSWORD: 0936223270
|
||||
# - TELEGRAM_BOT_TOKEN: 8075645931:AAH-EGKMo8ZC4QJs-Nc1_0s92xHrGdQvdpg
|
||||
# - LINE_CHANNEL_ACCESS_TOKEN
|
||||
# - EMAIL_HOST_PASSWORD: jopokbhdpnnborjd
|
||||
# - NGROK_AUTH_TOKEN: 36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF
|
||||
|
||||
# 2. 更新 .env 文件中的新憑證
|
||||
|
||||
# 3. 確保 .env 已加入 .gitignore(已完成)
|
||||
|
||||
# 4. 如果專案已推送到 Git,建議清理 commit 歷史中的敏感資訊
|
||||
# 使用工具如 git-filter-repo 或 BFG Repo-Cleaner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Critical #25: 修復 SQL Injection 漏洞 #1
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `app.py` (第 108-204 行) - 新增 SQL 安全驗證函數
|
||||
- `app.py` (第 2652 行) - Excel 匯入功能
|
||||
- `app.py` (第 3582 行) - 業績分析頁面
|
||||
|
||||
**防護機制:**
|
||||
1. **表名白名單驗證** - `validate_table_name()`
|
||||
- 允許的表名清單:`ALLOWED_TABLES`
|
||||
- 正則表達式驗證(僅允許 `[a-zA-Z0-9_]`)
|
||||
- SQL 關鍵字過濾
|
||||
|
||||
2. **欄位名驗證** - `validate_column_names()`
|
||||
- 支援中文欄位名(`[\w\u4e00-\u9fff]`)
|
||||
- 防止特殊字符注入
|
||||
|
||||
3. **安全查詢封裝** - `safe_read_sql()`
|
||||
- 自動驗證表名與欄位名
|
||||
- 使用 SQLAlchemy `text()` 避免注入
|
||||
|
||||
**修復位置:**
|
||||
```python
|
||||
# 修復前(危險):
|
||||
df_existing = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||||
|
||||
# 修復後(安全):
|
||||
df_existing = safe_read_sql(table_name, engine=engine)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 Critical #26: 修復 SQL Injection 漏洞 #2
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `database/manager.py` (第 15-31 行) - 新增時間戳清理函數
|
||||
- `database/manager.py` (第 77-78 行) - ALTER TABLE 語句
|
||||
|
||||
**防護機制:**
|
||||
- **時間戳格式驗證** - `sanitize_timestamp()`
|
||||
- 僅允許 `YYYY-MM-DD HH:MM:SS` 格式
|
||||
- 正則表達式嚴格驗證
|
||||
|
||||
**修復位置:**
|
||||
```python
|
||||
# 修復前(危險):
|
||||
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
session.execute(text(f"ALTER TABLE products ADD COLUMN updated_at TIMESTAMP DEFAULT '{now_str}'"))
|
||||
|
||||
# 修復後(安全):
|
||||
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
safe_timestamp = sanitize_timestamp(now_str)
|
||||
session.execute(text(f"ALTER TABLE products ADD COLUMN updated_at TIMESTAMP DEFAULT '{safe_timestamp}'"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 High #27: 強化登入驗證機制
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `auth.py` - 完整重寫登入邏輯
|
||||
- `app.py` (第 303-335 行) - Flask 安全配置
|
||||
- `generate_password_hash.py` - 新建密碼雜湊生成工具
|
||||
- `.env.example` + `.env` - 新增 USE_HTTPS 配置
|
||||
|
||||
**新增功能:**
|
||||
|
||||
1. **密碼雜湊支持**
|
||||
- 使用 `werkzeug.security.check_password_hash()`
|
||||
- 支援 `pbkdf2:sha256` 雜湊演算法
|
||||
- 向後兼容明文密碼(會發出警告)
|
||||
|
||||
2. **登入失敗追蹤(IP-based)**
|
||||
- 記錄每個 IP 的失敗次數
|
||||
- 30 分鐘內無活動自動重置計數
|
||||
- 支援代理伺服器 IP 偵測
|
||||
|
||||
3. **帳號鎖定機制**
|
||||
- 5 次失敗後鎖定 5 分鐘
|
||||
- 鎖定期間顯示剩餘時間
|
||||
- 登入成功自動清除失敗記錄
|
||||
|
||||
4. **Session 安全配置**
|
||||
- `SESSION_COOKIE_HTTPONLY = True` - 防 XSS
|
||||
- `SESSION_COOKIE_SAMESITE = 'Lax'` - 防 CSRF
|
||||
- `PERMANENT_SESSION_LIFETIME = 2小時` - 自動過期
|
||||
- `SESSION_COOKIE_SECURE` - HTTPS 環境啟用
|
||||
- `MAX_CONTENT_LENGTH = 10MB` - 檔案上傳限制
|
||||
|
||||
5. **密碼強度驗證函數**
|
||||
- 至少 8 個字元
|
||||
- 包含英文字母
|
||||
- 包含數字
|
||||
|
||||
**使用方法:**
|
||||
|
||||
**1. 生成雜湊密碼**
|
||||
```bash
|
||||
python generate_password_hash.py
|
||||
# 依照提示輸入新密碼(至少8字元,含英數)
|
||||
# 將生成的雜湊值複製到 .env 檔案
|
||||
```
|
||||
|
||||
**2. 更新 .env 檔案**
|
||||
```bash
|
||||
# 範例(請替換為您自己生成的雜湊值):
|
||||
LOGIN_PASSWORD=pbkdf2:sha256:600000$abc123def456$...長字串...
|
||||
```
|
||||
|
||||
**3. 重新啟動系統**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
**安全日誌範例:**
|
||||
```
|
||||
🔐 收到登入請求 | IP: 192.168.1.100
|
||||
❌ 登入失敗 | IP: 192.168.1.100 | 剩餘嘗試: 4
|
||||
🔒 帳號已鎖定 | IP: 192.168.1.100 | 原因: 連續 5 次失敗
|
||||
✅ 登入成功 | IP: 192.168.1.101
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 High #28: 加入 CSRF 防護
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `requirements.txt` - 添加 Flask-WTF
|
||||
- `app.py` (第 337-344 行) - 初始化 CSRF 防護
|
||||
- `login.html` - 新建登入頁面(含 CSRF token)
|
||||
- `settings.html` - 添加 CSRF meta tag 與 token headers
|
||||
- `dashboard.html` - 添加 CSRF meta tag 與 token headers
|
||||
- `edm_dashboard.html` - 添加 CSRF meta tag 與 token headers
|
||||
- `system_settings.html` - 添加 CSRF meta tag 與 token headers
|
||||
|
||||
**防護機制:**
|
||||
|
||||
1. **全局 CSRF 防護啟用**
|
||||
```python
|
||||
# app.py (第 341-344 行)
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
|
||||
csrf = CSRFProtect(app)
|
||||
sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)")
|
||||
```
|
||||
|
||||
2. **HTML 表單防護**
|
||||
- 所有 POST 表單添加 `<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>`
|
||||
- `login.html` (第 142 行) - 登入表單
|
||||
|
||||
3. **AJAX 請求防護**
|
||||
- 在所有 HTML 模板的 `<head>` 添加 CSRF meta tag:
|
||||
```html
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
```
|
||||
- JavaScript 輔助函數:
|
||||
```javascript
|
||||
function getCSRFToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
}
|
||||
```
|
||||
- 所有 POST/PUT/DELETE fetch 請求添加 header:
|
||||
```javascript
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': getCSRFToken()
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
```
|
||||
|
||||
**已防護的端點:**
|
||||
- `/login` (POST) - 登入表單
|
||||
- `/api/run_task` (POST) - 手動爬蟲觸發
|
||||
- `/api/trigger_momo_notification` (POST) - 通知觸發
|
||||
- `/api/run_edm_task` (POST) - EDM 爬蟲
|
||||
- `/api/trigger_edm_notification` (POST) - EDM 通知
|
||||
- `/api/run_festival_task` (POST) - Festival 爬蟲
|
||||
- `/api/categories` (POST/PUT/DELETE) - 分類管理
|
||||
- `/api/test_url` (POST) - URL 測試
|
||||
- `/api/backup` (POST) - 系統備份
|
||||
- `/api/import_excel` (POST) - Excel 匯入
|
||||
|
||||
**測試驗證:**
|
||||
```bash
|
||||
# 1. 測試 CSRF 防護是否生效
|
||||
curl -X POST http://localhost:5888/api/run_task
|
||||
# 預期結果: 400 Bad Request (The CSRF token is missing)
|
||||
|
||||
# 2. 測試附帶正確 CSRF token 的請求
|
||||
# 需從瀏覽器獲取 token 並添加到 header
|
||||
```
|
||||
|
||||
**注意事項:**
|
||||
- Flask-WTF 會自動驗證所有 POST/PUT/DELETE/PATCH 請求
|
||||
- GET 請求不受 CSRF 保護影響(符合 HTTP 語義)
|
||||
- 如需豁免特定端點,可使用 `@csrf.exempt` 裝飾器
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 🟠 High #29: 修復路徑遍歷漏洞
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `app.py` (第 206-240 行) - 新增 `safe_join()` 安全路徑函數
|
||||
- `app.py` (第 2586-2614 行) - 修復 `/api/backup/download/<filename>` 路由
|
||||
|
||||
**防護機制:**
|
||||
|
||||
1. **安全路徑拼接函數** - `safe_join()`
|
||||
- 使用 Python `pathlib.Path.resolve()` 取得絕對路徑
|
||||
- 驗證最終路徑必須在基礎目錄內(使用 `relative_to()`)
|
||||
- 偵測到路徑遍歷嘗試時拋出 `ValueError`
|
||||
- 記錄所有路徑遍歷嘗試到安全日誌
|
||||
|
||||
2. **下載端點強化**
|
||||
- 驗證檔案存在性
|
||||
- 確認是檔案而非目錄
|
||||
- 使用 `safe_path.name` 而非原始 filename
|
||||
- 適當的錯誤處理與日誌記錄
|
||||
|
||||
**測試案例:**
|
||||
```bash
|
||||
# 1. 正常下載(應該成功)
|
||||
curl http://localhost:5888/api/backup/download/momo_system_backup_V9.4_20260112_1430.zip
|
||||
|
||||
# 2. 路徑遍歷攻擊(應被阻擋)
|
||||
curl http://localhost:5888/api/backup/download/../../../etc/passwd
|
||||
# 預期結果: {"error":"非法路徑"} + 安全日誌警告
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟠 High #30: 檔案上傳驗證
|
||||
|
||||
**修復狀態:** ✅ 已完成
|
||||
|
||||
**修改檔案:**
|
||||
- `app.py` (第 44 行) - 添加 `from werkzeug.utils import secure_filename`
|
||||
- `app.py` (第 242-299 行) - 新增檔案上傳驗證函數
|
||||
- `app.py` (第 2676-2718 行) - 修復 `/api/import_excel` 端點
|
||||
|
||||
**防護機制:**
|
||||
|
||||
1. **副檔名白名單驗證**
|
||||
- 僅允許: `.xlsx`, `.xls`, `.csv`
|
||||
- 使用 `ALLOWED_UPLOAD_EXTENSIONS` 集合管理
|
||||
|
||||
2. **檔案名稱清理**
|
||||
- 使用 `werkzeug.utils.secure_filename()` 清理檔案名稱
|
||||
- 移除路徑遍歷字元與特殊字元
|
||||
|
||||
3. **檔案大小限制**
|
||||
- Flask 配置: `MAX_CONTENT_LENGTH = 10MB`
|
||||
- 超過限制時自動回傳 413 錯誤
|
||||
|
||||
**安全日誌範例:**
|
||||
```
|
||||
[Security] 檔案上傳驗證失敗 | Filename: ../../../etc/passwd | Error: 檔案名稱不合法
|
||||
[Web] [Import] 檔案上傳驗證通過 | Original: 即時業績(全月).xlsx | Safe: 即時業績全月.xlsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 待修復項目(Medium 級別)
|
||||
|
||||
# 3. 清理檔名
|
||||
safe_name = secure_filename(file.filename)
|
||||
|
||||
# 4. 檢查檔案大小(透過 seek)
|
||||
file.seek(0, os.SEEK_END)
|
||||
file_size = file.tell()
|
||||
file.seek(0) # 重置檔案指標
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return False, f'檔案過大(限制 {MAX_FILE_SIZE // (1024*1024)} MB)', None
|
||||
|
||||
# 5. 驗證 MIME type(可選,需安裝 python-magic)
|
||||
# mime_type = magic.from_buffer(file.read(2048), mime=True)
|
||||
# file.seek(0)
|
||||
# if mime_type not in ALLOWED_MIME_TYPES:
|
||||
# return False, 'MIME type 驗證失敗', None
|
||||
|
||||
return True, None, safe_name
|
||||
|
||||
# 在路由中使用
|
||||
@app.route('/api/import_excel', methods=['POST'])
|
||||
def import_excel():
|
||||
file = request.files.get('file')
|
||||
|
||||
is_valid, error_msg, safe_name = validate_file_upload(file)
|
||||
if not is_valid:
|
||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||
|
||||
# 繼續處理檔案...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⏳ 待修復項目(Medium 級別)
|
||||
|
||||
### 🟡 Medium #31: 缺少 HTTP 安全標頭
|
||||
|
||||
**修復方式:**
|
||||
```python
|
||||
# app.py
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers['X-Content-Type-Options'] = 'nosniff'
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||
response.headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net"
|
||||
return response
|
||||
```
|
||||
|
||||
### 🟡 Medium #32: Session 安全性不足
|
||||
|
||||
**修復狀態:** ✅ 已部分完成(已在 High #27 中修復)
|
||||
|
||||
### 🟡 Medium #33: 弱亂數產生器
|
||||
|
||||
**位置:** `app.py` (第 720 行)
|
||||
|
||||
**修復方式:**
|
||||
```python
|
||||
import secrets
|
||||
|
||||
# 修復前:
|
||||
new_id = int(time.time() * 1000)
|
||||
|
||||
# 修復後:
|
||||
new_id = secrets.randbits(64)
|
||||
```
|
||||
|
||||
### 🟡 Medium #34: 敏感資訊洩漏
|
||||
|
||||
**修復方式:**
|
||||
```python
|
||||
def mask_sensitive_data(data, visible_chars=2):
|
||||
"""遮罩敏感資料"""
|
||||
if len(data) <= visible_chars * 2:
|
||||
return '****'
|
||||
return data[:visible_chars] + '****' + data[-visible_chars:]
|
||||
|
||||
# 使用範例
|
||||
sys_log.info(f"使用者登入 | Token: {mask_sensitive_data(token)}")
|
||||
```
|
||||
|
||||
### 🟡 Medium #35: SSRF / Open Redirect 風險
|
||||
|
||||
**位置:** `app.py` (第 756-778 行)
|
||||
|
||||
**修復方式:**
|
||||
```python
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def is_safe_url(url):
|
||||
"""驗證 URL 安全性"""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
|
||||
# 檢查協議
|
||||
if parsed.scheme not in ['http', 'https']:
|
||||
return False
|
||||
|
||||
# 檢查 hostname
|
||||
hostname = parsed.hostname
|
||||
if hostname:
|
||||
try:
|
||||
ip = ipaddress.ip_address(hostname)
|
||||
# 禁止存取私有 IP
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local:
|
||||
return False
|
||||
except:
|
||||
pass
|
||||
|
||||
# 黑名單檢查
|
||||
blocked_hosts = ['localhost', '127.0.0.1', '0.0.0.0', '::1']
|
||||
if hostname in blocked_hosts:
|
||||
return False
|
||||
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
# 使用範例
|
||||
if not is_safe_url(url):
|
||||
return jsonify({'error': '不允許的 URL'}), 400
|
||||
```
|
||||
|
||||
### 🟡 Medium #36: 資源耗盡風險
|
||||
|
||||
**修復方式:**
|
||||
```python
|
||||
# 1. 安裝 Flask-Limiter
|
||||
pip install Flask-Limiter
|
||||
|
||||
# 2. 在 app.py 配置
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
limiter = Limiter(
|
||||
app=app,
|
||||
key_func=get_remote_address,
|
||||
default_limits=["200 per day", "50 per hour"]
|
||||
)
|
||||
|
||||
# 3. 為特定路由設定限制
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
@limiter.limit("5 per minute")
|
||||
def login():
|
||||
pass
|
||||
```
|
||||
|
||||
### 🟡 Medium #37: 缺少授權檢查
|
||||
|
||||
**修復方式:**
|
||||
檢視所有路由,確保需要登入的端點都有 `@login_required` 裝飾器:
|
||||
```python
|
||||
@app.route('/dashboard')
|
||||
@login_required # 確保加上此裝飾器
|
||||
def dashboard():
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 建議事項
|
||||
|
||||
### 38. 定期安全掃描
|
||||
|
||||
**工具安裝:**
|
||||
```bash
|
||||
# Python 程式碼安全掃描
|
||||
pip install bandit
|
||||
bandit -r . -f json -o bandit_report.json
|
||||
|
||||
# 依賴套件漏洞掃描
|
||||
pip install pip-audit
|
||||
pip-audit
|
||||
|
||||
# 進階程式碼分析(線上工具)
|
||||
# https://semgrep.dev/
|
||||
```
|
||||
|
||||
### 39. 建立安全開發規範
|
||||
|
||||
建議制定 `SECURITY_GUIDELINES.md`,包含:
|
||||
- Secure Coding Checklist
|
||||
- Code Review 檢查清單
|
||||
- Pre-commit hooks 設定
|
||||
- 依賴套件更新流程
|
||||
|
||||
### 40. 備份與災難復原計畫
|
||||
|
||||
建議實作:
|
||||
- 自動化資料庫備份(每日)
|
||||
- 系統還原 SOP 文件
|
||||
- 備份還原測試(每月)
|
||||
- 異地備份方案
|
||||
|
||||
---
|
||||
|
||||
## 📊 修復優先級建議
|
||||
|
||||
### 🚨 本週內必須完成
|
||||
1. ✅ 更換所有已外洩的憑證(Critical #24)
|
||||
2. ⏳ 加入 CSRF 防護(High #28)
|
||||
3. ⏳ 修復路徑遍歷漏洞(High #29)
|
||||
4. ⏳ 檔案上傳驗證(High #30)
|
||||
|
||||
### 📅 本月內建議完成
|
||||
5. ⏳ 新增 HTTP 安全標頭(Medium #31)
|
||||
6. ⏳ 修復弱亂數產生器(Medium #33)
|
||||
7. ⏳ 實作 SSRF 防護(Medium #35)
|
||||
8. ⏳ 資源耗盡防護(Medium #36)
|
||||
9. ⏳ 檢視授權檢查(Medium #37)
|
||||
|
||||
### 📌 持續改進
|
||||
10. ⏳ 密碼雜湊更新(使用 generate_password_hash.py)
|
||||
11. ⏳ 定期安全掃描
|
||||
12. ⏳ 建立安全開發規範
|
||||
13. ⏳ 備份與災難復原計畫
|
||||
|
||||
---
|
||||
|
||||
## 🔧 部署檢查清單
|
||||
|
||||
在重新啟動系統前,請確認:
|
||||
|
||||
### 環境變數配置
|
||||
- [ ] `.env` 檔案已建立並填入所有必要值
|
||||
- [ ] 所有憑證已更換為新值(不使用範例中的值)
|
||||
- [ ] SECRET_KEY 已設定為隨機強密碼
|
||||
- [ ] USE_HTTPS 根據環境正確設定
|
||||
|
||||
### 密碼更新
|
||||
- [ ] 已執行 `python generate_password_hash.py`
|
||||
- [ ] 新密碼雜湊已複製到 `.env` 的 `LOGIN_PASSWORD`
|
||||
- [ ] 密碼符合強度要求(8+ 字元,含英數)
|
||||
|
||||
### 檔案權限
|
||||
- [ ] `.env` 檔案權限設為 600(`chmod 600 .env`)
|
||||
- [ ] `.gitignore` 已包含 `.env`
|
||||
- [ ] 確認 `.env` 未被提交到 Git
|
||||
|
||||
### 測試
|
||||
- [ ] 使用新密碼測試登入
|
||||
- [ ] 測試登入失敗 5 次是否觸發鎖定
|
||||
- [ ] 測試 Session 2 小時後是否自動登出
|
||||
- [ ] 測試檔案上傳是否受 10MB 限制
|
||||
|
||||
---
|
||||
|
||||
## 📞 後續支援
|
||||
|
||||
如需協助完成剩餘安全修復,請參考:
|
||||
- [TODO_NEXT_STEPS.txt](TODO_NEXT_STEPS.txt) - 完整安全修復清單
|
||||
- [generate_password_hash.py](generate_password_hash.py) - 密碼雜湊生成工具
|
||||
- `.env.example` - 環境變數配置模板
|
||||
|
||||
---
|
||||
|
||||
**⚠️ 安全提醒:**
|
||||
1. 立即更換所有已外洩的憑證
|
||||
2. 定期更換密碼(建議每 90 天)
|
||||
3. 定期更新依賴套件至最新版本
|
||||
4. 執行定期安全掃描
|
||||
5. 建立安全事件應變計畫
|
||||
|
||||
**最後更新:** 2026-01-12
|
||||
**修復進度:** 4/17 項已完成(23.5%)
|
||||
855
TODO_NEXT_STEPS.txt
Normal file
855
TODO_NEXT_STEPS.txt
Normal file
@@ -0,0 +1,855 @@
|
||||
|
||||
================================================================================
|
||||
品牌資產最終處理與維護 (Phase 7) [DONE]
|
||||
================================================================================
|
||||
|
||||
26. [已完成] 品牌圖檔格式轉換:
|
||||
- 使用 `convert_assets.py` 生成多種格式。
|
||||
- 產出格式:TIFF, JPG, EPS, AI (PDF), SVG (x3 款 Logo)。
|
||||
- 位置:/Users/ogt/momo_pro_system/export_assets
|
||||
- 已部署至伺服器並建立品牌資產庫頁面:https://momo.wooo.work/brand_assets
|
||||
|
||||
27. [已完成] 維護頁面文案更新與部署:
|
||||
- 文案已更新為:「正在進行系統升級,為了提供更穩定的網站服務,系統正在進行必要的維護與優化,造成不便,敬請見諒」
|
||||
- 已同步至 GCP 生產環境。
|
||||
|
||||
================================================================================
|
||||
WOOO TECH 公司入口網站 (Phase 8) [NEW PROJECT]
|
||||
================================================================================
|
||||
|
||||
28. [待辦] 公司入口網站開發 (wooo.work):
|
||||
- 將使用 **Flutter Web** 開發。
|
||||
- 需在獨立專案中進行(非 momo_pro_system)。
|
||||
- 詳細規劃文件:implementation_plan_wooo_website.md
|
||||
|
||||
【網站定位】
|
||||
- 新創公司 WOOO TECH 的數位門面
|
||||
- 整合各子系統產品的導航與介紹
|
||||
- 一級域名:wooo.work
|
||||
- 子系統:momo.wooo.work, [未來產品].wooo.work
|
||||
|
||||
【頁面結構】
|
||||
- 首頁:Hero Section + 產品卡片
|
||||
- 產品介紹頁:各系統功能詳情
|
||||
- 公司介紹:願景、使命
|
||||
- 聯繫我們:表單或 Email
|
||||
|
||||
【視覺風格】
|
||||
- 延續 WOOO 品牌:漸變紫藍 + 深色科技感 + 玻璃質感
|
||||
- 動態流暢的互動效果
|
||||
|
||||
【待確認】
|
||||
- DNS 託管商 (wooo.work)
|
||||
- 公司簡介文案
|
||||
- 是否需要多語言支援
|
||||
|
||||
|
||||
|
||||
|
||||
【環境區分】
|
||||
|
||||
1. 開發/測試環境(本機 VS Code):
|
||||
- 位置:macOS 本機 (/Users/ogt/momo_pro_system)
|
||||
- 用途:開發新功能、測試、除錯
|
||||
- 啟動方式:`python app.py`
|
||||
- 訪問:http://localhost:5000 或 http://127.0.0.1:5000
|
||||
- 資料庫:data/momo_database.db (本機 SQLite)
|
||||
|
||||
================================================================================
|
||||
完整部署流程 (2026-01-13)
|
||||
================================================================================
|
||||
|
||||
【部署完成狀態】
|
||||
✅ 網站:https://momo.wooo.work
|
||||
✅ 響應時間:1.7-2 秒
|
||||
✅ SSL 有效期:2026-04-13
|
||||
✅ Google Drive 自動匯入:每 30 分鐘
|
||||
✅ 爬蟲任務:每小時執行
|
||||
✅ 自動備份:已配置
|
||||
|
||||
================================================================================
|
||||
日常開發工作流程
|
||||
================================================================================
|
||||
|
||||
【本機開發流程】
|
||||
|
||||
1. 啟動開發環境:
|
||||
cd /Users/ogt/momo_pro_system
|
||||
python app.py
|
||||
# 訪問:http://localhost:5000
|
||||
|
||||
2. 開發新功能:
|
||||
- 編輯程式碼
|
||||
- 本機測試
|
||||
- 確認功能正常
|
||||
|
||||
3. 測試資料庫變更:
|
||||
- 修改 database/models.py
|
||||
- 執行 python init_db.py
|
||||
- 驗證資料庫結構
|
||||
|
||||
4. 版本控制(如果使用 Git):
|
||||
git add .
|
||||
git commit -m "描述變更內容"
|
||||
|
||||
【版本同步檢查】
|
||||
|
||||
⚠️ 重要:每次更新後確認本機和 GCP 版本一致
|
||||
|
||||
1. 檢查關鍵文件的修改時間和大小:
|
||||
# 本機
|
||||
ls -lh /Users/ogt/momo_pro_system/app.py
|
||||
ls -lh /Users/ogt/momo_pro_system/dashboard.html
|
||||
|
||||
# GCP
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="ls -lh ~/momo_pro_system/app.py ~/momo_pro_system/dashboard.html"
|
||||
|
||||
2. 比對檔案 MD5 雜湊值(確保內容完全一致):
|
||||
# 本機
|
||||
md5 /Users/ogt/momo_pro_system/app.py
|
||||
|
||||
# GCP
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="md5sum ~/momo_pro_system/app.py"
|
||||
|
||||
3. 檢查最近更新的文件:
|
||||
# 本機(最近 1 天內修改的 Python 和 HTML 文件)
|
||||
find /Users/ogt/momo_pro_system -type f \( -name "*.py" -o -name "*.html" \) -mtime -1 -ls
|
||||
|
||||
# GCP
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="find ~/momo_pro_system -type f \( -name '*.py' -o -name '*.html' \) -mtime -1 -ls"
|
||||
|
||||
4. 版本追蹤建議:
|
||||
✅ 每次更新後記錄更新時間和文件清單
|
||||
✅ 使用 Git 進行版本控制(未來可考慮)
|
||||
✅ 重大更新前先備份 GCP 當前版本
|
||||
✅ 更新後測試關鍵功能確認正常
|
||||
|
||||
【常用維護命令】
|
||||
|
||||
# 查看服務狀態
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl status momo"
|
||||
|
||||
# 查看即時日誌
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo journalctl -u momo -f"
|
||||
|
||||
# 重啟服務
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl restart momo"
|
||||
|
||||
# 停止服務
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl stop momo"
|
||||
|
||||
# 啟動服務
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl start momo"
|
||||
|
||||
# 查看 Nginx 狀態
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl status nginx"
|
||||
|
||||
# 重新載入 Nginx 配置
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo nginx -t && sudo systemctl reload nginx"
|
||||
|
||||
# 檢查 SSL 證書狀態
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo certbot certificates"
|
||||
|
||||
# 手動更新 DuckDNS IP
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="~/duckdns/duck.sh && cat ~/duckdns/duck.log"
|
||||
|
||||
# 查看資源使用狀況
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="free -h && df -h && top -bn1 | head -n 15"
|
||||
|
||||
【維護模式管理】
|
||||
|
||||
# 啟用維護模式(顯示維護頁面,停止應用服務)
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="./enable_maintenance.sh"
|
||||
|
||||
# 禁用維護模式(恢復正常服務)
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="./disable_maintenance.sh"
|
||||
|
||||
# 維護模式說明:
|
||||
# - 維護模式會將網站切換到靜態維護頁面
|
||||
# - 顯示「系統維護中」訊息和即時時鐘
|
||||
# - SSL/HTTPS 持續運作
|
||||
# - 適用於系統更新、資料庫維護等需要暫停服務的場景
|
||||
# - 維護頁面位置:~/momo_pro_system/maintenance.html
|
||||
|
||||
================================================================================
|
||||
系統備份策略
|
||||
================================================================================
|
||||
|
||||
【本機備份】
|
||||
- 執行:python backup_system.py
|
||||
- 或在系統設定頁面點擊「系統備份」按鈕
|
||||
- 位置:backups/ 目錄
|
||||
- 最新備份:momo_pro_system_backup_20260113_111011_V9.4.zip (261 MB)
|
||||
|
||||
【生產環境備份】
|
||||
|
||||
✅ 已設置每日自動備份(每天凌晨 2:00)
|
||||
|
||||
1. 自動備份配置:
|
||||
- 執行時間:每天 02:00(台北時間 10:00)
|
||||
- 備份腳本:~/daily_backup.sh
|
||||
- 備份位置:~/momo_backups/
|
||||
- 保留期限:最近 7 天
|
||||
- 備份內容:
|
||||
* 資料庫(momo_database.db)
|
||||
* 配置文件(config/)
|
||||
* Google Drive 憑證
|
||||
* 系統配置(Nginx, systemd)
|
||||
* DuckDNS 配置
|
||||
* 最近 7 天日誌
|
||||
|
||||
2. 查看備份狀態:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="ls -lh ~/momo_backups/*.tar.gz | tail -5"
|
||||
|
||||
3. 查看備份日誌:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="tail -50 ~/momo_backups/backup.log"
|
||||
|
||||
4. 手動執行備份:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="~/daily_backup.sh"
|
||||
|
||||
5. 下載備份到本機:
|
||||
gcloud compute scp momo-server:~/momo_backups/momo_production_backup_*.tar.gz \
|
||||
/Users/ogt/momo_pro_system/backups/ --zone=asia-east1-a
|
||||
|
||||
6. 恢復備份:
|
||||
# 解壓備份檔
|
||||
tar -xzf momo_production_backup_YYYYMMDD_HHMMSS.tar.gz
|
||||
# 恢復資料庫
|
||||
cp momo_production_backup_*/momo_database.db ~/momo_pro_system/data/
|
||||
# 重啟服務
|
||||
sudo systemctl restart momo
|
||||
|
||||
================================================================================
|
||||
故障排除指南
|
||||
================================================================================
|
||||
|
||||
【問題:網站無法訪問】
|
||||
1. 檢查服務狀態:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo systemctl status momo nginx"
|
||||
|
||||
2. 檢查防火牆:
|
||||
gcloud compute firewall-rules list --filter="name:allow-http*"
|
||||
|
||||
3. 檢查 DNS 解析:
|
||||
nslookup momo.wooo.work
|
||||
|
||||
【問題:網站速度慢】
|
||||
1. 檢查資源使用:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="free -h && df -h && ps aux | grep python"
|
||||
|
||||
2. 檢查 Gunicorn workers:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="ps aux | grep gunicorn"
|
||||
|
||||
3. 查看應用日誌:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo journalctl -u momo -n 100 --no-pager"
|
||||
|
||||
【問題:SSL 證書過期】
|
||||
1. 檢查證書狀態:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo certbot certificates"
|
||||
|
||||
2. 手動續期:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo certbot renew"
|
||||
|
||||
【問題:Google Drive 自動匯入失敗】
|
||||
1. 檢查日誌:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="sudo journalctl -u momo | grep -i 'google drive' | tail -n 20"
|
||||
|
||||
2. 檢查憑證檔案:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a \
|
||||
--command="ls -lh ~/momo_pro_system/config/google_*.{json,pickle}"
|
||||
|
||||
3. 手動測試:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
cd ~/momo_pro_system
|
||||
source venv/bin/activate
|
||||
python -c "from services.google_drive_service import GoogleDriveService; g=GoogleDriveService(); print(g.list_files())"
|
||||
|
||||
【問題:504 Gateway Timeout】
|
||||
原因:某些頁面(如 sales_analysis)需要處理大量數據(31 萬+ 筆記錄),
|
||||
超過 Nginx 預設的 60 秒 timeout。
|
||||
|
||||
解決方案(已修復 2026-01-13):
|
||||
1. 增加 Nginx proxy timeout 到 300 秒:
|
||||
- proxy_read_timeout 300s;
|
||||
- proxy_connect_timeout 300s;
|
||||
- proxy_send_timeout 300s;
|
||||
|
||||
2. 如需調整 timeout,編輯 Nginx 配置:
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
sudo nano /etc/nginx/sites-available/momo
|
||||
# 修改 location / { 區塊內的 proxy_*_timeout 值
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
3. 如問題持續,考慮:
|
||||
- 查詢優化(已添加 11 個索引)
|
||||
- 增加 Gunicorn timeout(目前 120 秒)
|
||||
- 實作分頁或快取機制
|
||||
- 升級 VM 規格(✅ 已升級:e2-standard-2, 8GB RAM)
|
||||
|
||||
效能優化歷程:
|
||||
- 2026-01-13: Nginx timeout 增加到 300 秒
|
||||
- 2026-01-13: VM 記憶體從 4GB 升級到 8GB(解決 OOM 問題)
|
||||
- 記憶體使用率:從 94% 降至 7.5%,可用記憶體 6.9GB
|
||||
|
||||
【VM 規格升級方法】
|
||||
如需再次升級 VM 規格:
|
||||
1. 停止實例:gcloud compute instances stop momo-server --zone=asia-east1-a
|
||||
2. 更改類型:gcloud compute instances set-machine-type momo-server --machine-type=e2-standard-4 --zone=asia-east1-a
|
||||
3. 啟動實例:gcloud compute instances start momo-server --zone=asia-east1-a
|
||||
4. 驗證升級:gcloud compute ssh momo-server --zone=asia-east1-a --command="free -h"
|
||||
|
||||
可用機器類型:
|
||||
- e2-standard-2: 2 vCPU, 8GB RAM (當前)
|
||||
- e2-standard-4: 4 vCPU, 16GB RAM
|
||||
- e2-standard-8: 8 vCPU, 32GB RAM
|
||||
|
||||
================================================================================
|
||||
重要檔案位置
|
||||
================================================================================
|
||||
|
||||
【本機環境】
|
||||
- 專案根目錄:/Users/ogt/momo_pro_system/
|
||||
- 資料庫:data/momo_database.db
|
||||
- 配置檔:config.py, .env
|
||||
- Google Drive:config/google_credentials.json, config/google_token.pickle
|
||||
- 備份目錄:backups/
|
||||
- 日誌目錄:logs/
|
||||
|
||||
【生產環境 (VM)】
|
||||
- 專案目錄:/home/ogt/momo_pro_system/
|
||||
- 虛擬環境:/home/ogt/momo_pro_system/venv/
|
||||
- 資料庫:/home/ogt/momo_pro_system/data/momo_database.db
|
||||
- 配置檔:/home/ogt/momo_pro_system/.env
|
||||
- Systemd 服務:/etc/systemd/system/momo.service
|
||||
- Nginx 配置:/etc/nginx/sites-available/momo
|
||||
- SSL 證書:/etc/letsencrypt/live/momo.wooo.work/
|
||||
- DuckDNS 腳本:/home/ogt/duckdns/duck.sh
|
||||
- Gunicorn 日誌:
|
||||
- Access: /home/ogt/momo_pro_system/logs/gunicorn-access.log
|
||||
- Error: /home/ogt/momo_pro_system/logs/gunicorn-error.log
|
||||
- 系統日誌:sudo journalctl -u momo
|
||||
|
||||
================================================================================
|
||||
系統重啟後待辦事項清單
|
||||
================================================================================
|
||||
|
||||
【本機開發環境重啟】
|
||||
|
||||
1. 啟動系統:
|
||||
cd /Users/ogt/momo_pro_system
|
||||
python app.py
|
||||
|
||||
2. 驗證匯入功能 (關鍵):
|
||||
- 進入「系統設定」頁面,匯入 Excel 報表。
|
||||
- 觀察終端機日誌,確認出現「自動去重」或「已建立新資料表」的訊息。
|
||||
- 若重複匯入相同檔案,應顯示「發現 X 筆重複資料,已忽略」。
|
||||
|
||||
3. 檢查資料顯示:
|
||||
- 進入「業績分析」頁面 (/sales_analysis)。
|
||||
- 確認 '狀態' 欄位是否正確顯示 'F' (目前系統處於原始匯入模式,未執行強制轉 0)。
|
||||
|
||||
4. 決定清理策略:
|
||||
- 目前 app.py 中的「智慧資料清理」邏輯已被註解掉 (約第 1160 行)。
|
||||
- 若確認資料無誤且不需要自動轉型,可維持現狀。
|
||||
- 若需要恢復清理功能,請取消註解並重啟 app.py。
|
||||
|
||||
5. 系統備份:
|
||||
- 確認一切正常後,點擊「系統備份」按鈕或執行 `python backup_system.py`。
|
||||
|
||||
【進階報表與策略分析規劃 (Phase 2)】
|
||||
|
||||
6. [已完成] 營運成長報表 (Growth Strategy):
|
||||
- 建立獨立頁面 `growth_analysis.html`。
|
||||
- 實作 MoM (月增率) / YoY (年增率) 雙軸趨勢圖。
|
||||
- 實作 YTD (年初至今) 累計達成率。
|
||||
- 實作 客單價 (AOV) 趨勢分析。
|
||||
|
||||
7. [已完成] BCG 矩陣分析 (波士頓矩陣):
|
||||
- 已在業績分析頁加入散佈圖,並自動計算中位數劃分四象限。
|
||||
- 視覺化顯示:明星(黃)、金牛(綠)、問題(藍)、瘦狗(灰)。
|
||||
|
||||
8. [已完成] ABC 分析 (帕累托法則):
|
||||
- 已實作 A/B/C 類商品佔比計算與視覺化。
|
||||
- 已加入 Excel 匯出功能,包含 ABC 分類欄位。
|
||||
|
||||
9. [已完成] 廠商獲利能力排行:
|
||||
- 已在業績分析頁新增廠商排行表格。
|
||||
- 顯示總業績、毛利額、毛利率、SKU 數與平均單品產值。
|
||||
|
||||
10. [已完成] 淡旺季熱力圖:
|
||||
- 已實作 Month x Category 熱力圖。
|
||||
- 透過氣泡大小與顏色深淺,視覺化呈現各分類在不同月份的業績表現。
|
||||
|
||||
【系統優化與維護 (Phase 3)】
|
||||
|
||||
11. [已完成] 業績分析頁面效能優化:
|
||||
- 實作 Server-side AJAX 載入列表資料,解決頁面卡頓問題。
|
||||
- 優化篩選邏輯與快取機制。
|
||||
|
||||
12. [已完成] 淡旺季熱力圖匯出:
|
||||
- 實作點擊氣泡匯出該月份分類明細的功能。
|
||||
|
||||
13. [已完成] 廠商排行與詳細列表優化:
|
||||
- 廠商排行:新增佔比、銷量、ASP 欄位,並優化版面高度。
|
||||
- 詳細列表:改為多維度聚合 (商品+品牌+廠商+分類),新增品牌、均價、退貨率欄位。
|
||||
|
||||
14. [已完成] ABC 分析列表增加商品ID欄位:
|
||||
- 在 ABC 詳細報表頁面與匯出 Excel 中增加「商品ID」欄位。
|
||||
|
||||
【待辦功能與驗證 (Phase 4)】
|
||||
|
||||
15. [待辦] 廠商排行互動優化:
|
||||
- 實作點擊廠商名稱,自動跳轉並篩選該廠商的所有商品。
|
||||
|
||||
16. [待辦] 商品看板庫存顯示:
|
||||
- 研究是否能從爬蟲抓取一般商品的庫存量 (目前僅 EDM/Festival 有抓取)。
|
||||
- 若可行,在商品看板列表加入庫存欄位。
|
||||
|
||||
17. [已完成] 當日業績看板 (Daily Sales Dashboard):
|
||||
【功能目標】
|
||||
- ✅ 建立新頁面 `/daily_sales`,用於每日業績快照的分析與追蹤。
|
||||
|
||||
【資料表設計】
|
||||
- ✅ 新建資料表 `daily_sales_snapshot`。
|
||||
- ✅ 欄位結構與 `realtime_sales_monthly` 相同,並額外增加 `snapshot_date` (日期) 欄位。
|
||||
- ✅ 實作自動去重機制,避免重複匯入相同日期的資料。
|
||||
|
||||
【Excel 匯入功能】
|
||||
- ✅ 提供 Excel 上傳功能,欄位對應 `realtime_sales_monthly` 結構。
|
||||
- ✅ 智慧匯入:從 Excel 的「日期」欄位自動拆分 snapshot_date(支援單檔多日)。
|
||||
- ✅ 匯入後自動記錄快照日期,保留歷史記錄供比較分析。
|
||||
|
||||
【KPI 卡片顯示】
|
||||
- ✅ 6 個核心指標卡片,每個指標計算 DoD% 和 WoW%:
|
||||
1. 總業績 (DoD%, WoW%)
|
||||
2. 總成本 (DoD%, WoW%)
|
||||
3. 毛利 (DoD%, WoW%)
|
||||
4. SKU 數 (DoD%, WoW%)
|
||||
5. 客單價 (DoD%, WoW%)
|
||||
6. 總銷量 (DoD%, WoW%)
|
||||
- ✅ KPI 卡片文字對比度優化(白色文字 + 陰影)
|
||||
|
||||
【分析圖表】
|
||||
- ✅ 每日業績趨勢圖(混合圖表:柱狀 + 折線)
|
||||
- ✅ DoD 成長率圖(條件著色)
|
||||
- ✅ WoW 成長率圖(前 7 天灰色 + 第 8 天起彩色 + 說明工具提示)
|
||||
- ✅ 商品 Top 10 排行(橫向柱狀圖)
|
||||
|
||||
【業績行事曆視圖】(全新功能)
|
||||
- ✅ 完整月份行事曆,顯示每日業績、毛利、SKU、客單價、銷量
|
||||
- ✅ 星期標註(週一~週日)
|
||||
- ✅ 2026 年台灣國定假日標註(根據人事行政總處公佈)
|
||||
- ✅ DoD 顏色系統:紅色 = 正成長,綠色 = 負成長
|
||||
- ✅ DoD 百分比標籤顯示(右上角)
|
||||
- ✅ 行事曆文字對比度優化(彩色背景上的白色文字 + 陰影)
|
||||
- ✅ 點擊日期切換查看詳細業績
|
||||
- ✅ 月份切換按鈕(上個月/下個月)
|
||||
|
||||
【詳細列表】
|
||||
- ✅ 顯示當日各分類的業績明細(分類層級聚合)
|
||||
- ✅ 支援 DataTables 排序、搜尋、分頁功能
|
||||
|
||||
【日期選擇功能】
|
||||
- ✅ 下拉選單顯示所有可用日期
|
||||
- ✅ 切換日期時,KPI、圖表、列表同步更新
|
||||
- ✅ 行事曆與日期選擇器連動
|
||||
|
||||
【UI/UX 優化】(2026-01-12 完成)
|
||||
- ✅ 行事曆視覺優化:移除彩色背景,改用白底黑字 + DoD 徽章(上漲紅色、下跌綠色)
|
||||
- ✅ 金額格式改為完整顯示(千分位):$123,456 取代 $123K
|
||||
- ✅ 移除行事曆日期格中的重複星期顯示(標題列已有星期標註)
|
||||
- ✅ 國定假日標籤改為日期右側顯示(inline layout)
|
||||
- ✅ 響應式設計優化:
|
||||
• 手機版採用橫向滾動(min-width: 700px)
|
||||
• KPI 標籤改用 emoji 圖示(💰業績、📊毛利、📦SKU、🛒客單、📈銷量)
|
||||
• 平板與手機適配字體大小與間距
|
||||
• 新增滾動提示訊息
|
||||
|
||||
【多維度分析強化】(2026-01-12 完成)
|
||||
- ✅ 每日趨勢圖:多 Y 軸顯示(業績/毛利、客單價、銷量)
|
||||
- ✅ DoD 圖表:4 條線(業績、毛利、客單價、銷量)
|
||||
- ✅ WoW 圖表:4 條線 + 前 7 天灰色顯示 + 工具提示說明
|
||||
- ✅ 行事曆每格顯示 5 項指標(業績、毛利、SKU、客單價、銷量)
|
||||
- ✅ 毛利計算修正:自動計算 (revenue - cost)
|
||||
|
||||
【廠商維度增強】(2026-01-12 完成)
|
||||
- ✅ Top 10 商品:顯示格式改為「商品名稱 (廠商名稱)」
|
||||
- ✅ 分類業績明細:新增廠商欄位,顯示廠商名稱(非 ID)
|
||||
- ✅ 欄位優先順序修正:['廠商名稱', '廠商', 'Vendor', 'Supplier']
|
||||
|
||||
【系統配置更新】(2026-01-12 完成)
|
||||
- ✅ 服務端口從 5888 改為 80
|
||||
- ✅ 資料庫路徑確認:data/momo_database.db
|
||||
|
||||
【已完成測試】
|
||||
- ✅ 行事曆顯示 5 項指標(完整金額 + 千分位)
|
||||
- ✅ 2026 年國定假日正確標註
|
||||
- ✅ DoD 徽章顏色系統(紅色上漲、綠色下跌)
|
||||
- ✅ WoW 圖表灰色線條邏輯(前 7 天無對比資料)
|
||||
- ✅ 行事曆文字清晰可讀(白底黑字 + DoD 徽章)
|
||||
- ✅ 月份切換功能
|
||||
- ✅ 日期選擇功能
|
||||
- ✅ 手機版橫向滾動(行事曆、分類列表、Top 10 圖表)
|
||||
- ✅ 廠商名稱顯示(Top 10 + 分類明細)
|
||||
- ✅ 手機版 KPI 文字可見性優化(行事曆標籤由灰色改為深色)
|
||||
- ✅ 毛利率百分比顯示(顯示於毛利金額右側)
|
||||
- ✅ DoD/WoW 徽章對比度優化(改用深色背景白色文字)
|
||||
- ✅ 分類業績明細表格手機版滾動(min-width: 800px)
|
||||
- ✅ Top 10 商品圖表手機版滾動(min-width: 400-500px)
|
||||
|
||||
【修正的檔案】
|
||||
- app.py (第 3376-3450 行):多維度圖表數據準備(DoD/WoW 計算、毛利計算、廠商維度)
|
||||
- app.py (第 3416, 3459 行):廠商欄位優先順序修正
|
||||
- app.py (第 3456-3492 行):台灣國定假日資料
|
||||
- app.py (第 3548-3628 行):行事曆數據計算(客單價、銷量、毛利)
|
||||
- app.py (第 3752-3791 行):行事曆數據準備函式(margin_rate 計算)
|
||||
- app.py (多處):服務端口從 5888 改為 80
|
||||
- daily_sales.html (第 42-287 行):CSS 樣式重構(白底黑字、DoD 徽章、響應式設計)
|
||||
- daily_sales.html (第 106-114 行):Top 10 圖表響應式滾動容器樣式
|
||||
- daily_sales.html (第 177-200 行):行事曆 HTML(5 項指標完整顯示)
|
||||
- daily_sales.html (第 227-242 行):行事曆 KPI 文字顏色與毛利率樣式
|
||||
- daily_sales.html (第 260-262, 277-279 行):手機版 Top 10 圖表最小寬度設定
|
||||
- daily_sales.html (第 387 行):毛利率百分比顯示(inline 於毛利右側)
|
||||
- daily_sales.html (第 408-521 行):DoD/WoW 徽章改用深色背景
|
||||
- daily_sales.html (第 580-588 行):Top 10 圖表容器結構(加入滾動提示與響應式容器)
|
||||
- daily_sales.html (第 605-640 行):分類列表表格響應式容器(加入滾動提示)
|
||||
- daily_sales.html (第 644-896 行):Chart.js 多維度圖表配置
|
||||
|
||||
【系統架構優化與重構 (Phase 5)】
|
||||
|
||||
18. [已完成] 商品看板視覺優化 (V9.2):
|
||||
【爬蟲效能優化】(2026-01-12 完成)
|
||||
- ✅ 商品圖爬取改用 i_code 直接構造 CDN URL
|
||||
- ✅ 格式:https://m.momoshop.com.tw/moscdn/goods/{i_code}_m.webp
|
||||
- ✅ 移除複雜 DOM 查詢(8+ CSS 選擇器),速度提升 20-30%
|
||||
- ✅ 提高圖片載入準確性與穩定性
|
||||
|
||||
【UI/UX 全面升級】(2026-01-12 完成)
|
||||
- ✅ 統一紫色主題風格(#667eea, #764ba2)與 Daily Sales 一致
|
||||
- ✅ 現代化漸變效果(卡片、按鈕、表頭)
|
||||
- ✅ 商品卡片懸停動效(陰影、縮放)
|
||||
- ✅ 表格樣式優化(漸變表頭、hover 效果)
|
||||
- ✅ 商品圖片懸停效果(放大、邊框、陰影)
|
||||
- ✅ 分頁按鈕圓角與漸變設計
|
||||
|
||||
【版面與排版修正】(2026-01-12 完成)
|
||||
- ✅ 修正按鈕群組排版(全部、新上架、漲價、跌價、下架)
|
||||
- ✅ 修正表格文字可見性(tbody 文字顏色 #2c3e50)
|
||||
- ✅ 修正商品列表標題區塊(增加 card-body 包裹)
|
||||
- ✅ 商品 ID 字體放大至 0.875rem(原 small class)
|
||||
- ✅ 商品 ID 改用紫色主題色(#667eea)
|
||||
|
||||
【互動體驗增強】(2026-01-12 完成)
|
||||
- ✅ 價格顯示 hover 效果(背景漸變、放大、陰影)
|
||||
- ✅ 複製品號視覺回饋優化(✅ emoji + 縮放動畫)
|
||||
- ✅ 歷史圖表 Modal 視覺升級(漸變標題、圓角、陰影)
|
||||
- ✅ 圖表優化:漸變填充、平滑曲線、動態點樣式
|
||||
- ✅ Tooltip 優化:emoji 圖示、深色背景、紫色邊框
|
||||
- ✅ 圖表動畫:1 秒平滑動畫(easeInOutQuart)
|
||||
|
||||
【修正的檔案】
|
||||
- scheduler.py (第 228-244 行):商品圖 URL 構造邏輯
|
||||
- app.py (第 88 行):版本號更新至 V9.2
|
||||
- dashboard.html (第 166-331 行):完整 CSS 重構
|
||||
- dashboard.html (第 462-500 行):商品列表標題區塊結構調整
|
||||
- dashboard.html (第 548-550 行):商品 ID 樣式修正
|
||||
- dashboard.html (第 582-587 行):價格顯示互動效果
|
||||
- dashboard.html (第 730-813 行):圖表漸變與動畫配置
|
||||
- dashboard.html (第 777-789 行):複製回饋動畫
|
||||
|
||||
19. [已完成] 商品看板 KPI 卡片重構與新增指標 (V9.3):
|
||||
【需求整合】(2026-01-12 完成)
|
||||
- ✅ 將原有 4 張 KPI 卡片整合為 2 張卡片
|
||||
- ✅ 新增 7 個關鍵業務指標
|
||||
- ✅ 優化視覺層次與資訊密度
|
||||
- ✅ 提升使用者體驗與數據可讀性
|
||||
|
||||
【卡片 1:商品監控概況】(2026-01-12 完成)
|
||||
- ✅ 監控商品總數(千分位格式化)
|
||||
- ✅ 今日新增商品數
|
||||
- ✅ 週增長(過去 7 天新增商品數)
|
||||
- ✅ 價格穩定商品數(7 天內無變價)
|
||||
|
||||
【卡片 2:今日價格動態】(2026-01-12 完成)
|
||||
- ✅ 上排:漲價 / 降價 / 下架(3 個主要變動指標)
|
||||
- ✅ 中排:平均漲幅 / 平均跌幅 / 活躍度(價格分析)
|
||||
- ✅ 下排:最活躍分類 / 最大變動(關鍵商業指標)
|
||||
|
||||
【新增 KPI 指標】(2026-01-12 完成)
|
||||
- ✅ 平均漲幅:漲價商品平均價格變動
|
||||
- ✅ 平均跌幅:降價商品平均價格變動
|
||||
- ✅ 今日活躍度:有價格變動的商品百分比
|
||||
- ✅ 週增長:過去 7 天新增商品數
|
||||
- ✅ 價格穩定商品數:7 天內價格無變化的商品數
|
||||
- ✅ 最活躍分類:今日變動商品數最多的分類
|
||||
- ✅ 最大變動:單一商品最大價格變動(含商品名稱)
|
||||
|
||||
【後端數據計算】(2026-01-12 完成)
|
||||
- ✅ 平均漲跌幅計算邏輯(sum / count)
|
||||
- ✅ 活躍度百分比計算(變動商品 / 總商品 * 100)
|
||||
- ✅ 最大變動商品查找(絕對值比較)
|
||||
- ✅ 週增長查詢(7 天內 min(timestamp) 的商品數)
|
||||
- ✅ 價格穩定商品統計(7 天內 distinct(price) == 1)
|
||||
- ✅ 最活躍分類統計(category_activity 字典統計)
|
||||
|
||||
【前端視覺優化】(2026-01-12 完成)
|
||||
- ✅ 新增 .kpi-grid CSS 樣式(3 欄網格佈局)
|
||||
- ✅ 新增 .kpi-item 樣式(漸變背景、hover 效果)
|
||||
- ✅ 顏色區分:increase (紅色) / decrease (綠色) / neutral (灰色)
|
||||
- ✅ 響應式設計:手機版改為單欄佈局
|
||||
- ✅ 卡片 1 藍色漸變背景(與 Daily Sales 風格一致)
|
||||
- ✅ 卡片 2 白色背景 + 網格佈局(清晰易讀)
|
||||
|
||||
【修正的檔案】
|
||||
- app.py (第 494-554 行):新增 KPI 計算邏輯
|
||||
- app.py (第 659-692 行):render_template 傳遞新 KPI 數據
|
||||
- app.py (第 88 行):版本號更新至 V9.3
|
||||
- dashboard.html (第 369-421 行):新增 KPI 網格樣式
|
||||
- dashboard.html (第 440-448 行):響應式設計更新
|
||||
- dashboard.html (第 477-585 行):完整 2 張卡片 HTML 結構
|
||||
|
||||
20. [待辦] 爬蟲邏輯統一與效能優化 (Scheduler):
|
||||
- 將 run_momo_task 的優化邏輯 (批次寫入、無例外查找) 套用到 run_edm_task 與 run_festival_task。
|
||||
- 提升 EDM 與購物節爬蟲的穩定性與速度。
|
||||
|
||||
21. [待辦] 首頁 (Dashboard) 效能優化:
|
||||
- 對 get_consolidated_data 實作短時間快取 (5-10分鐘),減少資料庫全表掃描。
|
||||
- 評估改用 SQL 分頁取代記憶體分頁。
|
||||
|
||||
22. [待辦] 程式碼重構與模組化:
|
||||
- 將 app.py 的路由拆分為 Blueprints (dashboard, analysis, api)。
|
||||
- 將資料處理邏輯移至 services 層。
|
||||
|
||||
23. [待辦] 清理冗餘程式碼:
|
||||
- 移除 database/manager.py 中不再使用的 update_data 函式。
|
||||
|
||||
【系統安全強化 (Phase 6) - URGENT】
|
||||
|
||||
=== 重大安全風險 (Critical - 須立即修復) ===
|
||||
|
||||
24. [CRITICAL] 移除硬編碼敏感資訊:
|
||||
- 檔案: config.py (第 17, 22, 26, 35, 40, 173 行)
|
||||
- 問題: 所有 API 金鑰、密碼、Token 直接寫在程式碼中
|
||||
• LOGIN_PASSWORD = "0936223270"
|
||||
• TELEGRAM_BOT_TOKEN = "8075645931:AAH-EGKMo8ZC4QJs-Nc1_0s92xHrGdQvdpg"
|
||||
• LINE_CHANNEL_ACCESS_TOKEN = "nD6MSXjB2FyB111zpT6Yik5B275mi6olHjjf94VnqN..."
|
||||
• EMAIL_HOST_PASSWORD = "jopokbhdpnnborjd"
|
||||
• NGROK_AUTH_TOKEN = "36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF"
|
||||
- 風險: 任何有權限看到程式碼的人都能取得所有帳密,可接管系統
|
||||
- 修復:
|
||||
1. 安裝 python-dotenv: pip install python-dotenv
|
||||
2. 建立 .env 檔案存放所有敏感資訊
|
||||
3. 將 .env 加入 .gitignore
|
||||
4. 更新 config.py 改用 os.getenv() 讀取環境變數
|
||||
5. **立即更換所有已外洩的密碼與 Token**
|
||||
|
||||
25. [CRITICAL] 修復 SQL Injection 漏洞 #1:
|
||||
- 檔案: app.py (第 2539, 3469 行)
|
||||
- 問題: 使用字串插值直接組合 SQL 查詢
|
||||
• df_existing = pd.read_sql(f"SELECT * FROM {table_name}", engine)
|
||||
• df = pd.read_sql(f"SELECT {', '.join(req_cols)} FROM {table_name}", db.engine)
|
||||
- 風險: 攻擊者可透過惡意表格名稱執行任意 SQL 命令,竊取或刪除資料
|
||||
- 修復: 使用參數化查詢,或建立白名單驗證表格/欄位名稱
|
||||
|
||||
26. [CRITICAL] 修復 SQL Injection 漏洞 #2:
|
||||
- 檔案: database/manager.py (第 43, 52, 57 行)
|
||||
- 問題: ALTER TABLE 語句使用字串插值組合 SQL
|
||||
• session.execute(text(f"ALTER TABLE products ADD COLUMN updated_at TIMESTAMP DEFAULT '{now_str}'"))
|
||||
- 風險: 若 now_str 包含單引號可導致 SQL injection
|
||||
- 修復: 使用 SQLAlchemy 的 literal() 或驗證輸入格式
|
||||
|
||||
=== 高風險漏洞 (High - 本週內修復) ===
|
||||
|
||||
27. [HIGH] 強化登入驗證機制:
|
||||
- 檔案: auth.py (第 30-41 行)
|
||||
- 問題:
|
||||
• 明文密碼比對 (input_password == LOGIN_PASSWORD)
|
||||
• 無登入失敗次數限制
|
||||
• 無帳號鎖定機制
|
||||
• 使用簡單電話號碼作為密碼
|
||||
- 風險: 易遭受暴力破解攻擊
|
||||
- 修復:
|
||||
1. 使用 werkzeug.security 的 generate_password_hash / check_password_hash
|
||||
2. 實作登入失敗計數 (IP-based)
|
||||
3. 5 次失敗後鎖定 5 分鐘
|
||||
4. 要求更換為強密碼 (8+ 字元,含英數符號)
|
||||
|
||||
28. [HIGH] 加入 CSRF 防護:
|
||||
- 檔案: app.py (多處 POST 路由)
|
||||
- 問題: 所有 POST/PUT/DELETE 端點皆無 CSRF Token 驗證
|
||||
- 風險: 攻擊者可偽造請求執行未授權操作 (如刪除資料、修改設定)
|
||||
- 修復:
|
||||
1. 安裝 Flask-WTF: pip install Flask-WTF
|
||||
2. 在 app.py 設定 app.config['WTF_CSRF_ENABLED'] = True
|
||||
3. 在所有表單加入 {{ csrf_token() }}
|
||||
4. 為 AJAX 請求設定 X-CSRFToken header
|
||||
|
||||
29. [HIGH] 修復路徑遍歷漏洞:
|
||||
- 檔案: app.py (第 2069-2070 行)
|
||||
- 問題: 未驗證檔案路徑是否超出允許目錄
|
||||
• potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename)
|
||||
- 風險: 攻擊者可使用 ../../../etc/passwd 存取系統檔案
|
||||
- 修復: 使用 pathlib.Path.resolve() 確保路徑在合法目錄內
|
||||
|
||||
30. [HIGH] 檔案上傳驗證:
|
||||
- 檔案: app.py (第 2406-2422 行)
|
||||
- 問題: 上傳 Excel 檔案無驗證
|
||||
• 未檢查副檔名
|
||||
• 未檢查檔案大小
|
||||
• 未驗證 MIME type
|
||||
- 風險: 可上傳惡意檔案 (如 webshell, 病毒),或透過大檔案導致 DoS
|
||||
- 修復:
|
||||
1. 白名單副檔名: ['xlsx', 'xls', 'csv']
|
||||
2. 限制檔案大小: 10MB
|
||||
3. 使用 werkzeug.utils.secure_filename() 清理檔名
|
||||
4. 驗證 MIME type
|
||||
|
||||
=== 中風險漏洞 (Medium - 本月內修復) ===
|
||||
|
||||
31. [MEDIUM] 缺少 HTTP 安全標頭:
|
||||
- 檔案: app.py (全域)
|
||||
- 問題: 未設定安全相關 HTTP headers
|
||||
- 風險: 易受 XSS、點擊劫持、MIME type sniffing 攻擊
|
||||
- 修復: 在 @app.after_request 加入以下 headers:
|
||||
• X-Content-Type-Options: nosniff
|
||||
• X-Frame-Options: DENY
|
||||
• X-XSS-Protection: 1; mode=block
|
||||
• Strict-Transport-Security: max-age=31536000
|
||||
• Content-Security-Policy: default-src 'self'
|
||||
|
||||
32. [MEDIUM] Session 安全性不足:
|
||||
- 檔案: auth.py (第 34 行)
|
||||
- 問題: Session cookie 設定不安全
|
||||
- 風險: Session 易被劫持或遭 CSRF 攻擊
|
||||
- 修復: 在 app.py 設定:
|
||||
• app.config['SESSION_COOKIE_SECURE'] = True # HTTPS only
|
||||
• app.config['SESSION_COOKIE_HTTPONLY'] = True # 防 XSS
|
||||
• app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防 CSRF
|
||||
• app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=2)
|
||||
|
||||
33. [MEDIUM] 弱亂數產生器:
|
||||
- 檔案: app.py (第 720 行)
|
||||
- 問題: 使用時間戳生成 ID
|
||||
• new_id = int(time.time() * 1000)
|
||||
- 風險: ID 可預測,攻擊者可猜測其他資源 ID
|
||||
- 修復: 使用 secrets.randbits(64) 產生密碼學安全的隨機數
|
||||
|
||||
34. [MEDIUM] 敏感資訊洩漏:
|
||||
- 檔案: 多個日誌輸出
|
||||
- 問題: 日誌可能記錄敏感資料 (密碼、Token、個資)
|
||||
- 風險: 日誌檔若外洩會暴露機密資訊
|
||||
- 修復:
|
||||
1. 檢視所有 log.info / log.debug 輸出
|
||||
2. 實作密碼遮罩函式 (顯示前 2 後 2 碼,中間用 * 遮蔽)
|
||||
3. 避免記錄完整 Token 或信用卡資訊
|
||||
|
||||
35. [MEDIUM] SSRF / Open Redirect 風險:
|
||||
- 檔案: app.py (第 756-778 行)
|
||||
- 問題: 未驗證外部 URL 就發送請求
|
||||
• response = requests.get(url, headers=headers, timeout=10)
|
||||
- 風險: 攻擊者可探測內網服務或發動 SSRF 攻擊
|
||||
- 修復:
|
||||
1. 建立 URL 白名單或黑名單
|
||||
2. 禁止存取私有 IP (127.0.0.1, 10.0.0.0/8, 192.168.0.0/16)
|
||||
3. 使用 urllib.parse 驗證 URL 格式
|
||||
|
||||
36. [MEDIUM] 資源耗盡風險:
|
||||
- 檔案: app.py (多處查詢)
|
||||
- 問題:
|
||||
• 查詢結果無分頁限制
|
||||
• 無請求速率限制
|
||||
• 檔案上傳無大小限制
|
||||
- 風險: 攻擊者可發送大量請求或上傳大檔案導致服務中斷
|
||||
- 修復:
|
||||
1. 安裝 Flask-Limiter: pip install Flask-Limiter
|
||||
2. 設定 API 速率限制 (如 100 requests/min)
|
||||
3. 設定 app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
|
||||
4. 資料庫查詢加入 LIMIT 與分頁
|
||||
|
||||
37. [MEDIUM] 缺少授權檢查:
|
||||
- 檔案: app.py (多個路由)
|
||||
- 問題: 部分路由未加 @login_required 裝飾器
|
||||
- 風險: 未登入使用者可存取受保護功能
|
||||
- 修復: 檢視所有路由,確保需要登入的端點都有 @login_required
|
||||
|
||||
=== 後續安全維護建議 ===
|
||||
|
||||
38. [建議] 定期安全掃描:
|
||||
- 使用工具定期檢測漏洞:
|
||||
• bandit (Python 程式碼安全掃描): pip install bandit && bandit -r .
|
||||
• pip-audit (依賴套件漏洞掃描): pip install pip-audit && pip-audit
|
||||
• semgrep (進階程式碼分析): https://semgrep.dev/
|
||||
- 建議每月執行一次,或於部署前執行
|
||||
|
||||
39. [建議] 建立安全開發規範:
|
||||
- 制定 secure coding guidelines 文件
|
||||
- Code review 時檢查安全性
|
||||
- 使用 pre-commit hooks 執行安全掃描
|
||||
- 定期更新依賴套件至最新版本
|
||||
|
||||
40. [建議] 備份與災難復原計畫:
|
||||
- 確保資料庫定期備份
|
||||
- 建立系統還原 SOP
|
||||
- 測試備份還原流程
|
||||
- 考慮實作異地備份
|
||||
|
||||
================================================================================
|
||||
【業績分析儀表板邏輯定義 (2026-01-15 修復確認)】
|
||||
================================================================================
|
||||
所有後續開發與維護,必須嚴格遵守以下邏輯與定義:
|
||||
|
||||
1. 數據聚合維度 (Aggregation Granularity):
|
||||
- Dashboard Top 3 卡片:
|
||||
* 聚合鍵: `[商品ID, 商品名稱]` (不可僅用名稱,避免同名不同規商品混淆)。
|
||||
* 顯示名稱: 僅顯示 `商品名稱` (前端截斷)。
|
||||
* 目的: 確保卡片數值與詳細列表中的單品數值完全一致。
|
||||
- 詳細列表 (Modal):
|
||||
* 聚合鍵: `[商品ID, 商品名稱, 品牌, 廠商名稱, 商品館(分類)]`。
|
||||
* 目的: 提供最完整的商品細節。
|
||||
|
||||
2. 利潤計算邏輯 (Profit Logic):
|
||||
- 通用公式:
|
||||
* 若資料源存在「利潤」欄位 (`col_profit`): 優先使用 `SUM(col_profit)`。
|
||||
* 若無: 使用 `SUM(總業績) - SUM(總成本)`。
|
||||
- 應用範圍:
|
||||
* 所有 KPI 卡片 (利潤、毛利率)。
|
||||
* 所有圖表 (BCG 矩陣)。
|
||||
* 詳細列表 SQL 查詢。
|
||||
* Excel 匯出功能。
|
||||
|
||||
3. 欄位映射與動態性 (Dynamic Column Mapping):
|
||||
- 機制: 必須優先從 `_SALES_PROCESSED_CACHE` 讀取 `cols_map`。
|
||||
- 禁止行為: 嚴禁在 SQL 查詢中硬編碼中文欄位名稱 (如 "商品館", "品牌", "總業績")。
|
||||
- 變數使用: 必須使用變數 (如 `col_category`, `col_amount`) 構建 SQL。
|
||||
|
||||
4. 篩選連動性 (Filter Consistency):
|
||||
- 全域連動: 任何全域篩選條件 (日期、分類、品牌、廠商、價格區間、毛利區間、關鍵字) 必須同時作用於:
|
||||
* 主畫面所有圖表 (趨勢圖、圓餅圖、長條圖)。
|
||||
* Top 3 商業洞察卡片。
|
||||
* 點擊卡片後的詳細列表 (Modal)。
|
||||
* 詳細列表的 Excel 匯出。
|
||||
1
__init__.py
Normal file
1
__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
196
abc_analysis_detail.html
Normal file
196
abc_analysis_detail.html
Normal file
@@ -0,0 +1,196 @@
|
||||
<!-- cspell:ignore MOMO datatables -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ABC 分析詳情 - {{ info.title }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.11.5/css/dataTables.bootstrap5.min.css">
|
||||
<style>
|
||||
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: #f4f6f9;
|
||||
padding-top: 70px;
|
||||
}
|
||||
.card { border: none; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.03); margin-bottom: 1.5rem; background: #fff; }
|
||||
.table th { font-weight: 600; color: #495057; background-color: #f8f9fa; }
|
||||
.text-truncate-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar-dark.bg-primary {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-body-tertiary">
|
||||
{% include 'components/_navbar.html' %}
|
||||
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 mt-4">
|
||||
<div>
|
||||
<h4 class="mb-1 fw-bold text-{{ info.color }}">
|
||||
<i class="fas fa-layer-group me-2"></i>{{ info.title }}
|
||||
</h4>
|
||||
<span class="text-muted">{{ info.desc }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- V-New: 動態補貨係數調整 -->
|
||||
<div class="input-group input-group-sm" style="width: 200px;">
|
||||
<span class="input-group-text bg-white">補貨係數 x</span>
|
||||
<input type="number" id="restockFactor" class="form-control text-center" value="{{ current_factor }}" step="0.1" min="0">
|
||||
<button class="btn btn-outline-primary" onclick="applyFactor()">應用</button>
|
||||
</div>
|
||||
|
||||
<!-- V-Fix: 匯出連結需包含 factor 參數 -->
|
||||
<a href="/api/export/excel/abc?class={{ target_class }}&factor={{ current_factor }}&{{ query_string }}" class="btn btn-success btn-sm">
|
||||
<i class="fas fa-file-excel me-2"></i>匯出此類別報表
|
||||
</a>
|
||||
<button onclick="window.close()" class="btn btn-outline-secondary ms-2">關閉</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table id="dataTable" class="table table-hover align-middle mb-0" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center" style="width: 60px;">排名</th>
|
||||
{% if cols.pid %}<th>商品ID</th>{% endif %}
|
||||
<th style="width: 30%;">商品名稱</th>
|
||||
{% if cols.brand %}<th>品牌</th>{% endif %}
|
||||
{% if cols.vendor %}<th>廠商名稱</th>{% endif %}
|
||||
{% if cols.cat %}<th>分類</th>{% endif %}
|
||||
{% if cols.cost or cols.profit %}<th>毛利率</th>{% endif %}
|
||||
{% if cols.qty %}<th>平均單價</th>{% endif %}
|
||||
{% if cols.qty %}<th class="text-end">銷售數量</th>{% endif %}
|
||||
{% if cols.qty %}<th class="text-end table-info">建議補貨 (x{{ current_factor }})</th>{% endif %}
|
||||
<th class="text-end">銷售金額</th>
|
||||
<th class="text-end">累積營收佔比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td class="text-center fw-bold text-muted">{{ loop.index }}</td>
|
||||
{% if cols.pid %}
|
||||
<td class="small text-muted">{{ item[cols.pid] }}</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<div class="text-truncate-2" title="{{ item[cols.name] }}">
|
||||
{{ item[cols.name] }}
|
||||
</div>
|
||||
</td>
|
||||
{% if cols.brand %}
|
||||
<td class="small text-muted">{{ item[cols.brand] }}</td>
|
||||
{% endif %}
|
||||
{% if cols.vendor %}
|
||||
<td class="small text-muted">{{ item[cols.vendor] }}</td>
|
||||
{% endif %}
|
||||
{% if cols.cat %}
|
||||
<td><span class="badge bg-light text-dark border">{{ item[cols.cat] }}</span></td>
|
||||
{% endif %}
|
||||
{% if cols.cost or cols.profit %}
|
||||
<td>
|
||||
{% set margin = item['calculated_margin_rate'] %}
|
||||
<span class="{{ 'text-success' if margin >= 30 else ('text-danger' if margin < 10 else 'text-dark') }} fw-bold">
|
||||
{{ "{:.1f}%".format(margin) }}
|
||||
</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if cols.qty %}
|
||||
<td class="small">${{ "{:,.0f}".format(item['avg_unit_price']) }}</td>
|
||||
{% endif %}
|
||||
{% if cols.qty %}
|
||||
<td class="text-end">{{ "{:,.0f}".format(item[cols.qty]) }}</td>
|
||||
{% endif %}
|
||||
{% if cols.qty %}
|
||||
<td class="text-end table-info fw-bold">
|
||||
{% if item['suggested_restock'] > 0 %}
|
||||
{{ "{:,.0f}".format(item['suggested_restock']) }}
|
||||
{% else %}
|
||||
<span class="text-muted small">建議清倉</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="text-end fw-bold text-danger">
|
||||
${{ "{:,.0f}".format(item[cols.amount]) }}
|
||||
</td>
|
||||
<td class="text-end text-muted small">
|
||||
{{ "{:.2f}%".format(item['cumulative_pct']) }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.11.5/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script>
|
||||
// V-Fix: 提取排序索引變數,避免在物件實字中混用模板語法導致編輯器誤判
|
||||
const sortColIndex = parseInt("{{ sort_col_index }}");
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#dataTable').DataTable({
|
||||
"language": {
|
||||
"url": "//cdn.datatables.net/plug-ins/1.11.5/i18n/zh-HANT.json",
|
||||
"paginate": {
|
||||
"previous": "上一頁",
|
||||
"next": "下一頁"
|
||||
}
|
||||
},
|
||||
"pageLength": 25, // V-Adj: 預設每頁 25 筆,避免過長
|
||||
"lengthMenu": [[10, 25, 50, 100, -1], [10, 25, 50, 100, "全部"]], // V-New: 允許使用者選擇每頁筆數
|
||||
"order": [[ sortColIndex, "desc" ]], // 預設按金額排序
|
||||
"paging": true, // 確保分頁開啟
|
||||
"info": true // 顯示 "第 x 至 x 筆,共 x 筆"
|
||||
});
|
||||
});
|
||||
|
||||
// V-New: 應用新的補貨係數
|
||||
function applyFactor() {
|
||||
const factor = document.getElementById('restockFactor').value;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('factor', factor);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
200
aiops-core/README.md
Normal file
200
aiops-core/README.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# WOOO AIOps Core
|
||||
|
||||
智慧雲端運維平台核心模組
|
||||
|
||||
## 架構概覽
|
||||
|
||||
```
|
||||
aiops-core/
|
||||
├── deploy_engine/ # 部署引擎
|
||||
│ ├── deploy_service.py # 主部署服務
|
||||
│ ├── template_renderer.py # Jinja2 模板渲染
|
||||
│ └── k8s_client.py # Kubernetes 客戶端
|
||||
│
|
||||
├── monitor_engine/ # 監控引擎
|
||||
│ ├── monitor_service.py # 監控服務
|
||||
│ ├── prometheus_client.py # Prometheus API
|
||||
│ └── alert_manager.py # Alertmanager API
|
||||
│
|
||||
├── repair_engine/ # 自動修復引擎
|
||||
│ ├── repair_service.py # 修復決策引擎
|
||||
│ ├── repair_executor.py # 修復執行器
|
||||
│ └── repair_strategies.py # 修復策略
|
||||
│
|
||||
├── templates/ # K8s 部署模板
|
||||
│ ├── base/ # 基礎模板
|
||||
│ │ ├── namespace.yaml.j2
|
||||
│ │ ├── service.yaml.j2
|
||||
│ │ └── ingress.yaml.j2
|
||||
│ └── frameworks/ # 框架專用模板
|
||||
│ ├── fastapi/
|
||||
│ ├── flask/
|
||||
│ ├── express/
|
||||
│ └── nextjs/
|
||||
│
|
||||
├── api/ # FastAPI 後端
|
||||
│ ├── main.py # 應用入口
|
||||
│ └── routers/ # API 路由
|
||||
│ ├── auth.py # 認證
|
||||
│ ├── apps.py # 應用管理
|
||||
│ ├── deployments.py # 部署管理
|
||||
│ ├── monitoring.py # 監控
|
||||
│ ├── repairs.py # 自動修復
|
||||
│ └── users.py # 用戶管理
|
||||
│
|
||||
└── web/ # React 前端
|
||||
├── src/
|
||||
│ ├── pages/ # 頁面
|
||||
│ ├── components/ # 組件
|
||||
│ └── lib/ # 工具庫
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. Deploy Engine - 一鍵部署
|
||||
|
||||
```python
|
||||
from aiops_core.deploy_engine import DeployService
|
||||
|
||||
deploy_service = DeployService()
|
||||
|
||||
# 部署新應用
|
||||
result = deploy_service.deploy(
|
||||
app=AppConfig(
|
||||
name="my-api",
|
||||
framework="fastapi",
|
||||
git_repo="https://github.com/user/repo.git",
|
||||
branch="main"
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Monitor Engine - 智能監控
|
||||
|
||||
```python
|
||||
from aiops_core.monitor_engine import MonitorService
|
||||
|
||||
monitor_service = MonitorService()
|
||||
|
||||
# 設置監控
|
||||
monitor_service.setup_monitoring(
|
||||
config=MonitorConfig(
|
||||
app_name="my-api",
|
||||
namespace="default",
|
||||
telegram_chat_id="123456789"
|
||||
)
|
||||
)
|
||||
|
||||
# 取得健康狀態
|
||||
health = monitor_service.get_app_health("my-api", "default")
|
||||
```
|
||||
|
||||
### 3. Repair Engine - 自動修復
|
||||
|
||||
```python
|
||||
from aiops_core.repair_engine import RepairService
|
||||
|
||||
repair_service = RepairService()
|
||||
|
||||
# 處理告警,自動決定並執行修復
|
||||
repair_service.process_alert({
|
||||
"labels": {
|
||||
"alertname": "HighMemoryUsage",
|
||||
"app": "my-api",
|
||||
"namespace": "default"
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## 支援的框架
|
||||
|
||||
| 框架 | 狀態 | 預設端口 |
|
||||
|------|------|---------|
|
||||
| FastAPI | ✅ | 8000 |
|
||||
| Flask | ✅ | 5000 |
|
||||
| Express.js | ✅ | 3000 |
|
||||
| Next.js | ✅ | 3000 |
|
||||
| Django | 🚧 | 8000 |
|
||||
| NestJS | 🚧 | 3000 |
|
||||
|
||||
## 自動修復策略
|
||||
|
||||
| 告警類型 | 修復動作 |
|
||||
|---------|---------|
|
||||
| AppDown | 重啟 Pod |
|
||||
| HighMemoryUsage | 重啟 Pod |
|
||||
| PodOOMKilled | 增加記憶體限制 +50% |
|
||||
| HighCPUUsage | 擴容 +50% |
|
||||
| HighHTTP5xxRate | 回滾到上一版本 |
|
||||
| PostgresHighConnections | VACUUM ANALYZE |
|
||||
| DiskSpaceLow | 清理快取 |
|
||||
|
||||
## API 端點
|
||||
|
||||
### 認證
|
||||
- `POST /api/auth/login` - 登入
|
||||
- `POST /api/auth/register` - 註冊
|
||||
- `GET /api/auth/me` - 取得當前用戶
|
||||
|
||||
### 應用管理
|
||||
- `GET /api/apps` - 列出應用
|
||||
- `POST /api/apps` - 創建應用
|
||||
- `GET /api/apps/{id}` - 取得應用詳情
|
||||
- `PUT /api/apps/{id}` - 更新應用
|
||||
- `DELETE /api/apps/{id}` - 刪除應用
|
||||
- `POST /api/apps/{id}/start` - 啟動應用
|
||||
- `POST /api/apps/{id}/stop` - 停止應用
|
||||
- `POST /api/apps/{id}/restart` - 重啟應用
|
||||
|
||||
### 部署
|
||||
- `GET /api/deployments` - 列出部署記錄
|
||||
- `POST /api/deployments` - 創建部署
|
||||
- `POST /api/deployments/{id}/cancel` - 取消部署
|
||||
- `POST /api/deployments/{id}/rollback` - 回滾部署
|
||||
|
||||
### 監控
|
||||
- `GET /api/monitoring/dashboard` - 儀表板概覽
|
||||
- `GET /api/monitoring/apps/{id}/metrics` - 應用指標
|
||||
- `GET /api/monitoring/apps/{id}/health` - 健康狀態
|
||||
- `GET /api/monitoring/alerts` - 告警列表
|
||||
|
||||
### 自動修復
|
||||
- `GET /api/repairs` - 修復記錄
|
||||
- `GET /api/repairs/stats` - 修復統計
|
||||
- `POST /api/repairs/apps/{id}/trigger` - 手動觸發修復
|
||||
|
||||
## 快速開始
|
||||
|
||||
### 啟動 API 服務
|
||||
|
||||
```bash
|
||||
cd aiops-core/api
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### 啟動 Web 前端
|
||||
|
||||
```bash
|
||||
cd aiops-core/web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 環境變數
|
||||
|
||||
```bash
|
||||
# API
|
||||
JWT_SECRET=your-secret-key
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
TELEGRAM_BOT_TOKEN=your-bot-token
|
||||
|
||||
# Web
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8000/api
|
||||
```
|
||||
|
||||
## 授權
|
||||
|
||||
© 2026 WOOO TECH. All rights reserved.
|
||||
56
aiops-core/requirements.txt
Normal file
56
aiops-core/requirements.txt
Normal file
@@ -0,0 +1,56 @@
|
||||
# =============================================================================
|
||||
# WOOO AIOps - Python Dependencies
|
||||
# =============================================================================
|
||||
|
||||
# Web Framework
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
# Database
|
||||
sqlalchemy>=2.0.0
|
||||
psycopg2-binary>=2.9.9
|
||||
alembic>=1.13.0
|
||||
|
||||
# Authentication
|
||||
pyjwt>=2.8.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
|
||||
# Template Rendering
|
||||
jinja2>=3.1.2
|
||||
pyyaml>=6.0.1
|
||||
|
||||
# HTTP Client
|
||||
requests>=2.31.0
|
||||
httpx>=0.26.0
|
||||
|
||||
# Kubernetes
|
||||
kubernetes>=29.0.0
|
||||
|
||||
# Monitoring & Alerting
|
||||
prometheus-client>=0.19.0
|
||||
|
||||
# Caching
|
||||
redis>=5.0.0
|
||||
|
||||
# Task Queue (可選)
|
||||
celery>=5.3.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.1.0
|
||||
|
||||
# Logging
|
||||
structlog>=24.1.0
|
||||
|
||||
# Testing
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.23.0
|
||||
httpx>=0.26.0
|
||||
|
||||
# Security
|
||||
cryptography>=41.0.0
|
||||
|
||||
# Date/Time
|
||||
python-dateutil>=2.8.2
|
||||
7508
app.py.backup_login_required
Normal file
7508
app.py.backup_login_required
Normal file
File diff suppressed because it is too large
Load Diff
354
auth.py
Normal file
354
auth.py
Normal file
@@ -0,0 +1,354 @@
|
||||
import os
|
||||
import time
|
||||
from flask import render_template, redirect, url_for, request, session, flash
|
||||
from functools import wraps
|
||||
from werkzeug.security import check_password_hash
|
||||
from config import LOGIN_PASSWORD
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# ==========================================
|
||||
# 🔓 登入功能開關
|
||||
# ==========================================
|
||||
# 設定環境變數 DISABLE_LOGIN=true 可關閉登入驗證
|
||||
DISABLE_LOGIN = os.getenv('DISABLE_LOGIN', 'false').lower() == 'true'
|
||||
|
||||
if DISABLE_LOGIN:
|
||||
print("⚠️ 警告:登入驗證已關閉 (DISABLE_LOGIN=true)")
|
||||
|
||||
# ==========================================
|
||||
# 🔒 登入失敗追蹤與帳號鎖定機制
|
||||
# ==========================================
|
||||
|
||||
# 登入失敗記錄(IP-based)
|
||||
# 格式:{ip: {'count': 失敗次數, 'locked_until': 鎖定到期時間, 'first_attempt': 首次失敗時間}}
|
||||
LOGIN_ATTEMPTS = {}
|
||||
|
||||
# 鎖定設定
|
||||
MAX_LOGIN_ATTEMPTS = 5 # 最多失敗次數
|
||||
LOCKOUT_DURATION = 300 # 鎖定時長(秒)= 5 分鐘
|
||||
ATTEMPT_RESET_TIME = 1800 # 失敗記錄重置時間(秒)= 30 分鐘
|
||||
|
||||
def get_client_ip():
|
||||
"""
|
||||
取得客戶端 IP 位址(支援代理伺服器)
|
||||
"""
|
||||
if request.headers.get('X-Forwarded-For'):
|
||||
# 如果通過代理,取第一個 IP
|
||||
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
|
||||
elif request.headers.get('X-Real-IP'):
|
||||
return request.headers.get('X-Real-IP')
|
||||
else:
|
||||
return request.remote_addr
|
||||
|
||||
def is_ip_locked(ip):
|
||||
"""
|
||||
檢查 IP 是否被鎖定
|
||||
|
||||
Returns:
|
||||
tuple: (is_locked, remaining_seconds)
|
||||
"""
|
||||
if ip not in LOGIN_ATTEMPTS:
|
||||
return False, 0
|
||||
|
||||
attempt_data = LOGIN_ATTEMPTS[ip]
|
||||
|
||||
# 檢查是否已鎖定
|
||||
if 'locked_until' in attempt_data:
|
||||
locked_until = attempt_data['locked_until']
|
||||
current_time = time.time()
|
||||
|
||||
if current_time < locked_until:
|
||||
# 仍在鎖定期間
|
||||
remaining = int(locked_until - current_time)
|
||||
return True, remaining
|
||||
else:
|
||||
# 鎖定期已過,清除記錄
|
||||
del LOGIN_ATTEMPTS[ip]
|
||||
return False, 0
|
||||
|
||||
return False, 0
|
||||
|
||||
def record_login_failure(ip):
|
||||
"""
|
||||
記錄登入失敗,並檢查是否需要鎖定
|
||||
|
||||
Returns:
|
||||
bool: 是否已達到鎖定條件
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
if ip not in LOGIN_ATTEMPTS:
|
||||
LOGIN_ATTEMPTS[ip] = {
|
||||
'count': 1,
|
||||
'first_attempt': current_time
|
||||
}
|
||||
return False
|
||||
|
||||
attempt_data = LOGIN_ATTEMPTS[ip]
|
||||
|
||||
# 檢查是否超過重置時間(30分鐘無活動則重置計數)
|
||||
if current_time - attempt_data.get('first_attempt', 0) > ATTEMPT_RESET_TIME:
|
||||
LOGIN_ATTEMPTS[ip] = {
|
||||
'count': 1,
|
||||
'first_attempt': current_time
|
||||
}
|
||||
return False
|
||||
|
||||
# 增加失敗次數
|
||||
attempt_data['count'] += 1
|
||||
|
||||
# 檢查是否達到鎖定條件
|
||||
if attempt_data['count'] >= MAX_LOGIN_ATTEMPTS:
|
||||
attempt_data['locked_until'] = current_time + LOCKOUT_DURATION
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clear_login_attempts(ip):
|
||||
"""
|
||||
清除登入失敗記錄(登入成功時調用)
|
||||
"""
|
||||
if ip in LOGIN_ATTEMPTS:
|
||||
del LOGIN_ATTEMPTS[ip]
|
||||
|
||||
def validate_password_strength(password):
|
||||
"""
|
||||
驗證密碼強度
|
||||
|
||||
要求:
|
||||
- 至少 8 個字元
|
||||
- 包含英文字母
|
||||
- 包含數字
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, error_message)
|
||||
"""
|
||||
if not password:
|
||||
return False, "密碼不能為空"
|
||||
|
||||
if len(password) < 8:
|
||||
return False, "密碼長度至少需要 8 個字元"
|
||||
|
||||
has_letter = any(c.isalpha() for c in password)
|
||||
has_digit = any(c.isdigit() for c in password)
|
||||
|
||||
if not has_letter:
|
||||
return False, "密碼必須包含英文字母"
|
||||
|
||||
if not has_digit:
|
||||
return False, "密碼必須包含數字"
|
||||
|
||||
return True, None
|
||||
|
||||
# ==========================================
|
||||
# 權限驗證裝飾器
|
||||
# ==========================================
|
||||
|
||||
def login_required(f):
|
||||
"""
|
||||
權限驗證裝飾器:確保使用者必須登入才能存取特定路由。
|
||||
|
||||
若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_view(*args, **kwargs):
|
||||
# 如果登入功能已關閉,直接放行
|
||||
if DISABLE_LOGIN:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# 檢查 session 中是否有登入標記
|
||||
if not session.get('logged_in'):
|
||||
# 如果未登入,跳轉至登入頁面
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_view
|
||||
|
||||
|
||||
def get_current_user():
|
||||
"""
|
||||
取得目前登入的使用者資訊
|
||||
|
||||
Returns:
|
||||
dict: 包含使用者資訊的字典,如果未登入則返回 None
|
||||
"""
|
||||
if not session.get('logged_in'):
|
||||
return None
|
||||
|
||||
return {
|
||||
'logged_in': True,
|
||||
'login_time': session.get('login_time'),
|
||||
'client_ip': session.get('client_ip'),
|
||||
'role': session.get('role', 'admin'), # 預設為 admin(向後兼容)
|
||||
'username': session.get('username', 'admin')
|
||||
}
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""
|
||||
角色權限驗證裝飾器:確保使用者具有指定角色之一
|
||||
|
||||
Args:
|
||||
*roles: 允許的角色列表,例如 'admin', 'manager', 'user'
|
||||
|
||||
Usage:
|
||||
@role_required('admin')
|
||||
def admin_only_page():
|
||||
...
|
||||
|
||||
@role_required('admin', 'manager')
|
||||
def admin_or_manager_page():
|
||||
...
|
||||
|
||||
若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_view(*args, **kwargs):
|
||||
# 如果登入功能已關閉,直接放行
|
||||
if DISABLE_LOGIN:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# 先檢查登入狀態
|
||||
if not session.get('logged_in'):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 取得使用者角色(預設為 admin,向後兼容)
|
||||
user_role = session.get('role', 'admin')
|
||||
|
||||
# 檢查角色權限
|
||||
if user_role not in roles:
|
||||
# 權限不足,返回 403
|
||||
flash('您沒有權限存取此頁面', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_view
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
管理員權限驗證裝飾器:只允許 admin 角色存取
|
||||
|
||||
這是 role_required('admin') 的便捷寫法
|
||||
|
||||
若環境變數 DISABLE_LOGIN=true,則跳過驗證直接放行。
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_view(*args, **kwargs):
|
||||
# 如果登入功能已關閉,直接放行
|
||||
if DISABLE_LOGIN:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# 先檢查登入狀態
|
||||
if not session.get('logged_in'):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
# 取得使用者角色(預設為 admin,向後兼容)
|
||||
user_role = session.get('role', 'admin')
|
||||
|
||||
# 檢查是否為管理員
|
||||
if user_role != 'admin':
|
||||
flash('此功能僅限管理員使用', 'danger')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_view
|
||||
|
||||
# ==========================================
|
||||
# 路由初始化
|
||||
# ==========================================
|
||||
|
||||
def init_auth_routes(app):
|
||||
"""
|
||||
初始化驗證相關路由:註冊 /login 與 /logout。
|
||||
"""
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
client_ip = get_client_ip()
|
||||
|
||||
if request.method == 'POST':
|
||||
print(f"🔐 收到登入請求 | IP: {client_ip}")
|
||||
|
||||
# 1. 檢查 IP 是否被鎖定
|
||||
is_locked, remaining_seconds = is_ip_locked(client_ip)
|
||||
if is_locked:
|
||||
minutes = remaining_seconds // 60
|
||||
seconds = remaining_seconds % 60
|
||||
error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes} 分 {seconds} 秒後再試。'
|
||||
print(f"⚠️ 登入嘗試被拒絕 | IP: {client_ip} | 原因: 帳號鎖定")
|
||||
return render_template('login.html', error=error), 429
|
||||
|
||||
# 2. 取得輸入的密碼
|
||||
input_password = request.form.get('password')
|
||||
|
||||
if not input_password:
|
||||
error = '請輸入密碼'
|
||||
return render_template('login.html', error=error), 400
|
||||
|
||||
# 3. 驗證密碼
|
||||
# 注意:LOGIN_PASSWORD 可能是明文或雜湊值
|
||||
# 首先檢查是否為雜湊值(以 pbkdf2:sha256 開頭)
|
||||
is_password_valid = False
|
||||
|
||||
if LOGIN_PASSWORD.startswith('pbkdf2:sha256:'):
|
||||
# 使用雜湊比對
|
||||
is_password_valid = check_password_hash(LOGIN_PASSWORD, input_password)
|
||||
else:
|
||||
# 向後兼容:明文比對(僅用於過渡期)
|
||||
is_password_valid = (input_password == LOGIN_PASSWORD)
|
||||
if is_password_valid:
|
||||
print("⚠️ 警告:系統仍在使用明文密碼,請盡快執行密碼雜湊更新")
|
||||
|
||||
# 4. 驗證結果處理
|
||||
if is_password_valid:
|
||||
# 登入成功
|
||||
session.permanent = True
|
||||
session['logged_in'] = True
|
||||
session['login_time'] = datetime.now().isoformat()
|
||||
session['client_ip'] = client_ip
|
||||
|
||||
# 清除失敗記錄
|
||||
clear_login_attempts(client_ip)
|
||||
|
||||
print(f"✅ 登入成功 | IP: {client_ip}")
|
||||
return redirect(url_for('dashboard'))
|
||||
else:
|
||||
# 登入失敗
|
||||
is_now_locked = record_login_failure(client_ip)
|
||||
|
||||
if is_now_locked:
|
||||
error = f'登入失敗次數過多,帳號已鎖定 {LOCKOUT_DURATION // 60} 分鐘。'
|
||||
print(f"🔒 帳號已鎖定 | IP: {client_ip} | 原因: 連續 {MAX_LOGIN_ATTEMPTS} 次失敗")
|
||||
else:
|
||||
remaining_attempts = MAX_LOGIN_ATTEMPTS - LOGIN_ATTEMPTS.get(client_ip, {}).get('count', 0)
|
||||
error = f'密碼錯誤,請重新輸入。(剩餘嘗試次數:{remaining_attempts})'
|
||||
print(f"❌ 登入失敗 | IP: {client_ip} | 剩餘嘗試: {remaining_attempts}")
|
||||
|
||||
return render_template('login.html', error=error), 401
|
||||
|
||||
# GET 請求:檢查是否被鎖定
|
||||
is_locked, remaining_seconds = is_ip_locked(client_ip)
|
||||
if is_locked:
|
||||
minutes = remaining_seconds // 60
|
||||
seconds = remaining_seconds % 60
|
||||
error = f'登入失敗次數過多,帳號已鎖定。請在 {minutes} 分 {seconds} 秒後再試。'
|
||||
return render_template('login.html', error=error), 429
|
||||
|
||||
# 顯示登入頁面
|
||||
return render_template('login.html', error=None)
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
"""
|
||||
登出路由:清除 session 並導回登入頁。
|
||||
"""
|
||||
client_ip = get_client_ip()
|
||||
session.pop('logged_in', None)
|
||||
session.pop('login_time', None)
|
||||
session.pop('client_ip', None)
|
||||
print(f"👋 使用者已登出 | IP: {client_ip}")
|
||||
return redirect(url_for('login'))
|
||||
|
||||
print("✅ Auth 模組已載入(增強安全版本)")
|
||||
730
auto_import_index.html
Normal file
730
auto_import_index.html
Normal file
@@ -0,0 +1,730 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>當日業績報表匯入 - WOOO TECH</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||||
min-height: 100vh;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
.navbar-dark.bg-primary {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
padding: 1.2rem 1.5rem;
|
||||
}
|
||||
|
||||
.card-header h5 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||||
background: linear-gradient(135deg, #5568d3 0%, #6a3e8b 100%);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #51cf66 0%, #37b24d 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 1.5rem;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 12px rgba(81, 207, 102, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(81, 207, 102, 0.4);
|
||||
background: linear-gradient(135deg, #40c057 0%, #2f9e44 100%);
|
||||
}
|
||||
|
||||
.table {
|
||||
background: white;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody td {
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 錯誤訊息可換行 */
|
||||
.table tbody td.error-cell {
|
||||
white-space: normal;
|
||||
max-width: 250px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-downloading {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.badge-importing {
|
||||
background: #cce5ff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.badge-completed {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-failed {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* Custom Dark Gray Navbar */
|
||||
.navbar.bg-custom-dark {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar.bg-custom-dark .navbar-brand {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar.bg-custom-dark .navbar-text {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-body-tertiary">
|
||||
<!-- 導航列 -->
|
||||
{% include 'components/_navbar.html' %}
|
||||
|
||||
<div class="container">
|
||||
<!-- 頁面標題 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="fas fa-cloud-download-alt me-2"></i>當日業績報表匯入</h2>
|
||||
<p class="text-muted">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
支援兩種匯入方式:<strong>Google Drive 自動匯入</strong>(每 30 分鐘檢查)或 <strong>手動上傳</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置區 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-cog me-2"></i>Google Drive 自動匯入配置</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="configAlert"></div>
|
||||
|
||||
<div class="alert alert-light border">
|
||||
<p class="mb-0 small">
|
||||
<i class="fas fa-sync-alt me-1"></i>
|
||||
系統每 30 分鐘自動檢查 Google Drive → 下載檔案 → 匯入資料庫 → 刪除雲端原檔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="folderPath" class="form-label">Google Drive 資料夾路徑</label>
|
||||
<input type="text" class="form-control" id="folderPath" placeholder="例如: 業績報表/當日業績">
|
||||
<small class="text-muted">設定要監控的 Google Drive 資料夾路徑</small>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="filePattern" class="form-label">檔案名稱模式(選填)</label>
|
||||
<input type="text" class="form-control" id="filePattern" placeholder="例如: 即時業績_當日">
|
||||
<small class="text-muted">用於過濾特定名稱的檔案</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-primary" onclick="saveConfig()">
|
||||
<i class="fas fa-save me-1"></i>儲存配置
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="testConnection()">
|
||||
<i class="fas fa-plug me-1"></i>測試連接
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="listFiles()">
|
||||
<i class="fas fa-list me-1"></i>列出檔案
|
||||
</button>
|
||||
<button class="btn btn-success" onclick="manualImport()">
|
||||
<i class="fas fa-play me-1"></i>立即匯入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手動上傳區 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-upload me-2"></i>手動上傳匯入</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="uploadAlert"></div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<h6 class="fw-bold mb-2"><i class="fas fa-info-circle me-2"></i>每日業績快照</h6>
|
||||
<p class="mb-1">匯入格式:<code>即時業績_當日_YYYYMMDD.xlsx</code>(例如:即時業績_當日_20260113.xlsx)</p>
|
||||
<p class="mb-0 small">資料將會<strong>累加寫入</strong>至 <code>daily_sales_snapshot</code> 資料表,並自動去重。</p>
|
||||
</div>
|
||||
|
||||
<div class="row align-items-end">
|
||||
<div class="col-md-8 mb-3 mb-md-0">
|
||||
<label for="manualUploadFile" class="form-label">選擇檔案</label>
|
||||
<input type="file" class="form-control" id="manualUploadFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button class="btn btn-primary w-100" onclick="uploadManualFile()">
|
||||
<i class="fas fa-upload me-1"></i>上傳並匯入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 匯入任務清單 -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>匯入任務歷史</h5>
|
||||
<button class="btn btn-warning btn-sm" onclick="resetStuckJobs()">
|
||||
<i class="fas fa-redo me-1"></i>重置卡住的任務
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="jobsLoading" class="text-center py-4" style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">載入中...</span>
|
||||
</div>
|
||||
<p class="mt-2 text-muted">載入中...</p>
|
||||
</div>
|
||||
|
||||
<div id="jobsEmpty" class="empty-state" style="display: none;">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<p>尚無匯入記錄</p>
|
||||
</div>
|
||||
|
||||
<div id="jobsTableContainer" style="display: none;">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle" id="jobsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>檔案名稱</th>
|
||||
<th>狀態</th>
|
||||
<th>進度</th>
|
||||
<th>成功/總</th>
|
||||
<th>開始時間</th>
|
||||
<th>完成時間</th>
|
||||
<th>錯誤訊息</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// 載入配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/import_config');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
document.getElementById('folderPath').value = result.data.folder_path || '';
|
||||
document.getElementById('filePattern').value = result.data.file_pattern || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('載入配置失敗:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 儲存配置
|
||||
async function saveConfig() {
|
||||
const folderPath = document.getElementById('folderPath').value;
|
||||
const filePattern = document.getElementById('filePattern').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import_config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder_path: folderPath,
|
||||
file_pattern: filePattern
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>配置已儲存');
|
||||
} else {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>儲存配置失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 測試連接
|
||||
async function testConnection() {
|
||||
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在測試 Google Drive 連接...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/test_drive_connection', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>' + result.message);
|
||||
} else {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>測試失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 列出檔案
|
||||
async function listFiles() {
|
||||
const folderPath = document.getElementById('folderPath').value;
|
||||
const filePattern = document.getElementById('filePattern').value;
|
||||
|
||||
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在列出 Google Drive 檔案...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/list_drive_files', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
folder_path: folderPath,
|
||||
file_pattern: filePattern
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const fileList = result.data.map(f => `<li class="mb-1"><i class="fas fa-file-excel me-1"></i>${f.name}</li>`).join('');
|
||||
showAlert('configAlert', 'success', `<i class="fas fa-check-circle me-2"></i>找到 ${result.count} 個檔案:<ul class="mt-2 mb-0">${fileList || '<li>無</li>'}</ul>`);
|
||||
} else {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>列出檔案失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 手動匯入
|
||||
async function manualImport() {
|
||||
if (!confirm('確定要立即執行匯入嗎?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在執行匯入,請稍候...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/manual_import', {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>' + result.message);
|
||||
setTimeout(() => loadJobs(), 1000);
|
||||
} else {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>匯入失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 載入任務列表
|
||||
async function loadJobs() {
|
||||
document.getElementById('jobsLoading').style.display = 'block';
|
||||
document.getElementById('jobsEmpty').style.display = 'none';
|
||||
document.getElementById('jobsTableContainer').style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import_jobs?limit=50');
|
||||
const result = await response.json();
|
||||
|
||||
document.getElementById('jobsLoading').style.display = 'none';
|
||||
|
||||
if (result.success && result.data.length > 0) {
|
||||
document.getElementById('jobsTableContainer').style.display = 'block';
|
||||
renderJobs(result.data);
|
||||
} else {
|
||||
document.getElementById('jobsEmpty').style.display = 'block';
|
||||
}
|
||||
} catch (error) {
|
||||
document.getElementById('jobsLoading').style.display = 'none';
|
||||
document.getElementById('jobsEmpty').style.display = 'block';
|
||||
console.error('載入任務列表失敗:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染任務列表
|
||||
function renderJobs(jobs) {
|
||||
const tbody = document.getElementById('jobsTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
jobs.forEach(job => {
|
||||
const tr = document.createElement('tr');
|
||||
const fileName = job.drive_file_name || 'N/A';
|
||||
const errorMsg = job.error_message || '-';
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="fw-bold">#${job.id}</td>
|
||||
<td>
|
||||
<i class="fas fa-file-excel text-success me-1"></i>${escapeHtml(fileName)}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-${job.status}">${getStatusText(job.status)}</span>
|
||||
</td>
|
||||
<td style="min-width: 150px;">
|
||||
<div class="progress mb-1">
|
||||
<div class="progress-bar" role="progressbar" style="width: ${job.progress_percent}%"
|
||||
aria-valuenow="${job.progress_percent}" aria-valuemin="0" aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">${Math.round(job.progress_percent)}% - ${escapeHtml(job.current_step || '')}</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-success">${job.success_rows || 0}</span> /
|
||||
<span class="badge bg-secondary">${job.total_rows || 0}</span>
|
||||
</td>
|
||||
<td><small>${formatTime(job.started_at)}</small></td>
|
||||
<td><small>${formatTime(job.completed_at)}</small></td>
|
||||
<td class="error-cell"><small class="text-danger">${escapeHtml(errorMsg)}</small></td>
|
||||
<td class="text-center">
|
||||
${(job.status === 'downloading' || job.status === 'importing')
|
||||
? `<button class="btn btn-sm btn-outline-danger" onclick="failJob(${job.id})">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>`
|
||||
: '-'}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// 轉義 HTML 特殊字元
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 取得狀態文字
|
||||
function getStatusText(status) {
|
||||
const statusMap = {
|
||||
'pending': '⏳ 等待中',
|
||||
'downloading': '⬇️ 下載中',
|
||||
'importing': '📥 匯入中',
|
||||
'completed': '✅ 已完成',
|
||||
'failed': '❌ 失敗'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
// 格式化時間
|
||||
function formatTime(timeStr) {
|
||||
if (!timeStr) return '-';
|
||||
const date = new Date(timeStr);
|
||||
return date.toLocaleString('zh-TW', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// 顯示提示
|
||||
function showAlert(elementId, type, message) {
|
||||
const alertDiv = document.getElementById(elementId);
|
||||
alertDiv.innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (type === 'success' || type === 'danger') {
|
||||
setTimeout(() => {
|
||||
const alert = alertDiv.querySelector('.alert');
|
||||
if (alert) {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 手動上傳檔案
|
||||
async function uploadManualFile() {
|
||||
const fileInput = document.getElementById('manualUploadFile');
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
showAlert('uploadAlert', 'warning', '<i class="fas fa-exclamation-triangle me-2"></i>請先選擇檔案');
|
||||
return;
|
||||
}
|
||||
|
||||
// 檔名驗證
|
||||
if (!file.name.includes('即時業績') || !file.name.includes('當日')) {
|
||||
if (!confirm('檔名似乎不符合格式(應包含「即時業績」和「當日」),確定要繼續嗎?')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
showAlert('uploadAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在上傳並匯入檔案,請稍候...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import_excel', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert('uploadAlert', 'success',
|
||||
`<i class="fas fa-check-circle me-2"></i>${result.message}<br>` +
|
||||
`<small>資料表: ${result.table} | 共 ${result.rows} 筆資料</small>`
|
||||
);
|
||||
fileInput.value = '';
|
||||
setTimeout(() => loadJobs(), 1000);
|
||||
} else {
|
||||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>上傳失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadConfig();
|
||||
loadJobs();
|
||||
|
||||
// 每 10 秒自動刷新任務列表
|
||||
setInterval(loadJobs, 10000);
|
||||
|
||||
// 重置卡住的任務
|
||||
async function resetStuckJobs() {
|
||||
if (!confirm('確定要重置所有卡住超過 1 小時的任務嗎?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
loadJobs();
|
||||
} else {
|
||||
alert('重置失敗: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('重置失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 手動將任務標記為失敗
|
||||
async function failJob(jobId) {
|
||||
if (!confirm(`確定要取消任務 #${jobId} 嗎?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
loadJobs();
|
||||
} else {
|
||||
alert('取消失敗: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('取消失敗: ' + error.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
248
bin/Activate.ps1
Normal file
248
bin/Activate.ps1
Normal file
@@ -0,0 +1,248 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
76
bin/activate
Normal file
76
bin/activate
Normal file
@@ -0,0 +1,76 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# You cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past locations. Without forgetting
|
||||
# past locations the $PATH changes we made may not be respected.
|
||||
# See "man bash" for more details. hash is usually a builtin of your shell
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
# on Windows, a path can contain colons and backslashes and has to be converted:
|
||||
case "$(uname)" in
|
||||
CYGWIN*|MSYS*|MINGW*)
|
||||
# transform D:\path\to\venv to /d/path/to/venv on MSYS and MINGW
|
||||
# and to /cygdrive/d/path/to/venv on Cygwin
|
||||
VIRTUAL_ENV=$(cygpath /Users/ooo/Documents/momo_pro_system/venv)
|
||||
export VIRTUAL_ENV
|
||||
;;
|
||||
*)
|
||||
# use the path as-is
|
||||
export VIRTUAL_ENV=/Users/ooo/Documents/momo_pro_system/venv
|
||||
;;
|
||||
esac
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
VIRTUAL_ENV_PROMPT=venv
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1="("venv") ${PS1:-}"
|
||||
export PS1
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
27
bin/activate.csh
Normal file
27
bin/activate.csh
Normal file
@@ -0,0 +1,27 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /Users/ooo/Documents/momo_pro_system/venv
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
setenv VIRTUAL_ENV_PROMPT venv
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = "("venv") $prompt:q"
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
69
bin/activate.fish
Normal file
69
bin/activate.fish
Normal file
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/). You cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /Users/ooo/Documents/momo_pro_system/venv
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
set -gx VIRTUAL_ENV_PROMPT venv
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s(%s)%s " (set_color 4B8BBE) venv (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
end
|
||||
8
bin/flask
Executable file
8
bin/flask
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from flask.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/ngrok
Executable file
8
bin/ngrok
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pyngrok.ngrok import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/normalizer
Executable file
8
bin/normalizer
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from charset_normalizer.cli import cli_detect
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli_detect())
|
||||
8
bin/pip
Executable file
8
bin/pip
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/pip3
Executable file
8
bin/pip3
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/pip3.13
Executable file
8
bin/pip3.13
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
8
bin/pyngrok
Executable file
8
bin/pyngrok
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pyngrok.ngrok import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
1
bin/python
Symbolic link
1
bin/python
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
1
bin/python3
Symbolic link
1
bin/python3
Symbolic link
@@ -0,0 +1 @@
|
||||
/opt/anaconda3/bin/python3
|
||||
1
bin/python3.13
Symbolic link
1
bin/python3.13
Symbolic link
@@ -0,0 +1 @@
|
||||
python3
|
||||
8
bin/wsdump
Executable file
8
bin/wsdump
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/Users/ooo/Documents/momo_pro_system/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from websocket._wsdump import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
197
brand_assets.html
Normal file
197
brand_assets.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WOOO TECH 品牌資產庫</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 30px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.asset-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 棋盤格背景以顯示透明度 */
|
||||
background-image: linear-gradient(45deg, #eee 25%, transparent 25%), linear-gradient(-45deg, #eee 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eee 75%), linear-gradient(-45deg, transparent 75%, #eee 75%);
|
||||
background-size: 20px 20px;
|
||||
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.format-links a {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
text-decoration: none;
|
||||
color: #4F46E5;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #4F46E5;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.format-links a:hover {
|
||||
background: #4F46E5;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>WOOO TECH 品牌資產庫</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">1. 主品牌標誌 (Main Logo)</h2>
|
||||
<div class="asset-grid">
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Main_Logo.svg" alt="Main Logo">
|
||||
</div>
|
||||
<div>
|
||||
<strong>SVG (向量)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Main_Logo.svg" download>下載 SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Main_Logo.jpg" alt="Main Logo JPG">
|
||||
</div>
|
||||
<div>
|
||||
<strong>JPG (白底)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Main_Logo.jpg" download>下載 JPG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 其他格式僅提供下載 -->
|
||||
<div class="asset-item">
|
||||
<div style="padding: 20px;">
|
||||
<strong>其他格式</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Main_Logo.eps" download>EPS</a>
|
||||
<a href="/static/exports/WOOO_Main_Logo.pdf" download>PDF/AI</a>
|
||||
<a href="/static/exports/WOOO_Main_Logo.tiff" download>TIFF</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">2. 玻璃質感版 (Glass Version)</h2>
|
||||
<div class="asset-grid">
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Glass_Logo.svg" alt="Glass Logo">
|
||||
</div>
|
||||
<div>
|
||||
<strong>SVG (向量)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Glass_Logo.svg" download>下載 SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Glass_Logo.jpg" alt="Glass Logo JPG">
|
||||
</div>
|
||||
<div>
|
||||
<strong>JPG (白底)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Glass_Logo.jpg" download>下載 JPG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2 class="section-title">3. 能量流動版 (Gradient Version)</h2>
|
||||
<div class="asset-grid">
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Gradient_Logo.svg" alt="Gradient Logo">
|
||||
</div>
|
||||
<div>
|
||||
<strong>SVG (向量)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Gradient_Logo.svg" download>下載 SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-item">
|
||||
<div class="preview-box">
|
||||
<img src="/static/exports/WOOO_Gradient_Logo.jpg" alt="Gradient Logo JPG">
|
||||
</div>
|
||||
<div>
|
||||
<strong>JPG (白底)</strong><br>
|
||||
<div class="format-links">
|
||||
<a href="/static/exports/WOOO_Gradient_Logo.jpg" download>下載 JPG</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
components
Symbolic link
1
components
Symbolic link
@@ -0,0 +1 @@
|
||||
templates/components
|
||||
221
config.py
Normal file
221
config.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import os
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入 .env 環境變數
|
||||
load_dotenv()
|
||||
|
||||
# 基本路徑設定
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, 'data')
|
||||
LOG_DIR = os.path.join(BASE_DIR, 'logs')
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'web/static/uploads')
|
||||
|
||||
# 建立必要目錄
|
||||
for d in [DATA_DIR, LOG_DIR, UPLOAD_FOLDER]:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
# ==========================================
|
||||
# 資料庫設定
|
||||
# ==========================================
|
||||
# 支援 SQLite 和 PostgreSQL 兩種資料庫
|
||||
# 預設使用 SQLite (本地開發),可透過環境變數切換到 PostgreSQL
|
||||
USE_POSTGRESQL = os.getenv('USE_POSTGRESQL', 'false').lower() == 'true'
|
||||
|
||||
if USE_POSTGRESQL:
|
||||
# PostgreSQL 連線設定
|
||||
POSTGRES_HOST = os.getenv('POSTGRES_HOST', 'momo-postgres')
|
||||
POSTGRES_PORT = os.getenv('POSTGRES_PORT', '5432')
|
||||
POSTGRES_USER = os.getenv('POSTGRES_USER', 'momo')
|
||||
POSTGRES_PASSWORD = os.getenv('POSTGRES_PASSWORD', 'wooo_pg_2026')
|
||||
POSTGRES_DB = os.getenv('POSTGRES_DB', 'momo_analytics')
|
||||
|
||||
DATABASE_PATH = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}"
|
||||
DATABASE_TYPE = 'postgresql'
|
||||
else:
|
||||
# SQLite 連線設定 (開發環境或備用)
|
||||
DATABASE_PATH = f"sqlite:///{os.path.join(DATA_DIR, 'momo_database.db')}"
|
||||
DATABASE_TYPE = 'sqlite'
|
||||
|
||||
# ==========================================
|
||||
# 安全設定(從環境變數讀取)
|
||||
# ==========================================
|
||||
LOGIN_PASSWORD = os.getenv('LOGIN_PASSWORD', '0936223270') # 進入後台的密碼
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'your_flask_secret_key')
|
||||
|
||||
# ==========================================
|
||||
# 通訊模組設定(從環境變數讀取)
|
||||
# ==========================================
|
||||
|
||||
# --- Telegram Bot ---
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
|
||||
try:
|
||||
TELEGRAM_CHAT_IDS = json.loads(os.getenv('TELEGRAM_CHAT_IDS', '[]'))
|
||||
except json.JSONDecodeError:
|
||||
TELEGRAM_CHAT_IDS = []
|
||||
|
||||
# --- Line Notify ---
|
||||
LINE_ENABLED = os.getenv('LINE_ENABLED', 'false').lower() == 'true' # 預設關閉
|
||||
LINE_CHANNEL_ACCESS_TOKEN = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', '')
|
||||
LINE_GROUP_ID = os.getenv('LINE_GROUP_ID', '')
|
||||
|
||||
# --- Email (SMTP) ---
|
||||
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com')
|
||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') # 注意:若使用 Gmail,需設定「應用程式密碼」
|
||||
EMAIL_SENDER = os.getenv('EMAIL_SENDER', '')
|
||||
EMAIL_RECEIVER = os.getenv('EMAIL_RECEIVER', '')
|
||||
|
||||
# ==========================================
|
||||
# 網路設定(從環境變數讀取)
|
||||
# ==========================================
|
||||
PUBLIC_URL = os.getenv('PUBLIC_URL', 'https://mo.wooo.work')
|
||||
|
||||
# 補上 EXCEL_EXPORT_DIR 定義
|
||||
EXCEL_EXPORT_DIR = os.path.join(DATA_DIR, 'excel_exports')
|
||||
|
||||
# 更新建立目錄清單 (確保系統會自動建立這個資料夾)
|
||||
for d in [DATA_DIR, LOG_DIR, UPLOAD_FOLDER, EXCEL_EXPORT_DIR]:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
# MOMO 分類清單 (從 JSON 檔案動態讀取)
|
||||
def load_momo_categories():
|
||||
import json
|
||||
import time
|
||||
|
||||
categories_path = os.path.join(DATA_DIR, 'categories.json')
|
||||
|
||||
# 預設分類,用於首次啟動或檔案遺失時
|
||||
default_categories = [
|
||||
{"name": "保養超值組", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700012"},
|
||||
{"name": "化妝水", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700001"},
|
||||
{"name": "精華液", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700002&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "乳液", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700003&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "乳霜", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700004&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "凝膠", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700005&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "面膜", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700006&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "眼霜", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700007&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "護唇膏", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700008&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "防曬", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700009&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "素顏霜", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700010&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "美顏霜", "url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700011&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "身體護理", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901499&sourcePageType=4"},
|
||||
{"name": "手部保養", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901655&sourcePageType=4"},
|
||||
{"name": "足部保養", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901656&sourcePageType=4"},
|
||||
{"name": "局部護理", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901505&sourcePageType=4"},
|
||||
{"name": "止汗體香", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901503&sourcePageType=4"},
|
||||
{"name": "嬰幼身體保養品牌旗艦", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901724&sourcePageType=4"},
|
||||
{"name": "嬰幼本月主打", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200081&sourcePageType=4"},
|
||||
{"name": "嬰幼清潔用品", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200349&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "嬰幼保養護膚", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200350&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "媽咪孕期保養", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200352&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "送禮超值組", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200348&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "嬰幼品牌總覽", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200348&p_orderType=4&showType=chessboardType"},
|
||||
{"name": "私密保養本月主打", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900060&sourcePageType=4"},
|
||||
{"name": "私密保養", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900002&sourcePageType=4"},
|
||||
{"name": "私密清潔", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900003&sourcePageType=4"},
|
||||
{"name": "除毛", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900154&sourcePageType=4"},
|
||||
{"name": "私密保養推薦品牌", "url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900023&sourcePageType=4"}
|
||||
]
|
||||
|
||||
if not os.path.exists(categories_path):
|
||||
# 如果檔案不存在,使用預設值並為每項加上 ID 後建立新檔案
|
||||
categories_with_id = []
|
||||
for i, cat in enumerate(default_categories):
|
||||
cat['id'] = int(time.time() * 1000) + i
|
||||
categories_with_id.append(cat)
|
||||
try:
|
||||
with open(categories_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(categories_with_id, f, ensure_ascii=False, indent=4)
|
||||
return categories_with_id
|
||||
except Exception as e:
|
||||
print(f"Error creating categories.json: {e}")
|
||||
return default_categories
|
||||
|
||||
try:
|
||||
# V-Fix: 檢查檔案大小,如果過大 (例如超過 1MB) 則視為異常,直接使用預設值,避免讀取卡死
|
||||
if os.path.exists(categories_path):
|
||||
try:
|
||||
if os.path.getsize(categories_path) > 1024 * 1024:
|
||||
print(f"⚠️ Warning: categories.json is too large ({os.path.getsize(categories_path)} bytes). Using defaults.")
|
||||
return default_categories
|
||||
except OSError:
|
||||
return default_categories
|
||||
|
||||
with open(categories_path, 'r', encoding='utf-8') as f:
|
||||
try:
|
||||
content = f.read().strip()
|
||||
except Exception:
|
||||
return default_categories
|
||||
|
||||
if not content:
|
||||
return default_categories
|
||||
|
||||
data = json.loads(content)
|
||||
# 若讀取到的列表為空,則回傳預設值 (修正空檔案導致爬蟲不工作的問題)
|
||||
if not data:
|
||||
return default_categories
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError, OSError):
|
||||
# 如果檔案損毀或讀取失敗,回傳預設值
|
||||
return default_categories
|
||||
|
||||
MOMO_CATEGORIES = load_momo_categories()
|
||||
|
||||
# ==========================================
|
||||
# 密碼安全設定
|
||||
# ==========================================
|
||||
PASSWORD_MIN_LENGTH = int(os.getenv('PASSWORD_MIN_LENGTH', '8'))
|
||||
PASSWORD_REQUIRE_UPPERCASE = os.getenv('PASSWORD_REQUIRE_UPPERCASE', 'true').lower() == 'true'
|
||||
PASSWORD_REQUIRE_LOWERCASE = os.getenv('PASSWORD_REQUIRE_LOWERCASE', 'true').lower() == 'true'
|
||||
PASSWORD_REQUIRE_DIGIT = os.getenv('PASSWORD_REQUIRE_DIGIT', 'true').lower() == 'true'
|
||||
PASSWORD_REQUIRE_SPECIAL = os.getenv('PASSWORD_REQUIRE_SPECIAL', 'false').lower() == 'true'
|
||||
PASSWORD_SPECIAL_CHARS = os.getenv('PASSWORD_SPECIAL_CHARS', '!@#$%^&*()_+-=[]{}|;:,.<>?')
|
||||
PASSWORD_EXPIRY_DAYS = int(os.getenv('PASSWORD_EXPIRY_DAYS', '90'))
|
||||
|
||||
# ==========================================
|
||||
# 外部服務連結 (分析報表選單)
|
||||
# ==========================================
|
||||
METABASE_URL = os.getenv('METABASE_URL', '') # Metabase BI 連結
|
||||
GRIST_URL = os.getenv('GRIST_URL', '') # Grist 資料協作連結
|
||||
|
||||
# ==========================================
|
||||
# AI 服務設定
|
||||
# ==========================================
|
||||
# Ollama 本地 AI 服務
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'https://ollama.wooo.work/ollama')
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gemma3:4b')
|
||||
|
||||
# Google Gemini AI 雲端服務
|
||||
GEMINI_API_KEY = os.getenv('GEMINI_API_KEY', '')
|
||||
GEMINI_MODEL = os.getenv('GEMINI_MODEL', 'gemini-1.5-flash')
|
||||
|
||||
# 預設 AI 提供者: 'ollama' (本地免費) 或 'gemini' (雲端付費)
|
||||
AI_PROVIDER = os.getenv('AI_PROVIDER', 'ollama')
|
||||
|
||||
# YouTube API Key (用於趨勢爬蟲)
|
||||
YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.3"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
# ==========================================
|
||||
# 模組化路由設定
|
||||
# ==========================================
|
||||
# 控制是否啟用模組化路由,設為 True 時會自動清理 app.py 中的重複路由
|
||||
USE_MODULAR_ROUTES = {
|
||||
'system': True, # 系統設定、日誌、備份
|
||||
'edm': True, # EDM 與節慶儀表板
|
||||
'monthly': True, # 月結分析
|
||||
'dashboard': True, # 首頁商品看板
|
||||
'daily_sales': True, # 當日業績分析
|
||||
'api': True, # 通用 API
|
||||
'export': True, # 匯出功能
|
||||
'import': True, # 匯入功能
|
||||
'sales': True, # 業績分析
|
||||
}
|
||||
0
crawler/__init__.py
Normal file
0
crawler/__init__.py
Normal file
426
crawler_management.html
Normal file
426
crawler_management.html
Normal file
@@ -0,0 +1,426 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>爬蟲管理 - MOMO Pro System</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<style>
|
||||
.crawler-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.crawler-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.crawler-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.crawler-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.crawler-info {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.crawler-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #10b981;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6b7280;
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4b5563;
|
||||
}
|
||||
|
||||
.pause-reason {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #10b981;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.navbar-dark.bg-primary {
|
||||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-brand {
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link {
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link:hover {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-nav .nav-link.active {
|
||||
color: #ffffff !important;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.navbar-dark .navbar-text {
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Fixed Navbar Compensation */
|
||||
body {
|
||||
padding-top: 70px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔧 爬蟲管理</h1>
|
||||
<p>管理系統爬蟲的啟用狀態和執行頻率</p>
|
||||
</div>
|
||||
|
||||
<div id="alert-container"></div>
|
||||
|
||||
<div class="stats-summary">
|
||||
<div class="stat-box">
|
||||
<div class="stat-number" id="enabled-count">0</div>
|
||||
<div class="stat-label">啟用中</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-number" id="paused-count">0</div>
|
||||
<div class="stat-label">已暫停</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-number" id="total-count">0</div>
|
||||
<div class="stat-label">爬蟲總數</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="crawlers-container">
|
||||
<!-- 爬蟲卡片將通過 JavaScript 動態插入 -->
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 30px; padding: 15px; background: #f3f4f6; border-radius: 8px;">
|
||||
<h3 style="margin-top: 0;">💡 使用說明</h3>
|
||||
<ul style="color: #6b7280; line-height: 1.8;">
|
||||
<li>切換開關可以啟用或停用爬蟲</li>
|
||||
<li>停用的爬蟲程式碼和資料會保留,隨時可以重新啟用</li>
|
||||
<li>變更執行頻率後,需要重啟排程器服務才會生效</li>
|
||||
<li>重啟排程器:<code>sudo systemctl restart momo-scheduler</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 載入爬蟲配置
|
||||
async function loadCrawlers() {
|
||||
try {
|
||||
const response = await fetch('/api/crawlers');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
renderCrawlers(result.data);
|
||||
updateStats(result.data);
|
||||
} else {
|
||||
showAlert('載入失敗: ' + result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('載入爬蟲配置時發生錯誤', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染爬蟲卡片
|
||||
function renderCrawlers(crawlers) {
|
||||
const container = document.getElementById('crawlers-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
for (const [key, info] of Object.entries(crawlers)) {
|
||||
const card = createCrawlerCard(key, info);
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// 創建爬蟲卡片
|
||||
function createCrawlerCard(key, info) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'crawler-card';
|
||||
|
||||
const statusClass = info.enabled ? 'status-active' : 'status-paused';
|
||||
const statusText = info.enabled ? '運行中' : '已暫停';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="crawler-header">
|
||||
<div>
|
||||
<div class="crawler-title">${info.name}</div>
|
||||
<span class="status-badge ${statusClass}">${statusText}</span>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" ${info.enabled ? 'checked' : ''}
|
||||
onchange="toggleCrawler('${key}', this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="crawler-info">
|
||||
<div>📝 ${info.description || 'N/A'}</div>
|
||||
<div>⏰ 執行頻率:每 ${info.schedule_hours || 'N/A'} 小時</div>
|
||||
${info.lpn_code ? `<div>🔖 活動代碼:${info.lpn_code}</div>` : ''}
|
||||
${info.last_active_date ? `<div>📅 最後活動:${info.last_active_date}</div>` : ''}
|
||||
</div>
|
||||
|
||||
${!info.enabled && info.pause_reason ? `
|
||||
<div class="pause-reason">
|
||||
<strong>⏸️ 暫停原因:</strong>${info.pause_reason}
|
||||
${info.notes ? `<br><small>${info.notes}</small>` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="crawler-controls">
|
||||
${info.enabled ? `
|
||||
<button class="btn-secondary" onclick="changeSchedule('${key}', ${info.schedule_hours})">
|
||||
修改頻率
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// 更新統計資料
|
||||
function updateStats(crawlers) {
|
||||
const total = Object.keys(crawlers).length;
|
||||
const enabled = Object.values(crawlers).filter(c => c.enabled).length;
|
||||
const paused = total - enabled;
|
||||
|
||||
document.getElementById('enabled-count').textContent = enabled;
|
||||
document.getElementById('paused-count').textContent = paused;
|
||||
document.getElementById('total-count').textContent = total;
|
||||
}
|
||||
|
||||
// 切換爬蟲狀態
|
||||
async function toggleCrawler(key, enabled) {
|
||||
let reason = '';
|
||||
if (!enabled) {
|
||||
reason = prompt('請輸入停用原因(可選):');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crawlers/${key}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ enabled, reason })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert(result.message, 'success');
|
||||
loadCrawlers(); // 重新載入
|
||||
} else {
|
||||
showAlert('操作失敗: ' + result.message, 'error');
|
||||
loadCrawlers(); // 恢復原狀
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('操作時發生錯誤', 'error');
|
||||
console.error(error);
|
||||
loadCrawlers();
|
||||
}
|
||||
}
|
||||
|
||||
// 修改執行頻率
|
||||
async function changeSchedule(key, currentHours) {
|
||||
const newHours = prompt(`請輸入新的執行頻率(小時)\n目前:每 ${currentHours} 小時`, currentHours);
|
||||
|
||||
if (newHours === null || newHours === currentHours.toString()) {
|
||||
return; // 使用者取消或未變更
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/crawlers/${key}/schedule`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ schedule_hours: parseInt(newHours) })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'success') {
|
||||
showAlert(result.message + '(需重啟排程器生效)', 'success');
|
||||
loadCrawlers();
|
||||
} else {
|
||||
showAlert('更新失敗: ' + result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showAlert('更新時發生錯誤', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 顯示提示訊息
|
||||
function showAlert(message, type) {
|
||||
const container = document.getElementById('alert-container');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.textContent = message;
|
||||
|
||||
container.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// 頁面載入時執行
|
||||
document.addEventListener('DOMContentLoaded', loadCrawlers);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1906
daily_sales.html
Normal file
1906
daily_sales.html
Normal file
File diff suppressed because it is too large
Load Diff
1388
dashboard.html
Normal file
1388
dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
1
data/.migrated_sales_append_flag
Normal file
1
data/.migrated_sales_append_flag
Normal file
@@ -0,0 +1 @@
|
||||
2026-01-09T11:20:11.640213+08:00
|
||||
0
data/__init__.py
Normal file
0
data/__init__.py
Normal file
427
data/categories.json
Normal file
427
data/categories.json
Normal file
@@ -0,0 +1,427 @@
|
||||
[
|
||||
{
|
||||
"id": 1767566011261,
|
||||
"name": "保養超值組",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700012"
|
||||
},
|
||||
{
|
||||
"id": 1767566047822,
|
||||
"name": "化妝水",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700001"
|
||||
},
|
||||
{
|
||||
"id": 1767566096624,
|
||||
"name": "精華液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700002&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566317440,
|
||||
"name": "乳液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700003&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566348568,
|
||||
"name": "乳霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700004&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566389679,
|
||||
"name": "凝膠",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700005&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566423628,
|
||||
"name": "面膜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700006&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566449023,
|
||||
"name": "眼霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700007&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566476925,
|
||||
"name": "護唇膏",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700008&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566503911,
|
||||
"name": "防曬",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700009&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566540369,
|
||||
"name": "素顏霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700010&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566578662,
|
||||
"name": "美顏霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1111700011&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566653317,
|
||||
"name": "身體護理",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901499&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566672974,
|
||||
"name": "手部保養",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901655&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566694089,
|
||||
"name": "足部保養",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901656&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566711579,
|
||||
"name": "局部護理",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901505&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566730370,
|
||||
"name": "止汗體香",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901503&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566754498,
|
||||
"name": "嬰幼身體保養品牌旗艦",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1100901724&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566829338,
|
||||
"name": "嬰幼本月主打",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200081&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767566850432,
|
||||
"name": "嬰幼清潔用品",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200349&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566875317,
|
||||
"name": "嬰幼保養護膚",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200350&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566904602,
|
||||
"name": "媽咪孕期保養",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200352&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566928973,
|
||||
"name": "送禮超值組",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200348&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767566950586,
|
||||
"name": "嬰幼品牌總覽",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=2705200348&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567107685,
|
||||
"name": "私密保養本月主打",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900060&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767567129972,
|
||||
"name": "私密保養",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900002&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767567157432,
|
||||
"name": "私密清潔",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900003&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767567176084,
|
||||
"name": "除毛",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900154&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767567195225,
|
||||
"name": "私密保養推薦品牌",
|
||||
"url": "https://www.momoshop.com.tw/category/MgrpCategory.jsp?m_code=1105900023&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767567403179,
|
||||
"name": "指彩_指甲油",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600013&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567479084,
|
||||
"name": "指彩_水性無毒指彩",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600014&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567508354,
|
||||
"name": "指彩_類光療",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600037&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567543492,
|
||||
"name": "指彩_光療/凝膠",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600923&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567574406,
|
||||
"name": "指彩_指甲貼紙",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600751&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567605907,
|
||||
"name": "指彩_指甲貼片",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600752&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567633497,
|
||||
"name": "指彩_兒童專區",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600896&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567662170,
|
||||
"name": "指彩_熱銷套組",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600910&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567854365,
|
||||
"name": "指彩_指彩工具",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600017&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567938327,
|
||||
"name": "指彩_光療燈",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600760&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767567963617,
|
||||
"name": "指彩_去光水",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600034&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574360375,
|
||||
"name": "指彩_快乾劑",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600906&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574393966,
|
||||
"name": "指彩_指甲油稀釋液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600907&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574483775,
|
||||
"name": "指彩_修甲工具",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600029&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574506622,
|
||||
"name": "指彩_指緣油",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600032&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574538611,
|
||||
"name": "指彩_硬甲油",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104600895&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574581058,
|
||||
"name": "底妝_隔離霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300115&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574610345,
|
||||
"name": "底妝_飾底乳",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300116&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574638501,
|
||||
"name": "底妝_粉底液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300117&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574661213,
|
||||
"name": "底妝_粉底膏",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300119&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574689502,
|
||||
"name": "底妝_粉餅",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300118&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574717451,
|
||||
"name": "底妝_粉凝霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300120&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574746217,
|
||||
"name": "底妝_氣墊粉餅",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300121&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574775616,
|
||||
"name": "底妝_BB霜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300122&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574815058,
|
||||
"name": "底妝_蜜粉",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300123&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574846090,
|
||||
"name": "底妝_蜜粉餅",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300124&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574938246,
|
||||
"name": "底妝_遮瑕",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300125&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767574963730,
|
||||
"name": "底妝_定妝噴霧",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104300126&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575038693,
|
||||
"name": "眼眉彩_眼線筆",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400108&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575060387,
|
||||
"name": "眼眉彩_眼線膠筆",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400110&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767575082369,
|
||||
"name": "眼眉彩_眼線液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400109&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575100428,
|
||||
"name": "眼眉彩_眼影",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400111&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575142823,
|
||||
"name": "眼眉彩_睫毛膏",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400112&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575143449,
|
||||
"name": "眼眉彩_睫毛膏",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400112&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575165353,
|
||||
"name": "眼眉彩_睫毛滋養液",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400113&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575219526,
|
||||
"name": "眼眉彩_眉筆",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400115&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575242674,
|
||||
"name": "眼眉彩_眉粉",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400116&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767575276207,
|
||||
"name": "眼眉彩_染眉膏",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400117&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575314556,
|
||||
"name": "眼眉彩_染眉膠",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104400118&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575377335,
|
||||
"name": "唇頰彩_口紅",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500089&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575402519,
|
||||
"name": "唇頰彩_唇釉",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500090&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575425246,
|
||||
"name": "唇頰彩_唇蜜",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500091&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575454551,
|
||||
"name": "唇頰彩_唇筆",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500094&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575674085,
|
||||
"name": "唇頰彩_腮紅",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500098&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575705209,
|
||||
"name": "唇頰彩_修容",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500099&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575730090,
|
||||
"name": "唇頰彩_打亮",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500100&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575744839,
|
||||
"name": "唇頰彩_彩妝盤",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104500092&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575815083,
|
||||
"name": "精油_水氧機_水氧機★熱銷TOP",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104800051&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575858704,
|
||||
"name": "精油_水氧機_人氣獻禮★香氛送禮推薦",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104800024&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575881233,
|
||||
"name": "精油_水氧機_人氣★專櫃香氛品牌",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1104800128&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575909292,
|
||||
"name": "精油_水氧機_擴香",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300207&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575931015,
|
||||
"name": "精油_水氧機_車用擴香",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300294&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575952402,
|
||||
"name": "精油_水氧機_香氛蠟燭",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300019&sourcePageType=4"
|
||||
},
|
||||
{
|
||||
"id": 1767575969303,
|
||||
"name": "精油_水氧機_融蠟燈",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300296&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767575983113,
|
||||
"name": "精油_水氧機_香氛噴霧",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300011&p_orderType=4&showType=chessboardType"
|
||||
},
|
||||
{
|
||||
"id": 1767576005571,
|
||||
"name": "精油_水氧機_擴香補充瓶",
|
||||
"url": "https://www.momoshop.com.tw/category/DgrpCategory.jsp?d_code=1108300014&p_orderType=4&showType=chessboardType"
|
||||
}
|
||||
]
|
||||
47
data/crawler_config.json
Normal file
47
data/crawler_config.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"crawlers": {
|
||||
"momo_main": {
|
||||
"name": "商品看板爬蟲",
|
||||
"description": "MOMO 主站商品列表爬蟲",
|
||||
"enabled": true,
|
||||
"schedule_hours": 1,
|
||||
"function": "run_momo_task",
|
||||
"status": "active",
|
||||
"last_active_date": "2026-01-14"
|
||||
},
|
||||
"edm_promo": {
|
||||
"name": "EDM 限時搶購爬蟲",
|
||||
"description": "MOMO 限時搶購活動爬蟲",
|
||||
"enabled": true,
|
||||
"schedule_hours": 1,
|
||||
"function": "run_edm_task",
|
||||
"lpn_code": "O1K5FBOqsvN",
|
||||
"status": "active",
|
||||
"last_active_date": "2026-01-14"
|
||||
},
|
||||
"festival_11": {
|
||||
"name": "1.1 狂歡購物節爬蟲",
|
||||
"description": "1.1 狂歡購物節活動爬蟲",
|
||||
"enabled": false,
|
||||
"schedule_hours": 6,
|
||||
"function": "run_festival_task",
|
||||
"lpn_code": "O7ylWfihYUM",
|
||||
"status": "paused",
|
||||
"pause_reason": "活動已結束(頁面下架)",
|
||||
"paused_date": "2026-01-14",
|
||||
"last_active_date": "2026-01-08",
|
||||
"notes": "保留程式碼和邏輯,等待下次同版型活動時重新啟用"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"auto_import_enabled": true,
|
||||
"auto_import_interval_minutes": 30,
|
||||
"whitepage_check_enabled": true,
|
||||
"whitepage_check_interval_minutes": 30
|
||||
},
|
||||
"metadata": {
|
||||
"version": "1.0",
|
||||
"last_updated": "2026-01-14T10:50:00+08:00",
|
||||
"updated_by": "system"
|
||||
}
|
||||
}
|
||||
115
data/scheduler_stats.json
Normal file
115
data/scheduler_stats.json
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"edm_task": [
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 22:03:04"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 21:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 20:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 19:03:02"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 18:03:02"
|
||||
}
|
||||
],
|
||||
"momo_task": [
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 22:03:04"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 21:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 20:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 19:03:02"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 18:03:02"
|
||||
}
|
||||
],
|
||||
"festival_task": [
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 22:03:04"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 21:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 20:03:03"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 19:03:02"
|
||||
},
|
||||
{
|
||||
"status": "Failed",
|
||||
"error": "Message: Unable to obtain driver for chrome; For documentation on this error, please visit: https://www.selenium.dev/documentation/webdriver/troubleshooting/errors/driver_location\n",
|
||||
"last_run": "2026-01-15 18:03:02"
|
||||
}
|
||||
],
|
||||
"auto_import_task": [
|
||||
{
|
||||
"file_count": 0,
|
||||
"imported_count": 0,
|
||||
"status": "Success",
|
||||
"last_run": "2026-01-15 22:33:28"
|
||||
},
|
||||
{
|
||||
"file_count": 0,
|
||||
"imported_count": 0,
|
||||
"status": "Success",
|
||||
"last_run": "2026-01-15 22:03:27"
|
||||
},
|
||||
{
|
||||
"file_count": 0,
|
||||
"imported_count": 0,
|
||||
"status": "Success",
|
||||
"last_run": "2026-01-15 21:33:25"
|
||||
},
|
||||
{
|
||||
"file_count": 0,
|
||||
"imported_count": 0,
|
||||
"status": "Success",
|
||||
"last_run": "2026-01-15 21:03:24"
|
||||
},
|
||||
{
|
||||
"file_count": 0,
|
||||
"imported_count": 0,
|
||||
"status": "Success",
|
||||
"last_run": "2026-01-15 20:33:22"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
data/system_status.json
Normal file
1
data/system_status.json
Normal file
@@ -0,0 +1 @@
|
||||
{"status": "OK", "message": "任務執行完成", "timestamp": "2026-01-05 00:26:28"}
|
||||
1
data/url_config.json
Normal file
1
data/url_config.json
Normal file
@@ -0,0 +1 @@
|
||||
{"public_url": "https://mo.wooo.work"}
|
||||
0
database/__init__.py
Normal file
0
database/__init__.py
Normal file
319
database/ai_models.py
Normal file
319
database/ai_models.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI 生成歷史記錄資料庫模型
|
||||
儲存 Ollama/Gemini LLM 生成的文案和推薦結果
|
||||
支援多 AI 提供者和費用追蹤
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, Date, Text, Boolean, ForeignKey, Index
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, date
|
||||
from .models import Base
|
||||
|
||||
|
||||
class AIGenerationHistory(Base):
|
||||
"""AI 生成歷史記錄表"""
|
||||
__tablename__ = 'ai_generation_history'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# 生成類型:copy (文案), recommend (推薦), weather_analysis (天氣分析)
|
||||
generation_type = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# 商品相關
|
||||
product_name = Column(String(255), index=True)
|
||||
|
||||
# 輸入參數
|
||||
input_keywords = Column(Text) # JSON 格式的關鍵字列表
|
||||
input_style = Column(String(50)) # 文案風格
|
||||
input_trend_topic = Column(Text) # 趨勢話題(用於推薦)
|
||||
|
||||
# 生成結果
|
||||
output_content = Column(Text, nullable=False) # 生成的內容
|
||||
|
||||
# 模型資訊
|
||||
model_name = Column(String(100))
|
||||
generation_duration = Column(Float) # 生成耗時(秒)
|
||||
|
||||
# AI 提供者資訊 (新增 - 支援 Ollama/Gemini 切換)
|
||||
ai_provider = Column(String(20), default='ollama') # 'ollama' 或 'gemini'
|
||||
input_tokens = Column(Integer, default=0) # 輸入 token 數量 (用於 Gemini 費用計算)
|
||||
output_tokens = Column(Integer, default=0) # 輸出 token 數量
|
||||
|
||||
# 評價與狀態
|
||||
rating = Column(Integer) # 用戶評分 1-5
|
||||
is_favorite = Column(Boolean, default=False) # 是否收藏
|
||||
is_used = Column(Boolean, default=False) # 是否已使用
|
||||
notes = Column(Text) # 用戶備註
|
||||
|
||||
# 用戶追蹤
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 建立索引以優化查詢
|
||||
__table_args__ = (
|
||||
Index('idx_ai_history_type_created', 'generation_type', 'created_at'),
|
||||
Index('idx_ai_history_product', 'product_name'),
|
||||
Index('idx_ai_history_favorite', 'is_favorite', 'created_at'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
import json
|
||||
return {
|
||||
'id': self.id,
|
||||
'generation_type': self.generation_type,
|
||||
'product_name': self.product_name,
|
||||
'input_keywords': json.loads(self.input_keywords) if self.input_keywords else [],
|
||||
'input_style': self.input_style,
|
||||
'input_trend_topic': self.input_trend_topic,
|
||||
'output_content': self.output_content,
|
||||
'model_name': self.model_name,
|
||||
'generation_duration': self.generation_duration,
|
||||
'ai_provider': self.ai_provider,
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'rating': self.rating,
|
||||
'is_favorite': self.is_favorite,
|
||||
'is_used': self.is_used,
|
||||
'notes': self.notes,
|
||||
'created_by': self.created_by,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class AIUsageTracking(Base):
|
||||
"""
|
||||
AI 使用量追蹤表
|
||||
追蹤 Gemini API 費用和所有 AI 使用統計
|
||||
"""
|
||||
__tablename__ = 'ai_usage_tracking'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# AI 提供者: 'ollama', 'gemini'
|
||||
provider = Column(String(20), nullable=False, index=True)
|
||||
|
||||
# 模型名稱: 'gemma3:4b', 'gemini-1.5-flash', 'gemini-2.5-pro'
|
||||
model_name = Column(String(100), nullable=False)
|
||||
|
||||
# 使用類型: 'copy', 'web_search', 'product_insights', 'trend_keywords'
|
||||
usage_type = Column(String(50), nullable=False)
|
||||
|
||||
# Token 用量
|
||||
input_tokens = Column(Integer, default=0)
|
||||
output_tokens = Column(Integer, default=0)
|
||||
total_tokens = Column(Integer, default=0)
|
||||
|
||||
# 費用計算 (USD) - 主要用於 Gemini
|
||||
input_cost = Column(Float, default=0.0)
|
||||
output_cost = Column(Float, default=0.0)
|
||||
total_cost = Column(Float, default=0.0)
|
||||
|
||||
# 響應時間 (秒)
|
||||
duration = Column(Float)
|
||||
|
||||
# 請求資訊
|
||||
request_date = Column(Date, nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
|
||||
# 關聯到歷史記錄 (可選)
|
||||
history_id = Column(Integer, ForeignKey('ai_generation_history.id'))
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_usage_provider_date', 'provider', 'request_date'),
|
||||
Index('idx_usage_model_date', 'model_name', 'request_date'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'provider': self.provider,
|
||||
'model_name': self.model_name,
|
||||
'usage_type': self.usage_type,
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'total_tokens': self.total_tokens,
|
||||
'input_cost': self.input_cost,
|
||||
'output_cost': self.output_cost,
|
||||
'total_cost': self.total_cost,
|
||||
'duration': self.duration,
|
||||
'request_date': self.request_date.isoformat() if self.request_date else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class AIPromptTemplate(Base):
|
||||
"""AI 提示模板表 - 儲存常用的提示詞模板"""
|
||||
__tablename__ = 'ai_prompt_templates'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(100), nullable=False, unique=True) # 模板名稱
|
||||
description = Column(String(255)) # 模板描述
|
||||
template_type = Column(String(50), nullable=False, index=True) # copy, recommend, analysis
|
||||
|
||||
system_prompt = Column(Text) # 系統提示詞
|
||||
user_prompt_template = Column(Text, nullable=False) # 用戶提示詞模板
|
||||
|
||||
# 預設參數
|
||||
default_temperature = Column(Float, default=0.7)
|
||||
default_style = Column(String(50))
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_system = Column(Boolean, default=False) # 是否為系統內建
|
||||
|
||||
created_by = Column(Integer, ForeignKey('users.id'))
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'template_type': self.template_type,
|
||||
'system_prompt': self.system_prompt,
|
||||
'user_prompt_template': self.user_prompt_template,
|
||||
'default_temperature': self.default_temperature,
|
||||
'default_style': self.default_style,
|
||||
'is_active': self.is_active,
|
||||
'is_system': self.is_system,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# 預設的提示模板
|
||||
DEFAULT_PROMPT_TEMPLATES = [
|
||||
{
|
||||
'name': '吸睛電商文案',
|
||||
'description': '適合吸引眼球的促銷文案',
|
||||
'template_type': 'copy',
|
||||
'system_prompt': '''你是一位專業的電商銷售文案寫手,專門為台灣電商平台撰寫商品文案。
|
||||
你的文案特點:
|
||||
- 使用繁體中文
|
||||
- 簡潔有力,通常在 100 字以內
|
||||
- 善用表情符號增加吸引力
|
||||
- 強調商品賣點和消費者利益
|
||||
- 適時使用行動呼籲 (CTA)''',
|
||||
'user_prompt_template': '''請為以下商品撰寫銷售文案:
|
||||
|
||||
商品名稱:{product_name}
|
||||
|
||||
文案風格:使用吸引眼球的標題和表情符號
|
||||
{trend_context}
|
||||
|
||||
請生成一段吸引人的銷售文案(100字以內):''',
|
||||
'default_temperature': 0.8,
|
||||
'default_style': '吸睛',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': '專業產品介紹',
|
||||
'description': '適合強調功效和成分的專業文案',
|
||||
'template_type': 'copy',
|
||||
'system_prompt': '''你是一位專業的產品行銷專家,擅長撰寫專業且有說服力的產品介紹。
|
||||
你的文案特點:
|
||||
- 使用繁體中文
|
||||
- 強調產品的專業性和科學依據
|
||||
- 使用精確的數據和專業術語
|
||||
- 建立品牌信任感''',
|
||||
'user_prompt_template': '''請為以下商品撰寫專業介紹:
|
||||
|
||||
商品名稱:{product_name}
|
||||
|
||||
文案風格:使用專業術語,強調成分和功效
|
||||
{trend_context}
|
||||
|
||||
請生成一段專業的產品介紹(100字以內):''',
|
||||
'default_temperature': 0.5,
|
||||
'default_style': '專業',
|
||||
'is_system': True,
|
||||
},
|
||||
{
|
||||
'name': '限時促銷文案',
|
||||
'description': '創造緊迫感的促銷文案',
|
||||
'template_type': 'copy',
|
||||
'system_prompt': '''你是一位擅長製造緊迫感的行銷文案專家。
|
||||
你的文案特點:
|
||||
- 使用繁體中文
|
||||
- 善用限時、限量等字眼
|
||||
- 創造錯過可惜的感覺
|
||||
- 強調立即行動的好處''',
|
||||
'user_prompt_template': '''請為以下商品撰寫限時促銷文案:
|
||||
|
||||
商品名稱:{product_name}
|
||||
|
||||
文案風格:使用限時優惠的語氣,創造緊迫感
|
||||
{trend_context}
|
||||
|
||||
請生成一段有緊迫感的促銷文案(100字以內):''',
|
||||
'default_temperature': 0.7,
|
||||
'default_style': '急迫',
|
||||
'is_system': True,
|
||||
},
|
||||
]
|
||||
|
||||
class AIInsight(Base):
|
||||
"""
|
||||
AI 洞察與知識庫表 (符合 ADR-007 雙寫規範)
|
||||
Step 2 加入,供 OpenClaw 保存歷史 PPT、分析等輸出。
|
||||
(embedding 欄位將在 Step 3 透過 SQL ALTER 增加,不宣告於 SQLAlchemy,避免 SQLite 相容性錯誤)
|
||||
"""
|
||||
__tablename__ = 'ai_insights'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
insight_type = Column(String(50), nullable=False, index=True) # ppt, competitor_analysis, weekly_meta
|
||||
period = Column(String(50), index=True) # 2026-04-16, 2026-W15
|
||||
product_sku = Column(String(50), index=True) # 如果針對單一商品
|
||||
content = Column(Text, nullable=False) # 具體輸出內容
|
||||
metadata_json = Column(Text) # 附加元數據 (JSON 字串)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def to_dict(self):
|
||||
import json
|
||||
return {
|
||||
'id': self.id,
|
||||
'insight_type': self.insight_type,
|
||||
'period': self.period,
|
||||
'product_sku': self.product_sku,
|
||||
'content': self.content,
|
||||
'metadata': json.loads(self.metadata_json) if self.metadata_json else {},
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
def init_ai_tables(session):
|
||||
"""
|
||||
初始化 AI 相關表和預設資料
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy session
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# 檢查是否已有預設模板
|
||||
existing_count = session.query(AIPromptTemplate).filter_by(is_system=True).count()
|
||||
|
||||
if existing_count == 0:
|
||||
# 新增預設模板
|
||||
for template_data in DEFAULT_PROMPT_TEMPLATES:
|
||||
template = AIPromptTemplate(**template_data)
|
||||
session.add(template)
|
||||
|
||||
session.commit()
|
||||
return True, f"AI 模板初始化完成,新增 {len(DEFAULT_PROMPT_TEMPLATES)} 個預設模板"
|
||||
else:
|
||||
return True, f"AI 模板已存在 ({existing_count} 個系統模板)"
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
return False, f"AI 模板初始化失敗: {e}"
|
||||
145
database/edm_dashboard.html
Normal file
145
database/edm_dashboard.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
{% macro slugify(text) -%}
|
||||
{{ text|string|replace(' ', '_')|replace(':', '')|replace('!', '')|replace('?', '')|replace('/', '')|replace('&', '')|replace('(', '')|replace(')', '') }}
|
||||
{%- endmacro %}
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MOMO 限時搶購監控</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background-color: #f8f9fa; }
|
||||
.card { border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
|
||||
.badge-up { background-color: #dc3545; color: white; }
|
||||
.badge-down { background-color: #198754; color: white; }
|
||||
.badge-new { background-color: #0d6efd; color: white; }
|
||||
.nav-pills .nav-link.active { background-color: #d63384; }
|
||||
.nav-pills .nav-link { color: #666; }
|
||||
.price-tag { font-weight: bold; color: #d63384; font-size: 1.1em; }
|
||||
.status-badge { font-size: 0.8em; padding: 4px 8px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-dark bg-dark mb-4">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">MOMO 監控系統</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/">主看板</a>
|
||||
<a class="nav-link active" href="/edm">限時搶購 (EDM)</a>
|
||||
<a class="nav-link" href="/logs">系統日誌</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mb-5">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2>🔥 限時搶購監控儀表板</h2>
|
||||
<p class="text-muted">
|
||||
活動時間: {{ activity_time }} |
|
||||
最後更新: {{ last_update }} |
|
||||
商品總數: {{ total_edm_products }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
<button class="btn btn-outline-primary" onclick="triggerEdmTask()">🔄 手動更新</button>
|
||||
<button class="btn btn-outline-success" onclick="triggerNotification()">📢 發送通知</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 時段頁籤 -->
|
||||
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
|
||||
{% for slot, stats in slot_stats.items() %}
|
||||
{% set slot_id = slugify(slot) %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if slot == active_tab %}active{% endif %}"
|
||||
id="pills-{{ slot_id }}-tab"
|
||||
data-bs-toggle="pill"
|
||||
data-bs-target="#pills-{{ slot_id }}"
|
||||
type="button" role="tab"
|
||||
aria-controls="pills-{{ slot_id }}"
|
||||
aria-selected="{{ 'true' if slot == active_tab else 'false' }}">
|
||||
{{ slot }}
|
||||
<span class="badge bg-light text-dark rounded-pill ms-2">{{ stats.on_shelf }}</span>
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="pills-tabContent">
|
||||
{% for slot, stats in slot_stats.items() %}
|
||||
{% set items = grouped_items.get(slot, []) %}
|
||||
{% set slot_id = slugify(slot) %}
|
||||
<div class="tab-pane fade {% if slot == active_tab %}show active{% endif %}"
|
||||
id="pills-{{ slot_id }}" role="tabpanel" aria-labelledby="pills-{{ slot_id }}-tab">
|
||||
|
||||
<!-- 該時段統計 -->
|
||||
<div class="alert alert-light border mb-3">
|
||||
<strong>📊 時段統計:</strong>
|
||||
<span class="badge bg-primary me-2">新品: {{ stats['new'] }}</span>
|
||||
<span class="badge bg-danger me-2">漲價: {{ stats['up'] }}</span>
|
||||
<span class="badge bg-success me-2">降價: {{ stats['down'] }}</span>
|
||||
<span class="badge bg-secondary" title="今日異動">下架: {{ stats.get('delisted_last_run', 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||
{% for item in items %}
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title text-truncate">
|
||||
<a href="{{ item.url }}" target="_blank" class="text-decoration-none text-dark">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</h6>
|
||||
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||
<span class="price-tag">${{ item.price }}</span>
|
||||
<div>
|
||||
{% if item.status_change == 'NEW' %}
|
||||
<span class="badge badge-new status-badge">NEW</span>
|
||||
{% elif item.status_change == 'PRICE_DOWN' %}
|
||||
<span class="badge badge-down status-badge">↘ 降價</span>
|
||||
{% elif item.status_change == 'PRICE_UP' %}
|
||||
<span class="badge badge-up status-badge">↗ 漲價</span>
|
||||
{% elif item.status_change == 'DELISTED' %}
|
||||
<span class="badge bg-secondary status-badge">下架</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small">
|
||||
分類: <span style="color: {{ item.category_color }}">{{ item.main_category or '未分類' }}</span>
|
||||
<br>
|
||||
頻次: {{ item.frequency }} 次
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
function triggerEdmTask() {
|
||||
if(confirm('確定要手動執行 EDM 爬蟲嗎?')) {
|
||||
fetch('/api/run_edm_task', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => alert(data.message))
|
||||
.catch(e => alert('錯誤: ' + e));
|
||||
}
|
||||
}
|
||||
function triggerNotification() {
|
||||
if(confirm('確定要發送比價通知嗎?')) {
|
||||
fetch('/api/trigger_edm_notification', {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
.then(data => alert(data.message))
|
||||
.catch(e => alert('錯誤: ' + e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
30
database/edm_models.py
Normal file
30
database/edm_models.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from datetime import datetime
|
||||
from database.models import Base
|
||||
|
||||
class PromoProduct(Base):
|
||||
"""
|
||||
EDM (限時搶購) 商品資料表
|
||||
用於儲存從 MOMO 限時搶購頁面抓取的商品資訊
|
||||
"""
|
||||
__tablename__ = 'promo_products'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id = Column(String(64), index=True)
|
||||
crawled_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
time_slot = Column(String(20))
|
||||
activity_time_text = Column(String(100))
|
||||
session_time_text = Column(String(100))
|
||||
|
||||
i_code = Column(String(50), index=True)
|
||||
name = Column(String(255))
|
||||
price = Column(Integer)
|
||||
discount_text = Column(String(20))
|
||||
url = Column(Text)
|
||||
image_url = Column(Text)
|
||||
previous_price = Column(Integer)
|
||||
remain_qty = Column(Integer)
|
||||
|
||||
status_change = Column(String(20), default='NEW')
|
||||
page_type = Column(String(50), default='edm', index=True)
|
||||
115
database/import_models.py
Normal file
115
database/import_models.py
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
匯入進度追蹤模型
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, Float
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
# 台北時區
|
||||
TAIPEI_TZ = pytz.timezone('Asia/Taipei')
|
||||
|
||||
|
||||
def taipei_now():
|
||||
"""取得台北時區的當前時間(無時區資訊,用於資料庫存儲)"""
|
||||
return datetime.now(TAIPEI_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class ImportJob(Base):
|
||||
"""匯入任務模型"""
|
||||
__tablename__ = 'import_jobs'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, comment='匯入任務 ID')
|
||||
|
||||
# 任務資訊
|
||||
job_type = Column(String(50), nullable=False, comment='任務類型:daily_sales, vendor_stockout')
|
||||
status = Column(String(20), nullable=False, default='pending', comment='狀態:pending, downloading, importing, completed, failed')
|
||||
|
||||
# Google Drive 檔案資訊
|
||||
drive_file_id = Column(String(200), comment='Google Drive 檔案 ID')
|
||||
drive_file_name = Column(String(500), comment='Google Drive 檔案名稱')
|
||||
drive_file_size = Column(Integer, comment='檔案大小(bytes)')
|
||||
|
||||
# 本地檔案資訊
|
||||
local_file_path = Column(String(500), comment='本地檔案路徑')
|
||||
|
||||
# 進度資訊
|
||||
progress_percent = Column(Float, default=0.0, comment='進度百分比 (0-100)')
|
||||
current_step = Column(String(200), comment='當前步驟描述')
|
||||
total_rows = Column(Integer, comment='總行數')
|
||||
processed_rows = Column(Integer, default=0, comment='已處理行數')
|
||||
success_rows = Column(Integer, default=0, comment='成功匯入行數')
|
||||
error_rows = Column(Integer, default=0, comment='錯誤行數')
|
||||
|
||||
# 時間記錄 (2026-01-30 修正:使用台北時區)
|
||||
created_at = Column(DateTime, default=taipei_now, comment='建立時間')
|
||||
started_at = Column(DateTime, comment='開始時間')
|
||||
completed_at = Column(DateTime, comment='完成時間')
|
||||
|
||||
# 結果資訊
|
||||
error_message = Column(Text, comment='錯誤訊息')
|
||||
import_summary = Column(Text, comment='匯入摘要(JSON 格式)')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ImportJob(id={self.id}, type={self.job_type}, status={self.status}, progress={self.progress_percent}%)>"
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'job_type': self.job_type,
|
||||
'status': self.status,
|
||||
'drive_file_id': self.drive_file_id,
|
||||
'drive_file_name': self.drive_file_name,
|
||||
'drive_file_size': self.drive_file_size,
|
||||
'local_file_path': self.local_file_path,
|
||||
'progress_percent': self.progress_percent,
|
||||
'current_step': self.current_step,
|
||||
'total_rows': self.total_rows,
|
||||
'processed_rows': self.processed_rows,
|
||||
'success_rows': self.success_rows,
|
||||
'error_rows': self.error_rows,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
'error_message': self.error_message,
|
||||
'import_summary': self.import_summary,
|
||||
}
|
||||
|
||||
|
||||
class ImportConfig(Base):
|
||||
"""匯入配置模型"""
|
||||
__tablename__ = 'import_config'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 配置項目
|
||||
config_key = Column(String(100), unique=True, nullable=False, comment='配置鍵')
|
||||
config_value = Column(Text, comment='配置值')
|
||||
config_type = Column(String(50), comment='配置類型:string, int, bool, json')
|
||||
description = Column(String(500), comment='配置說明')
|
||||
|
||||
# 時間記錄 (2026-01-30 修正:使用台北時區)
|
||||
created_at = Column(DateTime, default=taipei_now, comment='建立時間')
|
||||
updated_at = Column(DateTime, default=taipei_now, onupdate=taipei_now, comment='更新時間')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ImportConfig(key={self.config_key}, value={self.config_value})>"
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典格式"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'config_key': self.config_key,
|
||||
'config_value': self.config_value,
|
||||
'config_type': self.config_type,
|
||||
'description': self.description,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
357
database/manager.py
Normal file
357
database/manager.py
Normal file
@@ -0,0 +1,357 @@
|
||||
import os
|
||||
import re
|
||||
from sqlalchemy import create_engine, desc, select, text, literal
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
from .models import Base, Category, Product, PriceRecord, MonthlySummaryAnalysis
|
||||
from .user_models import User, LoginHistory # noqa: F401 - 必須在 trend_models 之前導入,解決 ForeignKey 依賴問題
|
||||
from .edm_models import PromoProduct # V-Fix: 確保 EDM 模型被註冊,以便自動建表
|
||||
from .trend_models import TrendRecord, TrendKeyword, TrendAnalysis, WebSearchCache, TelegramUser # noqa: F401 - 趨勢資料表
|
||||
from .ai_models import AIGenerationHistory, AIInsight, AIUsageTracking, AIPromptTemplate # AI 記憶體與洞察模型
|
||||
|
||||
# 🚩 導入優化後的日誌管理模組
|
||||
from services.logger_manager import SystemLogger
|
||||
|
||||
# 初始化資料庫模組專用 Logger
|
||||
sys_log = SystemLogger("Database").get_logger()
|
||||
|
||||
def sanitize_timestamp(timestamp_str):
|
||||
"""
|
||||
驗證並清理時間戳字串,防止 SQL Injection
|
||||
|
||||
Args:
|
||||
timestamp_str: 時間戳字串(格式:YYYY-MM-DD HH:MM:SS)
|
||||
|
||||
Returns:
|
||||
str: 驗證通過的時間戳
|
||||
|
||||
Raises:
|
||||
ValueError: 格式不正確
|
||||
"""
|
||||
# 只允許標準時間格式:YYYY-MM-DD HH:MM:SS
|
||||
if not re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', timestamp_str):
|
||||
raise ValueError(f"時間戳格式不正確: {timestamp_str}")
|
||||
return timestamp_str
|
||||
|
||||
class DatabaseManager:
|
||||
def __init__(self, db_path=None):
|
||||
"""
|
||||
初始化資料庫連線。
|
||||
優先使用 PostgreSQL (透過 config.py 設定),否則回退到 SQLite。
|
||||
"""
|
||||
# V-Fix (2026-01-23): 優先使用 config.py 的資料庫設定
|
||||
from config import DATABASE_PATH, DATABASE_TYPE
|
||||
|
||||
if DATABASE_TYPE == 'postgresql':
|
||||
# PostgreSQL 模式 - 使用 config.py 的連線字串
|
||||
# 連線池配置以提升穩定性
|
||||
self.engine = create_engine(
|
||||
DATABASE_PATH,
|
||||
echo=False,
|
||||
pool_pre_ping=True, # 自動檢測斷線連線
|
||||
pool_size=5, # 連線池大小
|
||||
max_overflow=10, # 額外連線數
|
||||
pool_recycle=1800, # 30分鐘回收連線
|
||||
pool_timeout=30, # 獲取連線超時
|
||||
connect_args={
|
||||
'connect_timeout': 10, # 連線超時 10 秒
|
||||
'options': '-c statement_timeout=60000' # SQL 超時 60 秒
|
||||
}
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
sys_log.info(f"[Database] ✅ 使用 PostgreSQL 資料庫 (連線池已優化)")
|
||||
else:
|
||||
# SQLite 模式 - 向後相容
|
||||
if db_path is None:
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
db_path = os.path.join(base_dir, 'data', 'momo_database.db')
|
||||
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
self.engine = create_engine(f'sqlite:///{db_path}', echo=False)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self._check_and_fix_schema()
|
||||
sys_log.info(f"[Database] 使用 SQLite 資料庫: {db_path}")
|
||||
|
||||
def _check_and_fix_schema(self):
|
||||
"""自動檢查並修復資料庫結構 (僅限 SQLite)"""
|
||||
# 此方法使用 SQLite PRAGMA 語法,不適用於 PostgreSQL
|
||||
from config import DATABASE_TYPE
|
||||
if DATABASE_TYPE == 'postgresql':
|
||||
return # PostgreSQL 不需要此修復邏輯
|
||||
|
||||
session = self.get_session()
|
||||
try:
|
||||
# 1. 檢查 promo_products 是否缺少 url 欄位
|
||||
result = session.execute(text("PRAGMA table_info(promo_products)")).fetchall()
|
||||
if result:
|
||||
columns = [row[1] for row in result]
|
||||
if 'url' not in columns:
|
||||
sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...")
|
||||
session.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT"))
|
||||
|
||||
# 2. 檢查 products 表是否缺少 status 與 updated_at 欄位
|
||||
result_prod = session.execute(text("PRAGMA table_info(products)")).fetchall()
|
||||
if result_prod:
|
||||
prod_columns = [row[1] for row in result_prod]
|
||||
|
||||
if 'status' not in prod_columns:
|
||||
sys_log.warning("⚠️ 偵測到 products 表缺少 status 欄位,正在自動修復...")
|
||||
session.execute(text("ALTER TABLE products ADD COLUMN status TEXT DEFAULT 'ACTIVE'"))
|
||||
|
||||
if 'updated_at' not in prod_columns:
|
||||
sys_log.warning("⚠️ 偵測到 products 表缺少 updated_at 欄位,正在自動修復...")
|
||||
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# 使用清理函數防止 SQL Injection
|
||||
safe_timestamp = sanitize_timestamp(now_str)
|
||||
session.execute(text(f"ALTER TABLE products ADD COLUMN updated_at TIMESTAMP DEFAULT '{safe_timestamp}'"))
|
||||
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
sys_log.error(f"❌ 資料庫結構檢查失敗: {e}")
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_session(self):
|
||||
"""
|
||||
提供外部調用的 Session 實例。
|
||||
"""
|
||||
return self.Session()
|
||||
|
||||
def update_data(self, product_list):
|
||||
"""
|
||||
🚀 批次異動偵測與日誌分析邏輯:
|
||||
1. 利用記憶體快照進行三重比對,並記錄詳細數據流向。
|
||||
2. 針對大量數據新增 (異常監控) 觸發警告等級日誌。
|
||||
3. 維持每 100 筆分段 Commit 的效能優勢。
|
||||
"""
|
||||
session = self.get_session()
|
||||
count_added = 0
|
||||
count_skipped = 0
|
||||
|
||||
try:
|
||||
# 1. 建立對比快取:一次抓出所有商品最後一筆紀錄
|
||||
latest_prices = session.query(
|
||||
Product.i_code, Product.name, PriceRecord.price
|
||||
).join(PriceRecord).order_by(PriceRecord.timestamp.desc()).all()
|
||||
|
||||
db_cache = {row[0]: (row[1], row[2]) for row in latest_prices}
|
||||
|
||||
sys_log.info(f"💾 開始數據比對:目前資料庫已知商品數 {len(db_cache)}")
|
||||
|
||||
for item in product_list:
|
||||
i_code = item['i_code']
|
||||
current_name = item['name']
|
||||
current_price = item['price']
|
||||
|
||||
# 2. 三重比對邏輯:偵測商品是否真的需要更新
|
||||
if i_code in db_cache:
|
||||
last_name, last_price = db_cache[i_code]
|
||||
if last_name == current_name and last_price == current_price:
|
||||
count_skipped += 1
|
||||
continue
|
||||
|
||||
# 3. 處理分類項目
|
||||
category = session.query(Category).filter_by(name=item['category']).first()
|
||||
if not category:
|
||||
category = Category(name=item['category'])
|
||||
session.add(category)
|
||||
session.flush()
|
||||
|
||||
# 4. 處理商品基本資訊
|
||||
product = session.query(Product).filter_by(i_code=i_code).first()
|
||||
if not product:
|
||||
product = Product(
|
||||
i_code=i_code,
|
||||
name=current_name,
|
||||
url=item['url'],
|
||||
category=item['category'],
|
||||
category_id=category.id
|
||||
)
|
||||
session.add(product)
|
||||
session.flush()
|
||||
else:
|
||||
product.name = current_name
|
||||
product.category = item['category']
|
||||
product.category_id = category.id
|
||||
|
||||
# 5. 寫入新的價格紀錄
|
||||
new_price = PriceRecord(
|
||||
product_id=product.id,
|
||||
price=current_price,
|
||||
timestamp=datetime.now()
|
||||
)
|
||||
session.add(new_price)
|
||||
count_added += 1
|
||||
|
||||
if count_added % 100 == 0:
|
||||
session.commit()
|
||||
|
||||
# 7. 最後提交並彙報日誌
|
||||
session.commit()
|
||||
sys_log.info(f"📊 數據同步彙報: [新增監控: {count_added}] [跳過重複: {count_skipped}]")
|
||||
|
||||
if count_added > 50:
|
||||
sys_log.warning(f"⚠️ 偵測到異常大量新增數據 ({count_added} 筆),請確認分類 URL 或爬取範圍是否正確")
|
||||
|
||||
return count_added
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 資料寫入異常:{str(e)}")
|
||||
return 0
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_price_analysis(self, product_id, external_session=None):
|
||||
"""
|
||||
🚩 整合優化:取得該商品的歷史價格波動分析。
|
||||
用於 Excel 報表生成時計算漲跌數值。
|
||||
"""
|
||||
session = external_session if external_session else self.get_session()
|
||||
try:
|
||||
# 取得該商品所有的價格紀錄,按時間由新到舊排序
|
||||
records = session.query(PriceRecord).filter_by(product_id=product_id)\
|
||||
.order_by(PriceRecord.timestamp.desc()).all()
|
||||
|
||||
if not records:
|
||||
return {'current': 0, '7d_diff': 0, '30d_diff': 0}
|
||||
|
||||
current_price = records[0].price
|
||||
# 計算波動 (若數據長度不足則回傳 0)
|
||||
# 註:此處假設每天一筆數據,records[7] 約為一週前,records[30] 約為一月前
|
||||
diff_7d = current_price - records[7].price if len(records) > 7 else 0
|
||||
diff_30 = current_price - records[30].price if len(records) > 30 else 0
|
||||
|
||||
return {
|
||||
'current': current_price,
|
||||
'7d_diff': diff_7d,
|
||||
'30d_diff': diff_30
|
||||
}
|
||||
except Exception as e:
|
||||
sys_log.error(f"❌ 價格分析計算失敗 (ID: {product_id}): {e}")
|
||||
return {'current': 0, '7d_diff': 0, '30d_diff': 0}
|
||||
finally:
|
||||
if not external_session:
|
||||
session.close()
|
||||
|
||||
def get_sales_data(self, table_name='realtime_sales_monthly', start_date=None, end_date=None, months=None):
|
||||
"""
|
||||
從指定的銷售資料表中讀取資料
|
||||
|
||||
Args:
|
||||
table_name: 資料表名稱 (預設: realtime_sales_monthly)
|
||||
start_date: 開始日期 (格式: YYYY-MM-DD)
|
||||
end_date: 結束日期 (格式: YYYY-MM-DD)
|
||||
months: 查詢最近幾個月的資料
|
||||
|
||||
Returns:
|
||||
tuple: (DataFrame, cols_map) - 資料框和欄位映射字典
|
||||
"""
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
# 建立日期過濾條件
|
||||
date_filter = ""
|
||||
if start_date and end_date:
|
||||
date_filter = f" WHERE \"日期\" BETWEEN '{start_date}' AND '{end_date}'"
|
||||
elif months:
|
||||
# 計算 months 個月前的日期
|
||||
end_dt = datetime.now()
|
||||
start_dt = end_dt - timedelta(days=months * 30)
|
||||
start_date_str = start_dt.strftime('%Y-%m-%d')
|
||||
end_date_str = end_dt.strftime('%Y-%m-%d')
|
||||
date_filter = f" WHERE \"日期\" BETWEEN '{start_date_str}' AND '{end_date_str}'"
|
||||
|
||||
# 執行查詢
|
||||
sql = f"SELECT * FROM {table_name}{date_filter}"
|
||||
df = pd.read_sql(text(sql), self.engine)
|
||||
|
||||
# V-Fix: 將數值欄位轉換為數字類型
|
||||
numeric_columns = ['總業績', '數量', '總成本', '退貨數量', '商品單位售價',
|
||||
'折價券折扣金額', '折扣金額', '滿額再折扣金額', '分期手續費']
|
||||
for col in numeric_columns:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors='coerce').fillna(0)
|
||||
|
||||
# 建立欄位映射
|
||||
cols = df.columns.tolist()
|
||||
|
||||
def find_col(keywords):
|
||||
for keyword in keywords:
|
||||
for col in cols:
|
||||
if keyword in str(col):
|
||||
return col
|
||||
return None
|
||||
|
||||
cols_map = {
|
||||
'name': find_col(['商品名稱', '品名', 'Name', 'Product']),
|
||||
'pid': find_col(['商品ID', 'Product ID', 'ID', 'i_code']),
|
||||
'date': find_col(['日期', '交易日期', 'Date']),
|
||||
'time': find_col(['時間', 'Time']),
|
||||
'amount': find_col(['總業績', '銷售金額', '業績', '金額', 'Amount', 'Sales']),
|
||||
'qty': find_col(['數量', '銷售數量', '銷量', 'Qty', 'Quantity']),
|
||||
'cost': find_col(['總成本', '成本', 'Cost']),
|
||||
'profit': find_col(['毛利', 'Profit', 'Gross Margin']),
|
||||
'category': find_col(['商品館', '館別', '分類', 'Category']),
|
||||
'brand': find_col(['品牌', 'Brand']),
|
||||
'vendor': find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor']),
|
||||
'activity': find_col(['折扣活動名稱', '活動', 'Activity', 'Campaign']),
|
||||
'payment': find_col(['付款', 'Payment', '付款方式']),
|
||||
'price': find_col(['商品單位售價', '單價', 'Price']),
|
||||
}
|
||||
|
||||
sys_log.info(f"[DB] get_sales_data 成功 | 表: {table_name} | 筆數: {len(df)}")
|
||||
return df, cols_map
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"[DB] get_sales_data 失敗: {e}")
|
||||
return None, {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 全域資料庫管理器與便捷函數
|
||||
# =============================================================================
|
||||
# 預設使用 config.py 的 DATABASE_PATH (支援 SQLite 和 PostgreSQL)
|
||||
_default_db_manager = None
|
||||
|
||||
|
||||
def get_db_manager():
|
||||
"""
|
||||
取得全域 DatabaseManager 實例 (單例模式)
|
||||
|
||||
Returns:
|
||||
DatabaseManager: 資料庫管理器實例
|
||||
"""
|
||||
global _default_db_manager
|
||||
if _default_db_manager is None:
|
||||
try:
|
||||
from config import DATABASE_PATH
|
||||
_default_db_manager = DatabaseManager(DATABASE_PATH)
|
||||
except ImportError:
|
||||
# 若 config 不可用,使用預設路徑
|
||||
_default_db_manager = DatabaseManager()
|
||||
return _default_db_manager
|
||||
|
||||
|
||||
def get_session():
|
||||
"""
|
||||
取得資料庫 Session(便捷函數)
|
||||
|
||||
這是給其他模組使用的便捷函數,避免重複初始化 DatabaseManager。
|
||||
|
||||
Returns:
|
||||
Session: SQLAlchemy Session 實例
|
||||
|
||||
Usage:
|
||||
from database.manager import get_session
|
||||
session = get_session()
|
||||
try:
|
||||
# 進行資料庫操作
|
||||
session.query(...)
|
||||
session.commit()
|
||||
finally:
|
||||
session.close()
|
||||
"""
|
||||
return get_db_manager().get_session()
|
||||
108
database/models.py
Normal file
108
database/models.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = 'categories'
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(50), unique=True, nullable=False)
|
||||
products = relationship("Product", back_populates="category_rel")
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = 'products'
|
||||
id = Column(Integer, primary_key=True)
|
||||
i_code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
url = Column(String(500))
|
||||
image_url = Column(Text)
|
||||
category = Column(String(100))
|
||||
|
||||
# V9.52 新增欄位
|
||||
status = Column(String(20), default='ACTIVE')
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# 關聯設定
|
||||
category_id = Column(Integer, ForeignKey('categories.id'))
|
||||
category_rel = relationship("Category", back_populates="products")
|
||||
prices = relationship("PriceRecord", back_populates="product", cascade="all, delete-orphan")
|
||||
|
||||
class PriceRecord(Base):
|
||||
__tablename__ = 'price_records'
|
||||
id = Column(Integer, primary_key=True)
|
||||
product_id = Column(Integer, ForeignKey('products.id'), nullable=False)
|
||||
price = Column(Float, nullable=False)
|
||||
timestamp = Column(DateTime, default=datetime.now, index=True)
|
||||
product = relationship("Product", back_populates="prices")
|
||||
|
||||
class MonthlySummaryAnalysis(Base):
|
||||
__tablename__ = 'monthly_summary_analysis'
|
||||
id = Column(Integer, primary_key=True)
|
||||
year = Column(Integer, nullable=False, index=True)
|
||||
month = Column(Integer, nullable=False, index=True)
|
||||
department = Column(String(100))
|
||||
category_3c = Column(String(100))
|
||||
division = Column(String(100), index=True)
|
||||
section = Column(String(100))
|
||||
area_id = Column(String(50))
|
||||
area_name = Column(String(100))
|
||||
pm_name = Column(String(100), index=True)
|
||||
brand_name = Column(String(200), index=True)
|
||||
vendor_id = Column(Integer, index=True)
|
||||
vendor_name = Column(String(200))
|
||||
trade_type = Column(String(20))
|
||||
unit_price = Column(Float)
|
||||
|
||||
# 指標 - 銷售額
|
||||
sales_amt_curr = Column(Integer)
|
||||
sales_amt_prev = Column(Integer)
|
||||
sales_amt_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 毛1額
|
||||
profit_amt_curr = Column(Integer)
|
||||
profit_amt_prev = Column(Integer)
|
||||
profit_amt_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 折扣金額
|
||||
discount_amt_curr = Column(Integer)
|
||||
discount_amt_prev = Column(Integer)
|
||||
discount_amt_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 折價券
|
||||
coupon_amt_curr = Column(Integer)
|
||||
coupon_amt_prev = Column(Integer)
|
||||
coupon_amt_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 其他行銷活動
|
||||
other_mkt_curr = Column(Integer)
|
||||
other_mkt_prev = Column(Integer)
|
||||
other_mkt_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 點我折
|
||||
spot_disc_curr = Column(Integer)
|
||||
spot_disc_prev = Column(Integer)
|
||||
spot_disc_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 點數折抵
|
||||
point_disc_curr = Column(Integer)
|
||||
point_disc_prev = Column(Integer)
|
||||
point_disc_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 銷售量
|
||||
sales_vol_curr = Column(Integer)
|
||||
sales_vol_prev = Column(Integer)
|
||||
sales_vol_yoa = Column(Integer)
|
||||
|
||||
# 指標 - 轉換率與瀏覽數
|
||||
conv_rate = Column(Float)
|
||||
views_curr = Column(Integer)
|
||||
views_prev = Column(Integer)
|
||||
views_yoa = Column(Integer)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('year', 'month', 'department', 'category_3c', 'division', 'section', 'area_id', 'pm_name', 'brand_name', 'vendor_id', 'trade_type', name='_monthly_summary_uc'),
|
||||
)
|
||||
236
database/notification_models.py
Normal file
236
database/notification_models.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
通知模板資料模型
|
||||
用於管理 n8n 和系統通知的訊息模板
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
Base = declarative_base()
|
||||
TAIPEI_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
class NotificationTemplate(Base):
|
||||
"""通知模板"""
|
||||
__tablename__ = 'notification_templates'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String(50), unique=True, nullable=False) # 模板代碼,如 'disk_alert'
|
||||
name = Column(String(100), nullable=False) # 顯示名稱
|
||||
category = Column(String(50), nullable=False) # 分類:system, business, report
|
||||
channel = Column(String(20), default='telegram') # 通知渠道:telegram, line, both
|
||||
|
||||
# 模板內容
|
||||
title = Column(String(200)) # 標題
|
||||
body = Column(Text, nullable=False) # 訊息內容(支援變數)
|
||||
emoji_prefix = Column(String(10)) # 前綴 emoji
|
||||
|
||||
# 狀態
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# 時間戳
|
||||
created_at = Column(DateTime, default=lambda: datetime.now(TAIPEI_TZ).replace(tzinfo=None))
|
||||
updated_at = Column(DateTime, onupdate=lambda: datetime.now(TAIPEI_TZ).replace(tzinfo=None))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'category': self.category,
|
||||
'channel': self.channel,
|
||||
'title': self.title,
|
||||
'body': self.body,
|
||||
'emoji_prefix': self.emoji_prefix,
|
||||
'is_active': self.is_active,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
# 預設模板
|
||||
DEFAULT_TEMPLATES = [
|
||||
{
|
||||
'code': 'disk_warning',
|
||||
'name': '磁碟空間警告',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🟠',
|
||||
'title': '磁碟空間警告',
|
||||
'body': '''📊 使用率: {usage_percent}%
|
||||
💾 剩餘: {free_gb} GB / {total_gb} GB
|
||||
|
||||
🔧 正在執行自動清理...'''
|
||||
},
|
||||
{
|
||||
'code': 'disk_critical',
|
||||
'name': '磁碟空間嚴重不足',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🔴',
|
||||
'title': '磁碟空間嚴重不足',
|
||||
'body': '''📊 使用率: {usage_percent}%
|
||||
💾 剩餘: {free_gb} GB / {total_gb} GB
|
||||
|
||||
⚠️ 立即執行緊急清理!'''
|
||||
},
|
||||
{
|
||||
'code': 'cleanup_complete',
|
||||
'name': '自動清理完成',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '✅',
|
||||
'title': '自動清理完成',
|
||||
'body': '''🧹 清理結果:
|
||||
{results}
|
||||
|
||||
📊 清理後使用率: {new_usage_percent}%'''
|
||||
},
|
||||
{
|
||||
'code': 'ssl_warning',
|
||||
'name': 'SSL 證書即將到期',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🟡',
|
||||
'title': 'SSL 證書警告',
|
||||
'body': '''{issues}
|
||||
|
||||
💡 執行: sudo certbot renew --nginx'''
|
||||
},
|
||||
{
|
||||
'code': 'pod_unhealthy',
|
||||
'name': 'K8s Pod 異常',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🔴',
|
||||
'title': 'K8s Pod 異常',
|
||||
'body': '''📍 狀態: {status}
|
||||
🗄️ 資料庫: {database}
|
||||
|
||||
🔧 正在嘗試重啟...'''
|
||||
},
|
||||
{
|
||||
'code': 'pod_restart_result',
|
||||
'name': 'Pod 重啟結果',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '✅',
|
||||
'title': 'Pod 重啟完成',
|
||||
'body': '''{deployment} 已重啟,等待恢復中...'''
|
||||
},
|
||||
{
|
||||
'code': 'crawler_warning',
|
||||
'name': '爬蟲執行警告',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🟠',
|
||||
'title': '爬蟲監控警告',
|
||||
'body': '''{issues}
|
||||
|
||||
🔧 正在嘗試重啟 scheduler...'''
|
||||
},
|
||||
{
|
||||
'code': 'harbor_unhealthy',
|
||||
'name': 'Harbor Registry 異常',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🟠',
|
||||
'title': 'Harbor Registry 異常',
|
||||
'body': '''📍 狀態: {status}
|
||||
❌ 錯誤: {error}
|
||||
|
||||
💡 請檢查 Harbor 服務
|
||||
執行: docker restart harbor-core harbor-nginx'''
|
||||
},
|
||||
{
|
||||
'code': 'backup_warning',
|
||||
'name': '備份監控警告',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '🟠',
|
||||
'title': '備份監控警告',
|
||||
'body': '''{error}
|
||||
|
||||
💡 請檢查備份排程'''
|
||||
},
|
||||
{
|
||||
'code': 'cicd_success',
|
||||
'name': 'CI/CD 成功',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '✅',
|
||||
'title': 'CI/CD Pipeline SUCCESS',
|
||||
'body': '''📦 專案: {project}
|
||||
🌿 分支: {branch}
|
||||
🆔 Pipeline: #{pipeline_id}
|
||||
💬 Commit: {commit_message}
|
||||
👤 作者: {author}
|
||||
⏱️ 耗時: {duration}
|
||||
|
||||
🔗 查看詳情: {url}'''
|
||||
},
|
||||
{
|
||||
'code': 'cicd_failed',
|
||||
'name': 'CI/CD 失敗',
|
||||
'category': 'system',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '❌',
|
||||
'title': 'CI/CD Pipeline FAILED',
|
||||
'body': '''📦 專案: {project}
|
||||
🌿 分支: {branch}
|
||||
🆔 Pipeline: #{pipeline_id}
|
||||
💬 Commit: {commit_message}
|
||||
👤 作者: {author}
|
||||
|
||||
🔗 查看詳情: {url}'''
|
||||
},
|
||||
{
|
||||
'code': 'daily_report',
|
||||
'name': '每日系統狀態報告',
|
||||
'category': 'report',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '📊',
|
||||
'title': '每日系統狀態報告',
|
||||
'body': '''📅 日期: {date}
|
||||
|
||||
🖥️ 應用程式: {app_status}
|
||||
💾 資料備份: {backup_status}
|
||||
🕷️ 爬蟲排程: {crawler_status}
|
||||
|
||||
📦 最新備份: {last_backup}'''
|
||||
},
|
||||
{
|
||||
'code': 'weekly_sales',
|
||||
'name': '每週業績摘要',
|
||||
'category': 'business',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '📈',
|
||||
'title': '每週業績摘要',
|
||||
'body': '''📅 週期: {week_start} ~ {week_end}
|
||||
💰 總業績: ${total_sales}
|
||||
📦 訂單數: {order_count}
|
||||
📈 成長率: {growth_rate}%
|
||||
|
||||
詳細報表請登入系統查看'''
|
||||
},
|
||||
{
|
||||
'code': 'monthly_reminder',
|
||||
'name': '月初作業提醒',
|
||||
'category': 'business',
|
||||
'channel': 'telegram',
|
||||
'emoji_prefix': '📅',
|
||||
'title': '{month}月初作業提醒',
|
||||
'body': '''✅ 待辦事項:
|
||||
|
||||
1️⃣ 匯出上月 ({prev_month}) 月結報表
|
||||
2️⃣ 檢查 Google Drive 自動匯入設定
|
||||
3️⃣ 確認爬蟲排程正常運作
|
||||
4️⃣ 備份資料庫
|
||||
5️⃣ 檢查系統日誌
|
||||
|
||||
💡 登入系統: https://mo.wooo.work'''
|
||||
}
|
||||
]
|
||||
233
database/permission_models.py
Normal file
233
database/permission_models.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
權限系統資料模型
|
||||
================
|
||||
Permission - 權限定義表
|
||||
UserPermission - 用戶權限關聯表
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, UniqueConstraint
|
||||
from database.models import Base
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""權限定義表 - 系統預設權限項目"""
|
||||
__tablename__ = 'permissions'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True) # 權限代碼,如 'dashboard.view'
|
||||
name = Column(String(100), nullable=False) # 顯示名稱,如 '查看首頁看板'
|
||||
category = Column(String(50), nullable=False) # 分類,如 '首頁/看板'
|
||||
description = Column(String(200)) # 詳細說明
|
||||
sort_order = Column(Integer, default=0) # 排序順序
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'name': self.name,
|
||||
'category': self.category,
|
||||
'description': self.description,
|
||||
'sort_order': self.sort_order
|
||||
}
|
||||
|
||||
|
||||
class UserPermission(Base):
|
||||
"""用戶權限關聯表"""
|
||||
__tablename__ = 'user_permissions'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
||||
permission_code = Column(String(50), nullable=False, index=True)
|
||||
granted_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
granted_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('user_id', 'permission_code', name='uq_user_permission'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'permission_code': self.permission_code,
|
||||
'granted_by': self.granted_by,
|
||||
'granted_at': self.granted_at.isoformat() if self.granted_at else None
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 權限定義常數
|
||||
# ============================================================================
|
||||
|
||||
PERMISSIONS = [
|
||||
# 首頁/看板
|
||||
{'code': 'dashboard.view', 'name': '查看首頁看板', 'category': '首頁/看板', 'description': '訪問首頁商品看板', 'sort_order': 10},
|
||||
{'code': 'dashboard.export', 'name': '匯出看板資料', 'category': '首頁/看板', 'description': '匯出商品資料 Excel', 'sort_order': 11},
|
||||
|
||||
# 報表
|
||||
{'code': 'report.daily_sales.view', 'name': '查看每日銷售', 'category': '報表', 'description': '訪問每日銷售頁面', 'sort_order': 20},
|
||||
{'code': 'report.daily_sales.export', 'name': '匯出每日銷售', 'category': '報表', 'description': '匯出每日銷售 Excel', 'sort_order': 21},
|
||||
{'code': 'report.monthly_summary.view', 'name': '查看月度總結', 'category': '報表', 'description': '訪問月度總結分析頁面', 'sort_order': 22},
|
||||
{'code': 'report.monthly_summary.import', 'name': '匯入月度資料', 'category': '報表', 'description': '匯入月度總結 Excel', 'sort_order': 23},
|
||||
{'code': 'report.sales_analysis.view', 'name': '查看銷售分析', 'category': '報表', 'description': '訪問銷售分析頁面', 'sort_order': 24},
|
||||
{'code': 'report.growth_analysis.view', 'name': '查看成長分析', 'category': '報表', 'description': '訪問成長分析頁面', 'sort_order': 25},
|
||||
{'code': 'report.abc_analysis.view', 'name': '查看 ABC 分析', 'category': '報表', 'description': '訪問 ABC 分析頁面', 'sort_order': 26},
|
||||
|
||||
# 活動看板
|
||||
{'code': 'edm.view', 'name': '查看 EDM 看板', 'category': '活動看板', 'description': '訪問 EDM 看板頁面', 'sort_order': 30},
|
||||
{'code': 'edm.trigger', 'name': '觸發 EDM 爬蟲', 'category': '活動看板', 'description': '手動觸發 EDM 爬蟲', 'sort_order': 31},
|
||||
{'code': 'festival.view', 'name': '查看節慶看板', 'category': '活動看板', 'description': '訪問節慶看板頁面', 'sort_order': 32},
|
||||
{'code': 'festival.trigger', 'name': '觸發節慶爬蟲', 'category': '活動看板', 'description': '手動觸發節慶爬蟲', 'sort_order': 33},
|
||||
|
||||
# 廠商缺貨
|
||||
{'code': 'vendor.index.view', 'name': '查看廠商缺貨首頁', 'category': '廠商缺貨', 'description': '訪問廠商缺貨首頁', 'sort_order': 40},
|
||||
{'code': 'vendor.import', 'name': '匯入缺貨資料', 'category': '廠商缺貨', 'description': '匯入缺貨 Excel', 'sort_order': 41},
|
||||
{'code': 'vendor.list.view', 'name': '查看缺貨清單', 'category': '廠商缺貨', 'description': '訪問缺貨清單頁面', 'sort_order': 42},
|
||||
{'code': 'vendor.list.edit', 'name': '編輯缺貨資料', 'category': '廠商缺貨', 'description': '編輯/刪除缺貨記錄', 'sort_order': 43},
|
||||
{'code': 'vendor.management.view', 'name': '查看廠商管理', 'category': '廠商缺貨', 'description': '訪問廠商管理頁面', 'sort_order': 44},
|
||||
{'code': 'vendor.management.edit', 'name': '管理廠商資料', 'category': '廠商缺貨', 'description': '新增/編輯/刪除廠商', 'sort_order': 45},
|
||||
{'code': 'vendor.email.view', 'name': '查看郵件發送', 'category': '廠商缺貨', 'description': '訪問郵件發送頁面', 'sort_order': 46},
|
||||
{'code': 'vendor.email.send', 'name': '發送廠商郵件', 'category': '廠商缺貨', 'description': '發送缺貨通知郵件', 'sort_order': 47},
|
||||
{'code': 'vendor.history.view', 'name': '查看歷史記錄', 'category': '廠商缺貨', 'description': '訪問歷史記錄頁面', 'sort_order': 48},
|
||||
|
||||
# 匯入
|
||||
{'code': 'import.auto.view', 'name': '查看自動匯入', 'category': '匯入', 'description': '訪問自動匯入頁面', 'sort_order': 50},
|
||||
{'code': 'import.auto.manage', 'name': '管理匯入任務', 'category': '匯入', 'description': '新增/編輯/刪除匯入任務', 'sort_order': 51},
|
||||
{'code': 'import.manual', 'name': '手動匯入資料', 'category': '匯入', 'description': '系統設定頁手動匯入', 'sort_order': 52},
|
||||
|
||||
# 系統
|
||||
{'code': 'system.settings.view', 'name': '查看系統設定', 'category': '系統', 'description': '訪問系統設定頁面', 'sort_order': 60},
|
||||
{'code': 'system.settings.edit', 'name': '修改系統設定', 'category': '系統', 'description': '儲存系統設定變更', 'sort_order': 61},
|
||||
{'code': 'system.advanced.view', 'name': '查看進階設定', 'category': '系統', 'description': '訪問進階設定頁面', 'sort_order': 62},
|
||||
{'code': 'system.advanced.edit', 'name': '修改進階設定', 'category': '系統', 'description': '分類管理等進階操作', 'sort_order': 63},
|
||||
{'code': 'system.logs.view', 'name': '查看系統日誌', 'category': '系統', 'description': '訪問系統日誌頁面', 'sort_order': 64},
|
||||
{'code': 'system.crawler.view', 'name': '查看爬蟲管理', 'category': '系統', 'description': '訪問爬蟲管理頁面', 'sort_order': 65},
|
||||
{'code': 'system.crawler.manage', 'name': '管理爬蟲設定', 'category': '系統', 'description': '修改爬蟲設定', 'sort_order': 66},
|
||||
{'code': 'system.backup', 'name': '備份資料庫', 'category': '系統', 'description': '執行資料庫備份', 'sort_order': 67},
|
||||
{'code': 'system.users.view', 'name': '查看用戶管理', 'category': '系統', 'description': '訪問用戶管理頁面', 'sort_order': 68},
|
||||
{'code': 'system.users.manage', 'name': '管理用戶帳號', 'category': '系統', 'description': '新增/編輯/刪除用戶', 'sort_order': 69},
|
||||
|
||||
# 其他
|
||||
{'code': 'brand_assets.view', 'name': '查看品牌素材', 'category': '其他', 'description': '訪問品牌素材頁面', 'sort_order': 90},
|
||||
]
|
||||
|
||||
# 所有權限代碼列表
|
||||
ALL_PERMISSION_CODES = [p['code'] for p in PERMISSIONS]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 角色預設權限模板
|
||||
# ============================================================================
|
||||
|
||||
# admin: 全部權限
|
||||
ROLE_ADMIN_PERMISSIONS = ALL_PERMISSION_CODES.copy()
|
||||
|
||||
# manager: 大部分權限,排除用戶管理和高危操作
|
||||
ROLE_MANAGER_PERMISSIONS = [
|
||||
# 首頁/看板
|
||||
'dashboard.view', 'dashboard.export',
|
||||
# 報表
|
||||
'report.daily_sales.view', 'report.daily_sales.export',
|
||||
'report.monthly_summary.view', 'report.monthly_summary.import',
|
||||
'report.sales_analysis.view', 'report.growth_analysis.view', 'report.abc_analysis.view',
|
||||
# 活動看板
|
||||
'edm.view', 'edm.trigger', 'festival.view', 'festival.trigger',
|
||||
# 廠商缺貨
|
||||
'vendor.index.view', 'vendor.import', 'vendor.list.view', 'vendor.list.edit',
|
||||
'vendor.management.view', 'vendor.management.edit',
|
||||
'vendor.email.view', 'vendor.email.send', 'vendor.history.view',
|
||||
# 匯入
|
||||
'import.auto.view', 'import.auto.manage', 'import.manual',
|
||||
# 系統 (有限)
|
||||
'system.settings.view', 'system.settings.edit',
|
||||
'system.advanced.view', # 可查看但不能編輯進階設定
|
||||
'system.logs.view',
|
||||
'system.crawler.view', # 可查看但不能管理爬蟲
|
||||
# 其他
|
||||
'brand_assets.view',
|
||||
]
|
||||
|
||||
# user: 僅查看權限
|
||||
ROLE_USER_PERMISSIONS = [
|
||||
# 首頁/看板
|
||||
'dashboard.view',
|
||||
# 報表 (僅查看)
|
||||
'report.daily_sales.view', 'report.monthly_summary.view',
|
||||
'report.sales_analysis.view', 'report.growth_analysis.view', 'report.abc_analysis.view',
|
||||
# 活動看板 (僅查看)
|
||||
'edm.view', 'festival.view',
|
||||
# 廠商缺貨 (部分查看)
|
||||
'vendor.index.view', 'vendor.list.view', 'vendor.history.view',
|
||||
# 其他
|
||||
'brand_assets.view',
|
||||
]
|
||||
|
||||
# 角色權限模板映射
|
||||
ROLE_DEFAULT_PERMISSIONS = {
|
||||
'admin': ROLE_ADMIN_PERMISSIONS,
|
||||
'manager': ROLE_MANAGER_PERMISSIONS,
|
||||
'user': ROLE_USER_PERMISSIONS,
|
||||
}
|
||||
|
||||
|
||||
def init_permissions(db_session):
|
||||
"""初始化權限表資料
|
||||
|
||||
Args:
|
||||
db_session: 資料庫 session
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
try:
|
||||
# 檢查權限表是否已有資料
|
||||
existing_count = db_session.query(Permission).count()
|
||||
if existing_count > 0:
|
||||
# 同步更新權限(新增缺少的、更新已有的)
|
||||
existing_codes = {p.code for p in db_session.query(Permission).all()}
|
||||
|
||||
added = 0
|
||||
updated = 0
|
||||
|
||||
for perm_data in PERMISSIONS:
|
||||
if perm_data['code'] in existing_codes:
|
||||
# 更新已有權限
|
||||
perm = db_session.query(Permission).filter_by(code=perm_data['code']).first()
|
||||
perm.name = perm_data['name']
|
||||
perm.category = perm_data['category']
|
||||
perm.description = perm_data.get('description')
|
||||
perm.sort_order = perm_data.get('sort_order', 0)
|
||||
updated += 1
|
||||
else:
|
||||
# 新增權限
|
||||
perm = Permission(
|
||||
code=perm_data['code'],
|
||||
name=perm_data['name'],
|
||||
category=perm_data['category'],
|
||||
description=perm_data.get('description'),
|
||||
sort_order=perm_data.get('sort_order', 0)
|
||||
)
|
||||
db_session.add(perm)
|
||||
added += 1
|
||||
|
||||
db_session.commit()
|
||||
return True, f"權限表已同步:新增 {added} 項,更新 {updated} 項"
|
||||
|
||||
# 首次初始化,批量新增
|
||||
for perm_data in PERMISSIONS:
|
||||
perm = Permission(
|
||||
code=perm_data['code'],
|
||||
name=perm_data['name'],
|
||||
category=perm_data['category'],
|
||||
description=perm_data.get('description'),
|
||||
sort_order=perm_data.get('sort_order', 0)
|
||||
)
|
||||
db_session.add(perm)
|
||||
|
||||
db_session.commit()
|
||||
return True, f"權限表初始化完成,共 {len(PERMISSIONS)} 項權限"
|
||||
|
||||
except Exception as e:
|
||||
db_session.rollback()
|
||||
return False, f"權限表初始化失敗: {str(e)}"
|
||||
417
database/trend_models.py
Normal file
417
database/trend_models.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
趨勢資料庫模型
|
||||
|
||||
包含:
|
||||
- TrendRecord: 趨勢記錄表
|
||||
- TrendKeyword: 趨勢關鍵字表
|
||||
- TrendAnalysis: AI 趨勢分析報告表
|
||||
- WebSearchCache: Web Search 結果快取表
|
||||
- TelegramUser: Telegram 用戶綁定表
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, Text, Float, Boolean, DateTime, Date,
|
||||
ForeignKey, Index, UniqueConstraint, BigInteger
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime, date, timedelta
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
# 使用與其他模型相同的 Base
|
||||
from database.models import Base
|
||||
|
||||
|
||||
class TrendRecord(Base):
|
||||
"""趨勢資料記錄 - 儲存爬取的原始內容"""
|
||||
__tablename__ = 'trend_records'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# 來源識別
|
||||
source = Column(String(50), nullable=False, index=True)
|
||||
# 可選值: 'google_news', 'ptt', 'dcard', 'youtube', 'weather', 'ollama_web_search'
|
||||
|
||||
source_board = Column(String(100))
|
||||
# PTT/Dcard 看板名稱,如 'Gossiping', '網路購物'
|
||||
|
||||
source_url = Column(String(500))
|
||||
# 原始連結
|
||||
|
||||
source_id = Column(String(100))
|
||||
# 來源平台的唯一識別碼 (用於去重)
|
||||
|
||||
# 內容
|
||||
title = Column(String(500), nullable=False)
|
||||
content = Column(Text)
|
||||
# 全文內容或摘要
|
||||
|
||||
author = Column(String(100))
|
||||
# 作者/媒體名稱
|
||||
|
||||
# 互動指標
|
||||
popularity_score = Column(Integer, default=0)
|
||||
# 熱門度分數 (推數、讚數、觀看數等)
|
||||
|
||||
comment_count = Column(Integer, default=0)
|
||||
# 留言數
|
||||
|
||||
# 分類標籤
|
||||
category = Column(String(100), index=True)
|
||||
# 商品分類對應: '美妝', '3C', '家電', '服飾' 等
|
||||
|
||||
tags = Column(Text)
|
||||
# JSON 格式的標籤列表
|
||||
|
||||
# 時間資訊
|
||||
published_at = Column(DateTime)
|
||||
# 原始發布時間
|
||||
|
||||
trend_date = Column(Date, nullable=False, index=True)
|
||||
# 趨勢所屬日期 (用於聚合查詢)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
# 爬取時間
|
||||
|
||||
# AI 分析結果
|
||||
sentiment = Column(String(20))
|
||||
# 情緒分析: 'positive', 'negative', 'neutral'
|
||||
|
||||
ai_summary = Column(Text)
|
||||
# Ollama 生成的摘要
|
||||
|
||||
relevance_score = Column(Float, default=0.0)
|
||||
# 與商品銷售的相關性分數 (0-1)
|
||||
|
||||
# 索引優化
|
||||
__table_args__ = (
|
||||
Index('idx_trend_source_date', 'source', 'trend_date'),
|
||||
Index('idx_trend_category_date', 'category', 'trend_date'),
|
||||
Index('idx_trend_popularity', 'popularity_score', 'trend_date'),
|
||||
UniqueConstraint('source', 'source_id', name='uq_source_record'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'source': self.source,
|
||||
'source_board': self.source_board,
|
||||
'source_url': self.source_url,
|
||||
'title': self.title,
|
||||
'content': self.content[:200] if self.content else None,
|
||||
'author': self.author,
|
||||
'popularity_score': self.popularity_score,
|
||||
'comment_count': self.comment_count,
|
||||
'category': self.category,
|
||||
'tags': json.loads(self.tags) if self.tags else [],
|
||||
'published_at': self.published_at.isoformat() if self.published_at else None,
|
||||
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
|
||||
'sentiment': self.sentiment,
|
||||
'ai_summary': self.ai_summary,
|
||||
'relevance_score': self.relevance_score,
|
||||
}
|
||||
|
||||
|
||||
class TrendKeyword(Base):
|
||||
"""趨勢關鍵字 - 從文章中萃取的熱門詞彙"""
|
||||
__tablename__ = 'trend_keywords'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
keyword = Column(String(100), nullable=False, index=True)
|
||||
# 關鍵字
|
||||
|
||||
keyword_type = Column(String(50), default='general')
|
||||
# 類型: 'product' (商品), 'brand' (品牌), 'event' (事件), 'general'
|
||||
|
||||
source = Column(String(50), nullable=False)
|
||||
# 來源平台
|
||||
|
||||
category = Column(String(100), index=True)
|
||||
# 商品分類
|
||||
|
||||
mention_count = Column(Integer, default=1)
|
||||
# 提及次數
|
||||
|
||||
trend_date = Column(Date, nullable=False, index=True)
|
||||
# 趨勢日期
|
||||
|
||||
sentiment_avg = Column(Float, default=0.0)
|
||||
# 平均情緒分數 (-1 到 1)
|
||||
|
||||
related_keywords = Column(Text)
|
||||
# JSON 格式的相關關鍵字
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_keyword_date_count', 'trend_date', 'mention_count'),
|
||||
UniqueConstraint('keyword', 'source', 'trend_date', name='uq_keyword_source_date'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'keyword': self.keyword,
|
||||
'keyword_type': self.keyword_type,
|
||||
'source': self.source,
|
||||
'category': self.category,
|
||||
'mention_count': self.mention_count,
|
||||
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
|
||||
'sentiment_avg': self.sentiment_avg,
|
||||
'related_keywords': json.loads(self.related_keywords) if self.related_keywords else [],
|
||||
}
|
||||
|
||||
|
||||
class TrendAnalysis(Base):
|
||||
"""趨勢分析報告 - Ollama AI 生成的分析結果"""
|
||||
__tablename__ = 'trend_analysis'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
analysis_date = Column(Date, nullable=False, index=True)
|
||||
# 分析日期
|
||||
|
||||
category = Column(String(100), index=True)
|
||||
# 分析的商品分類 (null 表示全品類)
|
||||
|
||||
analysis_type = Column(String(50), nullable=False)
|
||||
# 分析類型: 'daily_summary', 'weekly_trend', 'hot_topic', 'marketing_insight'
|
||||
|
||||
# AI 分析內容
|
||||
summary = Column(Text, nullable=False)
|
||||
# 摘要說明
|
||||
|
||||
hot_keywords = Column(Text)
|
||||
# JSON: 熱門關鍵字列表
|
||||
|
||||
hot_topics = Column(Text)
|
||||
# JSON: 熱門話題列表
|
||||
|
||||
consumer_insights = Column(Text)
|
||||
# JSON: 消費者洞察
|
||||
|
||||
marketing_suggestions = Column(Text)
|
||||
# JSON: 行銷建議
|
||||
|
||||
copywriting_hints = Column(Text)
|
||||
# JSON: 文案撰寫提示
|
||||
|
||||
# 來源統計
|
||||
source_stats = Column(Text)
|
||||
# JSON: 各來源資料統計
|
||||
|
||||
record_count = Column(Integer, default=0)
|
||||
# 分析涵蓋的記錄數
|
||||
|
||||
# Ollama 資訊
|
||||
model_used = Column(String(50))
|
||||
# 使用的模型
|
||||
|
||||
generation_time = Column(Float)
|
||||
# 生成耗時 (秒)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint('analysis_date', 'category', 'analysis_type', name='uq_analysis'),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'analysis_date': self.analysis_date.isoformat() if self.analysis_date else None,
|
||||
'category': self.category,
|
||||
'analysis_type': self.analysis_type,
|
||||
'summary': self.summary,
|
||||
'hot_keywords': json.loads(self.hot_keywords) if self.hot_keywords else [],
|
||||
'hot_topics': json.loads(self.hot_topics) if self.hot_topics else [],
|
||||
'consumer_insights': json.loads(self.consumer_insights) if self.consumer_insights else [],
|
||||
'marketing_suggestions': json.loads(self.marketing_suggestions) if self.marketing_suggestions else [],
|
||||
'copywriting_hints': json.loads(self.copywriting_hints) if self.copywriting_hints else [],
|
||||
'source_stats': json.loads(self.source_stats) if self.source_stats else {},
|
||||
'record_count': self.record_count,
|
||||
'model_used': self.model_used,
|
||||
'generation_time': self.generation_time,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class WebSearchCache(Base):
|
||||
"""Web Search 結果快取 - 避免重複查詢"""
|
||||
__tablename__ = 'web_search_cache'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# 查詢識別
|
||||
query_hash = Column(String(64), nullable=False, unique=True, index=True)
|
||||
# MD5(query + search_type)
|
||||
|
||||
query = Column(String(500), nullable=False)
|
||||
# 原始查詢字串
|
||||
|
||||
search_type = Column(String(50), default='general')
|
||||
# 搜尋類型: general, news, shopping, trends
|
||||
|
||||
# 結果
|
||||
result_json = Column(Text, nullable=False)
|
||||
# JSON 格式的完整結果
|
||||
|
||||
summary = Column(Text)
|
||||
# AI 生成的摘要
|
||||
|
||||
result_count = Column(Integer, default=0)
|
||||
# 結果數量
|
||||
|
||||
# 元資料
|
||||
model_used = Column(String(50))
|
||||
generation_time = Column(Float)
|
||||
|
||||
# 時間
|
||||
created_at = Column(DateTime, default=datetime.now, index=True)
|
||||
expires_at = Column(DateTime)
|
||||
# 快取過期時間 (預設 24 小時)
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_cache_query_type', 'query', 'search_type'),
|
||||
Index('idx_cache_expires', 'expires_at'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_hash(query: str, search_type: str) -> str:
|
||||
"""產生查詢雜湊"""
|
||||
return hashlib.md5(f"{query}:{search_type}".encode(), usedforsecurity=False).hexdigest()
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""檢查是否已過期"""
|
||||
if not self.expires_at:
|
||||
return True
|
||||
return datetime.now() > self.expires_at
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'query': self.query,
|
||||
'search_type': self.search_type,
|
||||
'result': json.loads(self.result_json) if self.result_json else None,
|
||||
'summary': self.summary,
|
||||
'result_count': self.result_count,
|
||||
'model_used': self.model_used,
|
||||
'generation_time': self.generation_time,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
||||
'is_expired': self.is_expired(),
|
||||
}
|
||||
|
||||
|
||||
class TelegramUser(Base):
|
||||
"""Telegram 用戶綁定表"""
|
||||
__tablename__ = 'telegram_users'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
|
||||
# Telegram 用戶 ID
|
||||
|
||||
telegram_username = Column(String(100))
|
||||
# Telegram 用戶名稱
|
||||
|
||||
user_id = Column(Integer, ForeignKey('users.id'))
|
||||
# 綁定的系統用戶 ID (可選)
|
||||
|
||||
display_name = Column(String(100))
|
||||
# 顯示名稱
|
||||
|
||||
is_active = Column(Boolean, default=True)
|
||||
# 是否啟用
|
||||
|
||||
is_admin = Column(Boolean, default=False)
|
||||
# 是否為管理員
|
||||
|
||||
# 偏好設定
|
||||
notify_trends = Column(Boolean, default=True)
|
||||
# 是否接收趨勢通知
|
||||
|
||||
notify_daily_summary = Column(Boolean, default=True)
|
||||
# 是否接收每日摘要
|
||||
|
||||
preferred_categories = Column(Text)
|
||||
# JSON: 偏好的分類列表
|
||||
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
last_active_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'telegram_id': self.telegram_id,
|
||||
'telegram_username': self.telegram_username,
|
||||
'user_id': self.user_id,
|
||||
'display_name': self.display_name,
|
||||
'is_active': self.is_active,
|
||||
'is_admin': self.is_admin,
|
||||
'notify_trends': self.notify_trends,
|
||||
'notify_daily_summary': self.notify_daily_summary,
|
||||
'preferred_categories': json.loads(self.preferred_categories) if self.preferred_categories else [],
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
|
||||
}
|
||||
|
||||
|
||||
# PTT 目標看板
|
||||
PTT_BOARDS = [
|
||||
'Gossiping', # 八卦板 - 熱門話題
|
||||
'Lifeismoney', # 省錢板 - 優惠情報
|
||||
'e-shopping', # 網購板 - 電商趨勢
|
||||
'Beauty', # 美妝板 - 美妝趨勢
|
||||
'MakeUp', # 化妝板 - 彩妝趨勢
|
||||
'WomenTalk', # 女板 - 女性消費趨勢
|
||||
'home-sale', # 房屋板 - 居家用品參考
|
||||
'BabyMother', # 媽寶板 - 母嬰市場
|
||||
'Tech_Job', # 科技業 - 3C 消費力
|
||||
]
|
||||
|
||||
# Dcard 目標看板
|
||||
DCARD_BOARDS = [
|
||||
'網路購物', # 電商討論
|
||||
'美妝', # 美妝趨勢
|
||||
'穿搭', # 服飾趨勢
|
||||
'3C', # 科技產品
|
||||
'省錢', # 優惠情報
|
||||
'生活', # 生活趨勢
|
||||
'美食', # 餐飲趨勢
|
||||
]
|
||||
|
||||
# 看板對應分類
|
||||
BOARD_CATEGORY_MAPPING = {
|
||||
# PTT
|
||||
'Beauty': '美妝',
|
||||
'MakeUp': '美妝',
|
||||
'e-shopping': '電商',
|
||||
'Lifeismoney': '優惠',
|
||||
'home-sale': '居家',
|
||||
'BabyMother': '母嬰',
|
||||
'Gossiping': '熱門',
|
||||
'WomenTalk': '生活',
|
||||
'Tech_Job': '3C',
|
||||
# Dcard
|
||||
'美妝': '美妝',
|
||||
'穿搭': '服飾',
|
||||
'3C': '3C',
|
||||
'網路購物': '電商',
|
||||
'省錢': '優惠',
|
||||
'生活': '生活',
|
||||
'美食': '美食',
|
||||
}
|
||||
|
||||
|
||||
def get_category_for_board(board: str) -> str:
|
||||
"""根據看板名稱取得商品分類"""
|
||||
return BOARD_CATEGORY_MAPPING.get(board, '其他')
|
||||
110
database/user_models.py
Normal file
110
database/user_models.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
用戶與登入歷史資料模型
|
||||
|
||||
提供:
|
||||
- User: 用戶帳號表
|
||||
- LoginHistory: 登入歷史記錄表
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from database.models import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用戶帳號表"""
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
email = Column(String(120), unique=True, nullable=True)
|
||||
password_hash = Column(String(256), nullable=False)
|
||||
role = Column(String(20), default='user', index=True) # admin, manager, user
|
||||
display_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
password_changed_at = Column(DateTime) # 密碼變更時間
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, onupdate=datetime.now)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# 關聯
|
||||
login_history = relationship("LoginHistory", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
# 角色常數
|
||||
ROLE_ADMIN = 'admin'
|
||||
ROLE_MANAGER = 'manager'
|
||||
ROLE_USER = 'user'
|
||||
|
||||
ROLES = [ROLE_ADMIN, ROLE_MANAGER, ROLE_USER]
|
||||
|
||||
ROLE_LABELS = {
|
||||
'admin': '系統管理員',
|
||||
'manager': '管理者',
|
||||
'user': '一般用戶'
|
||||
}
|
||||
|
||||
def get_role_label(self):
|
||||
"""取得角色顯示名稱"""
|
||||
return self.ROLE_LABELS.get(self.role, self.role)
|
||||
|
||||
def is_admin(self):
|
||||
"""是否為管理員"""
|
||||
return self.role == self.ROLE_ADMIN
|
||||
|
||||
def is_manager_or_above(self):
|
||||
"""是否為管理者或以上"""
|
||||
return self.role in [self.ROLE_ADMIN, self.ROLE_MANAGER]
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'role': self.role,
|
||||
'role_label': self.get_role_label(),
|
||||
'display_name': self.display_name,
|
||||
'is_active': self.is_active,
|
||||
'password_changed_at': self.password_changed_at.isoformat() if self.password_changed_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class LoginHistory(Base):
|
||||
"""登入歷史記錄表"""
|
||||
__tablename__ = 'login_history'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # 允許 NULL,記錄失敗的登入嘗試
|
||||
username_attempted = Column(String(50)) # 嘗試登入的帳號名稱
|
||||
login_time = Column(DateTime, default=datetime.now, index=True)
|
||||
ip_address = Column(String(45)) # 支援 IPv6
|
||||
user_agent = Column(String(256))
|
||||
status = Column(String(20), index=True) # success, failed, locked
|
||||
failure_reason = Column(String(100))
|
||||
|
||||
# 關聯
|
||||
user = relationship("User", back_populates="login_history")
|
||||
|
||||
# 狀態常數
|
||||
STATUS_SUCCESS = 'success'
|
||||
STATUS_FAILED = 'failed'
|
||||
STATUS_LOCKED = 'locked'
|
||||
|
||||
def to_dict(self):
|
||||
"""轉換為字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'username_attempted': self.username_attempted,
|
||||
'login_time': self.login_time.isoformat() if self.login_time else None,
|
||||
'ip_address': self.ip_address,
|
||||
'user_agent': self.user_agent,
|
||||
'status': self.status,
|
||||
'failure_reason': self.failure_reason,
|
||||
}
|
||||
|
||||
|
||||
print("✅ User models 已載入")
|
||||
665
database/vendor_manager.py
Normal file
665
database/vendor_manager.py
Normal file
@@ -0,0 +1,665 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
廠商缺貨通知系統 - 資料庫管理器
|
||||
提供廠商缺貨資料的 CRUD 操作
|
||||
"""
|
||||
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
from .vendor_models import Base, VendorStockout, VendorList, VendorEmail, EmailSendLog
|
||||
|
||||
# 導入日誌管理模組
|
||||
from services.logger_manager import SystemLogger
|
||||
|
||||
# 初始化日誌
|
||||
sys_log = SystemLogger("VendorDatabase").get_logger()
|
||||
|
||||
|
||||
class VendorDatabaseManager:
|
||||
"""廠商缺貨系統資料庫管理器"""
|
||||
|
||||
def __init__(self, db_path=None):
|
||||
"""
|
||||
初始化資料庫連線。
|
||||
優先使用 PostgreSQL (透過 config.py 設定),否則回退到 SQLite。
|
||||
|
||||
Args:
|
||||
db_path: 資料庫檔案路徑 (僅 SQLite 模式使用),若為 None 則使用預設路徑
|
||||
"""
|
||||
# V-Fix (2026-01-23): 優先使用 config.py 的資料庫設定,與主 DatabaseManager 保持一致
|
||||
from config import DATABASE_PATH, DATABASE_TYPE
|
||||
|
||||
if DATABASE_TYPE == 'postgresql':
|
||||
# PostgreSQL 模式 - 使用 config.py 的連線字串
|
||||
self.engine = create_engine(DATABASE_PATH, echo=False, pool_pre_ping=True)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
Base.metadata.create_all(self.engine)
|
||||
sys_log.info(f"[VendorDatabase] ✅ 使用 PostgreSQL 資料庫")
|
||||
else:
|
||||
# SQLite 模式 - 向後相容
|
||||
if db_path is None:
|
||||
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
db_path = os.path.join(base_dir, 'data', 'momo_database.db')
|
||||
|
||||
sys_log.info(f"廠商缺貨系統資料庫連線初始化 | Path: {db_path}")
|
||||
|
||||
# 確保資料夾存在
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
# 建立引擎與 Session
|
||||
self.engine = create_engine(f'sqlite:///{db_path}', echo=False)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
sys_log.info("[VendorDatabase] 使用 SQLite 資料庫")
|
||||
|
||||
sys_log.info("✅ 廠商缺貨系統資料表已建立/更新")
|
||||
|
||||
def get_session(self):
|
||||
"""
|
||||
提供外部調用的 Session 實例。
|
||||
|
||||
Returns:
|
||||
sqlalchemy.orm.Session: 資料庫 Session
|
||||
"""
|
||||
return self.Session()
|
||||
|
||||
# ==========================================
|
||||
# 廠商清單管理
|
||||
# ==========================================
|
||||
|
||||
def add_vendor(self, vendor_code, vendor_name, is_active=True):
|
||||
"""
|
||||
新增廠商
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
vendor_name: 廠商名稱
|
||||
is_active: 是否啟用
|
||||
|
||||
Returns:
|
||||
VendorList: 新增的廠商物件,若已存在則回傳 None
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
# 檢查是否已存在
|
||||
existing = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if existing:
|
||||
sys_log.warning(f"廠商已存在 | 代碼: {vendor_code}")
|
||||
return None
|
||||
|
||||
# 建立新廠商
|
||||
vendor = VendorList(
|
||||
vendor_code=vendor_code,
|
||||
vendor_name=vendor_name,
|
||||
is_active=is_active
|
||||
)
|
||||
session.add(vendor)
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 新增廠商成功 | 代碼: {vendor_code} | 名稱: {vendor_name}")
|
||||
return vendor
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 新增廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_vendor_by_code(self, vendor_code):
|
||||
"""
|
||||
根據廠商代碼查詢廠商
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
|
||||
Returns:
|
||||
VendorList: 廠商物件,若不存在則回傳 None
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
return vendor
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_all_vendors(self, active_only=True):
|
||||
"""
|
||||
取得所有廠商清單
|
||||
|
||||
Args:
|
||||
active_only: 是否只取得啟用中的廠商
|
||||
|
||||
Returns:
|
||||
list: 廠商物件清單
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
query = session.query(VendorList)
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
vendors = query.order_by(VendorList.vendor_code).all()
|
||||
return vendors
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_vendor(self, vendor_code, vendor_name=None, is_active=None):
|
||||
"""
|
||||
更新廠商資訊
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
vendor_name: 廠商名稱 (可選)
|
||||
is_active: 是否啟用 (可選)
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if not vendor:
|
||||
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
|
||||
return False
|
||||
|
||||
# 更新欄位
|
||||
if vendor_name is not None:
|
||||
vendor.vendor_name = vendor_name
|
||||
if is_active is not None:
|
||||
vendor.is_active = is_active
|
||||
|
||||
vendor.updated_at = datetime.now()
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 更新廠商成功 | 代碼: {vendor_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 更新廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def delete_vendor(self, vendor_code):
|
||||
"""
|
||||
刪除廠商 (會連帶刪除相關的郵件與發送記錄)
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
|
||||
Returns:
|
||||
bool: 是否刪除成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if not vendor:
|
||||
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
|
||||
return False
|
||||
|
||||
session.delete(vendor)
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 刪除廠商成功 | 代碼: {vendor_code}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 刪除廠商失敗 | 代碼: {vendor_code} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# 廠商郵件管理
|
||||
# ==========================================
|
||||
|
||||
def add_vendor_email(self, vendor_code, email, contact_name=None,
|
||||
email_type='primary', is_active=True, notes=None):
|
||||
"""
|
||||
新增廠商郵件(自動去重)
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
email: 郵件地址
|
||||
contact_name: 聯絡人姓名
|
||||
email_type: 郵件類型 (primary/cc/bcc)
|
||||
is_active: 是否啟用
|
||||
notes: 備註
|
||||
|
||||
Returns:
|
||||
VendorEmail: 新增的郵件物件,若失敗或已存在則回傳 None
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
# 查詢廠商
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if not vendor:
|
||||
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
|
||||
return None
|
||||
|
||||
# 檢查郵件是否已存在(去重)
|
||||
existing_email = session.query(VendorEmail).filter_by(
|
||||
vendor_id=vendor.id,
|
||||
email=email
|
||||
).first()
|
||||
|
||||
if existing_email:
|
||||
sys_log.debug(f"郵件已存在,跳過 | 廠商: {vendor_code} | 郵件: {email}")
|
||||
return None
|
||||
|
||||
# 建立新郵件
|
||||
vendor_email = VendorEmail(
|
||||
vendor_id=vendor.id,
|
||||
email=email,
|
||||
contact_name=contact_name,
|
||||
email_type=email_type,
|
||||
is_active=is_active,
|
||||
notes=notes
|
||||
)
|
||||
session.add(vendor_email)
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 新增廠商郵件成功 | 廠商: {vendor_code} | 郵件: {email}")
|
||||
return vendor_email
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 新增廠商郵件失敗 | 廠商: {vendor_code} | 錯誤: {e}")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_vendor_emails(self, vendor_code, active_only=True):
|
||||
"""
|
||||
取得廠商的所有郵件
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
active_only: 是否只取得啟用中的郵件
|
||||
|
||||
Returns:
|
||||
dict: 郵件清單,依類型分類 {'primary': [...], 'cc': [...], 'bcc': [...]}
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if not vendor:
|
||||
return {'primary': [], 'cc': [], 'bcc': []}
|
||||
|
||||
query = session.query(VendorEmail).filter_by(vendor_id=vendor.id)
|
||||
if active_only:
|
||||
query = query.filter_by(is_active=True)
|
||||
|
||||
emails = query.all()
|
||||
|
||||
# 依類型分類
|
||||
result = {'primary': [], 'cc': [], 'bcc': []}
|
||||
for email in emails:
|
||||
result[email.email_type].append(email)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_vendor_email(self, email_id, email=None, contact_name=None,
|
||||
email_type=None, is_active=None, notes=None):
|
||||
"""
|
||||
更新廠商郵件
|
||||
|
||||
Args:
|
||||
email_id: 郵件 ID
|
||||
email: 郵件地址 (可選)
|
||||
contact_name: 聯絡人姓名 (可選)
|
||||
email_type: 郵件類型 (可選)
|
||||
is_active: 是否啟用 (可選)
|
||||
notes: 備註 (可選)
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor_email = session.query(VendorEmail).filter_by(id=email_id).first()
|
||||
if not vendor_email:
|
||||
sys_log.warning(f"郵件不存在 | ID: {email_id}")
|
||||
return False
|
||||
|
||||
# 更新欄位
|
||||
if email is not None:
|
||||
vendor_email.email = email
|
||||
if contact_name is not None:
|
||||
vendor_email.contact_name = contact_name
|
||||
if email_type is not None:
|
||||
vendor_email.email_type = email_type
|
||||
if is_active is not None:
|
||||
vendor_email.is_active = is_active
|
||||
if notes is not None:
|
||||
vendor_email.notes = notes
|
||||
|
||||
vendor_email.updated_at = datetime.now()
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 更新廠商郵件成功 | ID: {email_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 更新廠商郵件失敗 | ID: {email_id} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def delete_vendor_email(self, email_id):
|
||||
"""
|
||||
刪除廠商郵件
|
||||
|
||||
Args:
|
||||
email_id: 郵件 ID
|
||||
|
||||
Returns:
|
||||
bool: 是否刪除成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
vendor_email = session.query(VendorEmail).filter_by(id=email_id).first()
|
||||
if not vendor_email:
|
||||
sys_log.warning(f"郵件不存在 | ID: {email_id}")
|
||||
return False
|
||||
|
||||
session.delete(vendor_email)
|
||||
session.commit()
|
||||
|
||||
sys_log.info(f"✅ 刪除廠商郵件成功 | ID: {email_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 刪除廠商郵件失敗 | ID: {email_id} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# 缺貨記錄管理
|
||||
# ==========================================
|
||||
|
||||
def add_stockout_records(self, records_list, batch_id):
|
||||
"""
|
||||
批次新增缺貨記錄 (用於 Excel 匯入)
|
||||
|
||||
Args:
|
||||
records_list: 缺貨記錄清單 (dict list)
|
||||
batch_id: 批次編號
|
||||
|
||||
Returns:
|
||||
tuple: (成功筆數, 失敗筆數, 重複筆數)
|
||||
"""
|
||||
session = self.get_session()
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
duplicate_count = 0
|
||||
|
||||
try:
|
||||
for record in records_list:
|
||||
try:
|
||||
# 檢查是否為重複資料 (同一天 + 同廠商 + 同商品)
|
||||
import_date = record.get('import_date', datetime.now().date())
|
||||
vendor_code = record.get('vendor_code')
|
||||
product_code = record.get('product_code')
|
||||
|
||||
existing = session.query(VendorStockout).filter_by(
|
||||
import_date=import_date,
|
||||
vendor_code=vendor_code,
|
||||
product_code=product_code
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 標記為重複並更新計數
|
||||
existing.duplicate_count += 1
|
||||
existing.status = 'duplicate'
|
||||
duplicate_count += 1
|
||||
sys_log.debug(f"偵測到重複資料 | 廠商: {vendor_code} | 商品: {product_code}")
|
||||
continue
|
||||
|
||||
# 建立新記錄
|
||||
stockout = VendorStockout(
|
||||
batch_id=batch_id,
|
||||
import_date=import_date,
|
||||
department=record.get('department'),
|
||||
section=record.get('section'),
|
||||
pm_name=record.get('pm_name'),
|
||||
zone_id=record.get('zone_id'),
|
||||
zone_name=record.get('zone_name'),
|
||||
product_code=product_code,
|
||||
product_name=record.get('product_name'),
|
||||
product_spec=record.get('product_spec'),
|
||||
borrow_transfer=record.get('borrow_transfer'),
|
||||
sale_price=record.get('sale_price'),
|
||||
cost_price=record.get('cost_price'),
|
||||
vendor_code=vendor_code,
|
||||
vendor_name=record.get('vendor_name'),
|
||||
monthly_sales_qty=record.get('monthly_sales_qty'),
|
||||
monthly_sales_amount=record.get('monthly_sales_amount'),
|
||||
daily_avg_sales=record.get('daily_avg_sales'),
|
||||
current_stock=record.get('current_stock'),
|
||||
stockout_date=record.get('stockout_date'),
|
||||
stockout_days=record.get('stockout_days'),
|
||||
safe_stock_days=record.get('safe_stock_days'),
|
||||
notes=record.get('notes')
|
||||
)
|
||||
session.add(stockout)
|
||||
success_count += 1
|
||||
|
||||
# 每 100 筆提交一次
|
||||
if success_count % 100 == 0:
|
||||
session.commit()
|
||||
|
||||
except Exception as e:
|
||||
sys_log.error(f"❌ 新增缺貨記錄失敗 | 錯誤: {e}")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# 最後提交
|
||||
session.commit()
|
||||
sys_log.info(f"✅ 批次匯入完成 | 成功: {success_count} | 失敗: {failed_count} | 重複: {duplicate_count}")
|
||||
|
||||
return success_count, failed_count, duplicate_count
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 批次匯入失敗 | 錯誤: {e}")
|
||||
return success_count, failed_count, duplicate_count
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_stockout_records(self, batch_id=None, vendor_code=None, status=None,
|
||||
start_date=None, end_date=None, limit=None):
|
||||
"""
|
||||
查詢缺貨記錄
|
||||
|
||||
Args:
|
||||
batch_id: 批次編號 (可選)
|
||||
vendor_code: 廠商代碼 (可選)
|
||||
status: 狀態 (可選)
|
||||
start_date: 開始日期 (可選)
|
||||
end_date: 結束日期 (可選)
|
||||
limit: 限制筆數 (可選)
|
||||
|
||||
Returns:
|
||||
list: 缺貨記錄清單
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
query = session.query(VendorStockout)
|
||||
|
||||
# 篩選條件
|
||||
if batch_id:
|
||||
query = query.filter_by(batch_id=batch_id)
|
||||
if vendor_code:
|
||||
query = query.filter_by(vendor_code=vendor_code)
|
||||
if status:
|
||||
query = query.filter_by(status=status)
|
||||
if start_date:
|
||||
query = query.filter(VendorStockout.import_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(VendorStockout.import_date <= end_date)
|
||||
|
||||
# 排序與限制
|
||||
query = query.order_by(VendorStockout.import_date.desc())
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
records = query.all()
|
||||
return records
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_stockout_status(self, stockout_id, status, error_message=None,
|
||||
sent_date=None, sent_by=None):
|
||||
"""
|
||||
更新缺貨記錄狀態
|
||||
|
||||
Args:
|
||||
stockout_id: 缺貨記錄 ID
|
||||
status: 狀態
|
||||
error_message: 錯誤訊息 (可選)
|
||||
sent_date: 發送時間 (可選)
|
||||
sent_by: 發送人員 (可選)
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
stockout = session.query(VendorStockout).filter_by(id=stockout_id).first()
|
||||
if not stockout:
|
||||
sys_log.warning(f"缺貨記錄不存在 | ID: {stockout_id}")
|
||||
return False
|
||||
|
||||
stockout.status = status
|
||||
if error_message is not None:
|
||||
stockout.error_message = error_message
|
||||
if sent_date is not None:
|
||||
stockout.sent_date = sent_date
|
||||
if sent_by is not None:
|
||||
stockout.sent_by = sent_by
|
||||
|
||||
stockout.updated_at = datetime.now()
|
||||
session.commit()
|
||||
|
||||
sys_log.debug(f"更新缺貨記錄狀態 | ID: {stockout_id} | 狀態: {status}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 更新缺貨記錄狀態失敗 | ID: {stockout_id} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# 郵件發送記錄管理
|
||||
# ==========================================
|
||||
|
||||
def add_email_log(self, vendor_code, batch_id, sender_email, recipient_email,
|
||||
subject, product_count, cc_emails=None, bcc_emails=None,
|
||||
attachment_filename=None, attachment_size=None, stockout_id=None):
|
||||
"""
|
||||
新增郵件發送記錄
|
||||
|
||||
Args:
|
||||
vendor_code: 廠商代碼
|
||||
batch_id: 發送批次編號
|
||||
sender_email: 寄件者郵件
|
||||
recipient_email: 收件者郵件
|
||||
subject: 郵件主旨
|
||||
product_count: 商品數量
|
||||
cc_emails: CC 郵件清單 (JSON 字串)
|
||||
bcc_emails: BCC 郵件清單 (JSON 字串)
|
||||
attachment_filename: 附件檔名
|
||||
attachment_size: 附件大小
|
||||
stockout_id: 缺貨記錄 ID
|
||||
|
||||
Returns:
|
||||
EmailSendLog: 新增的記錄物件,若失敗則回傳 None
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
# 查詢廠商
|
||||
vendor = session.query(VendorList).filter_by(vendor_code=vendor_code).first()
|
||||
if not vendor:
|
||||
sys_log.warning(f"廠商不存在 | 代碼: {vendor_code}")
|
||||
return None
|
||||
|
||||
# 建立記錄
|
||||
log = EmailSendLog(
|
||||
vendor_id=vendor.id,
|
||||
stockout_id=stockout_id,
|
||||
batch_id=batch_id,
|
||||
sender_email=sender_email,
|
||||
recipient_email=recipient_email,
|
||||
cc_emails=cc_emails,
|
||||
bcc_emails=bcc_emails,
|
||||
subject=subject,
|
||||
product_count=product_count,
|
||||
attachment_filename=attachment_filename,
|
||||
attachment_size=attachment_size,
|
||||
status='pending'
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
|
||||
sys_log.debug(f"新增郵件發送記錄 | 廠商: {vendor_code} | 收件者: {recipient_email}")
|
||||
return log
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 新增郵件發送記錄失敗 | 廠商: {vendor_code} | 錯誤: {e}")
|
||||
return None
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def update_email_log_status(self, log_id, status, error_message=None, sent_at=None):
|
||||
"""
|
||||
更新郵件發送記錄狀態
|
||||
|
||||
Args:
|
||||
log_id: 記錄 ID
|
||||
status: 狀態
|
||||
error_message: 錯誤訊息 (可選)
|
||||
sent_at: 發送時間 (可選)
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
log = session.query(EmailSendLog).filter_by(id=log_id).first()
|
||||
if not log:
|
||||
sys_log.warning(f"郵件發送記錄不存在 | ID: {log_id}")
|
||||
return False
|
||||
|
||||
log.status = status
|
||||
if error_message is not None:
|
||||
log.error_message = error_message
|
||||
if sent_at is not None:
|
||||
log.sent_at = sent_at
|
||||
|
||||
session.commit()
|
||||
|
||||
sys_log.debug(f"更新郵件發送記錄狀態 | ID: {log_id} | 狀態: {status}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
sys_log.error(f"❌ 更新郵件發送記錄狀態失敗 | ID: {log_id} | 錯誤: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
165
database/vendor_models.py
Normal file
165
database/vendor_models.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
廠商缺貨通知系統 - 資料庫模型
|
||||
包含廠商缺貨表、廠商清單、廠商郵件、郵件發送記錄
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Numeric, Date, Text
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class VendorStockout(Base):
|
||||
"""廠商缺貨表 - 儲存匯入的缺貨資料"""
|
||||
__tablename__ = 'vendor_stockout'
|
||||
|
||||
# 主鍵
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 匯入批次資訊
|
||||
batch_id = Column(String(50), nullable=False, index=True, comment='批次編號 (格式: YYYYMMDD_HHMMSS)')
|
||||
import_date = Column(Date, nullable=False, index=True, comment='匯入日期')
|
||||
import_time = Column(DateTime, nullable=False, default=datetime.now, comment='匯入時間')
|
||||
|
||||
# 組織資訊
|
||||
department = Column(String(100), comment='部別')
|
||||
section = Column(String(100), comment='課別')
|
||||
pm_name = Column(String(100), index=True, comment='PM 姓名')
|
||||
zone_id = Column(String(100), comment='區ID')
|
||||
zone_name = Column(String(200), comment='區名稱')
|
||||
|
||||
# 商品資訊
|
||||
product_code = Column(String(100), nullable=False, index=True, comment='商品料號')
|
||||
product_name = Column(String(500), nullable=False, comment='商品名稱')
|
||||
product_spec = Column(Text, comment='商品規格')
|
||||
borrow_transfer = Column(String(100), comment='借採轉')
|
||||
sale_price = Column(Numeric(10, 2), comment='售價')
|
||||
cost_price = Column(Numeric(10, 2), comment='成本')
|
||||
|
||||
# 廠商資訊
|
||||
vendor_code = Column(String(100), nullable=False, index=True, comment='廠商代碼')
|
||||
vendor_name = Column(String(200), nullable=False, index=True, comment='廠商名稱')
|
||||
|
||||
# 業績資訊
|
||||
monthly_sales_qty = Column(Integer, comment='全月銷量')
|
||||
monthly_sales_amount = Column(Numeric(12, 2), comment='全月業績')
|
||||
daily_avg_sales = Column(Numeric(10, 2), comment='日均銷量')
|
||||
|
||||
# 庫存資訊
|
||||
current_stock = Column(Integer, comment='現有庫存')
|
||||
stockout_date = Column(Date, comment='缺貨日期')
|
||||
stockout_days = Column(Integer, comment='缺貨天數')
|
||||
safe_stock_days = Column(Integer, comment='安全庫存天數')
|
||||
|
||||
# 狀態追蹤
|
||||
status = Column(String(20), nullable=False, default='pending', index=True,
|
||||
comment='狀態: pending(待發送), sent(已發送), failed(失敗), duplicate(重複)')
|
||||
is_duplicate = Column(Boolean, default=False, index=True, comment='是否為重複資料')
|
||||
duplicate_count = Column(Integer, default=0, comment='重複次數')
|
||||
|
||||
# 發送記錄
|
||||
sent_date = Column(DateTime, comment='發送時間')
|
||||
sent_by = Column(String(100), comment='發送人員')
|
||||
error_message = Column(Text, comment='錯誤訊息')
|
||||
|
||||
# 備註與時間戳記
|
||||
notes = Column(Text, comment='備註')
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
|
||||
|
||||
# 關聯設定
|
||||
email_logs = relationship("EmailSendLog", back_populates="stockout_item", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class VendorList(Base):
|
||||
"""廠商清單表 - 管理廠商基本資料"""
|
||||
__tablename__ = 'vendor_list'
|
||||
|
||||
# 主鍵
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 廠商基本資訊
|
||||
vendor_code = Column(String(100), unique=True, nullable=False, index=True, comment='廠商代碼')
|
||||
vendor_name = Column(String(200), nullable=False, comment='廠商名稱')
|
||||
|
||||
# 狀態
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True, comment='是否啟用')
|
||||
|
||||
# 時間戳記
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
|
||||
|
||||
# 關聯設定
|
||||
emails = relationship("VendorEmail", back_populates="vendor", cascade="all, delete-orphan")
|
||||
email_logs = relationship("EmailSendLog", back_populates="vendor", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class VendorEmail(Base):
|
||||
"""廠商郵件表 - 管理廠商的多個聯絡郵件"""
|
||||
__tablename__ = 'vendor_emails'
|
||||
|
||||
# 主鍵
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 外鍵
|
||||
vendor_id = Column(Integer, ForeignKey('vendor_list.id'), nullable=False, index=True, comment='廠商ID')
|
||||
|
||||
# 郵件資訊
|
||||
email = Column(String(255), nullable=False, comment='電子郵件地址')
|
||||
contact_name = Column(String(100), comment='聯絡人姓名')
|
||||
email_type = Column(String(20), nullable=False, default='primary',
|
||||
comment='郵件類型: primary(主要), cc(副本), bcc(密件副本)')
|
||||
|
||||
# 狀態
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True, comment='是否啟用')
|
||||
|
||||
# 備註與時間戳記
|
||||
notes = Column(Text, comment='備註')
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now, nullable=False, comment='更新時間')
|
||||
|
||||
# 關聯設定
|
||||
vendor = relationship("VendorList", back_populates="emails")
|
||||
|
||||
|
||||
class EmailSendLog(Base):
|
||||
"""郵件發送記錄表 - 完整稽核追蹤"""
|
||||
__tablename__ = 'email_send_log'
|
||||
|
||||
# 主鍵
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 外鍵
|
||||
vendor_id = Column(Integer, ForeignKey('vendor_list.id'), nullable=False, index=True, comment='廠商ID')
|
||||
stockout_id = Column(Integer, ForeignKey('vendor_stockout.id'), index=True, comment='缺貨記錄ID')
|
||||
|
||||
# 批次資訊
|
||||
batch_id = Column(String(50), nullable=False, index=True, comment='發送批次編號')
|
||||
|
||||
# 郵件資訊
|
||||
sender_email = Column(String(255), nullable=False, comment='寄件者郵件')
|
||||
recipient_email = Column(String(255), nullable=False, comment='收件者郵件')
|
||||
cc_emails = Column(Text, comment='CC 郵件清單 (JSON 格式)')
|
||||
bcc_emails = Column(Text, comment='BCC 郵件清單 (JSON 格式)')
|
||||
subject = Column(String(500), nullable=False, comment='郵件主旨')
|
||||
|
||||
# 內容資訊
|
||||
product_count = Column(Integer, nullable=False, comment='商品數量')
|
||||
attachment_filename = Column(String(255), comment='附件檔名')
|
||||
attachment_size = Column(Integer, comment='附件大小 (bytes)')
|
||||
|
||||
# 發送狀態
|
||||
status = Column(String(20), nullable=False, default='pending', index=True,
|
||||
comment='狀態: pending(待發送), sent(成功), failed(失敗)')
|
||||
error_message = Column(Text, comment='錯誤訊息')
|
||||
retry_count = Column(Integer, default=0, comment='重試次數')
|
||||
|
||||
# 時間戳記
|
||||
sent_at = Column(DateTime, comment='發送時間')
|
||||
created_at = Column(DateTime, default=datetime.now, nullable=False, comment='建立時間')
|
||||
|
||||
# 關聯設定
|
||||
vendor = relationship("VendorList", back_populates="email_logs")
|
||||
stockout_item = relationship("VendorStockout", back_populates="email_logs")
|
||||
239
deploy/QUICK_START.md
Normal file
239
deploy/QUICK_START.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# MOMO Pro System - 快速部署指南
|
||||
|
||||
> 在全新主機上完成環境安裝 + 應用部署的完整流程
|
||||
> 最後更新: 2026-02-06
|
||||
|
||||
---
|
||||
|
||||
## 🚀 一鍵完整部署(推薦)
|
||||
|
||||
### 步驟 1: 複製專案到新主機
|
||||
|
||||
```bash
|
||||
# 從本地複製到新主機
|
||||
scp -r /path/to/momo-pro-system root@新主機IP:/opt/
|
||||
|
||||
# 或使用 Git clone
|
||||
ssh root@新主機IP
|
||||
git clone http://192.168.0.110:8929/root/momo-pro-system.git /opt/momo-pro-system
|
||||
```
|
||||
|
||||
### 步驟 2: 執行完整部署
|
||||
|
||||
```bash
|
||||
ssh root@新主機IP
|
||||
cd /opt/momo-pro-system
|
||||
|
||||
# 完整部署(環境 + 應用 + SSL)
|
||||
sudo ./deploy/scripts/full-deploy.sh --domain mo.example.com --ssl
|
||||
```
|
||||
|
||||
**一個命令完成全部工作!**
|
||||
|
||||
---
|
||||
|
||||
## 📦 自動安裝的套件清單
|
||||
|
||||
| 分類 | 套件 | 說明 |
|
||||
|------|------|------|
|
||||
| **基礎工具** | curl, wget, git | 檔案下載與版本控制 |
|
||||
| | vim, htop, iotop | 編輯器與系統監控 |
|
||||
| | jq, rsync, unzip | JSON 處理、檔案同步 |
|
||||
| **Python** | python3, pip, venv | Python 執行環境 |
|
||||
| **容器** | Docker CE | 容器運行環境 |
|
||||
| | Docker Compose | 多容器編排 |
|
||||
| **Kubernetes** | K3s | 輕量級 Kubernetes |
|
||||
| | Helm | K8s 套件管理 |
|
||||
| **Web 伺服器** | Nginx | 反向代理 + 負載均衡 |
|
||||
| **SSL** | Certbot | Let's Encrypt 自動證書 |
|
||||
| **資料庫** | PostgreSQL Client | 資料庫客戶端工具 |
|
||||
| **安全** | Fail2Ban | 防暴力破解 |
|
||||
| | UFW | 防火牆 |
|
||||
| **監控** | Node Exporter | 主機指標收集 |
|
||||
| | Prometheus | 指標儲存與查詢 |
|
||||
| | Grafana | 監控儀表板 |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 部署腳本說明
|
||||
|
||||
### 1. 環境安裝腳本 (`setup-environment.sh`)
|
||||
|
||||
只安裝環境,不部署應用:
|
||||
|
||||
```bash
|
||||
sudo ./deploy/scripts/setup-environment.sh [選項]
|
||||
|
||||
選項:
|
||||
--user <name> 部署用戶(預設: wooo)
|
||||
--domain <domain> 域名
|
||||
--no-docker 不安裝 Docker
|
||||
--no-k3s 不安裝 K3s
|
||||
--no-nginx 不安裝 Nginx
|
||||
--no-firewall 不設定防火牆
|
||||
```
|
||||
|
||||
### 2. 完整部署腳本 (`full-deploy.sh`)
|
||||
|
||||
環境安裝 + 應用部署:
|
||||
|
||||
```bash
|
||||
sudo ./deploy/scripts/full-deploy.sh [選項]
|
||||
|
||||
選項:
|
||||
--user <name> 部署用戶(預設: wooo)
|
||||
--domain <domain> 域名
|
||||
--ssl 設定 SSL 證書
|
||||
--skip-env 跳過環境安裝
|
||||
```
|
||||
|
||||
### 3. 快速部署腳本 (`build-and-deploy.sh`)
|
||||
|
||||
日常更新用(環境已準備好):
|
||||
|
||||
```bash
|
||||
./scripts/deploy/build-and-deploy.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 部署流程圖
|
||||
|
||||
```
|
||||
執行 full-deploy.sh
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 1: 環境安裝 ║
|
||||
║ • 檢測 OS (Ubuntu/Debian) ║
|
||||
║ • 安裝基礎套件 ║
|
||||
║ • 安裝 Docker + K3s ║
|
||||
║ • 安裝 Nginx + Certbot ║
|
||||
║ • 設定防火牆 + Fail2Ban ║
|
||||
╚═══════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 2: K8s 配置 ║
|
||||
║ • 建立 momo namespace ║
|
||||
║ • 部署 Secrets/ConfigMap ║
|
||||
║ • 部署 PostgreSQL ║
|
||||
║ • 部署 momo-app + scheduler ║
|
||||
╚═══════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 3: 映像建置 ║
|
||||
║ • docker build ║
|
||||
║ • k3s ctr images import ║
|
||||
║ • kubectl rollout restart ║
|
||||
╚═══════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 4-5: Nginx + SSL ║
|
||||
║ • 配置反向代理 ║
|
||||
║ • Let's Encrypt 證書 ║
|
||||
╚═══════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 6: 監控系統 ║
|
||||
║ • Prometheus + Grafana (Helm) ║
|
||||
╚═══════════════════════════════════════╝
|
||||
│
|
||||
▼
|
||||
╔═══════════════════════════════════════╗
|
||||
║ Phase 7-8: 自動啟動 + 健康檢查 ║
|
||||
║ • systemd 服務設定 ║
|
||||
║ • Telegram 通知 ║
|
||||
╚═══════════════════════════════════════╝
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 系統需求
|
||||
|
||||
| 項目 | 最低需求 | 建議配置 |
|
||||
|------|----------|----------|
|
||||
| **CPU** | 2 核心 | 4+ 核心 |
|
||||
| **RAM** | 4 GB | 8+ GB |
|
||||
| **硬碟** | 30 GB | 50+ GB SSD |
|
||||
| **作業系統** | Ubuntu 22.04 | Ubuntu 24.04 |
|
||||
| **網路** | 開放 80, 443 | 靜態 IP |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 安全配置
|
||||
|
||||
### 防火牆規則 (自動設定)
|
||||
|
||||
| 端口 | 服務 | 存取範圍 |
|
||||
|------|------|---------|
|
||||
| 22 | SSH | 公開 |
|
||||
| 80 | HTTP | 公開 |
|
||||
| 443 | HTTPS | 公開 |
|
||||
| 6443 | K3s API | 僅內網 |
|
||||
|
||||
### Fail2Ban 規則
|
||||
|
||||
- SSH: 3 次失敗封鎖 1 小時
|
||||
- Nginx: 5 次失敗封鎖 1 小時
|
||||
|
||||
---
|
||||
|
||||
## 🔄 日常更新流程
|
||||
|
||||
環境已安裝後,日常更新只需:
|
||||
|
||||
```bash
|
||||
# 方法 1: 使用快速部署腳本
|
||||
./scripts/deploy/build-and-deploy.sh
|
||||
|
||||
# 方法 2: 手動步驟
|
||||
docker build -t momo-pro-system:local .
|
||||
docker save momo-pro-system:local | sudo k3s ctr images import -
|
||||
kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常見問題
|
||||
|
||||
### Q: 部署中斷後如何繼續?
|
||||
|
||||
```bash
|
||||
# 跳過環境安裝,只部署應用
|
||||
sudo ./deploy/scripts/full-deploy.sh --skip-env
|
||||
```
|
||||
|
||||
### Q: 如何只更新應用不重裝環境?
|
||||
|
||||
```bash
|
||||
./scripts/deploy/build-and-deploy.sh
|
||||
```
|
||||
|
||||
### Q: 如何查看部署日誌?
|
||||
|
||||
```bash
|
||||
# K8s Pod 日誌
|
||||
kubectl logs -f deployment/momo-app -n momo
|
||||
|
||||
# 系統啟動日誌
|
||||
journalctl -u momo-startup-complete.service
|
||||
```
|
||||
|
||||
### Q: SSL 證書申請失敗?
|
||||
|
||||
```bash
|
||||
# 手動申請
|
||||
sudo certbot --nginx -d your-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 聯絡資訊
|
||||
|
||||
- **Telegram 告警**: Bot `@wooowooowooobot`
|
||||
- **GitLab**: http://192.168.0.110:8929
|
||||
- **正式網址**: https://mo.wooo.work
|
||||
579
deploy/README.md
Normal file
579
deploy/README.md
Normal file
@@ -0,0 +1,579 @@
|
||||
# MOMO Pro System - 一鍵部署指南 v2.0
|
||||
|
||||
> 將 MOMO Pro System 完整部署到新的 VM 環境
|
||||
> 支援 Docker Compose、Kubernetes、Harbor Registry、監控堆疊
|
||||
|
||||
## 目錄
|
||||
|
||||
- [快速開始](#快速開始)
|
||||
- [部署模式](#部署模式)
|
||||
- [K8s 部署](#k8s-部署)
|
||||
- [Harbor 管理](#harbor-管理)
|
||||
- [監控堆疊](#監控堆疊)
|
||||
- [Systemd 服務](#systemd-服務)
|
||||
- [環境需求](#環境需求)
|
||||
- [配置說明](#配置說明)
|
||||
- [備份與還原](#備份與還原)
|
||||
- [SSL 憑證](#ssl-憑證)
|
||||
- [故障排除](#故障排除)
|
||||
|
||||
---
|
||||
|
||||
## 快速開始
|
||||
|
||||
### Docker Compose 部署(最簡單)
|
||||
|
||||
```bash
|
||||
# 1. 進入部署目錄
|
||||
cd deploy
|
||||
|
||||
# 2. 執行部署腳本
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh deploy
|
||||
|
||||
# 3. 依照提示完成配置
|
||||
```
|
||||
|
||||
### Kubernetes 部署
|
||||
|
||||
```bash
|
||||
# 部署到 K8s(含 Harbor 映像推送)
|
||||
./deploy.sh --k8s deploy
|
||||
|
||||
# 或分步驟執行
|
||||
./deploy.sh harbor-push # 建構並推送映像
|
||||
./deploy.sh k8s-deploy # 部署到 K8s
|
||||
```
|
||||
|
||||
### 完整部署流程(生產環境)
|
||||
|
||||
```bash
|
||||
# 1. 環境檢查
|
||||
./deploy.sh check
|
||||
|
||||
# 2. 部署應用 + 設定開機自動啟動
|
||||
./deploy.sh --with-systemd deploy
|
||||
|
||||
# 3. 設定 SSL 憑證
|
||||
./deploy.sh -d mo.wooo.work ssl
|
||||
|
||||
# 4. 部署監控堆疊
|
||||
./deploy.sh monitoring-deploy
|
||||
|
||||
# 5. 健康檢查
|
||||
./deploy.sh health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署模式
|
||||
|
||||
### 模式 A: Docker Compose 本地部署
|
||||
|
||||
```bash
|
||||
./deploy.sh deploy
|
||||
# 或
|
||||
./deploy.sh --local deploy
|
||||
```
|
||||
|
||||
**適用場景:**
|
||||
- 開發/測試環境
|
||||
- 單機部署
|
||||
|
||||
### 模式 B: Kubernetes 部署
|
||||
|
||||
```bash
|
||||
./deploy.sh --k8s deploy
|
||||
```
|
||||
|
||||
**適用場景:**
|
||||
- 生產環境
|
||||
- 需要高可用性
|
||||
- 需要自動擴展
|
||||
|
||||
### 模式 C: SSH 遠端部署
|
||||
|
||||
```bash
|
||||
./deploy.sh --ssh \
|
||||
-h 192.168.1.100 \
|
||||
-u wooo \
|
||||
-p /opt/momo-pro-system \
|
||||
deploy
|
||||
```
|
||||
|
||||
**前置需求:**
|
||||
- SSH 金鑰已設定(無密碼登入)
|
||||
- 遠端主機已安裝 Docker
|
||||
|
||||
### 模式 D: 匯出部署包
|
||||
|
||||
```bash
|
||||
# 不含資料
|
||||
./deploy.sh --export
|
||||
|
||||
# 含資料庫備份
|
||||
./deploy.sh --export --with-data
|
||||
```
|
||||
|
||||
**輸出檔案:**
|
||||
```
|
||||
momo-pro-system_20260129_143000.tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K8s 部署
|
||||
|
||||
### K8s 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `./deploy.sh k8s-deploy` | 部署到 Kubernetes |
|
||||
| `./deploy.sh k8s-status` | 查看部署狀態 |
|
||||
| `./deploy.sh k8s-logs` | 查看應用日誌 |
|
||||
| `./deploy.sh k8s-rollback` | 回滾到上一版本 |
|
||||
| `./deploy.sh k8s-cleanup` | 清理所有 K8s 資源 |
|
||||
|
||||
### K8s 選項
|
||||
|
||||
| 選項 | 說明 |
|
||||
|------|------|
|
||||
| `-n, --namespace` | K8s 命名空間(預設: momo) |
|
||||
|
||||
### K8s 部署流程
|
||||
|
||||
```bash
|
||||
# 1. 建構並推送映像
|
||||
./deploy.sh harbor-push
|
||||
|
||||
# 2. 部署到 K8s
|
||||
./deploy.sh -n momo k8s-deploy
|
||||
|
||||
# 3. 查看狀態
|
||||
./deploy.sh k8s-status
|
||||
|
||||
# 4. 查看日誌
|
||||
./deploy.sh k8s-logs
|
||||
```
|
||||
|
||||
### K8s 回滾
|
||||
|
||||
```bash
|
||||
# 回滾 momo-app
|
||||
./deploy.sh k8s-rollback
|
||||
|
||||
# 回滾特定 deployment
|
||||
kubectl rollout undo deployment/momo-scheduler -n momo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Harbor 管理
|
||||
|
||||
### Harbor 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `./deploy.sh harbor-install` | 安裝 Harbor Registry |
|
||||
| `./deploy.sh harbor-push` | 建構並推送映像 |
|
||||
| `./deploy.sh harbor-scan` | 執行容器安全掃描 |
|
||||
| `./deploy.sh harbor-health` | 檢查 Harbor 健康狀態 |
|
||||
|
||||
### Harbor 選項
|
||||
|
||||
| 選項 | 說明 |
|
||||
|------|------|
|
||||
| `--registry` | Harbor URL(預設: harbor.wooo.work) |
|
||||
| `--harbor-user` | Harbor 用戶名(預設: admin) |
|
||||
| `--harbor-pass` | Harbor 密碼 |
|
||||
|
||||
### 使用 Harbor
|
||||
|
||||
```bash
|
||||
# 登入 Harbor
|
||||
docker login harbor.wooo.work -u admin
|
||||
|
||||
# 建構並推送映像
|
||||
./deploy.sh harbor-push
|
||||
|
||||
# 安全掃描
|
||||
./deploy.sh harbor-scan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 監控堆疊
|
||||
|
||||
### 監控命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `./deploy.sh monitoring-deploy` | 部署監控堆疊 |
|
||||
| `./deploy.sh monitoring-status` | 查看監控狀態 |
|
||||
|
||||
### 監控組件
|
||||
|
||||
| 組件 | 端口 | 用途 |
|
||||
|------|------|------|
|
||||
| Prometheus | 9090 | 指標收集 |
|
||||
| Grafana | 3000 | 視覺化儀表板 |
|
||||
| Alertmanager | 9093 | 告警管理 |
|
||||
| Node Exporter | 9100 | 主機監控 |
|
||||
| cAdvisor | 8080 | 容器監控 |
|
||||
| Loki | 3100 | 日誌收集 |
|
||||
|
||||
### 部署監控
|
||||
|
||||
```bash
|
||||
# Docker 環境
|
||||
./deploy.sh monitoring-deploy
|
||||
|
||||
# K8s 環境(使用 Helm)
|
||||
./deploy.sh --k8s monitoring-deploy
|
||||
```
|
||||
|
||||
### 存取監控
|
||||
|
||||
| 服務 | URL | 帳號 |
|
||||
|------|-----|------|
|
||||
| Grafana | http://localhost:3000 | admin / Wooo_Grafana_2026 |
|
||||
| Prometheus | http://localhost:9090 | - |
|
||||
|
||||
---
|
||||
|
||||
## Systemd 服務
|
||||
|
||||
### Systemd 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `./deploy.sh systemd-setup` | 設定開機自動啟動 |
|
||||
| `./deploy.sh systemd-status` | 查看服務狀態 |
|
||||
|
||||
### 設定開機啟動
|
||||
|
||||
```bash
|
||||
# 方法一:部署時設定
|
||||
./deploy.sh --with-systemd deploy
|
||||
|
||||
# 方法二:單獨設定
|
||||
./deploy.sh systemd-setup
|
||||
```
|
||||
|
||||
### 服務管理
|
||||
|
||||
```bash
|
||||
# 查看服務狀態
|
||||
systemctl status momo-pro-system
|
||||
|
||||
# 重啟服務
|
||||
sudo systemctl restart momo-pro-system
|
||||
|
||||
# 停止服務
|
||||
sudo systemctl stop momo-pro-system
|
||||
```
|
||||
|
||||
### 建立的 Systemd 服務
|
||||
|
||||
| 服務 | 說明 |
|
||||
|------|------|
|
||||
| `momo-pro-system.service` | 主應用程式 |
|
||||
| `harbor.service` | Harbor Registry |
|
||||
| `gitlab.service` | GitLab CE |
|
||||
| `n8n.service` | n8n 自動化 |
|
||||
| `momo-monitoring.service` | 監控堆疊 |
|
||||
| `momo-health-check.timer` | 健康監控定時器 |
|
||||
|
||||
---
|
||||
|
||||
## 環境需求
|
||||
|
||||
### 硬體需求
|
||||
|
||||
| 項目 | 最低需求 | 建議配置 |
|
||||
|------|----------|----------|
|
||||
| CPU | 4 核心 | 8 核心 |
|
||||
| RAM | 8 GB | 16 GB |
|
||||
| 硬碟 | 50 GB SSD | 100 GB SSD |
|
||||
|
||||
### 軟體需求
|
||||
|
||||
| 軟體 | 版本 | 說明 |
|
||||
|------|------|------|
|
||||
| Docker | 20.10+ | 容器運行環境 |
|
||||
| Docker Compose | v2.0+ | 容器編排 |
|
||||
| kubectl | 1.28+ | K8s 客戶端(K8s 模式) |
|
||||
| Helm | 3.0+ | K8s 套件管理(監控用) |
|
||||
| curl | - | HTTP 請求 |
|
||||
|
||||
### 端口需求
|
||||
|
||||
| 端口 | 服務 | 必要 |
|
||||
|------|------|------|
|
||||
| 80/443 | Nginx | ✓ |
|
||||
| 5001 | Flask App | ✓(Docker 模式) |
|
||||
| 5432 | PostgreSQL | ✓ |
|
||||
| 3000 | Grafana | 選填 |
|
||||
| 9090 | Prometheus | 選填 |
|
||||
| 5678 | n8n | 選填 |
|
||||
| 5050 | Harbor | 選填 |
|
||||
|
||||
---
|
||||
|
||||
## 配置說明
|
||||
|
||||
### 互動式配置
|
||||
|
||||
部署時會提示輸入以下配置:
|
||||
|
||||
```
|
||||
資料庫配置
|
||||
──────────────────────────────────
|
||||
PostgreSQL 用戶名 [momo]:
|
||||
PostgreSQL 密碼 [自動生成]:
|
||||
|
||||
應用程式配置
|
||||
──────────────────────────────────
|
||||
Flask Secret Key [自動生成]:
|
||||
|
||||
Ollama AI 配置
|
||||
──────────────────────────────────
|
||||
Ollama Host [http://192.168.0.188:11434]:
|
||||
|
||||
通知服務配置
|
||||
──────────────────────────────────
|
||||
Telegram Bot Token []:
|
||||
Telegram Chat ID []:
|
||||
```
|
||||
|
||||
### 使用預設配置
|
||||
|
||||
```bash
|
||||
./deploy.sh -y deploy
|
||||
```
|
||||
|
||||
### 使用自訂配置檔
|
||||
|
||||
```bash
|
||||
# 複製模板
|
||||
cp deploy/configs/.env.template .env
|
||||
|
||||
# 編輯配置
|
||||
nano .env
|
||||
|
||||
# 使用自訂配置部署
|
||||
./deploy.sh -e .env deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 備份與還原
|
||||
|
||||
### 完整備份
|
||||
|
||||
```bash
|
||||
./deploy.sh backup
|
||||
```
|
||||
|
||||
**備份內容:**
|
||||
- PostgreSQL 完整資料庫(SQL 格式)
|
||||
- 所有資料表的 CSV 匯出
|
||||
- 配置檔案(.env, docker-compose.yml)
|
||||
- n8n 工作流程
|
||||
|
||||
**輸出位置:**
|
||||
```
|
||||
backups/momo_backup_20260129_143000.tar.gz
|
||||
```
|
||||
|
||||
### 從備份還原
|
||||
|
||||
```bash
|
||||
./deploy.sh -b backups/momo_backup_20260129.tar.gz restore
|
||||
```
|
||||
|
||||
### 定時備份
|
||||
|
||||
```bash
|
||||
# 每天凌晨 2 點備份
|
||||
0 2 * * * cd /opt/momo-pro-system && ./deploy/deploy.sh -y backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSL 憑證
|
||||
|
||||
### 申請 Let's Encrypt 憑證
|
||||
|
||||
```bash
|
||||
./deploy.sh -d momo.example.com ssl
|
||||
```
|
||||
|
||||
**前置需求:**
|
||||
1. 域名已解析到此伺服器
|
||||
2. 端口 80 可被外部存取
|
||||
|
||||
### 手動續期
|
||||
|
||||
```bash
|
||||
certbot renew --force-renewal
|
||||
docker restart momo-nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 命令參考
|
||||
|
||||
### 基本命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `deploy` | 執行完整部署 |
|
||||
| `backup` | 備份現有環境 |
|
||||
| `restore` | 從備份還原 |
|
||||
| `check` | 環境檢查 |
|
||||
| `health` | 健康檢查 |
|
||||
| `ssl` | 設定 SSL 憑證 |
|
||||
| `export` | 匯出部署包 |
|
||||
|
||||
### K8s 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `k8s-deploy` | 部署到 K8s |
|
||||
| `k8s-status` | 查看 K8s 狀態 |
|
||||
| `k8s-logs` | 查看 K8s 日誌 |
|
||||
| `k8s-rollback` | 回滾 K8s 部署 |
|
||||
| `k8s-cleanup` | 清理 K8s 資源 |
|
||||
|
||||
### Harbor 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `harbor-install` | 安裝 Harbor |
|
||||
| `harbor-push` | 推送映像 |
|
||||
| `harbor-scan` | 安全掃描 |
|
||||
| `harbor-health` | 健康檢查 |
|
||||
|
||||
### 監控命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `monitoring-deploy` | 部署監控 |
|
||||
| `monitoring-status` | 監控狀態 |
|
||||
|
||||
### Systemd 命令
|
||||
|
||||
| 命令 | 說明 |
|
||||
|------|------|
|
||||
| `systemd-setup` | 設定服務 |
|
||||
| `systemd-status` | 服務狀態 |
|
||||
|
||||
---
|
||||
|
||||
## 選項參考
|
||||
|
||||
| 選項 | 說明 |
|
||||
|------|------|
|
||||
| `--local` | Docker Compose 部署 |
|
||||
| `--k8s` | Kubernetes 部署 |
|
||||
| `--ssh` | SSH 遠端部署 |
|
||||
| `--export` | 匯出部署包 |
|
||||
| `-h, --host` | SSH 目標主機 |
|
||||
| `-u, --user` | SSH 用戶名 |
|
||||
| `-p, --path` | 遠端路徑 |
|
||||
| `-n, --namespace` | K8s 命名空間 |
|
||||
| `--registry` | Harbor URL |
|
||||
| `-e, --env-file` | 環境變數檔案 |
|
||||
| `-d, --domain` | 域名 |
|
||||
| `-b, --backup` | 備份檔案路徑 |
|
||||
| `--no-monitoring` | 不部署監控 |
|
||||
| `--with-data` | 包含資料 |
|
||||
| `--with-systemd` | 設定開機啟動 |
|
||||
| `-y, --yes` | 跳過確認 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### Docker 連線失敗
|
||||
|
||||
```bash
|
||||
sudo systemctl restart docker
|
||||
```
|
||||
|
||||
### K8s 部署失敗
|
||||
|
||||
```bash
|
||||
# 查看 Pod 狀態
|
||||
kubectl get pods -n momo
|
||||
|
||||
# 查看詳細事件
|
||||
kubectl describe pod <pod-name> -n momo
|
||||
|
||||
# 查看日誌
|
||||
kubectl logs deployment/momo-app -n momo
|
||||
```
|
||||
|
||||
### Harbor 登入失敗
|
||||
|
||||
```bash
|
||||
# 確認 Harbor 運行中
|
||||
docker ps | grep harbor
|
||||
|
||||
# 重啟 Harbor
|
||||
cd /home/wooo/devops/harbor/harbor
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### 服務無法啟動
|
||||
|
||||
```bash
|
||||
# 查看容器日誌
|
||||
docker compose logs
|
||||
|
||||
# 查看 systemd 日誌
|
||||
journalctl -u momo-pro-system -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目錄結構
|
||||
|
||||
```
|
||||
deploy/
|
||||
├── deploy.sh # 主入口腳本 v2.0
|
||||
├── README.md # 本文件
|
||||
├── lib/
|
||||
│ ├── common.sh # 通用函數
|
||||
│ ├── check.sh # 環境檢查
|
||||
│ ├── config.sh # 配置生成
|
||||
│ ├── docker.sh # Docker 操作
|
||||
│ ├── database.sh # 資料庫備份/還原
|
||||
│ ├── ssl.sh # SSL 憑證
|
||||
│ ├── health.sh # 健康檢查
|
||||
│ ├── k8s.sh # K8s 部署(新增)
|
||||
│ ├── harbor.sh # Harbor 管理(新增)
|
||||
│ ├── monitoring.sh # 監控堆疊(新增)
|
||||
│ └── systemd.sh # Systemd 服務(新增)
|
||||
└── configs/
|
||||
└── .env.template # 環境變數模板
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更新記錄
|
||||
|
||||
- **2026-01-29**: v2.0.0
|
||||
- 新增 Kubernetes 部署支援
|
||||
- 新增 Harbor Registry 整合
|
||||
- 新增監控堆疊部署 (Prometheus/Grafana/Loki)
|
||||
- 新增 Systemd 服務管理
|
||||
- 新增容器安全掃描功能
|
||||
- 增強健康監控和自動修復
|
||||
|
||||
- **2026-01-26**: v1.0.0
|
||||
- 初始版本
|
||||
- 支援本地、SSH、匯出三種部署模式
|
||||
- 完整資料庫備份/還原
|
||||
- Let's Encrypt SSL 自動化
|
||||
862
deploy/deploy.sh
Executable file
862
deploy/deploy.sh
Executable file
@@ -0,0 +1,862 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# MOMO Pro System - 一鍵部署腳本
|
||||
# =============================================================================
|
||||
# 用途:將 MOMO Pro System 完整部署到新的 VM 環境
|
||||
# 支援:Docker Compose 部署、K8s 部署、SSH 遠端部署、匯出部署包
|
||||
# 版本:2.0.0
|
||||
# 更新:2026-01-29
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 腳本目錄
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# 載入函數庫
|
||||
source "$SCRIPT_DIR/lib/common.sh"
|
||||
source "$SCRIPT_DIR/lib/check.sh"
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
source "$SCRIPT_DIR/lib/docker.sh"
|
||||
source "$SCRIPT_DIR/lib/database.sh"
|
||||
source "$SCRIPT_DIR/lib/ssl.sh"
|
||||
source "$SCRIPT_DIR/lib/health.sh"
|
||||
|
||||
# 載入新增模組
|
||||
[[ -f "$SCRIPT_DIR/lib/k8s.sh" ]] && source "$SCRIPT_DIR/lib/k8s.sh"
|
||||
[[ -f "$SCRIPT_DIR/lib/monitoring.sh" ]] && source "$SCRIPT_DIR/lib/monitoring.sh"
|
||||
[[ -f "$SCRIPT_DIR/lib/systemd.sh" ]] && source "$SCRIPT_DIR/lib/systemd.sh"
|
||||
[[ -f "$SCRIPT_DIR/lib/harbor.sh" ]] && source "$SCRIPT_DIR/lib/harbor.sh"
|
||||
|
||||
# =============================================================================
|
||||
# 預設值
|
||||
# =============================================================================
|
||||
DEPLOY_MODE="local" # local | ssh | export | k8s
|
||||
TARGET_HOST="" # SSH 目標主機
|
||||
TARGET_USER="root" # SSH 用戶
|
||||
TARGET_PATH="/opt/momo-pro-system" # 遠端部署路徑
|
||||
ENV_FILE="" # 自訂環境變數檔案
|
||||
INCLUDE_MONITORING="true" # 是否包含監控服務
|
||||
INCLUDE_DATA="false" # 是否包含資料庫備份
|
||||
DOMAIN="" # 域名(用於 SSL)
|
||||
SKIP_CONFIRM="false" # 跳過確認提示
|
||||
BACKUP_PATH="" # 備份檔案路徑(用於還原)
|
||||
DEPLOY_TYPE="docker" # docker | k8s
|
||||
SETUP_SYSTEMD="false" # 是否設定 systemd 服務
|
||||
K8S_NAMESPACE="momo" # K8s 命名空間
|
||||
|
||||
# Harbor 設定
|
||||
HARBOR_REGISTRY="${HARBOR_REGISTRY:-harbor.wooo.work}"
|
||||
HARBOR_USERNAME="${HARBOR_USERNAME:-admin}"
|
||||
HARBOR_PASSWORD="${HARBOR_PASSWORD:-Wooo_Harbor_2026}"
|
||||
HARBOR_PROJECT="${HARBOR_PROJECT:-wooo}"
|
||||
|
||||
# Telegram 設定
|
||||
TELEGRAM_BOT_TOKEN="${TELEGRAM_BOT_TOKEN:-8075645931:AAH-EGKMo8ZC4QJs-Nc1_0s92xHrGdQvdpg}"
|
||||
TELEGRAM_CHAT_ID="${TELEGRAM_CHAT_ID:-5619078117}"
|
||||
|
||||
# =============================================================================
|
||||
# 顯示使用說明
|
||||
# =============================================================================
|
||||
show_help() {
|
||||
cat << EOF
|
||||
${CYAN}═══════════════════════════════════════════════════════════════════════════════${NC}
|
||||
${BOLD}MOMO Pro System - 一鍵部署腳本 v2.0.0${NC}
|
||||
${CYAN}═══════════════════════════════════════════════════════════════════════════════${NC}
|
||||
|
||||
${YELLOW}用法:${NC}
|
||||
./deploy.sh [選項] [命令]
|
||||
|
||||
${YELLOW}基本命令:${NC}
|
||||
deploy 執行完整部署(預設)
|
||||
backup 備份現有環境(資料庫 + 配置)
|
||||
restore 從備份還原
|
||||
check 僅執行環境檢查
|
||||
export 匯出部署包(不執行部署)
|
||||
ssl 設定/更新 SSL 憑證
|
||||
health 執行健康檢查
|
||||
|
||||
${YELLOW}K8s 命令:${NC}
|
||||
k8s-deploy 部署到 Kubernetes 叢集
|
||||
k8s-status 查看 K8s 部署狀態
|
||||
k8s-logs 查看 K8s 應用日誌
|
||||
k8s-rollback 回滾到上一個版本
|
||||
k8s-cleanup 清理 K8s 資源
|
||||
|
||||
${YELLOW}Harbor 命令:${NC}
|
||||
harbor-install 安裝 Harbor Registry
|
||||
harbor-push 建構並推送映像到 Harbor
|
||||
harbor-scan 觸發容器安全掃描
|
||||
harbor-health 檢查 Harbor 健康狀態
|
||||
|
||||
${YELLOW}監控命令:${NC}
|
||||
monitoring-deploy 部署監控堆疊 (Prometheus/Grafana)
|
||||
monitoring-status 查看監控服務狀態
|
||||
|
||||
${YELLOW}系統命令:${NC}
|
||||
systemd-setup 設定開機自動啟動服務
|
||||
systemd-status 查看 systemd 服務狀態
|
||||
|
||||
${YELLOW}部署模式選項:${NC}
|
||||
--local 本地 Docker Compose 部署(預設)
|
||||
--k8s Kubernetes 部署
|
||||
--ssh SSH 遠端部署
|
||||
--export 匯出部署包
|
||||
|
||||
${YELLOW}SSH 選項:${NC}
|
||||
-h, --host 目標主機 IP 或域名
|
||||
-u, --user SSH 用戶名(預設: root)
|
||||
-p, --path 遠端部署路徑(預設: /opt/momo-pro-system)
|
||||
|
||||
${YELLOW}K8s 選項:${NC}
|
||||
-n, --namespace K8s 命名空間(預設: momo)
|
||||
|
||||
${YELLOW}Harbor 選項:${NC}
|
||||
--registry Harbor Registry URL
|
||||
--harbor-user Harbor 用戶名
|
||||
--harbor-pass Harbor 密碼
|
||||
|
||||
${YELLOW}配置選項:${NC}
|
||||
-e, --env-file 使用自訂環境變數檔案
|
||||
-d, --domain 設定域名(用於 SSL 憑證)
|
||||
--no-monitoring 不部署監控服務
|
||||
--with-data 包含資料庫備份(匯出/遠端部署時)
|
||||
--with-systemd 設定 systemd 開機自動啟動
|
||||
|
||||
${YELLOW}備份/還原選項:${NC}
|
||||
-b, --backup 指定備份檔案路徑(還原時使用)
|
||||
|
||||
${YELLOW}其他選項:${NC}
|
||||
-y, --yes 跳過所有確認提示
|
||||
--help 顯示此說明
|
||||
|
||||
${YELLOW}基本範例:${NC}
|
||||
# 本地 Docker 部署(互動式配置)
|
||||
./deploy.sh deploy
|
||||
|
||||
# SSH 遠端部署
|
||||
./deploy.sh --ssh -h 192.168.1.100 -u wooo deploy
|
||||
|
||||
# 匯出部署包(含資料)
|
||||
./deploy.sh --export --with-data
|
||||
|
||||
${YELLOW}K8s 範例:${NC}
|
||||
# 部署到 K8s
|
||||
./deploy.sh --k8s deploy
|
||||
|
||||
# 建構映像並推送到 Harbor,然後部署到 K8s
|
||||
./deploy.sh harbor-push && ./deploy.sh --k8s deploy
|
||||
|
||||
# 查看 K8s 狀態
|
||||
./deploy.sh k8s-status
|
||||
|
||||
# 回滾 K8s 部署
|
||||
./deploy.sh k8s-rollback
|
||||
|
||||
${YELLOW}監控範例:${NC}
|
||||
# 部署完整監控堆疊
|
||||
./deploy.sh monitoring-deploy
|
||||
|
||||
# 查看監控狀態
|
||||
./deploy.sh monitoring-status
|
||||
|
||||
${YELLOW}完整部署流程:${NC}
|
||||
# 1. 環境檢查
|
||||
./deploy.sh check
|
||||
|
||||
# 2. 部署應用(含 systemd 設定)
|
||||
./deploy.sh --with-systemd deploy
|
||||
|
||||
# 3. 設定 SSL
|
||||
./deploy.sh -d mo.wooo.work ssl
|
||||
|
||||
# 4. 部署監控
|
||||
./deploy.sh monitoring-deploy
|
||||
|
||||
# 5. 健康檢查
|
||||
./deploy.sh health
|
||||
|
||||
${CYAN}═══════════════════════════════════════════════════════════════════════════════${NC}
|
||||
EOF
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 解析命令行參數
|
||||
# =============================================================================
|
||||
parse_args() {
|
||||
COMMAND="deploy" # 預設命令
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
# 基本命令
|
||||
deploy|backup|restore|check|export|ssl|health)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
# K8s 命令
|
||||
k8s-deploy|k8s-status|k8s-logs|k8s-rollback|k8s-cleanup)
|
||||
COMMAND="$1"
|
||||
DEPLOY_TYPE="k8s"
|
||||
shift
|
||||
;;
|
||||
# Harbor 命令
|
||||
harbor-install|harbor-push|harbor-scan|harbor-health)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
# 監控命令
|
||||
monitoring-deploy|monitoring-status)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
# systemd 命令
|
||||
systemd-setup|systemd-status)
|
||||
COMMAND="$1"
|
||||
shift
|
||||
;;
|
||||
# 部署模式
|
||||
--local)
|
||||
DEPLOY_MODE="local"
|
||||
DEPLOY_TYPE="docker"
|
||||
shift
|
||||
;;
|
||||
--k8s)
|
||||
DEPLOY_MODE="local"
|
||||
DEPLOY_TYPE="k8s"
|
||||
shift
|
||||
;;
|
||||
--ssh)
|
||||
DEPLOY_MODE="ssh"
|
||||
shift
|
||||
;;
|
||||
--export)
|
||||
DEPLOY_MODE="export"
|
||||
shift
|
||||
;;
|
||||
# SSH 選項
|
||||
-h|--host)
|
||||
TARGET_HOST="$2"
|
||||
shift 2
|
||||
;;
|
||||
-u|--user)
|
||||
TARGET_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
-p|--path)
|
||||
TARGET_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
# K8s 選項
|
||||
-n|--namespace)
|
||||
K8S_NAMESPACE="$2"
|
||||
shift 2
|
||||
;;
|
||||
# Harbor 選項
|
||||
--registry)
|
||||
HARBOR_REGISTRY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--harbor-user)
|
||||
HARBOR_USERNAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--harbor-pass)
|
||||
HARBOR_PASSWORD="$2"
|
||||
shift 2
|
||||
;;
|
||||
# 配置選項
|
||||
-e|--env-file)
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--domain)
|
||||
DOMAIN="$2"
|
||||
shift 2
|
||||
;;
|
||||
-b|--backup)
|
||||
BACKUP_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--no-monitoring)
|
||||
INCLUDE_MONITORING="false"
|
||||
shift
|
||||
;;
|
||||
--with-data)
|
||||
INCLUDE_DATA="true"
|
||||
shift
|
||||
;;
|
||||
--with-systemd)
|
||||
SETUP_SYSTEMD="true"
|
||||
shift
|
||||
;;
|
||||
-y|--yes)
|
||||
SKIP_CONFIRM="true"
|
||||
shift
|
||||
;;
|
||||
--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
log_error "未知選項: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 驗證 SSH 模式參數
|
||||
if [[ "$DEPLOY_MODE" == "ssh" && -z "$TARGET_HOST" ]]; then
|
||||
log_error "SSH 模式需要指定目標主機 (-h/--host)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 驗證還原命令參數
|
||||
if [[ "$COMMAND" == "restore" && -z "$BACKUP_PATH" ]]; then
|
||||
log_error "還原命令需要指定備份檔案路徑 (-b/--backup)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 匯出環境變數供子模組使用
|
||||
export HARBOR_REGISTRY HARBOR_USERNAME HARBOR_PASSWORD HARBOR_PROJECT
|
||||
export K8S_NAMESPACE
|
||||
export TELEGRAM_BOT_TOKEN TELEGRAM_CHAT_ID
|
||||
export PROJECT_ROOT SKIP_CONFIRM
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 顯示部署摘要
|
||||
# =============================================================================
|
||||
show_summary() {
|
||||
echo ""
|
||||
log_section "部署摘要"
|
||||
echo ""
|
||||
echo " ${BOLD}命令:${NC} $COMMAND"
|
||||
echo " ${BOLD}部署類型:${NC} $DEPLOY_TYPE"
|
||||
echo " ${BOLD}部署模式:${NC} $DEPLOY_MODE"
|
||||
|
||||
if [[ "$DEPLOY_MODE" == "ssh" ]]; then
|
||||
echo " ${BOLD}目標主機:${NC} ${TARGET_USER}@${TARGET_HOST}"
|
||||
echo " ${BOLD}遠端路徑:${NC} $TARGET_PATH"
|
||||
fi
|
||||
|
||||
if [[ "$DEPLOY_TYPE" == "k8s" ]]; then
|
||||
echo " ${BOLD}K8s 命名空間:${NC} $K8S_NAMESPACE"
|
||||
echo " ${BOLD}Harbor:${NC} $HARBOR_REGISTRY"
|
||||
fi
|
||||
|
||||
if [[ -n "$ENV_FILE" ]]; then
|
||||
echo " ${BOLD}環境變數:${NC} $ENV_FILE"
|
||||
fi
|
||||
|
||||
if [[ -n "$DOMAIN" ]]; then
|
||||
echo " ${BOLD}域名:${NC} $DOMAIN"
|
||||
fi
|
||||
|
||||
echo " ${BOLD}監控服務:${NC} $([ "$INCLUDE_MONITORING" == "true" ] && echo "✓ 包含" || echo "✗ 不包含")"
|
||||
echo " ${BOLD}資料備份:${NC} $([ "$INCLUDE_DATA" == "true" ] && echo "✓ 包含" || echo "✗ 不包含")"
|
||||
echo " ${BOLD}Systemd:${NC} $([ "$SETUP_SYSTEMD" == "true" ] && echo "✓ 設定開機啟動" || echo "✗ 不設定")"
|
||||
echo ""
|
||||
|
||||
if [[ "$SKIP_CONFIRM" != "true" ]]; then
|
||||
read -p " 確認繼續? [y/N]: " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
|
||||
log_info "已取消操作"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 執行部署
|
||||
# =============================================================================
|
||||
do_deploy() {
|
||||
log_section "開始部署 MOMO Pro System"
|
||||
|
||||
# Step 1: 環境檢查
|
||||
log_step 1 8 "環境檢查"
|
||||
check_prerequisites
|
||||
|
||||
# Step 2: 準備配置
|
||||
log_step 2 8 "準備配置"
|
||||
if [[ -n "$ENV_FILE" ]]; then
|
||||
validate_env_file "$ENV_FILE"
|
||||
else
|
||||
generate_env_interactive
|
||||
fi
|
||||
|
||||
# Step 3: 根據模式執行不同的部署
|
||||
case $DEPLOY_MODE in
|
||||
local)
|
||||
do_local_deploy
|
||||
;;
|
||||
ssh)
|
||||
do_ssh_deploy
|
||||
;;
|
||||
export)
|
||||
do_export_package
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 本地部署
|
||||
# =============================================================================
|
||||
do_local_deploy() {
|
||||
log_step 3 8 "拉取 Docker 映像"
|
||||
docker_pull_images
|
||||
|
||||
log_step 4 8 "啟動核心服務"
|
||||
docker_start_core
|
||||
|
||||
if [[ "$INCLUDE_MONITORING" == "true" ]]; then
|
||||
log_step 5 8 "啟動監控服務"
|
||||
docker_start_monitoring
|
||||
else
|
||||
log_step 5 8 "跳過監控服務"
|
||||
fi
|
||||
|
||||
log_step 6 8 "等待服務啟動"
|
||||
wait_for_services
|
||||
|
||||
log_step 7 8 "匯入 n8n 工作流程"
|
||||
import_n8n_workflows
|
||||
|
||||
log_step 8 8 "健康檢查"
|
||||
health_check_all
|
||||
|
||||
log_success "本地部署完成!"
|
||||
show_access_info "localhost"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SSH 遠端部署
|
||||
# =============================================================================
|
||||
do_ssh_deploy() {
|
||||
log_step 3 8 "測試 SSH 連線"
|
||||
test_ssh_connection "$TARGET_HOST" "$TARGET_USER"
|
||||
|
||||
log_step 4 8 "同步檔案到遠端"
|
||||
sync_files_to_remote "$TARGET_HOST" "$TARGET_USER" "$TARGET_PATH"
|
||||
|
||||
log_step 5 8 "遠端執行部署"
|
||||
ssh_execute_deploy "$TARGET_HOST" "$TARGET_USER" "$TARGET_PATH" "$INCLUDE_MONITORING"
|
||||
|
||||
log_step 6 8 "等待遠端服務啟動"
|
||||
ssh_wait_for_services "$TARGET_HOST" "$TARGET_USER"
|
||||
|
||||
log_step 7 8 "匯入 n8n 工作流程"
|
||||
ssh_import_n8n_workflows "$TARGET_HOST" "$TARGET_USER"
|
||||
|
||||
log_step 8 8 "遠端健康檢查"
|
||||
ssh_health_check "$TARGET_HOST"
|
||||
|
||||
log_success "SSH 遠端部署完成!"
|
||||
show_access_info "$TARGET_HOST"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 匯出部署包
|
||||
# =============================================================================
|
||||
do_export_package() {
|
||||
log_step 3 5 "準備匯出目錄"
|
||||
EXPORT_DIR="${PROJECT_ROOT}/export_$(date +%Y%m%d_%H%M%S)"
|
||||
mkdir -p "$EXPORT_DIR"
|
||||
|
||||
log_step 4 5 "複製必要檔案"
|
||||
copy_deploy_files "$EXPORT_DIR"
|
||||
|
||||
if [[ "$INCLUDE_DATA" == "true" ]]; then
|
||||
log_info "備份資料庫..."
|
||||
backup_database "$EXPORT_DIR/backup"
|
||||
fi
|
||||
|
||||
log_step 5 5 "建立壓縮包"
|
||||
PACKAGE_NAME="momo-pro-system_$(date +%Y%m%d_%H%M%S).tar.gz"
|
||||
tar -czf "${PROJECT_ROOT}/${PACKAGE_NAME}" -C "$(dirname "$EXPORT_DIR")" "$(basename "$EXPORT_DIR")"
|
||||
rm -rf "$EXPORT_DIR"
|
||||
|
||||
log_success "部署包已匯出: ${PROJECT_ROOT}/${PACKAGE_NAME}"
|
||||
echo ""
|
||||
echo " 大小: $(du -h "${PROJECT_ROOT}/${PACKAGE_NAME}" | cut -f1)"
|
||||
echo ""
|
||||
echo " 使用方式:"
|
||||
echo " 1. 將檔案複製到目標主機"
|
||||
echo " 2. 解壓縮: tar -xzf ${PACKAGE_NAME}"
|
||||
echo " 3. 進入目錄: cd momo-pro-system"
|
||||
echo " 4. 執行部署: ./deploy/deploy.sh deploy"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 備份
|
||||
# =============================================================================
|
||||
do_backup() {
|
||||
log_section "備份現有環境"
|
||||
|
||||
BACKUP_DIR="${PROJECT_ROOT}/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
BACKUP_NAME="momo_backup_$(date +%Y%m%d_%H%M%S)"
|
||||
BACKUP_FULL_PATH="${BACKUP_DIR}/${BACKUP_NAME}"
|
||||
mkdir -p "$BACKUP_FULL_PATH"
|
||||
|
||||
log_step 1 4 "備份資料庫"
|
||||
backup_database "$BACKUP_FULL_PATH"
|
||||
|
||||
log_step 2 4 "備份配置檔案"
|
||||
backup_configs "$BACKUP_FULL_PATH"
|
||||
|
||||
log_step 3 4 "備份 n8n 工作流程"
|
||||
backup_n8n_workflows "$BACKUP_FULL_PATH"
|
||||
|
||||
log_step 4 4 "建立壓縮包"
|
||||
tar -czf "${BACKUP_FULL_PATH}.tar.gz" -C "$BACKUP_DIR" "$BACKUP_NAME"
|
||||
rm -rf "$BACKUP_FULL_PATH"
|
||||
|
||||
log_success "備份完成: ${BACKUP_FULL_PATH}.tar.gz"
|
||||
echo " 大小: $(du -h "${BACKUP_FULL_PATH}.tar.gz" | cut -f1)"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 還原
|
||||
# =============================================================================
|
||||
do_restore() {
|
||||
log_section "從備份還原"
|
||||
|
||||
if [[ ! -f "$BACKUP_PATH" ]]; then
|
||||
log_error "備份檔案不存在: $BACKUP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step 1 4 "解壓縮備份"
|
||||
RESTORE_DIR=$(mktemp -d)
|
||||
tar -xzf "$BACKUP_PATH" -C "$RESTORE_DIR"
|
||||
BACKUP_CONTENT=$(ls "$RESTORE_DIR")
|
||||
|
||||
log_step 2 4 "停止現有服務"
|
||||
docker_stop_all
|
||||
|
||||
log_step 3 4 "還原資料庫"
|
||||
restore_database "${RESTORE_DIR}/${BACKUP_CONTENT}"
|
||||
|
||||
log_step 4 4 "還原配置檔案"
|
||||
restore_configs "${RESTORE_DIR}/${BACKUP_CONTENT}"
|
||||
|
||||
rm -rf "$RESTORE_DIR"
|
||||
|
||||
log_success "還原完成!請執行 ./deploy.sh deploy 重新啟動服務"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SSL 設定
|
||||
# =============================================================================
|
||||
do_ssl() {
|
||||
if [[ -z "$DOMAIN" ]]; then
|
||||
log_error "需要指定域名 (-d/--domain)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_section "設定 SSL 憑證: $DOMAIN"
|
||||
|
||||
setup_ssl_certificate "$DOMAIN"
|
||||
|
||||
log_success "SSL 憑證設定完成!"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 健康檢查
|
||||
# =============================================================================
|
||||
do_health() {
|
||||
log_section "執行健康檢查"
|
||||
|
||||
if [[ "$DEPLOY_MODE" == "ssh" && -n "$TARGET_HOST" ]]; then
|
||||
ssh_health_check "$TARGET_HOST"
|
||||
else
|
||||
health_check_all
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 環境檢查
|
||||
# =============================================================================
|
||||
do_check() {
|
||||
log_section "環境檢查"
|
||||
check_prerequisites
|
||||
log_success "環境檢查通過!"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# K8s 命令處理
|
||||
# =============================================================================
|
||||
|
||||
do_k8s_deploy() {
|
||||
log_section "部署到 Kubernetes"
|
||||
|
||||
# 檢查 K8s 環境
|
||||
check_k8s_prerequisites || exit 1
|
||||
|
||||
# 建構並推送映像
|
||||
if [[ "$SKIP_CONFIRM" != "true" ]]; then
|
||||
read -p " 是否先建構並推送映像到 Harbor? [y/N]: " build_first
|
||||
if [[ "$build_first" =~ ^[Yy]$ ]]; then
|
||||
k8s_build_and_push
|
||||
fi
|
||||
fi
|
||||
|
||||
# 執行 K8s 部署
|
||||
k8s_deploy_all "$K8S_NAMESPACE"
|
||||
|
||||
# 設定 systemd (可選)
|
||||
if [[ "$SETUP_SYSTEMD" == "true" ]]; then
|
||||
log_info "K8s 環境通常不需要額外的 systemd 配置"
|
||||
fi
|
||||
|
||||
log_success "K8s 部署完成!"
|
||||
k8s_status "$K8S_NAMESPACE"
|
||||
}
|
||||
|
||||
do_k8s_status() {
|
||||
k8s_status "$K8S_NAMESPACE"
|
||||
}
|
||||
|
||||
do_k8s_logs() {
|
||||
local deployment="${2:-momo-app}"
|
||||
k8s_logs "$K8S_NAMESPACE" "$deployment"
|
||||
}
|
||||
|
||||
do_k8s_rollback() {
|
||||
local deployment="${2:-momo-app}"
|
||||
k8s_rollback "$K8S_NAMESPACE" "$deployment"
|
||||
}
|
||||
|
||||
do_k8s_cleanup() {
|
||||
k8s_cleanup "$K8S_NAMESPACE"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Harbor 命令處理
|
||||
# =============================================================================
|
||||
|
||||
do_harbor_install() {
|
||||
install_harbor "$DOMAIN" "$HARBOR_PASSWORD"
|
||||
}
|
||||
|
||||
do_harbor_push() {
|
||||
harbor_build_and_push "momo-pro-system" "latest" "Dockerfile" "$PROJECT_ROOT"
|
||||
}
|
||||
|
||||
do_harbor_scan() {
|
||||
harbor_scan_image "wooo/momo-pro-system:latest"
|
||||
sleep 5
|
||||
harbor_get_scan_report "wooo/momo-pro-system:latest"
|
||||
}
|
||||
|
||||
do_harbor_health() {
|
||||
harbor_health_check
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 監控命令處理
|
||||
# =============================================================================
|
||||
|
||||
do_monitoring_deploy() {
|
||||
log_section "部署監控堆疊"
|
||||
|
||||
if [[ "$DEPLOY_TYPE" == "k8s" ]]; then
|
||||
# K8s 監控 (Helm)
|
||||
install_helm
|
||||
add_helm_repos
|
||||
deploy_prometheus_stack "$MONITORING_NAMESPACE"
|
||||
deploy_loki "$MONITORING_NAMESPACE"
|
||||
else
|
||||
# Docker 監控
|
||||
deploy_docker_monitoring
|
||||
fi
|
||||
|
||||
log_success "監控堆疊部署完成!"
|
||||
}
|
||||
|
||||
do_monitoring_status() {
|
||||
if [[ "$DEPLOY_TYPE" == "k8s" ]]; then
|
||||
check_monitoring_health "$MONITORING_NAMESPACE"
|
||||
else
|
||||
echo ""
|
||||
log_section "Docker 監控服務狀態"
|
||||
echo ""
|
||||
docker ps --filter "name=momo-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep -E "prometheus|grafana|alertmanager|loki|cadvisor|node-exporter" || echo " 無監控容器運行"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Systemd 命令處理
|
||||
# =============================================================================
|
||||
|
||||
do_systemd_setup() {
|
||||
setup_all_services "$PROJECT_ROOT"
|
||||
}
|
||||
|
||||
do_systemd_status() {
|
||||
show_services_status
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 更新 do_deploy 以支援多種部署類型
|
||||
# =============================================================================
|
||||
|
||||
do_deploy_enhanced() {
|
||||
log_section "開始部署 MOMO Pro System"
|
||||
|
||||
# Step 1: 環境檢查
|
||||
log_step 1 8 "環境檢查"
|
||||
check_prerequisites
|
||||
|
||||
if [[ "$DEPLOY_TYPE" == "k8s" ]]; then
|
||||
check_k8s_prerequisites || exit 1
|
||||
fi
|
||||
|
||||
# Step 2: 準備配置
|
||||
log_step 2 8 "準備配置"
|
||||
if [[ -n "$ENV_FILE" ]]; then
|
||||
validate_env_file "$ENV_FILE"
|
||||
else
|
||||
generate_env_interactive
|
||||
fi
|
||||
|
||||
# Step 3: 根據部署類型和模式執行部署
|
||||
case "$DEPLOY_TYPE" in
|
||||
k8s)
|
||||
do_k8s_deploy
|
||||
;;
|
||||
docker)
|
||||
case $DEPLOY_MODE in
|
||||
local)
|
||||
do_local_deploy
|
||||
;;
|
||||
ssh)
|
||||
do_ssh_deploy
|
||||
;;
|
||||
export)
|
||||
do_export_package
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
# 設定 systemd (如果指定)
|
||||
if [[ "$SETUP_SYSTEMD" == "true" && "$DEPLOY_TYPE" == "docker" ]]; then
|
||||
log_info "設定開機自動啟動..."
|
||||
setup_all_services "$PROJECT_ROOT"
|
||||
fi
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 主程式
|
||||
# =============================================================================
|
||||
main() {
|
||||
clear
|
||||
echo ""
|
||||
echo "${PURPLE}╔═══════════════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo "${PURPLE}║${NC} ${BOLD}MOMO Pro System - 一鍵部署工具 v2.0.0${NC} ${PURPLE}║${NC}"
|
||||
echo "${PURPLE}║${NC} ${DIM}WOOO TECH © 2026${NC} ${PURPLE}║${NC}"
|
||||
echo "${PURPLE}╚═══════════════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo ""
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
# 顯示摘要(除了 help、check 和狀態查詢命令)
|
||||
case "$COMMAND" in
|
||||
check|k8s-status|k8s-logs|monitoring-status|systemd-status|harbor-health)
|
||||
# 這些命令不需要確認
|
||||
;;
|
||||
*)
|
||||
show_summary
|
||||
;;
|
||||
esac
|
||||
|
||||
# 執行對應命令
|
||||
case $COMMAND in
|
||||
# 基本命令
|
||||
deploy)
|
||||
do_deploy_enhanced
|
||||
;;
|
||||
backup)
|
||||
do_backup
|
||||
;;
|
||||
restore)
|
||||
do_restore
|
||||
;;
|
||||
check)
|
||||
do_check
|
||||
;;
|
||||
export)
|
||||
DEPLOY_MODE="export"
|
||||
do_deploy
|
||||
;;
|
||||
ssl)
|
||||
do_ssl
|
||||
;;
|
||||
health)
|
||||
do_health
|
||||
;;
|
||||
|
||||
# K8s 命令
|
||||
k8s-deploy)
|
||||
do_k8s_deploy
|
||||
;;
|
||||
k8s-status)
|
||||
do_k8s_status
|
||||
;;
|
||||
k8s-logs)
|
||||
do_k8s_logs
|
||||
;;
|
||||
k8s-rollback)
|
||||
do_k8s_rollback
|
||||
;;
|
||||
k8s-cleanup)
|
||||
do_k8s_cleanup
|
||||
;;
|
||||
|
||||
# Harbor 命令
|
||||
harbor-install)
|
||||
do_harbor_install
|
||||
;;
|
||||
harbor-push)
|
||||
do_harbor_push
|
||||
;;
|
||||
harbor-scan)
|
||||
do_harbor_scan
|
||||
;;
|
||||
harbor-health)
|
||||
do_harbor_health
|
||||
;;
|
||||
|
||||
# 監控命令
|
||||
monitoring-deploy)
|
||||
do_monitoring_deploy
|
||||
;;
|
||||
monitoring-status)
|
||||
do_monitoring_status
|
||||
;;
|
||||
|
||||
# Systemd 命令
|
||||
systemd-setup)
|
||||
do_systemd_setup
|
||||
;;
|
||||
systemd-status)
|
||||
do_systemd_status
|
||||
;;
|
||||
|
||||
*)
|
||||
log_error "未知命令: $COMMAND"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 執行主程式
|
||||
main "$@"
|
||||
442
deploy_docker_guide.md
Normal file
442
deploy_docker_guide.md
Normal file
@@ -0,0 +1,442 @@
|
||||
# Momo Pro System - Docker 部署指南
|
||||
|
||||
## 方案一:本機測試 Docker 部署
|
||||
|
||||
### 1. 確認環境
|
||||
|
||||
```bash
|
||||
# 確認 Docker 已安裝
|
||||
docker --version
|
||||
docker-compose --version
|
||||
```
|
||||
|
||||
### 2. 建立並啟動容器
|
||||
|
||||
```bash
|
||||
# 建立映像並啟動服務
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日誌
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看容器狀態
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 3. 測試訪問
|
||||
|
||||
瀏覽器開啟:http://localhost
|
||||
|
||||
### 4. 停止服務
|
||||
|
||||
```bash
|
||||
# 停止容器
|
||||
docker-compose down
|
||||
|
||||
# 停止並刪除 volumes
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案二:部署到 GCP Cloud Run(推薦)
|
||||
|
||||
**優點:**
|
||||
- 完全託管,自動擴展
|
||||
- 按使用付費(沒有流量時不收費)
|
||||
- 自動 HTTPS
|
||||
- 不需要管理 VM
|
||||
|
||||
### 1. 設定 GCP 專案
|
||||
|
||||
```bash
|
||||
# 設定專案 ID
|
||||
export PROJECT_ID="your-project-id"
|
||||
gcloud config set project $PROJECT_ID
|
||||
|
||||
# 啟用必要的 API
|
||||
gcloud services enable \
|
||||
run.googleapis.com \
|
||||
cloudbuild.googleapis.com \
|
||||
artifactregistry.googleapis.com
|
||||
```
|
||||
|
||||
### 2. 建立 Artifact Registry
|
||||
|
||||
```bash
|
||||
# 建立 Docker repository
|
||||
gcloud artifacts repositories create momo-repo \
|
||||
--repository-format=docker \
|
||||
--location=asia-east1 \
|
||||
--description="Momo Pro System Docker Repository"
|
||||
```
|
||||
|
||||
### 3. 建立並推送 Docker 映像
|
||||
|
||||
```bash
|
||||
# 設定映像名稱
|
||||
export IMAGE_NAME="asia-east1-docker.pkg.dev/$PROJECT_ID/momo-repo/momo-app"
|
||||
|
||||
# 建立映像
|
||||
docker build -t $IMAGE_NAME .
|
||||
|
||||
# 推送到 Artifact Registry
|
||||
docker push $IMAGE_NAME
|
||||
```
|
||||
|
||||
### 4. 部署到 Cloud Run
|
||||
|
||||
```bash
|
||||
# 部署服務
|
||||
gcloud run deploy momo-pro-system \
|
||||
--image=$IMAGE_NAME \
|
||||
--platform=managed \
|
||||
--region=asia-east1 \
|
||||
--allow-unauthenticated \
|
||||
--port=5000 \
|
||||
--memory=2Gi \
|
||||
--cpu=2 \
|
||||
--min-instances=1 \
|
||||
--max-instances=10 \
|
||||
--timeout=300 \
|
||||
--set-env-vars="FLASK_ENV=production" \
|
||||
--set-secrets="DATABASE_URL=momo-db-url:latest"
|
||||
```
|
||||
|
||||
### 5. 設定環境變數和 Secrets
|
||||
|
||||
```bash
|
||||
# 建立 secret(例如:EMAIL_PASSWORD)
|
||||
echo -n "your-password" | gcloud secrets create email-password --data-file=-
|
||||
|
||||
# 更新 Cloud Run 服務使用 secret
|
||||
gcloud run services update momo-pro-system \
|
||||
--region=asia-east1 \
|
||||
--set-secrets=EMAIL_PASSWORD=email-password:latest
|
||||
```
|
||||
|
||||
### 6. 獲取服務 URL
|
||||
|
||||
```bash
|
||||
gcloud run services describe momo-pro-system \
|
||||
--region=asia-east1 \
|
||||
--format='value(status.url)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案三:部署到 GCP Compute Engine (VM)
|
||||
|
||||
### 1. 建立 VM
|
||||
|
||||
```bash
|
||||
# 建立具有 Docker 的 VM
|
||||
gcloud compute instances create momo-server \
|
||||
--zone=asia-east1-a \
|
||||
--machine-type=e2-medium \
|
||||
--image-family=cos-stable \
|
||||
--image-project=cos-cloud \
|
||||
--boot-disk-size=50GB \
|
||||
--tags=http-server,https-server
|
||||
```
|
||||
|
||||
### 2. 設定防火牆規則
|
||||
|
||||
```bash
|
||||
# 允許 HTTP 流量
|
||||
gcloud compute firewall-rules create allow-http \
|
||||
--allow=tcp:80 \
|
||||
--target-tags=http-server
|
||||
|
||||
# 允許 HTTPS 流量
|
||||
gcloud compute firewall-rules create allow-https \
|
||||
--allow=tcp:443 \
|
||||
--target-tags=https-server
|
||||
```
|
||||
|
||||
### 3. 上傳程式碼到 VM
|
||||
|
||||
```bash
|
||||
# 上傳整個專案
|
||||
gcloud compute scp --recurse . momo-server:~/momo_pro_system \
|
||||
--zone=asia-east1-a
|
||||
```
|
||||
|
||||
### 4. 連接到 VM 並啟動服務
|
||||
|
||||
```bash
|
||||
# SSH 到 VM
|
||||
gcloud compute ssh momo-server --zone=asia-east1-a
|
||||
|
||||
# 在 VM 上執行
|
||||
cd ~/momo_pro_system
|
||||
|
||||
# 啟動服務
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日誌
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
### 5. 設定自動啟動
|
||||
|
||||
```bash
|
||||
# 建立 systemd service
|
||||
sudo tee /etc/systemd/system/momo-docker.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Momo Pro System Docker
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/home/$USER/momo_pro_system
|
||||
ExecStart=/usr/local/bin/docker-compose up -d
|
||||
ExecStop=/usr/local/bin/docker-compose down
|
||||
TimeoutStartSec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 啟用自動啟動
|
||||
sudo systemctl enable momo-docker
|
||||
sudo systemctl start momo-docker
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案四:使用 Google Kubernetes Engine (GKE)
|
||||
|
||||
適合需要更複雜的擴展和管理需求。
|
||||
|
||||
### 1. 建立 GKE 集群
|
||||
|
||||
```bash
|
||||
gcloud container clusters create momo-cluster \
|
||||
--zone=asia-east1-a \
|
||||
--num-nodes=2 \
|
||||
--machine-type=e2-medium
|
||||
```
|
||||
|
||||
### 2. 建立 Kubernetes 部署配置
|
||||
|
||||
建立 `k8s-deployment.yaml`:
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: momo-app
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: momo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: momo
|
||||
spec:
|
||||
containers:
|
||||
- name: momo-app
|
||||
image: asia-east1-docker.pkg.dev/PROJECT_ID/momo-repo/momo-app
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: momo-service
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
selector:
|
||||
app: momo
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 5000
|
||||
```
|
||||
|
||||
### 3. 部署到 GKE
|
||||
|
||||
```bash
|
||||
# 部署應用
|
||||
kubectl apply -f k8s-deployment.yaml
|
||||
|
||||
# 查看狀態
|
||||
kubectl get pods
|
||||
kubectl get services
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 資料持久化
|
||||
|
||||
### Cloud Run(使用 Cloud SQL)
|
||||
|
||||
```bash
|
||||
# 建立 Cloud SQL 實例
|
||||
gcloud sql instances create momo-db \
|
||||
--database-version=POSTGRES_14 \
|
||||
--tier=db-f1-micro \
|
||||
--region=asia-east1
|
||||
|
||||
# 建立資料庫
|
||||
gcloud sql databases create momo \
|
||||
--instance=momo-db
|
||||
|
||||
# 連接 Cloud Run 到 Cloud SQL
|
||||
gcloud run services update momo-pro-system \
|
||||
--add-cloudsql-instances=$PROJECT_ID:asia-east1:momo-db
|
||||
```
|
||||
|
||||
### VM/GKE(使用 Persistent Disk)
|
||||
|
||||
```bash
|
||||
# 建立持久化磁碟
|
||||
gcloud compute disks create momo-data \
|
||||
--size=50GB \
|
||||
--zone=asia-east1-a
|
||||
|
||||
# 掛載到 VM
|
||||
gcloud compute instances attach-disk momo-server \
|
||||
--disk=momo-data \
|
||||
--zone=asia-east1-a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 備份策略
|
||||
|
||||
### 自動備份腳本
|
||||
|
||||
```bash
|
||||
# 在容器內設定 cron job
|
||||
docker exec momo-pro-system sh -c "echo '0 2 * * * /app/backup.sh' | crontab -"
|
||||
```
|
||||
|
||||
### 備份到 Cloud Storage
|
||||
|
||||
```bash
|
||||
# 建立 Cloud Storage bucket
|
||||
gsutil mb -l asia-east1 gs://momo-backups
|
||||
|
||||
# 備份資料庫
|
||||
docker exec momo-pro-system tar -czf - /app/data | \
|
||||
gsutil cp - gs://momo-backups/backup-$(date +%Y%m%d-%H%M%S).tar.gz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 監控和日誌
|
||||
|
||||
### Cloud Run
|
||||
|
||||
```bash
|
||||
# 查看日誌
|
||||
gcloud logging read "resource.type=cloud_run_revision AND resource.labels.service_name=momo-pro-system" \
|
||||
--limit=50 \
|
||||
--format=json
|
||||
|
||||
# 設定監控警報
|
||||
gcloud alpha monitoring policies create \
|
||||
--notification-channels=CHANNEL_ID \
|
||||
--display-name="Momo High Error Rate" \
|
||||
--condition-display-name="Error rate > 5%" \
|
||||
--condition-threshold-value=5
|
||||
```
|
||||
|
||||
### VM/Docker
|
||||
|
||||
```bash
|
||||
# 查看容器日誌
|
||||
docker-compose logs -f --tail=100
|
||||
|
||||
# 使用 Google Cloud Logging
|
||||
docker plugin install gcplogs --alias gcplogs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 成本估算
|
||||
|
||||
### Cloud Run(最省錢)
|
||||
- 免費額度:每月 200 萬請求
|
||||
- CPU: $0.00002400 / vCPU 秒
|
||||
- 記憶體: $0.00000250 / GiB 秒
|
||||
- **預估:約 $10-30 / 月**
|
||||
|
||||
### Compute Engine (e2-medium)
|
||||
- VM: ~$25 / 月
|
||||
- 儲存: ~$2 / 月
|
||||
- 網路: ~$5 / 月
|
||||
- **預估:約 $32 / 月**
|
||||
|
||||
### GKE
|
||||
- 集群管理費: $73 / 月
|
||||
- 節點: ~$50 / 月
|
||||
- **預估:約 $123 / 月**
|
||||
|
||||
---
|
||||
|
||||
## 推薦方案
|
||||
|
||||
**對於 Momo Pro System,建議使用 Cloud Run:**
|
||||
|
||||
1. ✅ 成本最低
|
||||
2. ✅ 自動擴展
|
||||
3. ✅ 免維護
|
||||
4. ✅ 自動 HTTPS
|
||||
5. ✅ 快速部署
|
||||
|
||||
**快速部署命令:**
|
||||
|
||||
```bash
|
||||
# 一鍵部署
|
||||
gcloud run deploy momo-pro-system \
|
||||
--source . \
|
||||
--region=asia-east1 \
|
||||
--allow-unauthenticated \
|
||||
--port=5000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 容器無法啟動
|
||||
|
||||
```bash
|
||||
# 查看詳細日誌
|
||||
docker-compose logs momo-app
|
||||
|
||||
# 進入容器調試
|
||||
docker exec -it momo-pro-system bash
|
||||
```
|
||||
|
||||
### 資料庫連接問題
|
||||
|
||||
```bash
|
||||
# 檢查環境變數
|
||||
docker exec momo-pro-system env | grep DATABASE
|
||||
|
||||
# 測試資料庫連接
|
||||
docker exec momo-pro-system python -c "import sqlite3; print(sqlite3.connect('/app/data/momo_database.db'))"
|
||||
```
|
||||
|
||||
### Cloud Run 超時
|
||||
|
||||
```bash
|
||||
# 增加超時時間
|
||||
gcloud run services update momo-pro-system \
|
||||
--timeout=900 \
|
||||
--region=asia-east1
|
||||
```
|
||||
275
deploy_docker_uat.sh
Executable file
275
deploy_docker_uat.sh
Executable file
@@ -0,0 +1,275 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# UAT Docker 一鍵部署腳本
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# 顏色定義
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 配置
|
||||
UAT_HOST="192.168.0.110"
|
||||
UAT_USER="wooo"
|
||||
UAT_PATH="/home/wooo/momo_pro_system"
|
||||
LOCAL_PATH="/Users/ogt/momo_pro_system"
|
||||
IMAGE_NAME="wooo/momo-pro-system"
|
||||
CONTAINER_NAME="momo-pro-system"
|
||||
|
||||
# 函數:打印帶顏色的訊息
|
||||
print_header() {
|
||||
echo ""
|
||||
echo -e "${PURPLE}=============================================================================${NC}"
|
||||
echo -e "${PURPLE} $1${NC}"
|
||||
echo -e "${PURPLE}=============================================================================${NC}"
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e "${CYAN}▶ $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
# 檢查 SSH 連線
|
||||
check_ssh() {
|
||||
print_step "檢查 SSH 連線..."
|
||||
if ssh -o ConnectTimeout=5 -o BatchMode=yes ${UAT_USER}@${UAT_HOST} "echo ok" 2>/dev/null; then
|
||||
print_success "SSH 連線正常(免密碼)"
|
||||
return 0
|
||||
else
|
||||
print_warning "需要 SSH 密碼驗證"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主要部署流程
|
||||
deploy() {
|
||||
print_header "WOOO TECH - UAT Docker 部署"
|
||||
echo -e "${CYAN}目標主機: ${UAT_USER}@${UAT_HOST}${NC}"
|
||||
echo -e "${CYAN}部署時間: $(date '+%Y-%m-%d %H:%M:%S')${NC}"
|
||||
echo ""
|
||||
|
||||
# 步驟 1: 同步配置檔案
|
||||
print_header "步驟 1/6: 同步 Docker 配置檔案到 UAT"
|
||||
|
||||
print_step "上傳 Dockerfile..."
|
||||
scp "${LOCAL_PATH}/Dockerfile" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 docker-compose.yml..."
|
||||
scp "${LOCAL_PATH}/docker-compose.yml" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 .dockerignore..."
|
||||
scp "${LOCAL_PATH}/.dockerignore" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 docker/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/docker" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 requirements.txt..."
|
||||
scp "${LOCAL_PATH}/requirements.txt" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳核心 Python 檔案..."
|
||||
scp "${LOCAL_PATH}/app.py" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
scp "${LOCAL_PATH}/auth.py" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
scp "${LOCAL_PATH}/config.py" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
scp "${LOCAL_PATH}/scheduler.py" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 HTML 模板..."
|
||||
scp "${LOCAL_PATH}"/*.html ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/ 2>/dev/null || true
|
||||
|
||||
print_step "上傳 services/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/services" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 database/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/database" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 utils/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/utils" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 static/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/static" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_step "上傳 web/ 目錄..."
|
||||
scp -r "${LOCAL_PATH}/web" ${UAT_USER}@${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_success "檔案同步完成"
|
||||
|
||||
# 步驟 2: 停止現有服務
|
||||
print_header "步驟 2/6: 停止現有服務"
|
||||
|
||||
print_step "停止 systemd momo 服務..."
|
||||
ssh ${UAT_USER}@${UAT_HOST} "sudo systemctl stop momo 2>/dev/null || echo '服務未運行'"
|
||||
|
||||
print_step "停止並刪除舊的 Docker 容器..."
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker stop ${CONTAINER_NAME} 2>/dev/null || true"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker rm ${CONTAINER_NAME} 2>/dev/null || true"
|
||||
|
||||
print_success "服務已停止"
|
||||
|
||||
# 步驟 3: 建置 Docker Image
|
||||
print_header "步驟 3/6: 建置 Docker Image"
|
||||
print_info "這可能需要幾分鐘,請耐心等候..."
|
||||
|
||||
ssh ${UAT_USER}@${UAT_HOST} "cd ${UAT_PATH} && docker build -t ${IMAGE_NAME}:latest ."
|
||||
|
||||
print_success "Docker Image 建置完成"
|
||||
|
||||
# 步驟 4: 啟動 Docker 容器
|
||||
print_header "步驟 4/6: 啟動 Docker 容器"
|
||||
|
||||
ssh ${UAT_USER}@${UAT_HOST} "cd ${UAT_PATH} && docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
-p 5000:5000 \
|
||||
-v ./data:/app/data \
|
||||
-v ./logs:/app/logs \
|
||||
-v ./config:/app/config \
|
||||
-v ./backups:/app/backups \
|
||||
--env-file .env \
|
||||
--restart unless-stopped \
|
||||
${IMAGE_NAME}:latest"
|
||||
|
||||
print_success "Docker 容器已啟動"
|
||||
|
||||
# 步驟 5: 等待服務啟動
|
||||
print_header "步驟 5/6: 等待服務啟動"
|
||||
print_info "等待 30 秒讓服務完全啟動..."
|
||||
|
||||
for i in {1..30}; do
|
||||
echo -ne "\r${CYAN}等待中... ${i}/30 秒${NC}"
|
||||
sleep 1
|
||||
done
|
||||
echo ""
|
||||
|
||||
print_success "等待完成"
|
||||
|
||||
# 步驟 6: 驗證部署
|
||||
print_header "步驟 6/6: 驗證部署"
|
||||
|
||||
print_step "檢查容器狀態..."
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker ps --filter name=${CONTAINER_NAME} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
|
||||
print_step "測試健康檢查端點..."
|
||||
HEALTH_STATUS=$(ssh ${UAT_USER}@${UAT_HOST} "curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/health" || echo "000")
|
||||
|
||||
if [ "$HEALTH_STATUS" = "200" ]; then
|
||||
print_success "健康檢查通過 (HTTP 200)"
|
||||
else
|
||||
print_warning "健康檢查回傳 HTTP ${HEALTH_STATUS}"
|
||||
fi
|
||||
|
||||
print_step "測試首頁..."
|
||||
HOME_STATUS=$(ssh ${UAT_USER}@${UAT_HOST} "curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/" || echo "000")
|
||||
|
||||
if [ "$HOME_STATUS" = "200" ] || [ "$HOME_STATUS" = "302" ]; then
|
||||
print_success "首頁正常 (HTTP ${HOME_STATUS})"
|
||||
else
|
||||
print_warning "首頁回傳 HTTP ${HOME_STATUS}"
|
||||
fi
|
||||
|
||||
# 完成
|
||||
print_header "部署完成!"
|
||||
echo ""
|
||||
echo -e "${GREEN}┌─────────────────────────────────────────────────────────────┐${NC}"
|
||||
echo -e "${GREEN}│ 🎉 UAT Docker 部署成功! │${NC}"
|
||||
echo -e "${GREEN}├─────────────────────────────────────────────────────────────┤${NC}"
|
||||
echo -e "${GREEN}│ 存取網址: http://${UAT_HOST}:5000 │${NC}"
|
||||
echo -e "${GREEN}│ 容器名稱: ${CONTAINER_NAME} │${NC}"
|
||||
echo -e "${GREEN}│ 映像名稱: ${IMAGE_NAME}:latest │${NC}"
|
||||
echo -e "${GREEN}└─────────────────────────────────────────────────────────────┘${NC}"
|
||||
echo ""
|
||||
echo -e "${CYAN}常用指令:${NC}"
|
||||
echo -e " 查看日誌: ssh ${UAT_USER}@${UAT_HOST} 'docker logs -f ${CONTAINER_NAME}'"
|
||||
echo -e " 重啟容器: ssh ${UAT_USER}@${UAT_HOST} 'docker restart ${CONTAINER_NAME}'"
|
||||
echo -e " 停止容器: ssh ${UAT_USER}@${UAT_HOST} 'docker stop ${CONTAINER_NAME}'"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 顯示使用說明
|
||||
usage() {
|
||||
echo "使用方式: $0 [選項]"
|
||||
echo ""
|
||||
echo "選項:"
|
||||
echo " deploy 執行完整部署 (預設)"
|
||||
echo " status 檢查 UAT 容器狀態"
|
||||
echo " logs 查看容器日誌"
|
||||
echo " restart 重啟容器"
|
||||
echo " stop 停止容器"
|
||||
echo " help 顯示此說明"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 檢查狀態
|
||||
check_status() {
|
||||
print_header "UAT Docker 容器狀態"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker ps -a --filter name=${CONTAINER_NAME} --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
|
||||
echo ""
|
||||
print_step "磁碟空間:"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "df -h | head -3"
|
||||
}
|
||||
|
||||
# 查看日誌
|
||||
view_logs() {
|
||||
print_header "UAT Docker 容器日誌 (最近 100 行)"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker logs --tail 100 ${CONTAINER_NAME}"
|
||||
}
|
||||
|
||||
# 重啟容器
|
||||
restart_container() {
|
||||
print_header "重啟 UAT Docker 容器"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker restart ${CONTAINER_NAME}"
|
||||
print_success "容器已重啟"
|
||||
}
|
||||
|
||||
# 停止容器
|
||||
stop_container() {
|
||||
print_header "停止 UAT Docker 容器"
|
||||
ssh ${UAT_USER}@${UAT_HOST} "docker stop ${CONTAINER_NAME}"
|
||||
print_success "容器已停止"
|
||||
}
|
||||
|
||||
# 主程式
|
||||
case "${1:-deploy}" in
|
||||
deploy)
|
||||
deploy
|
||||
;;
|
||||
status)
|
||||
check_status
|
||||
;;
|
||||
logs)
|
||||
view_logs
|
||||
;;
|
||||
restart)
|
||||
restart_container
|
||||
;;
|
||||
stop)
|
||||
stop_container
|
||||
;;
|
||||
help|--help|-h)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
print_error "未知選項: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
53
deploy_scripts/backup.sh
Normal file
53
deploy_scripts/backup.sh
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# backup.sh - 備份資料庫和重要檔案
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="$HOME/backups"
|
||||
APP_DIR="$HOME/momo_pro_system"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
echo "=========================================="
|
||||
echo "Momo Pro System - 備份"
|
||||
echo "=========================================="
|
||||
|
||||
# 建立備份目錄
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 備份資料庫
|
||||
echo "💾 備份資料庫..."
|
||||
if [ -f "$APP_DIR/data/momo_database.db" ]; then
|
||||
cp "$APP_DIR/data/momo_database.db" "$BACKUP_DIR/momo_db_$DATE.db"
|
||||
echo "✅ 資料庫已備份到: $BACKUP_DIR/momo_db_$DATE.db"
|
||||
else
|
||||
echo "⚠️ 找不到資料庫檔案"
|
||||
fi
|
||||
|
||||
# 備份 .env 檔案
|
||||
echo "🔐 備份環境變數..."
|
||||
if [ -f "$APP_DIR/.env" ]; then
|
||||
cp "$APP_DIR/.env" "$BACKUP_DIR/env_$DATE.backup"
|
||||
echo "✅ .env 已備份"
|
||||
fi
|
||||
|
||||
# 備份日誌(最近 7 天)
|
||||
echo "📋 備份日誌..."
|
||||
if [ -d "$APP_DIR/logs" ]; then
|
||||
tar -czf "$BACKUP_DIR/logs_$DATE.tar.gz" -C "$APP_DIR" logs/
|
||||
echo "✅ 日誌已備份"
|
||||
fi
|
||||
|
||||
# 清理舊備份(保留最近 7 天)
|
||||
echo "🧹 清理舊備份..."
|
||||
find "$BACKUP_DIR" -name "momo_db_*.db" -mtime +7 -delete
|
||||
find "$BACKUP_DIR" -name "env_*.backup" -mtime +7 -delete
|
||||
find "$BACKUP_DIR" -name "logs_*.tar.gz" -mtime +7 -delete
|
||||
|
||||
# 顯示備份大小
|
||||
echo ""
|
||||
echo "📊 備份統計:"
|
||||
du -sh "$BACKUP_DIR"
|
||||
|
||||
echo ""
|
||||
echo "✅ 備份完成!"
|
||||
echo "備份位置: $BACKUP_DIR"
|
||||
108
deploy_scripts/deploy_cloudrun.sh
Executable file
108
deploy_scripts/deploy_cloudrun.sh
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/bin/bash
|
||||
# deploy_cloudrun.sh - 一鍵部署到 Google Cloud Run
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "部署 Momo Pro System 到 Cloud Run"
|
||||
echo "=========================================="
|
||||
|
||||
# 檢查是否已登入 gcloud
|
||||
if ! gcloud auth list --filter=status:ACTIVE --format="value(account)" | grep -q .; then
|
||||
echo "❌ 請先登入 gcloud:"
|
||||
echo " gcloud auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 取得或設定專案 ID
|
||||
PROJECT_ID=$(gcloud config get-value project 2>/dev/null)
|
||||
if [ -z "$PROJECT_ID" ]; then
|
||||
read -p "請輸入 GCP 專案 ID: " PROJECT_ID
|
||||
gcloud config set project $PROJECT_ID
|
||||
fi
|
||||
|
||||
echo "📋 使用專案: $PROJECT_ID"
|
||||
|
||||
# 設定區域
|
||||
REGION="asia-east1"
|
||||
SERVICE_NAME="momo-pro-system"
|
||||
|
||||
# 詢問是否需要設定環境變數
|
||||
echo ""
|
||||
read -p "是否需要設定環境變數?(y/n): " SETUP_ENV
|
||||
if [ "$SETUP_ENV" = "y" ]; then
|
||||
echo ""
|
||||
echo "請輸入環境變數(留空跳過):"
|
||||
read -p "EMAIL_USER: " EMAIL_USER
|
||||
read -sp "EMAIL_PASSWORD: " EMAIL_PASSWORD
|
||||
echo ""
|
||||
read -p "SMTP_SERVER (預設: smtp.gmail.com): " SMTP_SERVER
|
||||
SMTP_SERVER=${SMTP_SERVER:-smtp.gmail.com}
|
||||
read -p "SMTP_PORT (預設: 587): " SMTP_PORT
|
||||
SMTP_PORT=${SMTP_PORT:-587}
|
||||
fi
|
||||
|
||||
# 啟用必要的 API
|
||||
echo ""
|
||||
echo "📡 啟用必要的 GCP API..."
|
||||
gcloud services enable \
|
||||
run.googleapis.com \
|
||||
cloudbuild.googleapis.com \
|
||||
artifactregistry.googleapis.com
|
||||
|
||||
# 部署到 Cloud Run(直接從原始碼建立)
|
||||
echo ""
|
||||
echo "🚀 部署到 Cloud Run..."
|
||||
echo " 這可能需要幾分鐘時間..."
|
||||
|
||||
DEPLOY_CMD="gcloud run deploy $SERVICE_NAME \
|
||||
--source . \
|
||||
--region=$REGION \
|
||||
--allow-unauthenticated \
|
||||
--port=5000 \
|
||||
--memory=2Gi \
|
||||
--cpu=2 \
|
||||
--min-instances=0 \
|
||||
--max-instances=10 \
|
||||
--timeout=300"
|
||||
|
||||
# 添加環境變數
|
||||
if [ "$SETUP_ENV" = "y" ] && [ ! -z "$EMAIL_USER" ]; then
|
||||
DEPLOY_CMD="$DEPLOY_CMD --set-env-vars=EMAIL_USER=$EMAIL_USER,SMTP_SERVER=$SMTP_SERVER,SMTP_PORT=$SMTP_PORT"
|
||||
|
||||
# 處理密碼(使用 Secret Manager)
|
||||
if [ ! -z "$EMAIL_PASSWORD" ]; then
|
||||
echo "🔐 設定 Email 密碼到 Secret Manager..."
|
||||
echo -n "$EMAIL_PASSWORD" | gcloud secrets create email-password --data-file=- || \
|
||||
echo -n "$EMAIL_PASSWORD" | gcloud secrets versions add email-password --data-file=-
|
||||
|
||||
DEPLOY_CMD="$DEPLOY_CMD --set-secrets=EMAIL_PASSWORD=email-password:latest"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 執行部署
|
||||
eval $DEPLOY_CMD
|
||||
|
||||
# 取得服務 URL
|
||||
SERVICE_URL=$(gcloud run services describe $SERVICE_NAME \
|
||||
--region=$REGION \
|
||||
--format='value(status.url)')
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 部署成功!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "🌐 服務 URL: $SERVICE_URL"
|
||||
echo ""
|
||||
echo "📊 查看日誌:"
|
||||
echo " gcloud logging read \"resource.type=cloud_run_revision AND resource.labels.service_name=$SERVICE_NAME\" --limit=50"
|
||||
echo ""
|
||||
echo "🔄 更新服務:"
|
||||
echo " ./deploy_scripts/deploy_cloudrun.sh"
|
||||
echo ""
|
||||
echo "❌ 刪除服務:"
|
||||
echo " gcloud run services delete $SERVICE_NAME --region=$REGION"
|
||||
echo ""
|
||||
echo "💰 查看費用:"
|
||||
echo " https://console.cloud.google.com/billing"
|
||||
echo ""
|
||||
87
deploy_scripts/setup_autostart.sh
Executable file
87
deploy_scripts/setup_autostart.sh
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# UAT VM 開機自動啟動 Docker 服務配置腳本
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "配置 Docker 開機自動啟動"
|
||||
echo "=========================================="
|
||||
|
||||
# 檢查是否為 root 或有 sudo 權限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "請使用 sudo 執行此腳本"
|
||||
echo "用法: sudo bash setup_autostart.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 確保 Docker 服務開機自動啟動
|
||||
echo ""
|
||||
echo "[1/4] 啟用 Docker 服務開機自動啟動..."
|
||||
systemctl enable docker
|
||||
systemctl is-enabled docker
|
||||
|
||||
# 2. 確保 Docker 服務正在運行
|
||||
echo ""
|
||||
echo "[2/4] 確認 Docker 服務狀態..."
|
||||
systemctl start docker
|
||||
systemctl status docker --no-pager | head -5
|
||||
|
||||
# 3. 創建 Momo Pro System 的 systemd 服務
|
||||
echo ""
|
||||
echo "[3/4] 創建 Momo Pro System systemd 服務..."
|
||||
|
||||
# 獲取實際的安裝路徑
|
||||
INSTALL_PATH="${INSTALL_PATH:-/home/wooo/momo_pro_system}"
|
||||
|
||||
cat > /etc/systemd/system/momo-docker.service << EOF
|
||||
[Unit]
|
||||
Description=Momo Pro System Docker Compose
|
||||
Documentation=https://github.com/wooo-tech/momo-pro-system
|
||||
Requires=docker.service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${INSTALL_PATH}
|
||||
ExecStart=/usr/bin/docker compose --profile monitoring up -d
|
||||
ExecStop=/usr/bin/docker compose --profile monitoring down
|
||||
ExecReload=/usr/bin/docker compose --profile monitoring restart
|
||||
TimeoutStartSec=300
|
||||
TimeoutStopSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
echo "服務檔案已創建: /etc/systemd/system/momo-docker.service"
|
||||
|
||||
# 4. 啟用並啟動服務
|
||||
echo ""
|
||||
echo "[4/4] 啟用 Momo Pro System 服務..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable momo-docker.service
|
||||
systemctl is-enabled momo-docker.service
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 配置完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "服務管理命令:"
|
||||
echo " 啟動服務: sudo systemctl start momo-docker"
|
||||
echo " 停止服務: sudo systemctl stop momo-docker"
|
||||
echo " 重啟服務: sudo systemctl restart momo-docker"
|
||||
echo " 查看狀態: sudo systemctl status momo-docker"
|
||||
echo " 查看日誌: sudo journalctl -u momo-docker -f"
|
||||
echo ""
|
||||
echo "驗證自動啟動配置:"
|
||||
echo " sudo systemctl is-enabled momo-docker"
|
||||
echo ""
|
||||
echo "如需修改安裝路徑,請設置環境變數:"
|
||||
echo " sudo INSTALL_PATH=/your/path bash setup_autostart.sh"
|
||||
echo ""
|
||||
73
deploy_scripts/setup_nginx.sh
Normal file
73
deploy_scripts/setup_nginx.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# setup_nginx.sh - 設定 Nginx 反向代理
|
||||
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "設定 Nginx 反向代理"
|
||||
echo "=========================================="
|
||||
|
||||
# 詢問域名或 IP
|
||||
read -p "請輸入您的域名或 IP 地址 (例如: example.com 或 34.80.1.1): " DOMAIN
|
||||
|
||||
echo "📝 建立 Nginx 設定檔..."
|
||||
sudo tee /etc/nginx/sites-available/momo > /dev/null <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
|
||||
# 上傳大小限制
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
|
||||
# WebSocket 支援(如果需要)
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超時設定
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 靜態檔案快取
|
||||
location /static/ {
|
||||
alias /home/$USER/momo_pro_system/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 建立軟連結
|
||||
echo "🔗 啟用站點..."
|
||||
sudo ln -sf /etc/nginx/sites-available/momo /etc/nginx/sites-enabled/
|
||||
|
||||
# 移除預設站點
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# 測試設定
|
||||
echo "🧪 測試 Nginx 設定..."
|
||||
sudo nginx -t
|
||||
|
||||
# 重啟 Nginx
|
||||
echo "🔄 重啟 Nginx..."
|
||||
sudo systemctl restart nginx
|
||||
sudo systemctl enable nginx
|
||||
|
||||
echo ""
|
||||
echo "✅ Nginx 設定完成!"
|
||||
echo ""
|
||||
echo "您的應用現在可以通過以下網址訪問:"
|
||||
echo " http://$DOMAIN"
|
||||
echo ""
|
||||
echo "如需設定 HTTPS (SSL),請執行:"
|
||||
echo " sudo apt install certbot python3-certbot-nginx"
|
||||
echo " sudo certbot --nginx -d $DOMAIN"
|
||||
73
deploy_scripts/setup_service.sh
Normal file
73
deploy_scripts/setup_service.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# setup_service.sh - 設定 systemd 服務(需要 sudo 權限)
|
||||
|
||||
set -e
|
||||
|
||||
USER_NAME=$(whoami)
|
||||
APP_DIR="$HOME/momo_pro_system"
|
||||
|
||||
echo "=========================================="
|
||||
echo "設定 systemd 服務"
|
||||
echo "=========================================="
|
||||
|
||||
# 建立主服務檔案
|
||||
echo "📝 建立 momo.service..."
|
||||
sudo tee /etc/systemd/system/momo.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Momo Pro System - Flask Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER_NAME
|
||||
WorkingDirectory=$APP_DIR
|
||||
Environment="PATH=$APP_DIR/venv/bin"
|
||||
ExecStart=$APP_DIR/venv/bin/python app.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 建立排程服務檔案
|
||||
echo "📝 建立 momo-scheduler.service..."
|
||||
sudo tee /etc/systemd/system/momo-scheduler.service > /dev/null <<EOF
|
||||
[Unit]
|
||||
Description=Momo Pro System - Scheduler
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$USER_NAME
|
||||
WorkingDirectory=$APP_DIR
|
||||
Environment="PATH=$APP_DIR/venv/bin"
|
||||
ExecStart=$APP_DIR/venv/bin/python scheduler.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 重新載入 systemd
|
||||
echo "🔄 重新載入 systemd..."
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# 啟用服務
|
||||
echo "✅ 啟用服務..."
|
||||
sudo systemctl enable momo
|
||||
sudo systemctl enable momo-scheduler
|
||||
|
||||
echo ""
|
||||
echo "✅ 服務設定完成!"
|
||||
echo ""
|
||||
echo "可以使用以下指令:"
|
||||
echo " 啟動服務: sudo systemctl start momo"
|
||||
echo " 查看狀態: sudo systemctl status momo"
|
||||
echo " 查看日誌: sudo journalctl -u momo -f"
|
||||
echo " 重啟服務: sudo systemctl restart momo"
|
||||
59
deploy_scripts/setup_vm.sh
Normal file
59
deploy_scripts/setup_vm.sh
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# setup_vm.sh - 在 GCP VM 上執行此腳本來設定環境
|
||||
|
||||
set -e # 發生錯誤時停止
|
||||
|
||||
echo "=========================================="
|
||||
echo "Momo Pro System - VM 環境設定"
|
||||
echo "=========================================="
|
||||
|
||||
# 更新系統
|
||||
echo "📦 更新系統套件..."
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
|
||||
# 安裝必要工具
|
||||
echo "🔧 安裝必要工具..."
|
||||
sudo apt install -y \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
git \
|
||||
nginx \
|
||||
chromium-browser \
|
||||
chromium-chromedriver \
|
||||
curl \
|
||||
wget
|
||||
|
||||
# 進入應用目錄
|
||||
cd ~/momo_pro_system
|
||||
|
||||
# 建立虛擬環境
|
||||
echo "🐍 建立 Python 虛擬環境..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 安裝 Python 依賴
|
||||
echo "📚 安裝 Python 套件..."
|
||||
pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 初始化資料庫
|
||||
echo "💾 初始化資料庫..."
|
||||
python init_db.py
|
||||
|
||||
# 建立必要目錄
|
||||
echo "📁 建立必要目錄..."
|
||||
mkdir -p logs
|
||||
mkdir -p data
|
||||
mkdir -p backups
|
||||
|
||||
# 設定權限
|
||||
chmod +x start.sh
|
||||
|
||||
echo ""
|
||||
echo "✅ 環境設定完成!"
|
||||
echo ""
|
||||
echo "接下來請執行:"
|
||||
echo "1. 編輯 .env 檔案: nano .env"
|
||||
echo "2. 設定 systemd 服務: sudo ./deploy_scripts/setup_service.sh"
|
||||
echo "3. 啟動服務: sudo systemctl start momo"
|
||||
99
deploy_scripts/test_docker_local.sh
Executable file
99
deploy_scripts/test_docker_local.sh
Executable file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
# test_docker_local.sh - 本機測試 Docker 部署
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "本機 Docker 測試"
|
||||
echo "=========================================="
|
||||
|
||||
# 檢查 Docker 是否安裝
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "❌ Docker 未安裝"
|
||||
echo " 請安裝 Docker Desktop: https://www.docker.com/products/docker-desktop"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 檢查 Docker 是否運行
|
||||
if ! docker info &> /dev/null; then
|
||||
echo "❌ Docker 未運行"
|
||||
echo " 請啟動 Docker Desktop"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 檢查 .env 檔案
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "⚠️ 找不到 .env 檔案"
|
||||
read -p "是否建立 .env 檔案?(y/n): " CREATE_ENV
|
||||
if [ "$CREATE_ENV" = "y" ]; then
|
||||
cat > .env <<EOF
|
||||
# Flask 設定
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Email 設定
|
||||
EMAIL_USER=your-email@gmail.com
|
||||
EMAIL_PASSWORD=your-app-password
|
||||
SMTP_SERVER=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
|
||||
# 資料庫
|
||||
DATABASE_PATH=data/momo_database.db
|
||||
EOF
|
||||
echo "✅ 已建立 .env 檔案,請編輯後再次執行此腳本"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 停止現有容器
|
||||
echo ""
|
||||
echo "🛑 停止現有容器..."
|
||||
docker-compose down 2>/dev/null || true
|
||||
|
||||
# 建立並啟動容器
|
||||
echo ""
|
||||
echo "🔨 建立 Docker 映像..."
|
||||
docker-compose build
|
||||
|
||||
echo ""
|
||||
echo "🚀 啟動容器..."
|
||||
docker-compose up -d
|
||||
|
||||
# 等待服務啟動
|
||||
echo ""
|
||||
echo "⏳ 等待服務啟動..."
|
||||
sleep 5
|
||||
|
||||
# 檢查容器狀態
|
||||
echo ""
|
||||
echo "📊 容器狀態:"
|
||||
docker-compose ps
|
||||
|
||||
# 測試健康檢查
|
||||
echo ""
|
||||
echo "🏥 測試健康檢查..."
|
||||
if curl -f http://localhost:80/health &>/dev/null; then
|
||||
echo "✅ 健康檢查通過"
|
||||
else
|
||||
echo "⚠️ 健康檢查失敗,查看日誌:"
|
||||
docker-compose logs --tail=20
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ Docker 容器已啟動!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "🌐 訪問應用: http://localhost"
|
||||
echo ""
|
||||
echo "📋 查看日誌:"
|
||||
echo " docker-compose logs -f"
|
||||
echo ""
|
||||
echo "🔍 進入容器:"
|
||||
echo " docker exec -it momo-pro-system bash"
|
||||
echo ""
|
||||
echo "🛑 停止容器:"
|
||||
echo " docker-compose down"
|
||||
echo ""
|
||||
echo "🗑️ 刪除所有數據:"
|
||||
echo " docker-compose down -v"
|
||||
echo ""
|
||||
304
deploy_to_uat.sh
Executable file
304
deploy_to_uat.sh
Executable file
@@ -0,0 +1,304 @@
|
||||
#!/bin/bash
|
||||
# ============================================================
|
||||
# MOMO Pro System - UAT 一鍵部署腳本
|
||||
# ============================================================
|
||||
# 用法: ./deploy_to_uat.sh [選項]
|
||||
# -f, --full 完整同步 (包含所有檔案)
|
||||
# -q, --quick 快速更新 (僅 Python/HTML)
|
||||
# -r, --restart 僅重啟容器
|
||||
# -h, --help 顯示說明
|
||||
#
|
||||
# 預設行為: 快速更新 + 重啟容器
|
||||
# ============================================================
|
||||
|
||||
set -e # 發生錯誤時停止執行
|
||||
|
||||
# === 配置 ===
|
||||
UAT_HOST="wooo@192.168.0.110"
|
||||
UAT_PATH="/home/wooo/momo_pro_system"
|
||||
LOCAL_PATH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
CONTAINER_NAME="momo-pro-system"
|
||||
|
||||
# === 顏色輸出 ===
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# === 函數定義 ===
|
||||
|
||||
print_header() {
|
||||
echo ""
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE} MOMO Pro System - UAT 部署工具${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e " 目標主機: ${GREEN}${UAT_HOST}${NC}"
|
||||
echo -e " 目標路徑: ${GREEN}${UAT_PATH}${NC}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_step() {
|
||||
echo -e "${YELLOW}[步驟]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[成功]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[錯誤]${NC} $1"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "用法: $0 [選項]"
|
||||
echo ""
|
||||
echo "選項:"
|
||||
echo " -f, --full 完整同步 (rsync 整個專案,排除 data/logs/venv)"
|
||||
echo " -q, --quick 快速更新 (僅同步 Python 和 HTML 檔案)"
|
||||
echo " -r, --restart 僅重啟容器 (不同步檔案)"
|
||||
echo " -m, --monitor 啟動監控服務 (Grafana/Prometheus/Loki)"
|
||||
echo " -v, --verify 驗證部署狀態"
|
||||
echo " -h, --help 顯示此說明"
|
||||
echo ""
|
||||
echo "範例:"
|
||||
echo " $0 # 預設: 快速更新 + 重啟"
|
||||
echo " $0 -f # 完整同步 + 重啟"
|
||||
echo " $0 -r # 僅重啟容器"
|
||||
echo " $0 -v # 驗證部署狀態"
|
||||
}
|
||||
|
||||
# 檢查 SSH 連線
|
||||
check_ssh() {
|
||||
print_step "檢查 SSH 連線..."
|
||||
if ssh -o ConnectTimeout=5 ${UAT_HOST} "echo 'SSH OK'" > /dev/null 2>&1; then
|
||||
print_success "SSH 連線正常"
|
||||
return 0
|
||||
else
|
||||
print_error "無法連線到 ${UAT_HOST}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 快速更新 (僅 Python/HTML)
|
||||
quick_sync() {
|
||||
print_step "快速同步 Python 和 HTML 檔案..."
|
||||
|
||||
# 同步 routes 目錄
|
||||
echo " - 同步 routes/*.py"
|
||||
scp -q ${LOCAL_PATH}/routes/*.py ${UAT_HOST}:${UAT_PATH}/routes/
|
||||
|
||||
# 同步根目錄的 Python 檔案
|
||||
echo " - 同步根目錄 Python 檔案"
|
||||
scp -q ${LOCAL_PATH}/app.py ${UAT_HOST}:${UAT_PATH}/
|
||||
scp -q ${LOCAL_PATH}/auth.py ${UAT_HOST}:${UAT_PATH}/
|
||||
scp -q ${LOCAL_PATH}/config.py ${UAT_HOST}:${UAT_PATH}/
|
||||
scp -q ${LOCAL_PATH}/auto_import_routes.py ${UAT_HOST}:${UAT_PATH}/ 2>/dev/null || true
|
||||
scp -q ${LOCAL_PATH}/vendor_routes.py ${UAT_HOST}:${UAT_PATH}/ 2>/dev/null || true
|
||||
scp -q ${LOCAL_PATH}/crawler_management_routes.py ${UAT_HOST}:${UAT_PATH}/ 2>/dev/null || true
|
||||
|
||||
# 同步 services 目錄
|
||||
echo " - 同步 services/*.py"
|
||||
scp -q ${LOCAL_PATH}/services/*.py ${UAT_HOST}:${UAT_PATH}/services/
|
||||
|
||||
# 同步 database 目錄
|
||||
echo " - 同步 database/*.py"
|
||||
scp -q ${LOCAL_PATH}/database/*.py ${UAT_HOST}:${UAT_PATH}/database/
|
||||
|
||||
# 同步 templates 目錄
|
||||
echo " - 同步 templates/"
|
||||
rsync -az --delete -e ssh \
|
||||
${LOCAL_PATH}/templates/ \
|
||||
${UAT_HOST}:${UAT_PATH}/templates/
|
||||
|
||||
# 同步根目錄的 HTML 檔案
|
||||
echo " - 同步根目錄 HTML 檔案"
|
||||
scp -q ${LOCAL_PATH}/*.html ${UAT_HOST}:${UAT_PATH}/ 2>/dev/null || true
|
||||
|
||||
print_success "快速同步完成"
|
||||
}
|
||||
|
||||
# 完整同步 (rsync)
|
||||
full_sync() {
|
||||
print_step "完整同步專案檔案..."
|
||||
|
||||
rsync -avz --progress -e ssh \
|
||||
--exclude='venv' \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='.git' \
|
||||
--exclude='backups' \
|
||||
--exclude='logs/*.log' \
|
||||
--exclude='data/*.db' \
|
||||
--exclude='data/*.db-*' \
|
||||
--exclude='.env' \
|
||||
--exclude='*.tar.gz' \
|
||||
${LOCAL_PATH}/ \
|
||||
${UAT_HOST}:${UAT_PATH}/
|
||||
|
||||
print_success "完整同步完成"
|
||||
}
|
||||
|
||||
# 重啟容器
|
||||
restart_container() {
|
||||
print_step "重啟 Docker 容器 (${CONTAINER_NAME})..."
|
||||
|
||||
ssh ${UAT_HOST} "docker restart ${CONTAINER_NAME}"
|
||||
|
||||
# 等待容器啟動
|
||||
echo " - 等待容器啟動..."
|
||||
sleep 5
|
||||
|
||||
# 檢查容器狀態
|
||||
local status=$(ssh ${UAT_HOST} "docker ps --filter name=${CONTAINER_NAME} --format '{{.Status}}' | head -1")
|
||||
|
||||
if [[ $status == *"healthy"* ]] || [[ $status == *"Up"* ]]; then
|
||||
print_success "容器已啟動: ${status}"
|
||||
else
|
||||
print_error "容器狀態異常: ${status}"
|
||||
echo " 查看日誌: ssh ${UAT_HOST} 'docker logs ${CONTAINER_NAME} --tail 50'"
|
||||
fi
|
||||
}
|
||||
|
||||
# 重建容器 (當 docker-compose.yml 有變更時)
|
||||
recreate_container() {
|
||||
print_step "重建 Docker 容器..."
|
||||
|
||||
ssh ${UAT_HOST} "cd ${UAT_PATH} && \
|
||||
docker rm -f ${CONTAINER_NAME} 2>/dev/null || true && \
|
||||
docker-compose up -d momo-app"
|
||||
|
||||
# 等待容器啟動
|
||||
echo " - 等待容器啟動..."
|
||||
sleep 8
|
||||
|
||||
local status=$(ssh ${UAT_HOST} "docker ps --filter name=${CONTAINER_NAME} --format '{{.Status}}' | head -1")
|
||||
print_success "容器已重建: ${status}"
|
||||
}
|
||||
|
||||
# 啟動監控服務
|
||||
start_monitoring() {
|
||||
print_step "啟動監控服務..."
|
||||
|
||||
ssh ${UAT_HOST} "cd ${UAT_PATH} && docker-compose --profile monitoring up -d"
|
||||
|
||||
print_success "監控服務已啟動"
|
||||
echo " - Grafana: http://192.168.0.110:3000"
|
||||
echo " - Prometheus: http://192.168.0.110:9090"
|
||||
}
|
||||
|
||||
# 驗證部署
|
||||
verify_deployment() {
|
||||
print_step "驗證部署狀態..."
|
||||
|
||||
echo ""
|
||||
echo " [容器狀態]"
|
||||
ssh ${UAT_HOST} "docker ps --filter name=momo --format 'table {{.Names}}\t{{.Status}}' | head -10"
|
||||
|
||||
echo ""
|
||||
echo " [健康檢查]"
|
||||
local health=$(curl -s -o /dev/null -w "%{http_code}" "https://mo.wooo.work/health" 2>/dev/null || echo "000")
|
||||
if [ "$health" = "200" ]; then
|
||||
echo -e " /health: ${GREEN}200 OK${NC}"
|
||||
else
|
||||
echo -e " /health: ${RED}${health}${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " [登入保護驗證] (應為 302)"
|
||||
for path in "/" "/auto_import" "/daily_sales" "/sales_analysis"; do
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" "https://mo.wooo.work${path}" 2>/dev/null || echo "000")
|
||||
if [ "$code" = "302" ]; then
|
||||
echo -e " ${path}: ${GREEN}${code}${NC}"
|
||||
else
|
||||
echo -e " ${path}: ${RED}${code}${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " [公開端點] (應為 200)"
|
||||
for path in "/health" "/metrics"; do
|
||||
local code=$(curl -s -o /dev/null -w "%{http_code}" "https://mo.wooo.work${path}" 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ]; then
|
||||
echo -e " ${path}: ${GREEN}${code}${NC}"
|
||||
else
|
||||
echo -e " ${path}: ${RED}${code}${NC}"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
print_success "驗證完成"
|
||||
}
|
||||
|
||||
# === 主程式 ===
|
||||
|
||||
print_header
|
||||
|
||||
# 解析參數
|
||||
MODE="quick" # 預設模式
|
||||
RESTART=true
|
||||
VERIFY=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-f|--full)
|
||||
MODE="full"
|
||||
shift
|
||||
;;
|
||||
-q|--quick)
|
||||
MODE="quick"
|
||||
shift
|
||||
;;
|
||||
-r|--restart)
|
||||
MODE="restart_only"
|
||||
shift
|
||||
;;
|
||||
-m|--monitor)
|
||||
MODE="monitor"
|
||||
shift
|
||||
;;
|
||||
-v|--verify)
|
||||
VERIFY=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知選項: $1"
|
||||
show_help
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 執行
|
||||
check_ssh
|
||||
|
||||
case $MODE in
|
||||
"quick")
|
||||
quick_sync
|
||||
restart_container
|
||||
;;
|
||||
"full")
|
||||
full_sync
|
||||
recreate_container
|
||||
;;
|
||||
"restart_only")
|
||||
restart_container
|
||||
;;
|
||||
"monitor")
|
||||
start_monitoring
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$VERIFY" = true ] || [ "$MODE" != "monitor" ]; then
|
||||
verify_deployment
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} 部署完成!${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo ""
|
||||
48
design_assets_recommendation.md
Normal file
48
design_assets_recommendation.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 網站品牌資產建議與設計 (WOOO TECH 改版)
|
||||
|
||||
根據您的新需求,我們將品牌重塑為 **「WOOO TECH」**。設計核心融合了 **科技 (Tech)、家庭 (Family)、AI (Artificial Intelligence)** 的理念,並保持與現有網站風格的協調性。
|
||||
|
||||
## 1. Logo 重新設計 (雲端 & AI 核心)
|
||||
根據您的反饋,我們將調整 Logo 的設計方向:
|
||||
* **核心元素**: 強化 **「雲端 (Cloud)」** 與 **「AI 智慧」** 的視覺連結。
|
||||
* **設計哲學**:
|
||||
* **雲端**: 使用輕盈、圓潤的流動線條或雲朵意象與 "WOOO" 字樣結合。
|
||||
* **AI**: 保留節點或光連結效果。
|
||||
* **字標**: 不再強調 "TECH",主打 **"WOOO"** 的品牌符號感。
|
||||
- [ ] 重新生成主 Logo (Focus on Cloud & AI)
|
||||
- [ ] 重新生成 Favicon
|
||||
|
||||
|
||||
## 2. 版權宣告 (Copyright)
|
||||
更新後的頁尾版權文字:
|
||||
|
||||
**中文版:**
|
||||
```text
|
||||
© 2026 WOOO TECH. 版權所有。
|
||||
以 AI 科技守護家庭與事業,提供最精準的決策支持。
|
||||
```
|
||||
|
||||
**英文版:**
|
||||
```text
|
||||
© 2026 WOOO TECH. All Rights Reserved.
|
||||
Empowering Families & Business with AI-Driven Technology.
|
||||
```
|
||||
|
||||
## 3. 建議網站必備圖片清單
|
||||
除了 Logo,一個完整的專業系統通常需要以下圖片資產來提升用戶體驗:
|
||||
|
||||
| 圖片類型 | 用途與建議風格 | 檔案名稱建議 |
|
||||
| :--- | :--- | :--- |
|
||||
| **登入頁背景圖** | 用於登入頁面背景。建議使用科技感、數據流動或簡約的抽象幾何圖形,不僅美觀也能強調系統屬性。 | `login_bg.jpg` |
|
||||
| **Open Graph (OG) 圖片** | 當連結分享到 Line, FB, Telegram 時顯示的預覽圖。應包含 Logo 與系統標語,提升分享時的專業感。 | `og_preview.jpg` |
|
||||
| **無數據插圖 (Empty State)** | 當報表或搜尋無結果時顯示。比起純文字,一張可愛或簡約的插圖 (如空箱子、放大鏡) 能降低用戶的挫折感。 | `no_data.svg` / `.png` |
|
||||
| **404 找不到頁面插圖** | 當用戶迷路時顯示。可以使用帶有幽默感的插圖 (如迷路的機器人),引導用戶回首頁。 | `404_error.svg` / `.png` |
|
||||
| **500 系統錯誤插圖** | 當系統發生內部錯誤時顯示。安撫用戶情緒,並提供回報管道。 | `500_error.svg` / `.png` |
|
||||
| **Loading 讀取動畫** | 數據加載時顯示。建議使用品牌色 (#4F46E5) 的動態圖示,避免用戶以為當機。 | `loading_spinner.svg` |
|
||||
|
||||
---
|
||||
|
||||
### 下一步建議
|
||||
如果您滿意以上的 Logo 設計,我可以協助:
|
||||
1. 將生成的 Logo 實際應用到網站的 `sales_analysis.html` 與其他頁面。
|
||||
2. 為您生成上述建議清單中的其他圖片 (如登入背景或無數據插圖)。
|
||||
22
disable_maintenance.sh
Normal file
22
disable_maintenance.sh
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# 禁用維護模式腳本
|
||||
|
||||
echo "=========================================="
|
||||
echo "禁用維護模式"
|
||||
echo "=========================================="
|
||||
|
||||
# 恢復正常 Nginx 配置
|
||||
echo "1. 恢復正常 Nginx 配置..."
|
||||
sudo ln -sf /etc/nginx/sites-available/momo /etc/nginx/sites-enabled/momo
|
||||
|
||||
# 測試並重載 Nginx
|
||||
echo "2. 重載 Nginx..."
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "✅ 維護模式已禁用"
|
||||
echo "=========================================="
|
||||
echo "網址:https://mo.wooo.work"
|
||||
echo "網站已恢復正常服務"
|
||||
echo "=========================================="
|
||||
84
docker-compose.devops.yml
Normal file
84
docker-compose.devops.yml
Normal file
@@ -0,0 +1,84 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - DevOps Services (GitLab + Runner)
|
||||
# =============================================================================
|
||||
# 部署位置: UAT Server (192.168.0.110)
|
||||
# 用途: 自建 Git 伺服器 + CI/CD 平台
|
||||
# =============================================================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitLab CE - Git 伺服器 + CI/CD
|
||||
# ---------------------------------------------------------------------------
|
||||
gitlab:
|
||||
image: gitlab/gitlab-ce:16.8.1-ce.0
|
||||
container_name: wooo-gitlab
|
||||
hostname: gitlab.wooo.local
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GITLAB_OMNIBUS_CONFIG: |
|
||||
# 外部 URL
|
||||
external_url 'http://192.168.0.110:8929'
|
||||
gitlab_rails['gitlab_shell_ssh_port'] = 2224
|
||||
|
||||
# 降低資源使用
|
||||
puma['workers'] = 2
|
||||
puma['min_threads'] = 1
|
||||
puma['max_threads'] = 4
|
||||
|
||||
sidekiq['max_concurrency'] = 10
|
||||
sidekiq['min_concurrency'] = 1
|
||||
|
||||
postgresql['shared_buffers'] = "256MB"
|
||||
postgresql['max_worker_processes'] = 4
|
||||
|
||||
prometheus_monitoring['enable'] = false
|
||||
grafana['enable'] = false
|
||||
|
||||
# 停用不需要的服務
|
||||
gitlab_kas['enable'] = false
|
||||
sentinel['enable'] = false
|
||||
|
||||
# 容器 Registry (使用外部 Harbor)
|
||||
registry['enable'] = false
|
||||
ports:
|
||||
- "8929:8929" # HTTP
|
||||
- "2224:22" # SSH
|
||||
volumes:
|
||||
- gitlab-config:/etc/gitlab
|
||||
- gitlab-logs:/var/log/gitlab
|
||||
- gitlab-data:/var/opt/gitlab
|
||||
shm_size: '256m'
|
||||
networks:
|
||||
- devops
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitLab Runner - CI/CD 執行器
|
||||
# ---------------------------------------------------------------------------
|
||||
gitlab-runner:
|
||||
image: gitlab/gitlab-runner:v16.8.0
|
||||
container_name: wooo-gitlab-runner
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- gitlab-runner-config:/etc/gitlab-runner
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- devops
|
||||
depends_on:
|
||||
- gitlab
|
||||
|
||||
networks:
|
||||
devops:
|
||||
name: wooo-devops
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
gitlab-config:
|
||||
name: wooo-gitlab-config
|
||||
gitlab-logs:
|
||||
name: wooo-gitlab-logs
|
||||
gitlab-data:
|
||||
name: wooo-gitlab-data
|
||||
gitlab-runner-config:
|
||||
name: wooo-gitlab-runner-config
|
||||
784
docker-compose.yml
Normal file
784
docker-compose.yml
Normal file
@@ -0,0 +1,784 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Docker Compose Configuration
|
||||
# Version: 12.0
|
||||
# =============================================================================
|
||||
#
|
||||
# 使用方式:
|
||||
# 核心服務: docker-compose up -d momo-app scheduler
|
||||
# 完整監控: docker-compose --profile monitoring up -d
|
||||
# BI 分析: docker-compose --profile bi up -d
|
||||
# 全部服務: docker-compose --profile monitoring --profile bi up -d
|
||||
# 本機開發: docker-compose --profile local-dev up -d (含 Docker Nginx)
|
||||
#
|
||||
# CI/CD 服務 (monitoring profile):
|
||||
# - Watchtower: 自動偵測 Registry 映像更新並重啟容器
|
||||
# - n8n: 工作流自動化平台 (僅 UAT 環境)
|
||||
#
|
||||
# Docker Registry:
|
||||
# - URL: registry.wooo.work (HTTPS + 認證)
|
||||
# - 帳號: admin / Wooo_Registry_2026
|
||||
#
|
||||
# 注意事項:
|
||||
# - GCP 生產環境使用 VM 原生 Nginx,不需要啟動 Docker nginx
|
||||
# - VM Nginx 配置位於: /etc/nginx/sites-available/momo.conf
|
||||
# - 支援域名: momo.wooo.work, mo.wooo.work, mon.wooo.work
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===========================================================================
|
||||
# Core Services
|
||||
# ===========================================================================
|
||||
|
||||
momo-app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
# 支援兩種模式:
|
||||
# 1. 本地構建: 不設定 MOMO_IMAGE 環境變數 (使用 build 區塊)
|
||||
# 2. Registry 拉取: MOMO_IMAGE=registry.wooo.work/wooo/momo-pro-system
|
||||
image: ${MOMO_IMAGE:-registry.wooo.work/wooo/momo-pro-system}:${VERSION:-latest}
|
||||
container_name: momo-pro-system
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
ports:
|
||||
- "127.0.0.1:5003:80" # 僅本地連線,透過 Nginx 反向代理(nginx 反代 5003)
|
||||
# 強制使用 gunicorn 綁定 port 80 (覆蓋 Dockerfile CMD)
|
||||
command: ["gunicorn", "--bind", "0.0.0.0:80", "--workers", "4", "--timeout", "300", "--access-logfile", "-", "--error-logfile", "-", "app:app"]
|
||||
volumes:
|
||||
# 持久化數據
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./backups:/app/backups
|
||||
- ./config:/app/config
|
||||
# Python 程式碼 (熱更新)
|
||||
- ./config.py:/app/config.py:ro
|
||||
- ./app.py:/app/app.py:ro
|
||||
- ./auth.py:/app/auth.py:ro
|
||||
- ./scheduler.py:/app/scheduler.py:ro
|
||||
- ./services:/app/services:ro
|
||||
- ./routes:/app/routes:ro
|
||||
- ./database:/app/database:ro
|
||||
# HTML 模板 (熱更新)
|
||||
- ./templates:/app/templates:ro
|
||||
- ./web/templates:/app/web/templates:ro
|
||||
- ./login.html:/app/login.html:ro
|
||||
- ./dashboard.html:/app/dashboard.html:ro
|
||||
- ./daily_sales.html:/app/daily_sales.html:ro
|
||||
- ./sales_analysis.html:/app/sales_analysis.html:ro
|
||||
- ./growth_analysis.html:/app/growth_analysis.html:ro
|
||||
- ./monthly_summary_analysis.html:/app/monthly_summary_analysis.html:ro
|
||||
- ./edm_dashboard.html:/app/edm_dashboard.html:ro
|
||||
- ./index.html:/app/index.html:ro
|
||||
- ./logs.html:/app/logs.html:ro
|
||||
- ./auto_import_index.html:/app/auto_import_index.html:ro
|
||||
- ./settings.html:/app/settings.html:ro
|
||||
- ./system_settings.html:/app/system_settings.html:ro
|
||||
# 廠商缺貨系統
|
||||
- ./vendor_routes.py:/app/vendor_routes.py:ro
|
||||
- ./vendor_stockout_index.html:/app/vendor_stockout_index.html:ro
|
||||
- ./vendor_stockout_import.html:/app/vendor_stockout_import.html:ro
|
||||
- ./vendor_stockout_list.html:/app/vendor_stockout_list.html:ro
|
||||
- ./vendor_stockout_send_email.html:/app/vendor_stockout_send_email.html:ro
|
||||
- ./vendor_stockout_history.html:/app/vendor_stockout_history.html:ro
|
||||
- ./vendor_management.html:/app/vendor_management.html:ro
|
||||
# 其他根目錄路由
|
||||
- ./auto_import_routes.py:/app/auto_import_routes.py:ro
|
||||
- ./crawler_management_routes.py:/app/crawler_management_routes.py:ro
|
||||
- ./import.html:/app/import.html:ro
|
||||
# AI 助手模板及相關依賴
|
||||
- ./templates/ai_recommend.html:/app/ai_recommend.html:ro
|
||||
- ./templates/ai_history.html:/app/ai_history.html:ro
|
||||
- ./templates/base.html:/app/base.html:ro
|
||||
- ./templates/components:/app/components:ro
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Taipei
|
||||
- METABASE_URL=https://mo.wooo.work/metabase
|
||||
- GRIST_URL=https://grist.wooo.work
|
||||
# 關閉登入驗證(開發/測試用)
|
||||
- DISABLE_LOGIN=true
|
||||
# 資料庫設定: Docker 環境使用 PostgreSQL
|
||||
- USE_POSTGRESQL=true
|
||||
- POSTGRES_HOST=momo-postgres
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=${POSTGRES_USER:-momo}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-wooo_pg_2026}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-momo_analytics}
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [DEPRECATED] Docker Nginx - 已改用 VM 原生 Nginx
|
||||
# GCP 生產環境現在使用 VM 上安裝的 Nginx,配置位於:
|
||||
# /etc/nginx/sites-available/momo.conf
|
||||
# 支援的域名:
|
||||
# - momo.wooo.work / mo.wooo.work => Flask App (port 5001)
|
||||
# - mon.wooo.work => Grafana (port 3000)
|
||||
# 保留此服務供本機開發使用,生產環境請勿啟動
|
||||
# ---------------------------------------------------------------------------
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: momo-nginx
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- local-dev # 只在本機開發時使用
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/nginx/html:/usr/share/nginx/html:ro
|
||||
- ./logs/nginx:/var/log/nginx
|
||||
# SSL 證書 (本機使用自簽名證書)
|
||||
- ./docker/nginx/ssl:/etc/letsencrypt:ro
|
||||
- ./docker/certbot/conf:/etc/letsencrypt-new:ro
|
||||
# Let's Encrypt ACME challenge
|
||||
- ./docker/certbot/www:/var/www/certbot:ro
|
||||
# 應用靜態資源
|
||||
- ./static:/app/static:ro
|
||||
- ./web/static:/app/web/static:ro
|
||||
depends_on:
|
||||
- momo-app
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [DEPRECATED] Nginx Monitor - 已整合到主 nginx
|
||||
# 監控路由現在直接在 momo-nginx 的 mon.wooo.work 配置中處理
|
||||
# 保留此服務以供過渡期間使用,將於下個版本移除
|
||||
# ---------------------------------------------------------------------------
|
||||
nginx-monitor:
|
||||
image: nginx:alpine
|
||||
container_name: momo-nginx-monitor
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- deprecated
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./docker/nginx-monitor/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx-monitor/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/nginx-monitor/html:/usr/share/nginx/html:ro
|
||||
- ./logs/nginx-monitor:/var/log/nginx
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scheduler - 排程服務 (商品看板、活動看板、Google Drive 自動匯入)
|
||||
# ---------------------------------------------------------------------------
|
||||
scheduler:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ${MOMO_IMAGE:-registry.wooo.work/wooo/momo-pro-system}:${VERSION:-latest}
|
||||
container_name: momo-scheduler
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
init: true # 使用 tini 作為 init 進程,自動回收僵屍進程
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config:/app/config # Google Drive 認證檔案 (需要寫入權限以更新 token)
|
||||
- ./config.py:/app/config.py:ro
|
||||
- ./scheduler.py:/app/scheduler.py:ro
|
||||
- ./run_scheduler.py:/app/run_scheduler.py:ro
|
||||
- ./services:/app/services:ro
|
||||
- ./database:/app/database:ro # 資料庫模型
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Taipei
|
||||
# 資料庫設定: Docker 環境使用 PostgreSQL
|
||||
- USE_POSTGRESQL=true
|
||||
- POSTGRES_HOST=momo-postgres
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=${POSTGRES_USER:-momo}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-wooo_pg_2026}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-momo_analytics}
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "run_scheduler.py"]
|
||||
depends_on:
|
||||
- momo-app
|
||||
- postgres
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telegram Bot - 趨勢查詢與推播服務
|
||||
# 啟動: docker-compose up -d telegram-bot
|
||||
# ---------------------------------------------------------------------------
|
||||
telegram-bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: ${MOMO_IMAGE:-registry.wooo.work/wooo/momo-pro-system}:${VERSION:-latest}
|
||||
container_name: momo-telegram-bot
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
init: true
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
- ./config.py:/app/config.py:ro
|
||||
- ./run_telegram_bot.py:/app/run_telegram_bot.py:ro
|
||||
- ./services:/app/services:ro
|
||||
- ./database:/app/database:ro
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
- PYTHONUNBUFFERED=1
|
||||
- TZ=Asia/Taipei
|
||||
env_file:
|
||||
- .env
|
||||
command: ["python", "run_telegram_bot.py"]
|
||||
depends_on:
|
||||
- momo-app
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ===========================================================================
|
||||
# Monitoring Services (使用 --profile monitoring 啟用)
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus - 指標收集與儲存
|
||||
# ---------------------------------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.47.0
|
||||
container_name: momo-prometheus
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:9090:9090"
|
||||
volumes:
|
||||
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- ./docker/prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=15d'
|
||||
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.external-url=https://mon.wooo.work/prometheus/'
|
||||
- '--web.route-prefix=/'
|
||||
depends_on:
|
||||
- alertmanager
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alertmanager - 告警管理
|
||||
# ---------------------------------------------------------------------------
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.26.0
|
||||
container_name: momo-alertmanager
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "9093:9093"
|
||||
volumes:
|
||||
- ./docker/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
- alertmanager-data:/alertmanager
|
||||
command:
|
||||
- '--config.file=/etc/alertmanager/alertmanager.yml'
|
||||
- '--storage.path=/alertmanager'
|
||||
- '--web.external-url=https://mon.wooo.work/alertmanager/'
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node Exporter - 主機指標(CPU, Memory, Disk, Network)
|
||||
# ---------------------------------------------------------------------------
|
||||
node-exporter:
|
||||
image: prom/node-exporter:v1.6.1
|
||||
container_name: momo-node-exporter
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:9100:9100" # 僅本地連線,Node Exporter 會洩漏硬體資訊
|
||||
volumes:
|
||||
- /proc:/host/proc:ro
|
||||
- /sys:/host/sys:ro
|
||||
- /:/rootfs:ro
|
||||
command:
|
||||
- '--path.procfs=/host/proc'
|
||||
- '--path.sysfs=/host/sys'
|
||||
- '--path.rootfs=/rootfs'
|
||||
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blackbox Exporter - 網站/端口/網路探測
|
||||
# ---------------------------------------------------------------------------
|
||||
blackbox-exporter:
|
||||
image: prom/blackbox-exporter:v0.24.0
|
||||
container_name: momo-blackbox-exporter
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "9115:9115"
|
||||
volumes:
|
||||
- ./docker/blackbox/blackbox.yml:/etc/blackbox_exporter/config.yml:ro
|
||||
command:
|
||||
- '--config.file=/etc/blackbox_exporter/config.yml'
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cAdvisor - 容器指標監控
|
||||
# ---------------------------------------------------------------------------
|
||||
cadvisor:
|
||||
image: gcr.io/cadvisor/cadvisor:v0.47.0
|
||||
container_name: momo-cadvisor
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- /:/rootfs:ro
|
||||
- /var/run:/var/run:ro
|
||||
- /sys:/sys:ro
|
||||
- /var/lib/docker/:/var/lib/docker:ro
|
||||
- /dev/disk/:/dev/disk:ro
|
||||
privileged: true
|
||||
devices:
|
||||
- /dev/kmsg
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loki - 日誌聚合
|
||||
# ---------------------------------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:2.9.0
|
||||
container_name: momo-loki
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./docker/loki/loki-config.yaml:/etc/loki/local-config.yaml:ro
|
||||
- loki-data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Promtail - 日誌收集
|
||||
# ---------------------------------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:2.9.0
|
||||
container_name: momo-promtail
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
volumes:
|
||||
- ./docker/promtail/promtail-config.yaml:/etc/promtail/config.yaml:ro
|
||||
- ./logs:/var/log/app:ro
|
||||
- ./logs/nginx:/var/log/nginx:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
depends_on:
|
||||
- loki
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grafana - 視覺化儀表板
|
||||
# ---------------------------------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:10.2.0
|
||||
container_name: momo-grafana
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-wooo_grafana_2026}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_ROOT_URL=https://mon.wooo.work/grafana/
|
||||
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
||||
- GF_INSTALL_PLUGINS=grafana-clock-panel,grafana-simple-json-datasource
|
||||
depends_on:
|
||||
- prometheus
|
||||
- loki
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# [DEPRECATED] SQLite Web - 已停用 (改用 PostgreSQL + pgAdmin)
|
||||
# ---------------------------------------------------------------------------
|
||||
# sqlite-web:
|
||||
# profiles:
|
||||
# - deprecated
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL Exporter - PostgreSQL 指標監控
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||
container_name: momo-postgres-exporter
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "9187:9187"
|
||||
environment:
|
||||
- DATA_SOURCE_NAME=postgresql://${POSTGRES_USER:-momo}:${POSTGRES_PASSWORD:-wooo_pg_2026}@postgres:5432/${POSTGRES_DB:-momo_analytics}?sslmode=disable
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# pgAdmin - PostgreSQL 管理介面 (取代 SQLite Web)
|
||||
# ---------------------------------------------------------------------------
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: momo-pgadmin
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:8088:80"
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=${PGADMIN_EMAIL:-admin@wooo.work}
|
||||
- PGADMIN_DEFAULT_PASSWORD=${PGADMIN_PASSWORD:-wooo_pgadmin_2026}
|
||||
- PGADMIN_CONFIG_SERVER_MODE=False
|
||||
volumes:
|
||||
- pgadmin-data:/var/lib/pgadmin
|
||||
depends_on:
|
||||
- postgres
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Portainer - Docker 管理介面
|
||||
# ---------------------------------------------------------------------------
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: momo-portainer
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:9000:9000" # 僅本地連線,透過 SSH Tunnel 存取
|
||||
- "127.0.0.1:9443:9443" # 僅本地連線
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- portainer-data:/data
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Watchtower - 自動更新容器 (偵測 Registry 映像更新)
|
||||
# ---------------------------------------------------------------------------
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
container_name: momo-watchtower
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Registry 認證: 掛載 Docker config 讓 Watchtower 可以從 Registry 拉取映像
|
||||
- /home/wooo/.docker/config.json:/config.json:ro
|
||||
environment:
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
- WATCHTOWER_POLL_INTERVAL=30 # 每 30 秒檢查一次(近即時更新)
|
||||
- WATCHTOWER_INCLUDE_STOPPED=false
|
||||
- WATCHTOWER_LABEL_ENABLE=true
|
||||
- TZ=Asia/Taipei
|
||||
- DOCKER_API_VERSION=1.44
|
||||
command: --label-enable
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# n8n - Workflow Automation (CI/CD notifications, webhooks)
|
||||
# UAT only: http://192.168.0.110:5678
|
||||
# ---------------------------------------------------------------------------
|
||||
n8n:
|
||||
image: n8nio/n8n:latest
|
||||
container_name: momo-n8n
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- monitoring
|
||||
ports:
|
||||
- "127.0.0.1:5678:5678"
|
||||
volumes:
|
||||
- n8n-data:/home/node/.n8n
|
||||
environment:
|
||||
- N8N_HOST=${N8N_HOST:-192.168.0.110}
|
||||
- N8N_PORT=5678
|
||||
- N8N_PROTOCOL=${N8N_PROTOCOL:-http}
|
||||
- WEBHOOK_URL=${N8N_WEBHOOK_BASE_URL:-http://192.168.0.110:5678/}
|
||||
- GENERIC_TIMEZONE=Asia/Taipei
|
||||
- TZ=Asia/Taipei
|
||||
- N8N_SECURE_COOKIE=false
|
||||
- N8N_BASIC_AUTH_ACTIVE=true
|
||||
- N8N_BASIC_AUTH_USER=${N8N_USER:-admin}
|
||||
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD:-wooo_n8n_2026}
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ===========================================================================
|
||||
# Database Services (核心服務)
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL - 主要資料庫 (取代 SQLite)
|
||||
# ---------------------------------------------------------------------------
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: momo-postgres
|
||||
restart: unless-stopped
|
||||
# 移除 profiles,作為核心服務始終啟動
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432" # 僅本地連線,防止資料庫暴露
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./docker/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||
- ./docker/postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-momo}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-wooo_pg_2026}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-momo_analytics}
|
||||
- TZ=Asia/Taipei
|
||||
command: postgres -c config_file=/etc/postgresql/postgresql.conf
|
||||
# PostgreSQL 效能優化參數 (8GB RAM 伺服器)
|
||||
# - shared_buffers=2GB (25% RAM)
|
||||
# - work_mem=64MB (大型查詢排序)
|
||||
# - effective_cache_size=6GB (75% RAM)
|
||||
# - max_parallel_workers_per_gather=2 (並行查詢)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U momo -d momo_analytics"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Metabase - 自定義圖表 BI 平台
|
||||
# ---------------------------------------------------------------------------
|
||||
metabase:
|
||||
image: metabase/metabase:v0.48.6
|
||||
container_name: momo-metabase
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- bi
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- metabase-data:/metabase-data
|
||||
environment:
|
||||
- MB_DB_TYPE=postgres
|
||||
- MB_DB_DBNAME=metabase
|
||||
- MB_DB_PORT=5432
|
||||
- MB_DB_USER=${POSTGRES_USER:-momo}
|
||||
- MB_DB_PASS=${POSTGRES_PASSWORD:-wooo_pg_2026}
|
||||
- MB_DB_HOST=postgres
|
||||
- JAVA_TIMEZONE=Asia/Taipei
|
||||
- MB_SITE_NAME=WOOO Analytics
|
||||
- MB_SITE_URL=${MB_SITE_URL:-https://mo.wooo.work/metabase}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grist - 開源電子表格/資料庫平台
|
||||
# ---------------------------------------------------------------------------
|
||||
grist:
|
||||
image: gristlabs/grist:latest
|
||||
container_name: momo-grist
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- bi
|
||||
ports:
|
||||
- "8484:8484"
|
||||
volumes:
|
||||
- grist-data:/persist
|
||||
environment:
|
||||
- GRIST_DEFAULT_EMAIL=${GRIST_ADMIN_EMAIL:-admin@wooo.work}
|
||||
- GRIST_SUPPORT_ANON=true
|
||||
- GRIST_FORCE_LOGIN=false
|
||||
- GRIST_HIDE_UI_ELEMENTS=helpCenter,billing,templates,multiSite,multiAccounts
|
||||
- APP_HOME_URL=https://grist.wooo.work
|
||||
- TZ=Asia/Taipei
|
||||
networks:
|
||||
- momo-network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
momo-network:
|
||||
driver: bridge
|
||||
name: momo-network
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
prometheus-data:
|
||||
name: momo-prometheus-data
|
||||
alertmanager-data:
|
||||
name: momo-alertmanager-data
|
||||
loki-data:
|
||||
name: momo-loki-data
|
||||
grafana-data:
|
||||
name: momo-grafana-data
|
||||
portainer-data:
|
||||
name: momo-portainer-data
|
||||
postgres-data:
|
||||
name: momo-postgres-data
|
||||
pgadmin-data:
|
||||
name: momo-pgadmin-data
|
||||
metabase-data:
|
||||
name: momo-metabase-data
|
||||
grist-data:
|
||||
name: momo-grist-data
|
||||
n8n-data:
|
||||
name: momo-n8n-data
|
||||
45
docker/alertmanager/alertmanager.yml
Normal file
45
docker/alertmanager/alertmanager.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
# =============================================================================
|
||||
# Alertmanager 配置 - 告警通知管理
|
||||
# =============================================================================
|
||||
|
||||
global:
|
||||
resolve_timeout: 5m
|
||||
|
||||
route:
|
||||
group_by: ['alertname', 'severity']
|
||||
group_wait: 30s
|
||||
group_interval: 5m
|
||||
repeat_interval: 1h
|
||||
receiver: 'telegram-webhook'
|
||||
routes:
|
||||
# 高優先級告警 - CPU/RAM 超過 50%
|
||||
- match:
|
||||
severity: warning
|
||||
receiver: 'telegram-webhook'
|
||||
group_wait: 10s
|
||||
repeat_interval: 30m
|
||||
|
||||
# 嚴重告警 - CPU/RAM 超過 80%
|
||||
- match:
|
||||
severity: critical
|
||||
receiver: 'telegram-webhook'
|
||||
group_wait: 5s
|
||||
repeat_interval: 15m
|
||||
|
||||
receivers:
|
||||
- name: 'telegram-webhook'
|
||||
webhook_configs:
|
||||
- url: 'http://momo-pro-system:5000/api/alert/webhook'
|
||||
send_resolved: true
|
||||
http_config:
|
||||
basic_auth:
|
||||
username: 'alertmanager'
|
||||
password: 'wooo_alert_2026'
|
||||
|
||||
inhibit_rules:
|
||||
# 如果已有 critical 告警,抑制 warning 告警
|
||||
- source_match:
|
||||
severity: 'critical'
|
||||
target_match:
|
||||
severity: 'warning'
|
||||
equal: ['alertname', 'instance']
|
||||
89
docker/blackbox/blackbox.yml
Normal file
89
docker/blackbox/blackbox.yml
Normal file
@@ -0,0 +1,89 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Blackbox Exporter Configuration
|
||||
# =============================================================================
|
||||
|
||||
modules:
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP/HTTPS 探測模組
|
||||
# ---------------------------------------------------------------------------
|
||||
http_2xx:
|
||||
prober: http
|
||||
timeout: 10s
|
||||
http:
|
||||
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
|
||||
valid_status_codes: [200, 301, 302, 303]
|
||||
method: GET
|
||||
follow_redirects: true
|
||||
fail_if_ssl: false
|
||||
fail_if_not_ssl: false
|
||||
tls_config:
|
||||
insecure_skip_verify: false
|
||||
|
||||
http_2xx_insecure:
|
||||
prober: http
|
||||
timeout: 10s
|
||||
http:
|
||||
valid_http_versions: ["HTTP/1.1", "HTTP/2.0"]
|
||||
valid_status_codes: [200, 301, 302, 303]
|
||||
method: GET
|
||||
follow_redirects: true
|
||||
tls_config:
|
||||
insecure_skip_verify: true
|
||||
|
||||
http_post_2xx:
|
||||
prober: http
|
||||
timeout: 10s
|
||||
http:
|
||||
method: POST
|
||||
valid_status_codes: [200, 201, 202]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP 連接探測模組
|
||||
# ---------------------------------------------------------------------------
|
||||
tcp_connect:
|
||||
prober: tcp
|
||||
timeout: 5s
|
||||
tcp:
|
||||
preferred_ip_protocol: "ip4"
|
||||
|
||||
tcp_connect_tls:
|
||||
prober: tcp
|
||||
timeout: 5s
|
||||
tcp:
|
||||
preferred_ip_protocol: "ip4"
|
||||
tls: true
|
||||
tls_config:
|
||||
insecure_skip_verify: false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ICMP Ping 探測模組
|
||||
# ---------------------------------------------------------------------------
|
||||
icmp:
|
||||
prober: icmp
|
||||
timeout: 5s
|
||||
icmp:
|
||||
preferred_ip_protocol: "ip4"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS 解析探測模組
|
||||
# ---------------------------------------------------------------------------
|
||||
dns_check:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
dns:
|
||||
preferred_ip_protocol: "ip4"
|
||||
query_name: "mo.wooo.work"
|
||||
query_type: "A"
|
||||
valid_rcodes:
|
||||
- NOERROR
|
||||
|
||||
dns_check_momo:
|
||||
prober: dns
|
||||
timeout: 5s
|
||||
dns:
|
||||
preferred_ip_protocol: "ip4"
|
||||
query_name: "momo.wooo.work"
|
||||
query_type: "A"
|
||||
valid_rcodes:
|
||||
- NOERROR
|
||||
18
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal file
18
docker/grafana/provisioning/dashboards/dashboards.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Grafana Dashboard Provisioning Configuration
|
||||
# =============================================================================
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'WOOO Dashboards'
|
||||
orgId: 1
|
||||
folder: 'WOOO Monitoring'
|
||||
folderUid: 'wooo-monitoring'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards/json
|
||||
@@ -0,0 +1,497 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 100,
|
||||
"panels": [],
|
||||
"title": "Docker 容器概覽",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 1 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "運行中容器",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "count(container_last_seen{name=~\".+\"})",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 2147483648 },
|
||||
{ "color": "red", "value": 4294967296 }
|
||||
]
|
||||
},
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 5, "x": 4, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "容器總記憶體使用",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(container_memory_usage_bytes{name=~\".+\"})",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 50 },
|
||||
{ "color": "red", "value": 80 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 5, "x": 9, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "容器總 CPU 使用",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(container_cpu_usage_seconds_total{name=~\".+\"}[5m])) * 100",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "Bps"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 5, "x": 14, "y": 1 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "網路 RX 流量",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(container_network_receive_bytes_total{name=~\".+\"}[5m]))",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "Bps"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 5, "x": 19, "y": 1 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "網路 TX 流量",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "sum(rate(container_network_transmit_bytes_total{name=~\".+\"}[5m]))",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
|
||||
"id": 101,
|
||||
"panels": [],
|
||||
"title": "各容器資源使用",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "opacity",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 6 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "各容器 CPU 使用率",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(container_cpu_usage_seconds_total{name=~\"momo.*\"}[5m]) * 100",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "opacity",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 6 },
|
||||
"id": 11,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "各容器記憶體使用",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "container_memory_usage_bytes{name=~\"momo.*\"}",
|
||||
"legendFormat": "{{name}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 },
|
||||
"id": 102,
|
||||
"panels": [],
|
||||
"title": "網路流量",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "Bps"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 15 },
|
||||
"id": 20,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "容器網路流量",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(container_network_receive_bytes_total{name=~\"momo.*\"}[5m])",
|
||||
"legendFormat": "{{name}} RX",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "rate(container_network_transmit_bytes_total{name=~\"momo.*\"}[5m])",
|
||||
"legendFormat": "{{name}} TX",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 23 },
|
||||
"id": 103,
|
||||
"panels": [],
|
||||
"title": "容器狀態列表",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"custom": {
|
||||
"align": "auto",
|
||||
"cellOptions": { "type": "auto" },
|
||||
"inspect": false
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Container" },
|
||||
"properties": [{ "id": "custom.width", "value": 200 }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "CPU %" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "percent" },
|
||||
{ "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "gauge" } },
|
||||
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 50 }, { "color": "red", "value": 80 }] } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "Memory" },
|
||||
"properties": [
|
||||
{ "id": "unit", "value": "bytes" },
|
||||
{ "id": "custom.cellOptions", "value": { "mode": "gradient", "type": "gauge" } },
|
||||
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 536870912 }, { "color": "red", "value": 1073741824 }] } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||
"id": 30,
|
||||
"options": {
|
||||
"cellHeight": "sm",
|
||||
"footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false },
|
||||
"showHeader": true,
|
||||
"sortBy": [{ "desc": true, "displayName": "CPU %" }]
|
||||
},
|
||||
"title": "容器資源使用表",
|
||||
"type": "table",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(container_cpu_usage_seconds_total{name=~\"momo.*\"}[5m]) * 100",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "",
|
||||
"refId": "CPU"
|
||||
},
|
||||
{
|
||||
"expr": "container_memory_usage_bytes{name=~\"momo.*\"}",
|
||||
"format": "table",
|
||||
"instant": true,
|
||||
"legendFormat": "",
|
||||
"refId": "Memory"
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{
|
||||
"id": "seriesToColumns",
|
||||
"options": { "byField": "name" }
|
||||
},
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "__name__": true, "__name__ 1": true, "__name__ 2": true, "id": true, "id 1": true, "id 2": true, "image": true, "image 1": true, "image 2": true, "instance": true, "instance 1": true, "instance 2": true, "job": true, "job 1": true, "job 2": true },
|
||||
"renameByName": { "Value #CPU": "CPU %", "Value #Memory": "Memory", "name": "Container" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["wooo", "container", "docker"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "Asia/Taipei",
|
||||
"title": "WOOO 容器監控",
|
||||
"uid": "wooo-container-monitoring",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 100,
|
||||
"panels": [],
|
||||
"title": "資料庫狀態概覽",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "離線" }, "1": { "color": "green", "index": 0, "text": "正常" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 0, "y": 1 },
|
||||
"id": 1,
|
||||
"options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "資料庫狀態",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_database_up", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 536870912 }, { "color": "red", "value": 1073741824 }] },
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 3, "y": 1 },
|
||||
"id": 2,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "資料庫大小",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_database_size_bytes", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 104857600 }, { "color": "red", "value": 209715200 }] },
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 7, "y": 1 },
|
||||
"id": 3,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "WAL 大小",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_database_wal_size_bytes", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 11, "y": 1 },
|
||||
"id": 4,
|
||||
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "今日新增商品",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_products_today_total", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 14, "y": 1 },
|
||||
"id": 5,
|
||||
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "今日價格變動",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_price_records_today_total", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 10 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 3, "x": 17, "y": 1 },
|
||||
"id": 6,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "碎片率",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_sqlite_fragmentation_percent", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 1 },
|
||||
"id": 7,
|
||||
"options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "SQLite 頁面數",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_sqlite_page_count", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 },
|
||||
"id": 101,
|
||||
"panels": [],
|
||||
"title": "查詢效能監控",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 6 },
|
||||
"id": 10,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "總查詢數",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_total", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 10 }, { "color": "red", "value": 50 }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 6 },
|
||||
"id": 11,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "慢查詢數 (>1秒)",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_slow_total", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 20 }] },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 6 },
|
||||
"id": 12,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "極慢查詢數 (>5秒)",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_very_slow_total", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 500 }, { "color": "red", "value": 1000 }] },
|
||||
"unit": "ms"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 6 },
|
||||
"id": 13,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "平均查詢時間",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_avg_time_ms", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 5 }, { "color": "red", "value": 10 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 6 },
|
||||
"id": 14,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "慢查詢率",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_slow_rate_percent", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ms"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 6 },
|
||||
"id": 15,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "累計查詢時間",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_query_time_total_ms", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 },
|
||||
"id": 102,
|
||||
"panels": [],
|
||||
"title": "資料表記錄數",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "blue", "value": null }] }, "unit": "short" } },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 11 },
|
||||
"id": 20,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "Products 表",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_table_rows{table=\"products\"}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "purple", "value": null }] }, "unit": "short" } },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 11 },
|
||||
"id": 21,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "Price Records 表",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_table_rows{table=\"price_records\"}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "orange", "value": null }] }, "unit": "short" } },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 11 },
|
||||
"id": 22,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "Monthly Summary 表",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_table_rows{table=\"monthly_summary_analysis\"}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "short" } },
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 11 },
|
||||
"id": 23,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "Promo Products 表",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_table_rows{table=\"promo_products\"}", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 15 },
|
||||
"id": 103,
|
||||
"panels": [],
|
||||
"title": "磁碟使用狀況",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"max": 100, "min": 0,
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 70 }, { "color": "red", "value": 85 }] },
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 8, "x": 0, "y": 16 },
|
||||
"id": 30,
|
||||
"options": { "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "showThresholdLabels": false, "showThresholdMarkers": true },
|
||||
"title": "磁碟使用率",
|
||||
"type": "gauge",
|
||||
"targets": [{ "expr": "(momo_disk_used_bytes / momo_disk_total_bytes) * 100", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }, "unit": "bytes" } },
|
||||
"gridPos": { "h": 6, "w": 8, "x": 8, "y": 16 },
|
||||
"id": 31,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "磁碟總空間",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_disk_total_bytes", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "yellow", "value": 5368709120 }, { "color": "green", "value": 10737418240 }] },
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 8, "x": 16, "y": 16 },
|
||||
"id": 32,
|
||||
"options": { "colorMode": "value", "graphMode": "area", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
|
||||
"title": "磁碟可用空間",
|
||||
"type": "stat",
|
||||
"targets": [{ "expr": "momo_disk_free_bytes", "refId": "A" }]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 22 },
|
||||
"id": 104,
|
||||
"panels": [],
|
||||
"title": "歷史趨勢",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "bytes"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 },
|
||||
"id": 40,
|
||||
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||
"title": "資料庫大小趨勢",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{ "expr": "momo_database_size_bytes", "legendFormat": "DB Size", "refId": "A" },
|
||||
{ "expr": "momo_database_wal_size_bytes", "legendFormat": "WAL Size", "refId": "B" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 20, "gradientMode": "opacity", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "smooth", "lineWidth": 2, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "never", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
|
||||
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
|
||||
"unit": "ms"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 },
|
||||
"id": 41,
|
||||
"options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true }, "tooltip": { "mode": "multi", "sort": "desc" } },
|
||||
"title": "查詢效能趨勢",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{ "expr": "momo_query_avg_time_ms", "legendFormat": "Avg Query Time", "refId": "A" },
|
||||
{ "expr": "rate(momo_query_slow_total[5m]) * 60", "legendFormat": "Slow Queries/min", "refId": "B" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["wooo", "database", "sqlite", "slow-query"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "Asia/Taipei",
|
||||
"title": "WOOO 資料庫監控",
|
||||
"uid": "wooo-database-monitoring",
|
||||
"version": 2,
|
||||
"weekStart": ""
|
||||
}
|
||||
255
docker/grafana/provisioning/dashboards/json/logs-dashboard.json
Normal file
255
docker/grafana/provisioning/dashboards/json/logs-dashboard.json
Normal file
@@ -0,0 +1,255 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"gridPos": { "h": 2, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"code": { "language": "plaintext", "showLineNumbers": false, "showMiniMap": false },
|
||||
"content": "# 📋 Momo Pro System - 日誌監控中心\n\n實時查看應用程式日誌、訪問記錄和錯誤追蹤",
|
||||
"mode": "markdown"
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 2 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({job=\"gunicorn-access\"}[$__range])) + sum(count_over_time({job=\"momo-app\"}[$__range])) + sum(count_over_time({job=\"gunicorn-error\"}[$__range]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "📊 日誌總數",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 2 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({job=\"gunicorn-access\"} |~ \" 5[0-9][0-9] \"[$__range]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "❌ 5xx 錯誤",
|
||||
"type": "stat",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 1 },
|
||||
{ "color": "red", "value": 10 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 2 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({job=\"gunicorn-access\"} |~ \" 4[0-9][0-9] \"[$__range]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "⚠️ 4xx 錯誤",
|
||||
"type": "stat",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 10 },
|
||||
{ "color": "orange", "value": 50 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 2 },
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["sum"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({job=\"gunicorn-access\"} |~ \" 200 \"[$__range]))",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "✅ 成功請求",
|
||||
"type": "stat",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 6, "w": 24, "x": 0, "y": 6 },
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": { "displayMode": "list", "placement": "bottom", "showLegend": true },
|
||||
"tooltip": { "mode": "single", "sort": "none" }
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({job=\"gunicorn-access\"}[1m]))",
|
||||
"legendFormat": "HTTP Requests",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "📈 HTTP 請求趨勢",
|
||||
"type": "timeseries",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"custom": {
|
||||
"drawStyle": "bars",
|
||||
"fillOpacity": 80
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 12 },
|
||||
"id": 9,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{job=\"gunicorn-access\"}",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "📝 Gunicorn 訪問日誌",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 22 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": true,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{job=\"momo-app\"}",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "📝 應用程式日誌",
|
||||
"type": "logs"
|
||||
},
|
||||
{
|
||||
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 32 },
|
||||
"id": 6,
|
||||
"options": {
|
||||
"dedupStrategy": "none",
|
||||
"enableLogDetails": true,
|
||||
"prettifyLogMessage": false,
|
||||
"showCommonLabels": false,
|
||||
"showLabels": false,
|
||||
"showTime": true,
|
||||
"sortOrder": "Descending",
|
||||
"wrapLogMessage": false
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{job=\"gunicorn-error\"}",
|
||||
"queryType": "range",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "🚨 Gunicorn 錯誤日誌",
|
||||
"type": "logs"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["momo", "logs", "loki"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "Asia/Taipei",
|
||||
"title": "Momo Pro - 日誌監控",
|
||||
"uid": "momo-logs",
|
||||
"version": 3
|
||||
}
|
||||
675
docker/grafana/provisioning/dashboards/json/system-overview.json
Normal file
675
docker/grafana/provisioning/dashboards/json/system-overview.json
Normal file
@@ -0,0 +1,675 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": []
|
||||
},
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 },
|
||||
"id": 100,
|
||||
"panels": [],
|
||||
"title": "主機資源 (UAT Server)",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 1 },
|
||||
"id": 1,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"title": "CPU 使用率",
|
||||
"type": "gauge",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "100 - (avg(irate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU %",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 1 },
|
||||
"id": 2,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"title": "記憶體使用率",
|
||||
"type": "gauge",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "Memory %",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 70 },
|
||||
{ "color": "red", "value": 85 }
|
||||
]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 1 },
|
||||
"id": 3,
|
||||
"options": {
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true
|
||||
},
|
||||
"title": "磁碟使用率 (/)",
|
||||
"type": "gauge",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\",fstype!=\"rootfs\"} / node_filesystem_size_bytes{mountpoint=\"/\",fstype!=\"rootfs\"})) * 100",
|
||||
"legendFormat": "Disk %",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null }
|
||||
]
|
||||
},
|
||||
"unit": "dtdurations"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 1 },
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "系統運行時間",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "node_time_seconds - node_boot_time_seconds",
|
||||
"legendFormat": "Uptime",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 7 },
|
||||
"id": 101,
|
||||
"panels": [],
|
||||
"title": "網站健康狀態",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 0, "y": 8 },
|
||||
"id": 10,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "mo.wooo.work (UAT)",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{instance=\"https://mo.wooo.work\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 4, "y": 8 },
|
||||
"id": 11,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "mo.wooo.work (已廢棄)",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{instance=\"https://mo.wooo.work\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 8, "y": 8 },
|
||||
"id": 12,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "wooo.work (公司官網)",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{instance=\"https://wooo.work\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 12, "y": 8 },
|
||||
"id": 13,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "UAT Server (Ping)",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{instance=\"192.168.0.110\", probe_type=\"icmp\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "DOWN" }, "1": { "color": "green", "index": 0, "text": "UP" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 16, "y": 8 },
|
||||
"id": 14,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "GCP PROD (Ping)",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{instance=\"34.80.130.190\", probe_type=\"icmp\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"mappings": [
|
||||
{ "options": { "0": { "color": "red", "index": 1, "text": "FAIL" }, "1": { "color": "green", "index": 0, "text": "OK" } }, "type": "value" }
|
||||
],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "red", "value": null },
|
||||
{ "color": "green", "value": 1 }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 4, "w": 4, "x": 20, "y": 8 },
|
||||
"id": 15,
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"textMode": "auto"
|
||||
},
|
||||
"title": "DNS 解析",
|
||||
"type": "stat",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_success{job=\"blackbox-dns\"}",
|
||||
"legendFormat": "",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 12 },
|
||||
"id": 102,
|
||||
"panels": [],
|
||||
"title": "系統歷史趨勢",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "opacity",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 13 },
|
||||
"id": 20,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "CPU 使用率趨勢",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "100 - (avg(irate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
|
||||
"legendFormat": "CPU %",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 20,
|
||||
"gradientMode": "opacity",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "percent"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
|
||||
"id": 21,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "記憶體使用率趨勢",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
|
||||
"legendFormat": "Memory %",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": { "h": 1, "w": 24, "x": 0, "y": 21 },
|
||||
"id": 103,
|
||||
"panels": [],
|
||||
"title": "網路與響應時間",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "s"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 },
|
||||
"id": 30,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "網站響應時間",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "probe_duration_seconds{job=~\"blackbox-http.*\"}",
|
||||
"legendFormat": "{{instance}}",
|
||||
"refId": "A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "prometheus"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": { "legend": false, "tooltip": false, "viz": false },
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "smooth",
|
||||
"lineWidth": 2,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": { "type": "linear" },
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": { "group": "A", "mode": "none" },
|
||||
"thresholdsStyle": { "mode": "off" }
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "green", "value": null }]
|
||||
},
|
||||
"unit": "Bps"
|
||||
}
|
||||
},
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 },
|
||||
"id": 31,
|
||||
"options": {
|
||||
"legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true },
|
||||
"tooltip": { "mode": "multi", "sort": "desc" }
|
||||
},
|
||||
"title": "網路流量",
|
||||
"type": "timeseries",
|
||||
"targets": [
|
||||
{
|
||||
"expr": "rate(node_network_receive_bytes_total{device!~\"lo|docker.*|br.*|veth.*\"}[5m])",
|
||||
"legendFormat": "{{device}} RX",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"expr": "rate(node_network_transmit_bytes_total{device!~\"lo|docker.*|br.*|veth.*\"}[5m])",
|
||||
"legendFormat": "{{device}} TX",
|
||||
"refId": "B"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["wooo", "system", "overview"],
|
||||
"templating": { "list": [] },
|
||||
"time": { "from": "now-1h", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "Asia/Taipei",
|
||||
"title": "WOOO 系統總覽",
|
||||
"uid": "wooo-system-overview",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
40
docker/grafana/provisioning/datasources/datasources.yaml
Normal file
40
docker/grafana/provisioning/datasources/datasources.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Grafana Datasources Configuration
|
||||
# =============================================================================
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus - 指標數據源(主要)
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
queryTimeout: "60s"
|
||||
httpMethod: "POST"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loki - 日誌數據源
|
||||
# ---------------------------------------------------------------------------
|
||||
- name: Loki
|
||||
uid: loki
|
||||
type: loki
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
isDefault: false
|
||||
editable: false
|
||||
jsonData:
|
||||
maxLines: 1000
|
||||
derivedFields:
|
||||
- datasourceUid: prometheus
|
||||
matcherRegex: "traceID=(\\w+)"
|
||||
name: TraceID
|
||||
url: "$${__value.raw}"
|
||||
58
docker/loki/loki-config.yaml
Normal file
58
docker/loki/loki-config.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Loki Configuration
|
||||
# =============================================================================
|
||||
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
|
||||
common:
|
||||
instance_addr: 127.0.0.1
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
query_range:
|
||||
results_cache:
|
||||
cache:
|
||||
embedded_cache:
|
||||
enabled: true
|
||||
max_size_mb: 100
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2020-10-24
|
||||
store: boltdb-shipper
|
||||
object_store: filesystem
|
||||
schema: v11
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
ruler:
|
||||
alertmanager_url: http://localhost:9093
|
||||
|
||||
# 日誌保留策略
|
||||
limits_config:
|
||||
retention_period: 168h # 7 天
|
||||
enforce_metric_name: false
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h
|
||||
max_entries_limit_per_query: 5000
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
shared_store: filesystem
|
||||
compaction_interval: 10m
|
||||
retention_enabled: true
|
||||
retention_delete_delay: 2h
|
||||
retention_delete_worker_count: 150
|
||||
268
docker/nginx-monitor/conf.d/monitor.conf
Normal file
268
docker/nginx-monitor/conf.d/monitor.conf
Normal file
@@ -0,0 +1,268 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Monitoring Services Nginx Configuration (HTTP Only)
|
||||
# 所有監控服務統一入口
|
||||
# 使用動態 DNS 解析,避免服務不存在時 nginx 無法啟動
|
||||
# =============================================================================
|
||||
|
||||
# HTTP 監控服務入口
|
||||
server {
|
||||
listen 80;
|
||||
server_name mon.wooo.work localhost;
|
||||
|
||||
# Docker 內部 DNS resolver
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
|
||||
# 從上游 nginx 獲取真實客戶端 IP
|
||||
set_real_ip_from 172.16.0.0/12;
|
||||
set_real_ip_from 10.0.0.0/8;
|
||||
set_real_ip_from 192.168.0.0/24;
|
||||
real_ip_header X-Real-IP;
|
||||
real_ip_recursive on;
|
||||
|
||||
# 安全標頭
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
# 日誌
|
||||
access_log /var/log/nginx/monitor-access.log;
|
||||
error_log /var/log/nginx/monitor-error.log;
|
||||
|
||||
# 靜態檔案 (Logo 等)
|
||||
location /static/ {
|
||||
alias /usr/share/nginx/html/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 根路徑 - 顯示服務列表 (使用靜態 HTML 檔案)
|
||||
location = / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# 視覺化與分析
|
||||
# =========================================
|
||||
|
||||
# Grafana (代理到 Grafana 的 /grafana/ 路徑,因為 Grafana 配置了 SERVE_FROM_SUB_PATH)
|
||||
location /grafana/ {
|
||||
set $grafana_backend "http://momo-grafana:3000";
|
||||
proxy_pass $grafana_backend$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
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 支援 (Grafana Live)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# Prometheus (route-prefix=/ 所以要 rewrite 掉 /prometheus 前綴)
|
||||
location /prometheus/ {
|
||||
set $prometheus_backend "http://momo-prometheus:9090";
|
||||
rewrite ^/prometheus/(.*) /$1 break;
|
||||
proxy_pass $prometheus_backend;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Prometheus 根路徑重定向
|
||||
location = /prometheus {
|
||||
return 301 /prometheus/;
|
||||
}
|
||||
|
||||
# Loki API (僅供 Grafana 使用,無 Web UI)
|
||||
location /loki/ {
|
||||
set $loki_backend "http://momo-loki:3100";
|
||||
rewrite ^/loki/(.*) /$1 break;
|
||||
proxy_pass $loki_backend;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Loki ready 健康檢查
|
||||
location = /loki/ready {
|
||||
set $loki_backend "http://momo-loki:3100";
|
||||
proxy_pass $loki_backend/ready;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# 系統管理
|
||||
# =========================================
|
||||
|
||||
# Portainer (無法正常使用子路徑,建議直接訪問)
|
||||
location /portainer/ {
|
||||
set $portainer_backend "http://momo-portainer:9000";
|
||||
rewrite ^/portainer/(.*) /$1 break;
|
||||
proxy_pass $portainer_backend;
|
||||
proxy_http_version 1.1;
|
||||
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_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SQLite Web (僅限內網訪問 - 資料庫安全考量)
|
||||
location /sqlite/ {
|
||||
# 內網 IP 白名單
|
||||
allow 192.168.0.0/24;
|
||||
allow 10.0.0.0/8;
|
||||
allow 172.16.0.0/12;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
|
||||
set $sqlite_backend "http://momo-sqlite-web:8080";
|
||||
# 移除 /sqlite 前綴後代理到後端
|
||||
rewrite ^/sqlite/(.*) /$1 break;
|
||||
proxy_pass $sqlite_backend;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
proxy_set_header X-Script-Name /sqlite;
|
||||
|
||||
# 重寫內部連結路徑 (順序重要:具體規則在前)
|
||||
sub_filter 'href="/static/' 'href="/sqlite/static/';
|
||||
sub_filter 'src="/static/' 'src="/sqlite/static/';
|
||||
sub_filter 'action="/' 'action="/sqlite/';
|
||||
sub_filter 'href="/' 'href="/sqlite/';
|
||||
sub_filter_once off;
|
||||
sub_filter_types text/html text/css application/javascript;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# SQLite Web 靜態資源 (僅限內網)
|
||||
location /sqlite/static/ {
|
||||
allow 192.168.0.0/24;
|
||||
allow 10.0.0.0/8;
|
||||
allow 172.16.0.0/12;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
|
||||
set $sqlite_backend "http://momo-sqlite-web:8080";
|
||||
rewrite ^/sqlite/static/(.*) /static/$1 break;
|
||||
proxy_pass $sqlite_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
expires 7d;
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# Exporters
|
||||
# =========================================
|
||||
|
||||
# cAdvisor
|
||||
location /cadvisor/ {
|
||||
set $cadvisor_backend "http://momo-cadvisor:8080";
|
||||
proxy_pass $cadvisor_backend/;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Node Exporter
|
||||
location /node-exporter/ {
|
||||
set $node_exporter_backend "http://momo-node-exporter:9100";
|
||||
proxy_pass $node_exporter_backend/;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Blackbox Exporter
|
||||
location /blackbox/ {
|
||||
set $blackbox_backend "http://momo-blackbox-exporter:9115";
|
||||
proxy_pass $blackbox_backend/;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 健康檢查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'OK';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 服務不可用時的友善錯誤頁面
|
||||
error_page 502 503 504 @service_unavailable;
|
||||
location @service_unavailable {
|
||||
default_type 'text/html; charset=utf-8';
|
||||
return 503 '<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Service Unavailable</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, sans-serif; background: linear-gradient(135deg, #1e3c72, #2a5298); min-height: 100vh; margin: 0; display: flex; align-items: center; justify-content: center; color: white; }
|
||||
.container { text-align: center; padding: 40px; }
|
||||
h1 { margin-bottom: 20px; }
|
||||
p { opacity: 0.8; }
|
||||
a { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Service Unavailable</h1>
|
||||
<p>The requested monitoring service is not running.</p>
|
||||
<p>Please start monitoring services with:</p>
|
||||
<p><code>docker-compose --profile monitoring up -d</code></p>
|
||||
<p><a href="/">Back to Service List</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
}
|
||||
}
|
||||
362
docker/nginx-monitor/html/index.html
Normal file
362
docker/nginx-monitor/html/index.html
Normal file
@@ -0,0 +1,362 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-TW">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WOOO Monitoring Services</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #1e3c72;
|
||||
--secondary-color: #2a5298;
|
||||
--accent-color: #00d4ff;
|
||||
--card-bg: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 50%, #1a1a2e 100%);
|
||||
background-attachment: fixed;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 背景裝飾 */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(0, 212, 255, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(0, 212, 255, 0.05) 0%, transparent 30%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 動態粒子效果 */
|
||||
.particles {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: rgba(0, 212, 255, 0.6);
|
||||
border-radius: 50%;
|
||||
animation: float 15s infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { transform: translateY(-100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.main-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 40px 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 200px;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: white;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid rgba(0, 212, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-title i {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Cards Grid */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Service Card */
|
||||
.service-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, var(--accent-color), var(--secondary-color));
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.service-card:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon.grafana { background: linear-gradient(135deg, #f46800, #f9b233); color: white; }
|
||||
.card-icon.prometheus { background: linear-gradient(135deg, #e6522c, #f0a000); color: white; }
|
||||
.card-icon.portainer { background: linear-gradient(135deg, #13bef9, #0db7ed); color: white; }
|
||||
.card-icon.sqlite { background: linear-gradient(135deg, #003b57, #044a6c); color: white; }
|
||||
.card-icon.cadvisor { background: linear-gradient(135deg, #4285f4, #34a853); color: white; }
|
||||
.card-icon.node { background: linear-gradient(135deg, #e84d3d, #c0392b); color: white; }
|
||||
.card-icon.blackbox { background: linear-gradient(135deg, #9b59b6, #8e44ad); color: white; }
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-content h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--secondary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 60px;
|
||||
padding: 20px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header h1 { font-size: 1.8rem; }
|
||||
.cards-grid { grid-template-columns: 1fr; }
|
||||
.main-container { padding: 20px 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 粒子背景 -->
|
||||
<div class="particles">
|
||||
<div class="particle" style="left: 10%; animation-delay: 0s;"></div>
|
||||
<div class="particle" style="left: 20%; animation-delay: 2s;"></div>
|
||||
<div class="particle" style="left: 30%; animation-delay: 4s;"></div>
|
||||
<div class="particle" style="left: 40%; animation-delay: 1s;"></div>
|
||||
<div class="particle" style="left: 50%; animation-delay: 3s;"></div>
|
||||
<div class="particle" style="left: 60%; animation-delay: 5s;"></div>
|
||||
<div class="particle" style="left: 70%; animation-delay: 2.5s;"></div>
|
||||
<div class="particle" style="left: 80%; animation-delay: 4.5s;"></div>
|
||||
<div class="particle" style="left: 90%; animation-delay: 1.5s;"></div>
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="logo-container">
|
||||
<img src="/static/images/WOOO_Logo_trimmed.jpg" alt="WOOO Logo" class="logo">
|
||||
</div>
|
||||
<h1>Monitoring Services</h1>
|
||||
<p class="subtitle"><i class="fas fa-server me-2"></i>WOOO TECH 監控服務中心</p>
|
||||
</div>
|
||||
|
||||
<!-- 視覺化與分析 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title"><i class="fas fa-chart-line"></i>視覺化與分析</h2>
|
||||
<div class="cards-grid">
|
||||
<a href="/grafana/" class="service-card">
|
||||
<div class="card-icon grafana"><i class="fas fa-chart-area"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>Grafana</h3>
|
||||
<p>視覺化儀表板,整合 Loki 日誌查詢與 Prometheus 指標</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/prometheus/" class="service-card">
|
||||
<div class="card-icon prometheus"><i class="fas fa-fire"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>Prometheus</h3>
|
||||
<p>時序資料庫,收集與查詢系統指標</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系統管理 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title"><i class="fas fa-cogs"></i>系統管理</h2>
|
||||
<div class="cards-grid">
|
||||
<a href="http://192.168.0.110:9000/" class="service-card" target="_blank">
|
||||
<div class="card-icon portainer"><i class="fab fa-docker"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>Portainer</h3>
|
||||
<p>Docker 容器視覺化管理介面</p>
|
||||
</div>
|
||||
<span class="card-badge">直連</span>
|
||||
</a>
|
||||
<a href="/sqlite/" class="service-card">
|
||||
<div class="card-icon sqlite"><i class="fas fa-database"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>SQLite Web</h3>
|
||||
<p>資料庫瀏覽與管理工具</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exporters -->
|
||||
<div class="section">
|
||||
<h2 class="section-title"><i class="fas fa-download"></i>Exporters</h2>
|
||||
<div class="cards-grid">
|
||||
<a href="http://192.168.0.110:8080/" class="service-card" target="_blank">
|
||||
<div class="card-icon cadvisor"><i class="fas fa-cube"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>cAdvisor</h3>
|
||||
<p>容器資源監控與效能分析</p>
|
||||
</div>
|
||||
<span class="card-badge">直連</span>
|
||||
</a>
|
||||
<a href="/node-exporter/metrics" class="service-card">
|
||||
<div class="card-icon node"><i class="fas fa-microchip"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>Node Exporter</h3>
|
||||
<p>主機系統指標 (CPU、記憶體、磁碟)</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/blackbox/" class="service-card">
|
||||
<div class="card-icon blackbox"><i class="fas fa-satellite-dish"></i></div>
|
||||
<div class="card-content">
|
||||
<h3>Blackbox Exporter</h3>
|
||||
<p>HTTP/HTTPS/TCP 端點探測監控</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>© 2026 WOOO TECH. All rights reserved.</p>
|
||||
<p><a href="https://mo.wooo.work/">返回 Momo Pro System</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docker/nginx-monitor/html/static/images/WOOO_Logo_trimmed.jpg
Normal file
BIN
docker/nginx-monitor/html/static/images/WOOO_Logo_trimmed.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 161 KiB |
BIN
docker/nginx-monitor/html/static/images/WOOO_Main_Logo.jpg
Normal file
BIN
docker/nginx-monitor/html/static/images/WOOO_Main_Logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 394 KiB |
BIN
docker/nginx-monitor/html/static/images/logo_v4_glass.png
Normal file
BIN
docker/nginx-monitor/html/static/images/logo_v4_glass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
44
docker/nginx-monitor/nginx.conf
Normal file
44
docker/nginx-monitor/nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Monitoring Nginx Configuration
|
||||
# 專用於監控服務,與主站分離
|
||||
# =============================================================================
|
||||
|
||||
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;
|
||||
|
||||
# Gzip 壓縮
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml;
|
||||
|
||||
# 包含站點配置
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
BIN
docker/nginx-monitor/static/images/logo_v4_glass.png
Normal file
BIN
docker/nginx-monitor/static/images/logo_v4_glass.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
69
docker/nginx/conf.d/default.conf
Normal file
69
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,69 @@
|
||||
# =============================================================================
|
||||
# WOOO TECH - Momo Pro System
|
||||
# Nginx Site Configuration
|
||||
# =============================================================================
|
||||
|
||||
upstream momo_app {
|
||||
server momo-app:5000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 日誌
|
||||
access_log /var/log/nginx/momo-access.log main;
|
||||
error_log /var/log/nginx/momo-error.log;
|
||||
|
||||
# 靜態檔案
|
||||
location /static/ {
|
||||
alias /app/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /web/static/ {
|
||||
alias /app/web/static/;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 反向代理到 Flask 應用
|
||||
location / {
|
||||
proxy_pass http://momo_app;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Proxy headers
|
||||
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;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# Timeout 設定(配合長時間查詢)
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# Buffer 設定
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
}
|
||||
|
||||
# 健康檢查端點
|
||||
location /health {
|
||||
access_log off;
|
||||
proxy_pass http://momo_app;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
|
||||
# 錯誤頁面
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user