commit 1b4f3a7bbea1f1f627a7124b4ba03b6e24829d37 Author: ogt Date: Sun Apr 19 01:21:13 2026 +0800 feat: EwoooC 初始化 — 完整專案推版至 Gitea - 建立 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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e2c9bc6 --- /dev/null +++ b/.claude/settings.json @@ -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\")" + ] + } +} diff --git a/.claude/skills/ai-self-learning-flow.py b/.claude/skills/ai-self-learning-flow.py new file mode 100644 index 0000000..b15da7f --- /dev/null +++ b/.claude/skills/ai-self-learning-flow.py @@ -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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67e0e5d --- /dev/null +++ b/.env.example @@ -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=即時業績_當日 diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml new file mode 100644 index 0000000..ee3fafe --- /dev/null +++ b/.gitea/workflows/cd.yaml @@ -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') + MSG=$(printf '🚀 EwoooC 部署開始\n├ 📝 %s\n├ 🔖 %s\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') + MSG=$(printf '✅ EwoooC 部署成功\n├ 📝 %s\n├ 🔖 %s\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') + MSG=$(printf '❌ EwoooC 部署失敗\n├ 📝 %s\n├ 🔖 %s\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"}')" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6570eb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..eab034e --- /dev/null +++ b/.gitlab-ci.yml @@ -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="✅ UAT 部署成功%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="❌ UAT 部署失敗%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="✅ GCP 部署成功%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 diff --git a/AUTO_IMPORT_README.md b/AUTO_IMPORT_README.md new file mode 100644 index 0000000..23e1001 --- /dev/null +++ b/AUTO_IMPORT_README.md @@ -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. 在網頁介面監控進度 + +**無需手動操作!** 🚀 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbabe1b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5283 @@ +# EwoooC — Claude 專案記憶(原 MOMO Pro System) + +> 此檔案供 Claude Code 自動讀取,記錄專案的關鍵資訊與待辦事項。 +> **專案正式名稱**:EwoooC(ADR-006,2026-04-18 決策) +> **範圍**:本目錄唯一範圍為 `momo-pro-system`,**不含 AWOOOI / WOOO AIOps SaaS**。 + +--- + +## 🏛️ 第零章:遊戲規則憲法(Claude Code 官方規範 — 2026-04-18 訂立) + +> **這是本系統的元憲法。所有 AI 助手(Claude / Antigravity)在任何 Session 開始前必須先讀此章。** + +### 📐 記憶架構分層(四層缺一不可) + +| 層次 | 位置 | 用途 | 生命週期 | +|---|---|---|---| +| **Memory(持久記憶)** | `~/.claude/projects/.../memory/*.md` | 統帥偏好、技術決策、協作 context | 永久,跨 Session | +| **ADR(架構決策記錄)** | `docs/adr/ADR-XXX-*.md` | 重大架構決策,含背景/替代方案/後果 | 永久,只增不刪 | +| **SOT(單一事實來源)** | `docs/AI_INTELLIGENCE_MODULE_SOT.md` | 當前 AI 模組架構狀態(可更新) | 隨架構演進 | +| **Skills(執行流程)** | `.claude/skills/*.py` | Claude 執行複雜任務的 SOP checklist | 隨流程成熟迭代 | + +### 📋 失憶防範三大鐵律(絕對禁止違反) + +#### 鐵律一:每次決策必沉澱 +- ✅ 任何架構決策 → 立刻建 `ADR-XXX.md` + 更新 `docs/adr/README.md` +- ✅ 任何統帥偏好/風格 → 立刻寫入 `memory/user_profile.md` 或新 memory 文件 +- ✅ 任何技術債/待辦 → 立刻寫入 `memory/project_tech_debt_backlog.md` +- ❌ **禁止**:口頭決定不留文字記錄(下個 Session 必失憶) + +#### 鐵律二:CLAUDE.md 是憲法主幹 +- ✅ 每次 Session 開始,先讀 CLAUDE.md → memory/ → 相關 ADR +- ✅ 重大架構章節必須同步更新到 CLAUDE.md(SOT 章節) +- ✅ 新憲法條款必須更新 CONSTITUTION.md +- ❌ **禁止**:依賴 Claude 對話 Session 記憶(context window 有截斷) + +#### 鐵律三:範圍邊界 +- ✅ 本 CLAUDE.md 範圍 = **Momo Pro System(EwoooC)唯一** +- ❌ **禁止**:將 AWOOOI / WOOO AIOps SaaS 的決策混入本文件 +- ❌ **禁止**:跨專案邊界做架構決策 + +### 🔄 Session 開始 SOP(每次 Claude 啟動必做) + +``` +1. 讀 CLAUDE.md 頭部(此章 + 環境章) +2. 讀 memory/MEMORY.md 索引 → 依需求讀具體 memory +3. 讀 docs/adr/README.md 索引 → 依任務讀相關 ADR +4. 確認當前任務屬於哪個 Agent 層(Hermes/NemoTron/OpenClaw) +5. 確認任務結束後需更新哪些記憶層 +``` + +### 📝 記憶更新 SOP(每次 Session 結束前必做) + +``` +□ 有架構決策?→ 新建或更新 docs/adr/ADR-XXX.md + README.md 索引 +□ 有統帥偏好/回饋?→ 更新 memory/user_profile.md 或 feedback_*.md +□ 有技術債?→ 更新 memory/project_tech_debt_backlog.md +□ 有 SOT 變更?→ 更新 docs/AI_INTELLIGENCE_MODULE_SOT.md +□ 有憲法新條款?→ 更新 CONSTITUTION.md + 本 CLAUDE.md +□ 有技能流程變更?→ 更新 .claude/skills/ 對應 skill 文件 +□ 更新 memory/MEMORY.md 索引(加入新 memory 文件連結) +``` + +--- + +## 專案概覽 + +| 項目 | 說明 | +|-----|------| +| 專案名稱 | EwoooC(原 MOMO Pro System,WOOO TECH) | +| 技術棧 | Flask (gunicorn) + PostgreSQL (pgvector) + Docker Compose V12.0 | +| 主要功能 | 商品看板、每日業績報表、廠商缺貨通知、EDM 爬蟲、AI 知識庫(KM) | +| 正式運行主機 | **`ollama@192.168.0.188`**(Docker Compose,非 K8s) | +| 對外域名 | `mo.wooo.work` / `momo.wooo.work`(皆透過 110 VM Nginx 反向代理) | + +--- + +## 🌐 環境架構總覽(2026-04-19 依 SSH 實地審計重寫,見 ADR-008) + +> **重要**: 先前文件誤記為「K3s / kubectl / 192.168.0.110 運行應用」。實地審計 (2026-04-18) 確認 EwoooC 從未遷移到 K8s,188 始終是 **Docker Compose** 架構。本章以實際現況為準。 + +| 主機 | 角色 | 承載內容 | +|------|------|---------| +| **`192.168.0.188` (`ollama@`)** | **EwoooC 正式運行主機** | `momo-pro-system` / `momo-db` / `momo-scheduler` / `openclaw` / `n8n`(Docker) / Ollama | +| `192.168.0.110` (`wooo@`) | 周邊服務主機 | VM Nginx 反向代理、Harbor(仍在運行)、Sentry self-hosted、SigNoz、Gitea (+ runner)、Langfuse、監控 exporter | +| `192.168.0.112` (`kali@`) | 安全掃描工作站 | Nmap / Bandit / Trivy / WireGuard | + +> ⚠️ 110 的 `/home/wooo/momo_pro_system/` 實際已空置(僅剩 `docker/` + `venv/`),不是 EwoooC 的運行目錄。 +> ⚠️ 110 載載 load avg ≈ 14.5,負責 monitor.wooo.work / registry.wooo.work / Harbor / Sentry / SigNoz 等周邊堆疊。 + +### EwoooC 運行主機細節(`ollama@192.168.0.188`) + +| 項目 | 值 | +|------|----| +| **專案路徑** | `/home/ollama/momo-pro/` | +| **部署方式** | Docker Compose V12.0(無 Git 版控,純檔案系統) | +| **主容器** | `momo-pro-system` (Flask + gunicorn) / `momo-db` (pgvector/pgvector:pg14) / `momo-scheduler` / `openclaw` (clawbot-v5) | +| **容器啟動指令** | `gunicorn --bind 0.0.0.0:80 --workers 4 --timeout 300 app:app` | +| **主容器端口** | `127.0.0.1:5003:80`(gunicorn 內部聽 80,host 僅本地 5003) | +| **Volume 掛載** | `app.py` / `scheduler.py` / `config.py` / `auth.py` / `config/` / `database/` / `templates/` / `services/` / `routes/` / `data/` / `logs/` / `backups/`(皆為 host 目錄掛到容器 `/app`,修改即時生效) | +| **未掛載** | `docs/`(CLAUDE.md / ADR 純屬本地參考文件) | +| **資料庫** | `momo-db` 使用 **pgvector/pgvector:pg14**,為 AI KM 模組(ADR-002/003/007)提供 `vector(1024)` 欄位 | + +### SSH 連線 SOP(必須經 110 跳板) + +```bash +# 先跳 110 +ssh wooo@192.168.0.110 +# 再進 188(SSH Key 已預先建立) +ssh ollama@192.168.0.188 +``` + +Claude 工具層可直接執行:`ssh -J wooo@192.168.0.110 ollama@192.168.0.188 ""`。 + +### Docker Compose 管理指令(在 188 上) + +```bash +cd /home/ollama/momo-pro + +# 查看所有容器 +docker ps + +# 即時日誌(Flask 主應用) +docker logs -f momo-pro-system --tail 100 + +# 重啟主應用(Python 檔案 volume 掛載已更新時使用) +docker restart momo-pro-system + +# 完全重建(新增 Python 套件/更改 Dockerfile 時) +docker compose build momo-app && docker compose up -d momo-app + +# 進入資料庫 +docker exec -it momo-db psql -U momo -d momo_analytics +``` + +--- + +## 網路架構圖(2026-04-19 實地重繪) + +``` + ┌────────────────────────────────────────────────┐ + Internet │ 110 (wooo@192.168.0.110) │ + │ │ 周邊服務主機 + VM Nginx 反向代理 │ + ▼ │ │ + ┌──────────┐ │ VM Nginx (systemd, port 80/443, SSL) │ + │ DNS │ │ ├─ mo.wooo.work ──┐ │ + │ Records │ │ ├─ momo.wooo.work ──┤ ⚠️ TODO:需統帥確認 │ + └────┬─────┘ │ ├─ registry.wooo.work ─┤ 這兩個域名目前 │ + │ │ ├─ monitor.wooo.work │ 實際 upstream 為何?│ + ▼ │ └─ ollama.wooo.work ─┘ 檔案 /etc/nginx/ │ + ┌──────────┐ │ sites-enabled/momo│ + │DNS → 110 │ │ (110 本地的周邊容器:) │ + └────┬─────┘ │ Harbor (11 個 harbor-* 容器,仍在運行) │ + │ │ Sentry self-hosted (30+ 容器) │ + │ │ SigNoz / Gitea + runner / Langfuse │ + │ │ Docker Registry / Superset │ + │ │ Prometheus exporters / Fail2Ban exporter │ + │ (VM Nginx 反向代理) └────────────────────────────┬───────────────────┘ + │ │ (proxy_pass) + ▼ ▼ + ┌────────────────────────────────────────────────────────────────────┐ + │ 188 (ollama@192.168.0.188) EwoooC 正式運行主機 │ + │ /home/ollama/momo-pro/ (Docker Compose V12.0) │ + ├────────────────────────────────────────────────────────────────────┤ + │ │ + │ ┌────────────────────┐ ┌───────────────────┐ │ + │ │ momo-pro-system │──▶│ momo-db │ │ + │ │ Flask + gunicorn │ │ pgvector/pg14 │ │ + │ │ 127.0.0.1:5003:80 │ │ (DB + KM vector) │ │ + │ └────────────────────┘ └───────────────────┘ │ + │ ┌────────────────────┐ ┌───────────────────┐ │ + │ │ momo-scheduler │ │ openclaw │ │ + │ │ (爬蟲排程) │ │ (clawbot-v5) │ │ + │ └────────────────────┘ └───────────────────┘ │ + │ ┌────────────────────┐ ┌───────────────────┐ │ + │ │ n8n (Docker) │ │ Ollama + WebUI │ │ + │ └────────────────────┘ └───────────────────┘ │ + └────────────────────────────────────────────────────────────────────┘ +``` + +### 服務端口對照表 + +| 服務 | 主機 | 類型 | 本地綁定 | 外部訪問 | 說明 | +|------|------|------|---------|---------|------| +| **momo-pro-system** | 188 | Docker | `127.0.0.1:5003:80` | `mo.wooo.work` / `momo.wooo.work` (經 110 VM Nginx) | Flask + gunicorn (4 workers) | +| **momo-db** | 188 | Docker | 內部 network | 僅容器網路 | pgvector/pg14 | +| **momo-scheduler** | 188 | Docker | — | — | 爬蟲排程 | +| **openclaw** | 188 | Docker | — | — | clawbot-v5 | +| **n8n** | 188 | Docker | `127.0.0.1:5678:5678` | `monitor.wooo.work/n8n/` (經 110 代理) | Webhook 自動化 | +| **Ollama** | 188 | Docker | 內部 network | `ollama.wooo.work` (經 110) | AI 模型服務 | +| **Registry** | 110 | Docker | `127.0.0.1:5000` | `registry.wooo.work` (經 VM Nginx) | Container Registry | +| **Prometheus** | 110 | Docker | 內部 | `monitor.wooo.work/prometheus/` | 監控指標收集 | +| **Grafana** | 110 | Docker | 內部 | `monitor.wooo.work/grafana/` | 監控儀表板 | +| **Alertmanager** | 110 | Docker | 內部 | `monitor.wooo.work/alertmanager/` | 告警路由 | +| **Harbor** | 110 | Docker | 內部 | 待定 | 尚未撤除(文件先前誤報已移除) | + +### VM Nginx 反向代理(110 上) + +Nginx 配置位置:`/etc/nginx/sites-enabled/momo` + +`mo.wooo.work` / `momo.wooo.work` 的流量路徑(現況推測,待驗證): +``` +用戶 → {mo|momo}.wooo.work:443 → 110 VM Nginx (SSL 終結) → ??? → 188:5003 momo-pro-system +``` + +> ⚠️ **TODO: 需統帥確認** — 110 上 `proxy_pass` 的具體 upstream。若為 `http://192.168.0.188:5003`,要求 188 的 5003 對 110 開放(非純 127.0.0.1 綁定);若 110 本機另跑一個 reverse proxy daemon 聽 `127.0.0.1:5001` 再轉 188,需補查。此段待 SSH 到 110 檢視 `sites-enabled/momo` 實際內容後更新。 + +### 監控堆疊(Docker,分佈於 110 + 188) + +| 組件 | 主機 | 說明 | +|------|------|------| +| Prometheus | 110 Docker | 抓取 110 + 188 兩邊的 exporter | +| Grafana | 110 Docker | `monitor.wooo.work/grafana/` | +| Alertmanager | 110 Docker | 告警 → Telegram | +| Node Exporter | 110 + 188 | 各自主機指標 | +| cAdvisor | 110 + 188 | 容器資源指標 | +| n8n | 188 Docker | 自動化 Webhook(容器名 `n8n`) | + +**Grafana 登入**: +- URL: `http://192.168.0.110:30030`(若已改內部綁定,需透過 monitor.wooo.work) +- 帳號 / 密碼: `admin` / `Wooo_Grafana_2026` + +**Telegram 告警配置**: +- Bot: `@wooowooowooobot` +- Chat ID: `5619078117` + +### 故障排除:服務 502 / 容器停止 + +> K3s / kubectl / PVC / StatefulSet 相關故障排除流程已作廢(188 無 K8s)。改用 Docker Compose 操作: + +```bash +# 在 188 上 +docker ps -a | grep momo # 查看容器狀態 +docker logs -f momo-pro-system --tail 100 # 看日誌 +docker restart momo-pro-system # 單純重啟 +cd /home/ollama/momo-pro && docker compose up -d # 全部重新拉起 + +# 在 110 上(VM Nginx 若異常) +sudo systemctl reload nginx +sudo nginx -t +``` + +### 回滾(Docker Compose 原生機制) + +188 無 K8s,無所謂「回滾到 Docker Compose」。版本回退方式是: +1. 重建 Docker image 至舊版本並 `docker compose up -d` +2. 若 Python 檔案損壞,可從 `/home/ollama/momo-pro/backups/` 取回 + +> **過往 `/home/wooo/backups/pre-k8s-migration/quick_rollback.sh` 已失去意義(188 從未 K8s 化),保留檔案但不再作為回滾途徑。** + +--- + +## 🔐 服務帳號密碼總覽 (2026-04-18 更新) + +> **單一監控中心**: 所有服務集中於 UAT 主機 + +### 應用服務 + +| 環境 | 服務 | URL | 帳號 | 密碼 | 版本 | +|------|------|-----|------|------|------| +| 🟢 正式 | **EwoooC(MOMO Pro)** | https://mo.wooo.work、https://momo.wooo.work | - | `0936223270` | V9.4(Docker Compose 於 188) | +| 🟢 正式 | **CI/CD Dashboard** | https://mo.wooo.work/cicd | - | - | - | +| 🟢 正式 | **PostgreSQL (momo-db, pgvector)** | 容器內部 network on 188 | `momo` | `wooo_pg_2026` | pgvector/pg14,含 KM `vector(1024)` | + +### 監控服務 (集中於 UAT) + +| 服務 | URL | 帳號 | 密碼 | 說明 | +|------|-----|------|------|------| +| **監控入口頁面** | https://monitor.wooo.work/ | - | - | 所有監控服務入口 (110) | +| **Grafana** | http://192.168.0.110:30030 或 monitor.wooo.work/grafana/ | `admin` | `Wooo_Grafana_2026` | 監控儀表板 (110 Docker) | +| **Prometheus** | monitor.wooo.work/prometheus/ | - | - | 指標收集 (110 Docker) | +| **Alertmanager** | monitor.wooo.work/alertmanager/ | - | - | 告警路由 → Telegram (110 Docker) | +| **n8n** | http://192.168.0.188:5678 (內部) | `admin@wooo.work` | `Wooo_N8n_2026` | 自動化工作流程 (188 Docker, 容器名 `n8n`) | + +### 開發工具 + +| 服務 | URL | 帳號 | 密碼 | 說明 | +|------|-----|------|------|------| +| **Registry** | https://registry.wooo.work | `admin` | `Wooo_Registry_2026` | Container Registry | + +### BI 分析平台 + +| 服務 | URL | 帳號 | 密碼 | 說明 | +|------|-----|------|------|------| +| **Apache Superset** | https://monitor.wooo.work/superset/ | `admin` | `Wooo_Superset_2026` | BI 分析儀表板 | +| **Superset DB (readonly)** | 10.42.0.199:5432 | `superset_readonly` | `Wooo_Superset_RO_2026` | 唯讀資料庫用戶 | + +### 管理工具 + +| 服務 | URL | 帳號 | 密碼 | 說明 | +|------|-----|------|------|------| +| **Portainer** | https://monitor.wooo.work/portainer/ | `admin` | `Wooo_Portainer_2026` | Docker 容器管理 | + +### AI 服務 + +| 服務 | URL | API Key | 說明 | +|------|-----|---------|------| +| **Ollama AI** | https://ollama.wooo.work | `0df8b4f247a4497998248f013ce92a17` | AI 模型服務 | +| **Gemini AI** | API | `AIzaSyCqv7TY2iTGi2wa91d2irwH08VYXjT9YUk` | 文案生成 | + +### 指標收集器 (Exporters) + +| 服務 | URL | 說明 | +|------|-----|------| +| **Node Exporter** | 110 + 188 (127.0.0.1:9100) | 各主機 CPU/記憶體/磁碟 | +| **cAdvisor** | 110 + 188 (127.0.0.1:8080) | 容器資源使用 | +| **Postgres Exporter** | 188 Docker (針對 momo-db pgvector) | PostgreSQL 監控 | +| **Blackbox Exporter** | 110 (127.0.0.1:9115) | 外部服務探測 | + +### SSH 連線 + +| 伺服器 | 指令 | 密碼 | 說明 | +|--------|------|------|------| +| **EwoooC 正式主機** | `ssh -J wooo@192.168.0.110 ollama@192.168.0.188` | `0936223270` | 運行 Flask 主應用 / momo-db / scheduler / openclaw / n8n / Ollama | +| **周邊服務主機** | `ssh wooo@192.168.0.110` | `0936223270` | VM Nginx 反代 / Harbor / Sentry / SigNoz / Gitea / 監控堆疊 | +| **Kali DevSecOps** | `ssh kali@192.168.0.112` | - | 安全掃描工作站 | + +### Telegram 告警 + +| 項目 | 值 | +|------|-----| +| Bot Token | `8075645931:AAH-EGKMo8ZC4QJs-Nc1_0s92xHrGdQvdpg` | +| Chat ID | `5619078117` | +| 告警來源 | Alertmanager + n8n 工作流程 | +| 告警類型 | Pod OOMKilled、重啟過多、記憶體不足等 | + +### API Keys 總覽 + +| 服務 | Key | 用途 | +|------|-----|------| +| **Gemini AI** | `AIzaSyCqv7TY2iTGi2wa91d2irwH08VYXjT9YUk` | Google Gemini AI 文案生成 | +| **YouTube API** | `AIzaSyBA9n7-rYIQVMq8rSF7kz486avBAfFzJ0s` | YouTube 趨勢爬蟲 | +| **n8n API** | `wooo_n8n_api_2026` | n8n 工作流 API 存取 | +| **Ollama API** | `0df8b4f247a4497998248f013ce92a17.vqSWDEK0RppTZIwcdT-ei-Sz` | Ollama AI 模型服務 | +| **LINE Channel** | `nD6MSXjB2FyB111zpT6Yik5B275mi6olHjjf94VnqN1ljUcqzcA7KtSSslxsOCEG6pERzmidNJFdzol6h+9V+t1x3j4Q8ljAacqC+i0627RuwbkiLxoHTJ/9HbIdehhoSJoeuNJHLraE721iDDfIuQdB04t89/1O/w1cDnyilFU=` | LINE 訊息推送 | +| **Telegram Bot** | `8075645931:AAH-EGKMo8ZC4QJs-Nc1_0s92xHrGdQvdpg` | Telegram 告警通知 | + +--- + +## 🔄 運維架構(2026-04-19 依 SSH 實地審計重寫) + +> **架構**: EwoooC 正式服務跑在 **188 Docker Compose**,監控告警堆疊跑在 **110 Docker**,n8n Webhook 引擎跑在 **188 Docker** (容器名 `n8n`)。 + +### 架構圖 + +``` +┌──────────────────────────────────────────────┐ ┌─────────────────────────────────────────────┐ +│ 110 (wooo@192.168.0.110) 周邊服務主機 │ │ 188 (ollama@192.168.0.188) EwoooC 正式主機 │ +│ │ │ │ +│ ┌────────────────────────────────────────┐ │ │ ┌───────────────────────────────────────┐ │ +│ │ Prometheus + Grafana + Alertmanager │ │ │ │ /home/ollama/momo-pro/ (Compose V12) │ │ +│ │ (Docker) │──┼────┼─▶│ momo-pro-system / momo-db (pgvector) │ │ +│ │ 每 15s 抓取 110 + 188 exporter 指標 │ │ │ │ momo-scheduler / openclaw / n8n │ │ +│ └────────────────────────────────────────┘ │ │ └───────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────┐ │ │ ┌───────────────────────────────────────┐ │ +│ │ VM Nginx (systemd) SSL 終結 + 反代 │──┼────┼─▶│ 127.0.0.1:5003 momo-pro-system │ │ +│ │ mo.wooo.work / momo.wooo.work → 188 │ │ │ │ (⚠️ TODO 待確認 5003 對 110 開放路徑) │ │ +│ └────────────────────────────────────────┘ │ │ └───────────────────────────────────────┘ │ +│ │ │ ┌───────────────────────────────────────┐ │ +│ Harbor / Sentry / SigNoz / Gitea / Langfuse │ │ │ n8n Docker (容器名 `n8n`) │ │ +│ Docker Registry / Superset │ │ │ 自動化工作流程引擎 │ │ +│ (monitor.wooo.work 反代入口) │ │ └───────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ └─────────────────────────────────────────────┘ +``` + +### 監控 → 告警 → 自動修復 流程 + +``` +Prometheus(110) 每 15s 抓取 + │ + ├─ 110 cAdvisor/Node exporter + ├─ 188 cAdvisor/Node exporter + ├─ momo-db pg_exporter (188) + └─ Blackbox: mo.wooo.work / momo.wooo.work / monitor.wooo.work + │ + ▼ + Alertmanager(110) 告警路由 + │ + ├─ Telegram 通知(@wooowooowooobot) + └─ n8n webhook 觸發自動修復 + │ + ▼ + ssh 188 → docker restart +``` + +### 域名健康監控腳本(2026-02-09 / 2026-04-19 修正) + +**腳本位置**: `/home/wooo/scripts/domain-health-monitor.sh`(110 上執行,因為 110 是 VM Nginx 所在,也能 SSH 到 188) +**Cron 排程**: 每 5 分鐘執行 + +**監控域名清單**: + +| 域名 | 預期狀態碼 | 服務名稱 | 自動修復命令 | +|------|-----------|---------|-------------| +| `https://mo.wooo.work/health` | 200 | EwoooC App (正式) | `ssh ollama@188 "docker restart momo-pro-system"` | +| `https://momo.wooo.work/health` | 200 | EwoooC App (momo 域名別名) | `ssh ollama@188 "docker restart momo-pro-system"` | +| `https://monitor.wooo.work/` | 200 | Monitor 首頁 (110) | `sudo systemctl reload nginx` | +| `https://registry.wooo.work/v2/` | 401 | Docker Registry (110) | `cd /home/wooo/devops/registry && docker compose restart` | +| `http://192.168.0.188:5678/` | 200 | n8n (188) | `ssh ollama@188 "docker restart n8n"` | +| `https://monitor.wooo.work/superset/login/` | 200 | Superset (110) | `docker compose restart` | + +**Cron 設定**: +```bash +*/5 * * * * /home/wooo/scripts/domain-health-monitor.sh >> /home/wooo/logs/domain_health_monitor.log 2>&1 +``` + +**日誌檔案**: `/home/wooo/logs/domain_health_monitor.log` + +### 基礎設施清單 + +| 項目 | 主機 | 狀態 | 備註 | +|------|------|------|------| +| **Docker Compose** | 188 | ✅ V12.0 | 承載 EwoooC 正式服務 | +| **pgvector DB** | 188 Docker | ✅ pg14 | AI KM `vector(1024)` | +| **Prometheus** | 110 Docker | ✅ | 指標收集 | +| **Grafana** | 110 Docker | ✅ | 儀表板 | +| **Alertmanager** | 110 Docker | ✅ | Telegram 告警 | +| **n8n** | 188 Docker | ✅ | 容器名 `n8n`,自動化任務 | +| **PostgreSQL Exporter** | 188 Docker | ✅ | 對 momo-db 做指標 | +| **自動修復** | 110 Cron | ✅ | domain-health-monitor.sh via SSH | + +--- + +## n8n 自動化工作流程 (2026-02-07 更新) + +### 概述 + +n8n 作為 **UAT 監控管理平台**,負責: +- UAT 服務監控與自動修復 +- 系統告警通知 (Telegram) + +| 類型 | Python Scheduler | n8n | +|------|-----------------|-----| +| 系統健康監控 | ❌ | ✅ 每 5 分鐘 | +| 資料爬蟲 | ✅ 專業爬蟲邏輯 | ❌ | +| Google Drive 匯入 | ✅ 複雜資料處理 | ✅ 匯入監控 | +| Telegram/LINE 通知 | ✅ 業績通知 | ✅ 系統告警 | +| 自動修復 | ❌ | ✅ 本地 kubectl | + +### 已部署工作流程 + +> **2026-04-18 更新**: 單一 UAT 環境監控 + 自動修復 + +#### 系統監控類 (12 個) + +| # | 工作流程 | 頻率 | 自動修復 | 說明 | +|---|----------|------|----------|------| +| 01 | 磁碟空間監控 | 每小時 | ✅ 自動清理 | 警告 80%、緊急 90% | +| 02 | SSL 證書監控 | 每日 09:00 | ❌ | 30 天內到期告警 | +| 03 | CI/CD Pipeline 通知 | Webhook | ❌ | CI/CD 成功/失敗通知(webhook 來源待定,GitLab 已撤除) | +| 04 | 資料庫備份監控 | 每日 10:00 | ❌ | 檢查最新備份狀態 | +| 05 | 爬蟲執行監控 | 每 2 小時 | ✅ 重啟 scheduler | 檢查爬蟲排程執行 | +| 06 | K8s Pod 狀態監控 | 每 10 分鐘 | ✅ 自動重啟 Pod | 應用健康檢查 | +| 07 | Docker Registry 健康檢查 | 每 30 分鐘 | ❌ | Registry 可用性 | +| 11 | Registry 健康監控 | 每 10 分鐘 | ❌ | 詳細 Registry 狀態 | +| 12 | Google Drive 匯入監控 | 每 30 分鐘 | ❌ | 自動匯入狀態追蹤 | +| 15 | Ollama/WebUI 健康監控 | 每 5 分鐘 | ✅ 自動重啟 | AI 服務監控 | +| 16 | K8s MOMO App 健康監控 | 每 5 分鐘 | ✅ 自動重啟 | 主應用深度監控 | +| 18 | Clawdbot 健康監控 | 每 5 分鐘 | ✅ 自動修復 | Bot 服務監控 | + +#### 環境監控類 + +| # | 工作流程 | 頻率 | 自動修復 | 說明 | +|---|----------|------|----------|------| +| 17 | UAT 健康監控 | 每 5 分鐘 | ❌ | 服務可用性檢查 | +| 18 | PostgreSQL 資料庫監控 | 每 15 分鐘 | ❌ | 資料庫狀態 | +| 20 | UAT 自動修復機制 | 每 5 分鐘 | ✅ 自動重啟 | 服務自我修復 | + +#### 定期報告類 (3 個) + +| # | 工作流程 | 頻率 | 說明 | +|---|----------|------|------| +| 08 | 每日系統狀態報告 | 每日 09:00 | 應用、備份、爬蟲狀態彙報 | +| 09 | 每週業績摘要 | 每週一 09:00 | 週業績、訂單數、成長率 | +| 10 | 月初作業提醒 | 每月 1 日 09:00 | 月結待辦事項提醒 | + +#### 其他工作流程 + +| # | 工作流程 | 說明 | +|---|----------|------| +| 13 | PostgreSQL 慢查詢監控 | 慢查詢告警 + 自動 VACUUM | +| 14 | Windows 遠端控制 | Telegram Bot 遠端控制 | +| 19 | UAT 頁面健康監控 | 關鍵頁面可用性檢查 | +| - | EwoooC 系統健康監控 | 整體系統狀態 | + +#### 工作流程檔案位置 + +``` +n8n-workflows/ +├── 01-disk-space-monitor.json # 磁碟空間監控 +├── 02-ssl-certificate-monitor.json # SSL 證書監控 +├── 03-cicd-pipeline-notify.json # CI/CD 通知 (Webhook) +├── 04-backup-monitor.json # 備份監控 +├── 05-crawler-monitor.json # 爬蟲監控 +├── 06-k8s-pod-monitor.json # K8s Pod 監控 +├── 07-registry-health.json # Registry 健康檢查 +├── 08-daily-system-report.json # 每日系統報告 +├── 09-weekly-sales-summary.json # 每週業績摘要 +├── 10-monthly-reminder.json # 月初提醒 +├── 11-harbor-health-monitor.json # Harbor 健康監控 +├── 12-google-drive-import-monitor.json # Google Drive 匯入監控 +├── 13-slow-query-monitor.json # PostgreSQL 慢查詢監控 +├── 14-ollama-health-monitor.json # Ollama 健康監控 +├── 17-uat-health-monitor.json # UAT 健康監控 +├── 18-postgres-health-monitor.json # PostgreSQL 資料庫監控 +└── 20-auto-repair-uat.json # UAT 自動修復機制 +``` + +#### 系統管理 API (供 n8n 呼叫) + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/system/cleanup` | POST | 自動清理 (Docker prune, 日誌輪替) | +| `/api/system/k8s/restart` | POST | 重啟 K8s Deployment | +| `/api/system/crawler/status` | GET | 爬蟲執行狀態 | +| `/api/system/backup/status` | GET | 備份狀態檢查 | +| `/api/system/ssl/check` | GET | SSL 證書檢查 | +| `/api/system/registry/health` | GET | Registry 健康檢查 | + +--- + +### 通知模板管理系統 + +#### 功能說明 + +提供 Web UI 管理 Telegram/LINE 通知訊息內容,可自訂 emoji、標題、內容格式。 + +#### 管理頁面 + +- URL: `/notification_templates` +- 功能: 編輯模板、預覽訊息、初始化預設模板 + +#### API 端點 + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/notification/templates` | GET | 取得所有模板 | +| `/api/notification/templates/` | GET | 取得單一模板 | +| `/api/notification/templates/` | PUT | 更新模板 | +| `/api/notification/render` | POST | 渲染模板 (n8n 呼叫) | + +#### 預設模板列表 (14 個) + +| 分類 | 模板代碼 | 說明 | +|------|----------|------| +| system | `disk_warning` | 磁碟空間警告 (80%) | +| system | `disk_critical` | 磁碟空間嚴重不足 (90%) | +| system | `cleanup_complete` | 自動清理完成 | +| system | `ssl_warning` | SSL 證書即將到期 | +| system | `pod_unhealthy` | K8s Pod 異常 | +| system | `pod_restart_result` | Pod 重啟結果 | +| system | `crawler_warning` | 爬蟲執行警告 | +| system | `registry_unhealthy` | Registry 異常 | +| system | `backup_warning` | 備份監控警告 | +| system | `cicd_success` | CI/CD 成功 | +| system | `cicd_failed` | CI/CD 失敗 | +| report | `daily_report` | 每日系統狀態報告 | +| business | `weekly_sales` | 每週業績摘要 | +| business | `monthly_reminder` | 月初作業提醒 | + +#### n8n 整合方式 + +在 n8n 中呼叫 `/api/notification/render` 取得格式化訊息後發送: + +```json +POST /api/notification/render +{ + "code": "disk_warning", + "variables": { + "usage_percent": 85, + "free_gb": 15, + "total_gb": 100 + } +} +``` + +--- + +### n8n 管理指令 + +```bash +# SSH 到 UAT +ssh wooo@192.168.0.110 + +# 查看 n8n 容器狀態 +docker ps | grep n8n + +# 查看 n8n 日誌 +docker logs momo-n8n --tail 50 + +# 列出所有工作流程 +docker exec momo-n8n n8n list:workflow + +# 手動觸發工作流程 +docker exec momo-n8n n8n execute --id=H7O3cl6sUPSLM7UD +``` + +### n8n API 存取 + +```bash +# 列出工作流程 +curl -H "X-N8N-API-KEY: wooo_n8n_api_2026" \ + http://192.168.0.110:5678/api/v1/workflows + +# 執行工作流程 +curl -X POST -H "X-N8N-API-KEY: wooo_n8n_api_2026" \ + http://192.168.0.110:5678/api/v1/workflows/H7O3cl6sUPSLM7UD/activate +``` + +--- + +### Docker Registry 詳細資訊 + +| 項目 | 值 | +|-----|-----| +| **外部 URL** | `https://registry.wooo.work` (HTTPS, 推薦) | +| 內部 URL | `http://127.0.0.1:5000` (僅本機可存取) | +| 帳號 | `admin` | +| 密碼 | `Wooo_Registry_2026` | +| 映像位置 | `registry.wooo.work/wooo/momo-pro-system:latest` | + +> ⚠️ **安全提醒**: Port 5000 已改為僅綁定 127.0.0.1,外部訪問必須透過 HTTPS 域名。 + +### Rancher (K8s 管理平台) + +| 項目 | 值 | +|-----|-----| +| URL | `https://192.168.0.110:8443` | +| 帳號 | `admin` | +| 密碼 | `0B5-z2m0yxle6HtTq5WA` | +| 功能 | K3s 集群管理、監控、日誌 | +| K3s 集群 | `momo-k3s` (已匯入) | +| Cluster ID | `c-52mz8` | +| K8s 版本 | `v1.34.3+k3s1` | + +### Registry 映像管理 +```bash +# 登入 Registry (使用 HTTPS 域名) +echo 'Wooo_Registry_2026' | docker login registry.wooo.work -u admin --password-stdin + +# 映像位置 (使用域名) +registry.wooo.work/wooo/momo-pro-system:latest +``` + +### CI/CD 流程(GitLab 已撤除,待定新方案) + +> **2026-04-18 更新**: GitLab CE + GitLab CI/CD 已從基礎設施移除,新的 CI/CD 方案尚未定案。 +> 目前部署以「本地 build + SCP + kubectl rollout restart」為暫時流程,相關腳本待 CI/CD 選型後重寫。 + +### 手動觸發 K8s 更新 +```bash +# SSH 到 UAT +ssh wooo@192.168.0.110 + +# 重啟 K8s Deployment(會自動拉取最新 Image) +kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo + +# 查看更新狀態 +kubectl rollout status deployment/momo-app -n momo +``` + +### Docker 容器(DevOps 服務) +| 容器名稱 | 說明 | Port | +|---------|------|------| +| registry | Docker Registry 容器 | 5000 | +| watchtower | Docker 映像自動更新 | - | +| n8n | Webhook 自動化 | 5678 | + +### K8s Pod(應用服務) +| Pod | 說明 | 資源限制 | +|-----|------|---------| +| momo-app | Flask 主應用 | 512Mi-4Gi / 200m-2000m | +| momo-postgres | PostgreSQL 資料庫 | 256Mi-1Gi / 100m-500m | +| momo-scheduler | 爬蟲排程服務 | 256Mi-2Gi / 100m-1000m | + +### K8s 配置檔案 +| 檔案 | 說明 | +|------|------| +| `k8s/02-configmap.yaml` | 環境變數配置(Ollama AI、密碼策略等) | +| `k8s/04-momo-app.yaml` | Flask 主應用 Deployment + Google Drive 認證 | +| `k8s/05-scheduler.yaml` | 爬蟲排程 Deployment + Google Drive 認證 | +| `k8s/08-google-drive-secret.yaml` | Google OAuth 認證 Secret | +| `k8s/nginx/monitor.conf` | VM Nginx 配置(含 Rancher 代理) | + +### Nginx 代理路徑(monitor.wooo.work) +| 路徑 | 後端服務 | Port | +|------|----------|------| +| `/grafana/` | Docker Grafana | 3000 | +| `/prometheus/` | Prometheus | 9090 | +| `/alertmanager/` | Alertmanager | 9093 | +| `/portainer/` | Portainer | 9000 | +| `/n8n/` | n8n | 5678 | +| `/pgadmin/` | pgAdmin | 8088 | +| `/loki/` | Loki | 3100 | +| `/rancher/` | Rancher | 8443 (HTTPS) | +| `/k8s-grafana/` | K8s Grafana | 30030 | + +--- + +## 關鍵路徑 + +| 用途 | 路徑 | +|-----|------| +| 主資料庫 | `data/momo_database.db` | +| UAT 資料庫 | `data/momo_database_uat.db` | +| Google Drive 認證 | `config/google_credentials.json` | +| 排程器程式 | `run_scheduler.py` | +| 排程統計 | `data/scheduler_stats.json` | +| 系統日誌 | `logs/system.log` | + +--- + +## Ollama AI 服務 + +| 項目 | 值 | +|-----|-----| +| Host | `http://192.168.0.188:11434` | +| 預設模型 | `llama3:70b-instruct-q2_K` | +| API Key | `0df8b4f247a4497998248f013ce92a17.vqSWDEK0RppTZIwcdT-ei-Sz` | +| 超時設定 | 基本 180 秒 / 文案生成 240 秒 | + +### K8s ConfigMap 設定 +```yaml +# k8s/02-configmap.yaml +OLLAMA_HOST: "http://192.168.0.188:11434" +OLLAMA_MODEL: "llama3:70b-instruct-q2_K" +``` + +### 環境變數 +```bash +OLLAMA_HOST=http://192.168.0.188:11434 +OLLAMA_MODEL=llama3:70b-instruct-q2_K +OLLAMA_API_KEY=0df8b4f247a4497998248f013ce92a17.vqSWDEK0RppTZIwcdT-ei-Sz +``` + +### Ollama 伺服器健康監控 (2026-01-28 新增) + +**監控腳本**: `/home/ollama/scripts/ollama_health_monitor.sh` +**排程**: 每 5 分鐘執行 (cron) +**n8n 工作流程**: `14-ollama-health-monitor.json` + +**監控項目**: +| 項目 | 端點 | 說明 | +|------|------|------| +| Nginx | 進程檢查 | 確保代理服務正常 | +| Ollama API | http://127.0.0.1:11434/api/tags | AI 模型服務 | +| Open WebUI | http://192.168.0.188/ | Web 介面 | + +**自動修復機制**: +| 問題 | 修復動作 | +|------|---------| +| Nginx 停止 | `sudo systemctl restart nginx` | +| Ollama 停止 | `systemctl restart ollama` | +| Open WebUI 502 (Docker 網路失效) | `sudo systemctl restart docker` | +| Open WebUI 容器卡住 | `docker restart open-webui` | + +**SSH 連線**: +```bash +ssh ollama@192.168.0.188 +# 密碼: 0936223270 +``` + +**手動檢查**: +```bash +# 執行健康檢查 +/home/ollama/scripts/ollama_health_monitor.sh + +# 查看日誌 +tail -f /var/log/ollama_health_monitor.log +``` + +### Ollama 伺服器安全加固 (2026-02-02 新增) + +**安全腳本**: `scripts/security/harden_ollama_server.sh` + +**已安裝防護**: +| 服務 | 配置 | 說明 | +|------|------|------| +| Fail2Ban | SSH 3 次失敗封鎖 1 小時 | 防暴力破解 | +| UFW 防火牆 | 預設拒絕入站 | 網路存取控制 | + +**UFW 規則**: +```bash +# 公開服務 +22/tcp # SSH +80/tcp # HTTP +443/tcp # HTTPS + +# 僅限內網 (192.168.0.0/24) +11434 # Ollama API +3000 # Open WebUI +5678 # n8n +8080 # SearXNG +``` + +**管理指令**: +```bash +# 查看 Fail2Ban 封鎖狀態 +sudo fail2ban-client status sshd + +# 查看防火牆規則 +sudo ufw status verbose +``` + +--- + +## Kali Linux DevSecOps 工作站 + +| 項目 | 值 | +|-----|-----| +| IP | `192.168.0.112` | +| 用戶 | `kali` | +| 用途 | 集中式安全掃描、滲透測試、弱點管理 | + +### SSH 連線 +```bash +ssh kali@192.168.0.112 +``` + +### 目錄結構 +``` +/home/kali/ +├── projects/ # 專案程式碼 (Git clone) +│ └── momo-pro-system/ # 主專案 +└── scripts/ # 自動化掃描腳本 + ├── port_monitor.py # 端口監控 + ├── port_baseline.json # 端口基線 + ├── code_security_scan.py # 程式碼安全掃描 (Bandit) + ├── registry_image_scan.py # 容器映像掃描 (Trivy) + └── logs/ # 掃描日誌 +``` + +### 自動化掃描工具 + +#### 1. 端口監控 (port_monitor.py) +- **執行頻率**: 每小時 +- **監控目標**: `192.168.0.110`, `192.168.0.188` +- **功能**: Nmap 掃描,與基線比對,發現新端口時發送 Telegram 告警 +- **Cron**: `0 * * * * /usr/bin/python3 /home/kali/scripts/port_monitor.py` + +#### 2. 程式碼安全掃描 (code_security_scan.py) +- **執行頻率**: 每日 08:00 +- **工具**: Bandit (Python 安全分析器) +- **功能**: 掃描 ~/projects 下所有專案,發送 Telegram 報告 +- **Cron**: `0 8 * * * /usr/bin/python3 /home/kali/scripts/code_security_scan.py` + +#### 3. Registry 映像掃描 (registry_image_scan.py) +- **執行頻率**: 每週日 09:00 +- **工具**: Trivy (容器弱點掃描器) +- **功能**: 掃描 Docker Registry 中的映像,發送 Telegram 報告 +- **Cron**: `0 9 * * 0 /usr/bin/python3 /home/kali/scripts/registry_image_scan.py` +- **掃描目標**: `registry.wooo.work/wooo/momo-pro-system:latest` + +### 基線端口清單 (2026-01-25 更新) + +**192.168.0.110** (外部可見): +| Port | Service | +|------|---------| +| 22 | SSH | +| 80 | HTTP | +| 443 | HTTPS (SSL, Registry/mo.wooo.work) | + +> ⚠️ Port 5050, 5678, 9100 已隱藏,僅本地可存取 + +**192.168.0.188** (外部可見): +| Port | Service | +|------|---------| +| 22 | SSH | +| 80 | HTTP | +| 443 | HTTPS | + +### 手動執行掃描 +```bash +# 端口掃描 +python3 ~/scripts/port_monitor.py + +# 程式碼安全掃描 (Bandit) +python3 ~/scripts/code_security_scan.py + +# Registry 映像掃描 (Trivy) +python3 ~/scripts/registry_image_scan.py +``` + +### WireGuard VPN (WG-Easy) + +| 項目 | 值 | +|-----|-----| +| 管理介面 | `http://192.168.0.112:51821` | +| 外部端點 | `114.32.151.246:51820/udp` | +| 密碼 | `Wooo_VPN_2026` | +| 允許網段 | `192.168.0.0/24`, `10.0.0.0/8` | +| DNS | `8.8.8.8`, `8.8.4.4` | + +**使用方式**: +1. 開啟管理介面 `http://192.168.0.112:51821` +2. 輸入密碼登入 +3. 點擊「+ New」建立新用戶 +4. 手機下載 WireGuard App,掃描 QR Code 即可連線 + +**Docker 容器管理**: +```bash +# 查看狀態 +docker ps --filter name=wg-easy + +# 重啟服務 +docker restart wg-easy + +# 查看日誌 +docker logs wg-easy +``` + +> ⚠️ **安全提醒**: 需要在路由器設定 Port Forwarding: 51820/UDP → 192.168.0.112:51820 + +--- + +## 🛡️ 服務變更安全協議 (2026-02-08 新增) + +> **核心原則**: 避免「改一個壞一個」,每次變更前必須確認所有相關服務狀態 + +### 服務依賴關係圖 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ UAT 服務依賴架構圖 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ +│ │ VM Nginx │ ← 所有外部流量入口 │ +│ │ (Port 80/443) │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ┌────────┴────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ ▼ ▼ ▼ │ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │mo.wooo │ │monitor. │ │registry. │ │ │ +│ │.work │ │wooo.work │ │wooo.work │ │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ │ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │K8s │ │Superset │ │Registry │ │ │ +│ │momo-app │ │Grafana │ │Docker │ │ │ +│ │ │ │Prometheus│ │ │ │ │ +│ └────┬─────┘ │n8n │ └──────────┘ │ │ +│ │ │Portainer │ │ │ +│ │ └──────────┘ │ │ +│ ▼ │ │ +│ ┌──────────┐ │ │ +│ │momo- │ │ │ +│ │postgres │ │ │ +│ └──────────┘ │ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + +⚠️ 重要依賴關係: +1. Nginx 故障 → 所有外部服務不可訪問 +2. Docker 故障 → Superset、n8n、Registry 全停 +3. K8s 故障 → momo-app、momo-scheduler、momo-postgres 全停 +4. Registry 故障 → K8s 無法拉取新映像 +``` + +### 變更前必做檢查清單 ✅ + +**在進行任何配置變更之前,必須執行以下檢查:** + +```bash +# 1. 記錄當前所有服務狀態(變更前快照) +ssh wooo@192.168.0.110 " +echo '=== 變更前服務狀態快照 ===' +echo '時間:' \$(date '+%Y-%m-%d %H:%M:%S') +echo '' +echo '--- Docker 容器 ---' +docker ps --format 'table {{.Names}}\t{{.Status}}' | head -20 +echo '' +echo '--- K8s Pods ---' +kubectl get pods -n momo --no-headers 2>/dev/null +echo '' +echo '--- 健康檢查 ---' +curl -s -o /dev/null -w 'mo.wooo.work: %{http_code}\n' https://mo.wooo.work/health +curl -s -o /dev/null -w 'Superset: %{http_code}\n' https://monitor.wooo.work/superset/login/ +curl -s -o /dev/null -w 'n8n: %{http_code}\n' http://127.0.0.1:5678/ 2>/dev/null || echo 'n8n: 連線失敗' +" | tee /tmp/pre_change_status.txt +``` + +### 服務恢復 SOP + +#### 1. Docker 容器停止 (Exit 128/137) + +**常見原因**: 系統重啟、Docker 重啟、OOM + +```bash +# 檢查停止的容器 +ssh wooo@192.168.0.110 "docker ps -a --filter status=exited" + +# 恢復 n8n (如有衝突需先刪除) +ssh wooo@192.168.0.110 "docker rm -f momo-n8n; cd /home/wooo && docker compose up -d n8n" + +# 恢復 Superset +ssh wooo@192.168.0.110 "cd /home/wooo/momo_pro_system/docker/superset && docker compose down && docker compose up -d" + +# 恢復 Registry +ssh wooo@192.168.0.110 "cd /home/wooo/devops/registry && docker compose up -d" +``` + +#### 2. Nginx 502 Bad Gateway + +**診斷流程**: +```bash +# Step 1: 確認後端容器狀態 +docker ps | grep + +# Step 2: 測試容器內部 +docker exec curl -s http://127.0.0.1:/health + +# Step 3: 如果內部正常但外部 502 = Docker 網路問題 +sudo systemctl restart docker +``` + +#### 3. Superset 路徑問題 (/superset/superset/) + +**根本原因**: Superset 內部路由已是 `/superset/...`,Nginx `proxy_redirect / /superset/` 會重複添加 + +**正確的 Nginx 配置**: +```nginx +location /superset/ { + proxy_pass http://127.0.0.1:8088/; + + # 關鍵:Superset 內部已是 /superset/,不需要再添加 + proxy_redirect /superset/ /superset/; + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + + # 只重寫 static 和 api 路徑 + sub_filter '"/static/' '"/superset/static/'; + sub_filter "'/static/" "'/superset/static/"; + sub_filter_once off; +} +``` + +**Superset 配置 (superset_config.py)**: +```python +ENABLE_PROXY_FIX = True +PROXY_FIX_CONFIG = { + "x_for": 1, + "x_proto": 1, + "x_host": 1, + "x_prefix": 0, # 必須為 0!Nginx 已處理前綴 +} +``` + +#### 4. K8s Pod 問題 + +```bash +# 查看 Pod 狀態 +kubectl get pods -n momo + +# 查看 Pod 詳情 +kubectl describe pod -n momo + +# 重啟 Deployment +kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo + +# 查看日誌 +kubectl logs -f deployment/momo-app -n momo --tail=100 +``` + +### 變更後驗證清單 ✅ + +**每次變更完成後,必須執行以下驗證:** + +```bash +# 完整服務健康檢查 +ssh wooo@192.168.0.110 " +echo '=== 變更後服務驗證 ===' +echo '時間:' \$(date '+%Y-%m-%d %H:%M:%S') +echo '' + +# Docker 容器 +echo '--- Docker 容器狀態 ---' +docker ps --format 'table {{.Names}}\t{{.Status}}' | head -15 + +# K8s +echo '' +echo '--- K8s Pods ---' +kubectl get pods -n momo --no-headers 2>/dev/null + +# 健康檢查 +echo '' +echo '--- 健康檢查 ---' +curl -s -o /dev/null -w 'mo.wooo.work: %{http_code}\n' https://mo.wooo.work/health +curl -s -o /dev/null -w 'Superset 登入: %{http_code}\n' https://monitor.wooo.work/superset/login/ +curl -s -o /dev/null -w 'n8n: %{http_code}\n' http://127.0.0.1:5678/ 2>/dev/null || echo 'n8n: 連線失敗' +curl -s -o /dev/null -w 'Registry: %{http_code}\n' https://registry.wooo.work/v2/ 2>/dev/null || echo 'Registry: 連線失敗' +curl -s -o /dev/null -w 'CI/CD Dashboard: %{http_code}\n' https://mo.wooo.work/cicd 2>/dev/null + +echo '' +echo '=== 驗證完成 ===' +" +``` + +### Claude 協作改進方案 + +> **2026-02-08 新增** - 回應用戶「改一個壞一個」的反饋 + +**1. 變更前自動快照** +- 在進行任何配置變更前,Claude 應主動執行「變更前必做檢查清單」 +- 將結果保存以便變更後對比 + +**2. 相關服務連帶檢查** +- 修改 Nginx → 同時檢查所有代理的服務 +- 修改 Docker Compose → 同時檢查相關容器 +- 修改 K8s YAML → 同時檢查 Pod 狀態 + +**3. 變更後完整驗證** +- 不只驗證目標服務,還要驗證所有可能受影響的服務 +- 使用「變更後驗證清單」確保沒有遺漏 + +**4. 文檔記錄** +- 每次修復重要問題後,立即更新 CLAUDE.md +- 記錄問題症狀、根本原因、解決方案 + +--- + +## 常見問題與解法 + +### 1. Scheduler 無法匯入 Google Drive 檔案 +**症狀**: 日誌顯示 `找不到認證檔案: config/google_credentials.json` + +**K8s 解法**: 使用 initContainer 從 Secret 複製認證到可寫入目錄 +```yaml +# k8s/04-momo-app.yaml & k8s/05-scheduler.yaml +initContainers: + - name: copy-google-credentials + image: busybox:1.36 + command: ['sh', '-c', 'cp /secrets/* /config/ && chmod 644 /config/*'] + volumeMounts: + - name: google-drive-secrets + mountPath: /secrets + readOnly: true + - name: momo-config-volume + mountPath: /config +volumes: + - name: google-drive-secrets + secret: + secretName: google-drive-credentials + - name: momo-config-volume + emptyDir: {} +``` + +**Docker 解法**: 確認 docker-compose.yml 中 scheduler 有掛載 config 目錄 +```yaml +scheduler: + volumes: + - ./config:/app/config:ro # 必須有這行 +``` + +### 2. 資料沒有同步到 realtime_sales_monthly +**症狀**: `daily_sales_snapshot` 有資料但 `realtime_sales_monthly` 沒有 + +**解法**: 檢查 `services/import_service.py` 中的同步邏輯 (約 line 404-459) + +### 3. LINE 通知發送失敗 +**症狀**: `You have reached your monthly limit` + +**原因**: LINE Notify 免費方案每月 1000 則限制,需等待下月重置 + +### 4. 502 Bad Gateway (Docker 網路問題) + +> **2026-01-28 新增** - 詳細排查過程與自動修復方案 + +**症狀**: +- 訪問 `https://mo.wooo.work/` 返回 `502 Bad Gateway` +- Nginx 無法連接到後端服務 + +**診斷步驟**: + +1. **檢查 Docker 容器狀態** + ```bash + ssh wooo@192.168.0.110 "sudo docker ps | grep momo" + ``` + 結果:容器顯示 `Up About a minute (healthy)`,看起來正常 + +2. **檢查外部端口連通性** + ```bash + curl -v http://127.0.0.1:5001/health + ``` + 結果:`Connection reset by peer` 或超時(約 30 秒) + +3. **檢查容器內部健康** + ```bash + docker exec momo-pro-system curl -s http://127.0.0.1:80/health + ``` + 結果:`{"status": "healthy"}` - 容器內部正常! + +4. **診斷結論** + - ✅ 容器內部服務正常運行 + - ❌ Docker 端口映射/網路轉發失效 + - Docker-proxy 進程存在但不轉發流量 + +**根本原因**: Docker 網路棧故障,通常發生在: +- 系統重啟後 Docker 網路未正確初始化 +- iptables 規則被其他服務清除 +- Docker daemon 內部狀態不一致 + +**解決方案**: +```bash +# 重啟 Docker 服務(會重建網路棧) +sudo systemctl restart docker + +# 等待容器自動啟動(約 30 秒) +sleep 30 + +# 驗證服務恢復 +curl -s https://mo.wooo.work/health +``` + +**自動修復腳本**: 見 `scripts/docker_health_monitor.sh` + +--- + +## 🔧 自動化監控與修復 + +### Docker 網路健康監控 (Cron) + +**腳本位置**: `/home/wooo/scripts/docker_health_monitor.sh` + +**功能**: +1. 每 5 分鐘檢查 mo.wooo.work 健康狀態 +2. 檢測到 502/超時時自動執行診斷 +3. 確認是 Docker 網路問題後自動重啟 Docker +4. 發送 Telegram 通知 + +**Cron 設定**: +```bash +# 每 5 分鐘執行健康檢查 +*/5 * * * * /home/wooo/scripts/docker_health_monitor.sh >> /var/log/docker_health_monitor.log 2>&1 +``` + +**監控邏輯**: +``` +外部健康檢查 (curl mo.wooo.work) + │ + ├── 成功 (HTTP 200) → 結束 + │ + └── 失敗 (502/超時) + │ + ├── 檢查容器內部 (docker exec curl) + │ │ + │ ├── 內部正常 → Docker 網路問題 → 重啟 Docker + │ │ + │ └── 內部失敗 → 應用問題 → 重啟容器 + │ + └── 發送 Telegram 告警 +``` + +**Telegram 告警範例**: +``` +🔴 MOMO Pro 服務異常 + +症狀: 502 Bad Gateway +診斷: Docker 網路轉發失效 +動作: 已自動重啟 Docker 服務 +時間: 2026-01-28 15:30:00 +``` + +### 5. K8s ImagePullBackOff (Registry 服務中斷) + +> **2026-01-28 新增** - Docker Registry 服務中斷導致 K8s 無法拉取映像檔 + +**症狀**: +- K8s Pods 顯示 `ImagePullBackOff` 或 `ErrImagePull` +- Google Drive 自動匯入停止運作 +- Scheduler 和 App 服務中斷 + +**診斷步驟**: + +1. **檢查 K8s Pod 狀態** + ```bash + sudo kubectl get pods -n momo + ``` + 結果:`momo-app` 和 `momo-scheduler` 顯示 `ImagePullBackOff` + +2. **查看詳細錯誤** + ```bash + sudo kubectl describe pod -n momo + ``` + 結果:`Failed to pull image: dial tcp 192.168.0.110:5000: connect: connection refused` + +3. **檢查 Registry 服務** + ```bash + docker ps | grep registry + ``` + 結果:Registry 容器未運行 + +**根本原因**: +- Docker Registry 服務停止(可能因系統重啟) +- Registry 端口配置改為 127.0.0.1:5000(安全配置)導致 K8s 無法連線 + +**解決方案**: + +```bash +# 1. 啟動 Registry +cd /home/wooo/devops/registry +docker compose up -d + +# 2. 更新 K8s 使用 HTTPS 域名 +# 修改 k8s/04-momo-app.yaml 和 k8s/05-scheduler.yaml +# 將 image: 192.168.0.110:5000/wooo/momo-pro-system:latest +# 改為 image: registry.wooo.work/wooo/momo-pro-system:latest + +# 3. 更新 registry secret +kubectl delete secret registry-secret -n momo +kubectl create secret docker-registry registry-secret \ + --docker-server=registry.wooo.work \ + --docker-username=admin \ + --docker-password=Wooo_Registry_2026 \ + -n momo + +# 4. 套用配置並重啟 +kubectl apply -f k8s/04-momo-app.yaml -f k8s/05-scheduler.yaml +kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo +``` + +**自動修復腳本**: 見 `scripts/registry_health_monitor.sh` + +### 6. Scheduler CrashLoopBackOff (run_scheduler.py 遺失) + +> **2026-02-02 新增** - Scheduler Pod 重啟 387 次 + +**症狀**: +- momo-scheduler Pod 顯示 CrashLoopBackOff +- 日誌:`python: can't open file '/app/run_scheduler.py': [Errno 2] No such file or directory` + +**原因**: `run_scheduler.py` 檔案在某次部署中遺失 + +**解決方案**: +```bash +# 1. 確認檔案存在 +ls /Users/ogt/momo-pro-system/run_scheduler.py + +# 2. 重建 Docker 映像 +cd /home/wooo/momo_pro_system +docker build -t registry.wooo.work/wooo/momo-pro-system:latest . +docker push registry.wooo.work/wooo/momo-pro-system:latest + +# 3. 重啟 scheduler +kubectl rollout restart deployment/momo-scheduler -n momo +``` + +### 7. vendor-stockout 500 錯誤 (模板路徑錯誤) + +> **2026-02-02 新增** - 模板檔案位置不正確 + +**症狀**: +- 訪問 `/vendor-stockout/` 返回 Internal Server Error +- 日誌:`jinja2.exceptions.TemplateNotFound: vendor_stockout/index.html` + +**原因**: 模板檔案放在根目錄 (`vendor_stockout_index.html`),而非正確路徑 (`templates/vendor_stockout/index.html`) + +**解決方案**: +```bash +# 建立正確目錄並移動模板 +mkdir -p templates/vendor_stockout +mv vendor_stockout_*.html templates/vendor_stockout/ + +# 重新命名為正確名稱 +mv templates/vendor_stockout/vendor_stockout_index.html templates/vendor_stockout/index.html +# ... 其他檔案同理 +``` + +### 8. Alertmanager 無法發送 Telegram 告警 + +> **2026-02-02 新增** - 訊息模板語法錯誤 + +**症狀**: +- Alertmanager 日誌持續報錯 +- 錯誤:`template: :12:27: can't evaluate field StartsAt in type *template.Data` + +**原因**: Telegram 訊息模板中 `.StartsAt` 放在 `{{ range .Alerts }}` 區塊外,而 `StartsAt` 是每個 Alert 的屬性 + +**解決方案**: +```yaml +# k8s/monitoring/values-prometheus.yaml + +# ❌ 錯誤寫法 +{{ range .Alerts }} +📝 摘要: {{ .Annotations.summary }} +{{ end }} +⏰ 時間: {{ .StartsAt.Local.Format "..." }} # 在 range 外,.StartsAt 不存在 + +# ✅ 正確寫法 +{{ range .Alerts }} +📝 摘要: {{ .Annotations.summary }} +⏰ 時間: {{ .StartsAt.Local.Format "..." }} # 在 range 內 +{{ end }} +``` + +**套用修復**: +```bash +# 更新 Helm values +scp k8s/monitoring/values-prometheus.yaml wooo@192.168.0.110:/home/wooo/momo_pro_system/k8s/monitoring/ + +# 升級 Prometheus stack +helm upgrade prometheus prometheus-community/kube-prometheus-stack \ + -n monitoring \ + -f /home/wooo/momo_pro_system/k8s/monitoring/values-prometheus.yaml + +# 重啟 Alertmanager +kubectl rollout restart statefulset/alertmanager-prometheus-kube-prometheus-alertmanager -n monitoring +``` + +--- + +## 🔧 Registry 自動監控 + +### Registry 健康監控 (Cron) + +**腳本位置**: `/home/wooo/scripts/registry_health_monitor.sh` + +**功能**: +1. 每 5 分鐘檢查 Registry 服務狀態 +2. 檢測到 Registry 停止時自動啟動 +3. 發送 Telegram 告警通知 + +**Cron 設定**: +```bash +# 每 5 分鐘執行 Registry 健康檢查 +*/5 * * * * /home/wooo/scripts/registry_health_monitor.sh >> /var/log/registry_health_monitor.log 2>&1 +``` + +--- + +## 排程任務設定 + +| 任務 | 頻率 | 說明 | +|-----|------|------| +| Google Drive 自動匯入 | 每 30 分鐘 | 匯入 `即時業績_當日.xlsx` | +| 商品看板爬蟲 | 每 1 小時 | 爬取 momo 商品資料 | +| EDM 限時搶購爬蟲 | 每 1 小時 | 爬取 EDM 活動 | +| 購物節活動爬蟲 | 每 6 小時 | 爬取節慶活動 | + +--- + +## 🔄 系統重開機自動啟動機制 (2026-01-31 新增) + +### 概述 + +系統重開機後,透過 systemd 服務自動啟動所有必要服務,確保服務可用性。 + +### 服務啟動順序 + +``` +重開機完成 + ↓ (30 秒延遲,等待系統穩定) +1. 確認 Docker 服務啟動 + ↓ +2. 停止 Supabase 非必要服務 (減少資源競爭) + ↓ +3. 清理並啟動 Docker Registry (等待 healthy) + ↓ +4. 重啟 K8s momo-app/scheduler (等待就緒) + ↓ +5. 啟動監控服務 + ↓ +6. 健康檢查 + Telegram 通知 +``` + +> ⚠️ **2026-04-18 備註(ADR-008)**: 以下 systemd 服務設定與路徑為 110 主機早期架構,188 的 Docker Compose 自帶 `restart: unless-stopped` 即可自動恢復,**目前 188 不需要此 systemd 腳本**。保留本章以供 110 周邊服務(Harbor/Sentry 等)啟動順序參考。 + +### 相關檔案(110 周邊服務用,若仍存在) + +| 檔案 | 位置 | 說明 | +|------|------|------| +| 啟動腳本 | `/home/wooo/momo_pro_system/scripts/tools/system_startup.sh` ⚠️(110 上路徑已空置,腳本存在與否需確認) | 舊啟動腳本 | +| systemd 服務 | `/etc/systemd/system/momo-startup.service` | 開機自動執行 | +| 日誌檔案 | `/var/log/momo_startup.log` | 啟動日誌 | + +### 188 主機啟動策略(實況) + +188 不需要客製 systemd 服務 — Docker Compose 的 `restart: unless-stopped` 已足夠: +```yaml +# docker-compose.yml(V12.0 已設定) +services: + momo-app: + restart: unless-stopped + momo-scheduler: + restart: unless-stopped + momo-db: + restart: unless-stopped +``` +開機後 Docker daemon 啟動 → 容器自動恢復 → 無需額外腳本介入。 + +### 故障排除 + +| 問題 | 解決方法 | +|------|---------| +| Registry 啟動失敗 | 手動執行 `cd /home/wooo/devops/registry && docker compose down && docker compose up -d` | +| K8s Pod 卡在 Init | 檢查 Registry 是否健康,重啟 Pod | +| 服務未自動啟動 | 確認 `systemctl is-enabled momo-startup.service` 返回 `enabled` | +| 查看詳細日誌 | `cat /var/log/momo_startup.log` 或 `journalctl -u momo-startup.service -n 100` | + +### 模組化輔助腳本庫 + +``` +deploy/lib/ +├── registry.sh # Registry 管理函數 +├── k8s.sh # K8s 管理函數 +├── monitoring.sh # 監控與 Telegram 通知 +└── systemd.sh # Systemd 服務管理 +``` + +--- + +## 已完成功能 (2026-02) + +- [x] SSL 憑證配置 (Let's Encrypt) +- [x] UAT 服務自動啟動腳本優化 +- [x] 服務依賴關係文檔建立 +- [x] **Harbor 移除,改用 Docker Registry** (registry.wooo.work) +- [x] **CI/CD Dashboard 完整版** (`/cicd` 頁面) +- [x] **監控告警環境標籤** + +--- + +## 已完成功能 (2026-01) + +- [x] 廠商缺貨郵件系統(含自訂模板編輯) +- [x] 缺貨清單分頁 UI(待發送/已發送分開) +- [x] 郵件發送歷史頁面(含內容預覽) +- [x] 每日業績 Telegram/LINE 通知(含趨勢 emoji) +- [x] 修復 scheduler config 目錄掛載問題 +- [x] PostgreSQL 資料庫支援(雙資料庫架構) +- [x] 商品看板效能優化(索引 + FileLock 快取鎖) +- [x] K8s Google Drive 認證(initContainer 架構) +- [x] Ollama AI 服務配置(192.168.0.188) +- [x] Rancher K8s 管理平台部署 +- [x] K3s 集群匯入 Rancher(momo-k3s) +- [x] CI/CD 安全掃描 (Bandit + Trivy) — 原 GitLab CI 已撤除,待新方案重建 +- [x] Kali Linux DevSecOps 工作站(192.168.0.112) +- [x] 自動化端口監控與程式碼掃描 (Cron) +- [x] Registry HTTPS 安全配置(隱藏 5000 端口) +- [x] WireGuard VPN (WG-Easy) 遠端存取 +- [x] 系統重開機自動啟動機制 (systemd + 健康檢查) + +--- + +## 更新記錄 (2026-01-22 ~ 02-13) + +> ⚠️ **2026-04-18 補正說明(ADR-008)**: 以下歷史紀錄多處提到「K8s 遷移」「kubectl rollout restart」「`/home/wooo/momo_pro_system/` 路徑」等,**經實地 SSH 審計確認 EwoooC 從未切換到 K3s/K8s**,實際始終是 188 上的 Docker Compose。相關歷史段落保留為「文件考古」,**不可據此執行任何部署動作**。最新部署 SOP 見本文件開頭「🌐 環境架構總覽」與「部署指令」章節。 + +### 2026-02-13:重開機服務修復與 Clawdbot 整合 + +#### 1. 系統啟動腳本更新 (v2.0) + +**問題**: 每次重開機後服務異常,Harbor 已移除但腳本仍嘗試啟動 + +**修復內容**: +- 更新 `scripts/tools/system_startup.sh` 為 v2.0 版本 +- 移除 Harbor 相關啟動邏輯 +- 改用 docker-registry 作為容器倉庫 +- 簡化啟動流程:Docker → Registry → n8n → Superset → K8s + +**啟動腳本新流程**: +``` +1. 檢查 Docker 服務 +2. 啟動 Docker Registry (docker-registry) +3. 啟動 n8n (momo-n8n) +4. 啟動 Superset (docker compose) +5. 重啟 K8s 應用 (momo-app, momo-scheduler) +6. 健康檢查 + Telegram 通知 +``` + +#### 2. Monitor 首頁更新 + +**問題**: monitor.wooo.work 顯示許多不存在的服務 (Grafana Docker, Portainer, Loki, Metabase 等) + +**修復**: +- 重新設計 Monitor 首頁,只顯示實際部署的服務 +- 移除不存在的服務連結:Docker Grafana、Portainer、Loki、Metabase、Nextcloud、Grist、pgAdmin、cAdvisor + +**目前顯示的服務**: +| 分類 | 服務 | +|------|------| +| 應用服務 | EwoooC (mo.wooo.work / momo.wooo.work) | +| 開發工具 | Docker Registry、n8n | +| 監控服務 | Grafana (K8s)、Prometheus、Alertmanager | +| BI 分析 | Apache Superset | + +#### 3. Harbor 完全移除 + +- 清理所有 Harbor 相關容器 +- 更新域名健康監控腳本移除 Harbor 檢查 +- 改用 docker-registry 容器 + +#### 4. Clawdbot 整合腳本 (Ollama Server) + +**新增腳本** (位於 `192.168.0.188:~/scripts/`): + +| 腳本 | 功能 | 使用方式 | +|------|------|---------| +| `health_check.sh` | 服務健康檢查 | `/ssh ~/scripts/health_check.sh` | +| `k8s_manage.sh` | K8s 管理 | `/ssh ~/scripts/k8s_manage.sh [pods\|logs\|restart]` | +| `backup_manage.sh` | 備份管理 | `/ssh ~/scripts/backup_manage.sh [status\|run]` | +| `sales_query.sh` | 業績查詢 | `/ssh ~/scripts/sales_query.sh [today\|status]` | +| `gemini_cost_report.sh` | Gemini 費用 | 每日 9:00 Cron 自動發送 | + +**SSH Key 設定**: +- Ollama → UAT SSH Key 已添加到 authorized_keys +- 允許 Clawdbot 透過 SSH 執行 K8s 管理命令 + +#### 5. 域名監控更新 + +**檔案**: `scripts/domain-health-monitor.sh` +- 移除 Metabase 監控(未部署) +- 目前監控服務:EwoooC App、Monitor、Registry、n8n、Superset + +#### 6. 監控告警異常修復 (2026-02-13 06:30) + +**發現的問題**: +1. **n8n 容器不存在** - 容器未設置自動重啟或被意外刪除 +2. **Superset 容器未啟動** - 容器意外停止且未自動重啟 +3. **監控腳本配置錯誤** - n8n 和 Superset 監控路徑錯誤(寫成 K8s 但實際是 Docker) + +**服務修復記錄**: +| 服務 | 問題 | 修復方式 | 修復時間 | +|------|------|---------|---------| +| n8n | 容器不存在 | `docker run -d --name momo-n8n ...` | 06:32 | +| Superset | 容器停止 | `cd docker/superset && docker compose up -d` | 06:33 | +| Clawdbot Gateway | 服務停止 | `systemctl --user start clawdbot-gateway` | 06:33 | + +**監控腳本重大更新** (`scripts/domain-health-monitor.sh`): + +```diff +# 舊配置(錯誤) +- ["https://monitor.wooo.work/n8n/healthz"]="200|n8n (K8s)|kubectl rollout restart ..." +- ["https://monitor.wooo.work/superset/health"]="200|Superset (K8s)|kubectl rollout restart ..." +- 固定等待時間 30 秒 + +# 新配置(正確) ++ ["http://127.0.0.1:5678/"]="200|n8n|docker start momo-n8n ..."|30" ++ ["http://127.0.0.1:8088/health"]="200|Superset|cd docker/superset && docker compose up -d|60" ++ 加入重試機制(最多 3 次,每次間隔 10 秒) +``` + +**服務優先級定義**: +| 優先級 | 服務 | 說明 | +|--------|------|------| +| 1 (最高) | EwoooC App | 核心業務 | +| 2 | Registry | 映像倉庫 | +| 3 | n8n | 自動化工作流程 | +| 4 | Superset | BI 分析平台 | +| 5 | Monitor 頁面 | 監控入口 | + +**最終服務狀態** (06:38 驗證): +| 服務 | 狀態碼 | 狀態 | +|------|--------|------| +| EwoooC App (mo) | 200 | ✅ 正常 | +| EwoooC App (momo) | 200 | ✅ 正常 | +| Registry | 401 | ✅ 正常 (需認證) | +| n8n | 200 | ✅ 正常 | +| Superset | 200 | ✅ 正常 | +| Monitor | 200 | ✅ 正常 | + +**變更檔案**: +- `scripts/domain-health-monitor.sh` - 修正監控路徑、增加等待時間、加入重試機制 + +#### 7. 下午修復 (2026-02-13 17:00) + +**Clawdbot Gateway 穩定性優化**: +- **問題**:Clawdbot Gateway 經常異常,出現鎖定檔案衝突 +- **原因**:舊進程未正確終止,留下 `~/.clawdbot/gateway.lock` +- **修復**:在 systemd service 添加 `ExecStartPre` 清理鎖定檔案 +- **位置**:`~/.config/systemd/user/clawdbot-gateway.service` (192.168.0.188) + +```ini +[Service] +ExecStartPre=/bin/bash -c 'pkill -9 clawdbot 2>/dev/null || true; rm -f ~/.clawdbot/gateway.lock 2>/dev/null || true; sleep 2' +ExecStart=/usr/bin/clawdbot gateway +Restart=always +RestartSec=15 +``` + +**已修復問題**: +| 問題 | 狀態 | 修復方式 | +|------|------|---------| +| Monitor Logo 破圖 | ✅ 已修復 | 創建 `/var/www/monitor/monitor-static/images/` 並複製 logo | +| Superset 404 | ✅ 已修復 | Nginx 添加 `/superset/` 重定向和 `/superset/login/` 代理規則 | +| Clawdbot Linger | ✅ 已修復 | `loginctl enable-linger ollama` 已啟用 | + +**Superset Nginx 配置修復**: +```nginx +# 根路徑重定向 +location /superset/ { + if ($request_uri = /superset/) { + return 302 /superset/welcome/; + } + # ... 其他配置 +} + +# 登入頁面代理到正確路徑 +location = /superset/login/ { + proxy_pass http://superset_backend/login/; + # ... 其他 headers +} +``` + +**待修復問題**: +| 問題 | 狀態 | 說明 | +|------|------|------| +| Clawdbot Telegram 衝突 | ⏳ 待修復 | Bot Token 與監控告警系統共用,需創建新的 Telegram Bot 給 Clawdbot | + +**最終服務狀態** (17:09 驗證): +| 服務 | 狀態碼 | 狀態 | +|------|--------|------| +| Monitor Logo | 200 | ✅ 正常 | +| Superset 根路徑 | 302 | ✅ 重定向正常 | +| Superset Login | 200 | ✅ 正常 | +| MOMO App | 200 | ✅ 正常 | + +#### 8. 晚間修復 (2026-02-13 19:30-20:00) + +**Superset 登入轉圈問題修復**: +- **問題**:Superset 登入頁面一直轉圈,無法登入 +- **原因**:`/static/` 靜態資源路徑返回 302 重定向而非正確的代理 +- **修復**:修改 Nginx `/static/` location 為直接代理到 Superset backend + +```nginx +# 修正後 +location ^~ /static/ { + proxy_pass http://superset_backend/static/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; +} +``` + +**n8n 工作流程重新匯入**: +- **問題**:n8n 只有 1 個工作流程(應有 29 個) +- **原因**:n8n 容器重建後資料遺失 +- **修復**:從 `n8n-workflows/` 目錄重新匯入所有 27+ 個工作流程 + +**Alertmanager 路由規則更新**: +- **問題**:Telegram 告警未正確發送到 momo namespace 的告警 +- **修復**:Helm upgrade prometheus-stack 套用正確的路由規則 + +**n8n executeCommand 節點問題**: +- **問題**:n8n 2.6.4 版本不支援 `executeCommand` 節點類型 +- **影響工作流程**:UAT 自動修復、Clawdbot 健康監控、UAT 頁面健康監控 +- **修復**:停用有問題的工作流程,改用 Cron 腳本自動修復(已正常運作) + +**自動修復機制確認**: +| 機制 | 狀態 | 說明 | +|------|------|------| +| Cron domain-health-monitor.sh | ✅ 運作中 | 每 5 分鐘檢查 7 個服務,自動修復 | +| n8n 磁碟空間監控 | ✅ 運作中 | 自動清理磁碟 | +| n8n 健康監控 | ✅ 運作中 | HTTP 檢查 UAT 服務 | +| Prometheus Alertmanager | ✅ 運作中 | K8s momo namespace 告警 → Telegram | +| n8n 自動修復工作流程 | ❌ 已停用 | executeCommand 不支援,改用 Cron | + +**最終驗證** (20:00): +- ✅ Superset 登入成功 +- ✅ 所有 7 個監控服務正常 +- ✅ Telegram 告警測試成功 +- ✅ Cron 自動修復機制運作正常 +- ✅ 29 個 n8n 工作流程已載入 + +#### 9. 進階自動修復系統部署 (2026-02-13 20:15) + +**新增自動修復腳本** (`scripts/auto-repair/`): + +| 腳本 | 功能 | 執行頻率 | +|------|------|---------| +| `oom-handler.sh` | OOM 檢測,自動增加記憶體 +50% 並重啟 | 每 15 分鐘 | +| `postgres-repair.sh` | 連線檢測、死鎖處理、VACUUM、週備份 | 每 30 分鐘 | +| `auto-rollback.sh` | 連續 5 次健康檢查失敗,自動回滾版本 | 每 5 分鐘 | +| `master-auto-repair.sh` | 主協調腳本 | 每 5 分鐘 | + +**Cron 設定**: +```bash +*/5 * * * * /home/wooo/scripts/auto-repair/master-auto-repair.sh >> /var/log/master_auto_repair.log 2>&1 +``` + +**自動修復能力總覽**: + +| 問題類型 | 自動修復方式 | 狀態 | +|---------|-------------|------| +| OOM 記憶體不足 | 自動增加限制 +50%,重啟 Pod | ✅ 已實作 | +| PostgreSQL 連線失敗 | 自動重啟 Pod | ✅ 已實作 | +| PostgreSQL 死鎖 | 終止長時間查詢 | ✅ 已實作 | +| 表膨脹/Dead Tuples | 自動 VACUUM ANALYZE | ✅ 已實作 | +| 程式碼 Bug (5次失敗) | 自動回滾到上一版本 | ✅ 已實作 | +| 服務無回應 | Cron 腳本自動重啟 | ✅ 已實作 | + +**新增 Prometheus 告警規則** (`k8s/monitoring/complete-alerting-rules.yaml`): + +完整的 Prometheus → Alertmanager → Telegram → 自動修復 流程配置: + +```yaml +# 告警規則群組 +- name: memory-alerts # OOM 相關告警 +- name: postgres-alerts # PostgreSQL 告警 +- name: app-health-alerts # 應用健康告警 +- name: infrastructure-alerts # 基礎設施告警 +``` + +| 告警名稱 | 觸發條件 | 告警等級 | 自動修復標籤 | +|---------|---------|---------|-------------| +| `PodOOMKilled` | OOMKilled 事件 | critical | `oom-handler` | +| `HighMemoryUsage` | 記憶體 > 85% | warning | 無 | +| `MemoryNearLimit` | 記憶體 > 95% | critical | `oom-handler` | +| `PostgresDown` | pg_up == 0 | critical | `postgres-repair` | +| `PostgresHighConnections` | 連線 > 80% | warning | `postgres-repair` | +| `PostgresDeadlocks` | 死鎖發生 | warning | `postgres-repair` | +| `PostgresSlowQueries` | 查詢 > 5 分鐘 | warning | `postgres-repair` | +| `MomoAppDown` | 健康檢查失敗 | critical | `auto-rollback` | +| `HighHTTP5xxRate` | 5xx > 5% | warning | `auto-rollback` | +| `PodRestartTooMany` | 1小時重啟>5次 | warning | `auto-rollback` | +| `DiskSpaceLow` | 磁碟 < 15% | warning | `disk-cleanup` | +| `DiskSpaceCritical` | 磁碟 < 5% | critical | `disk-cleanup` | +| `MomoSchedulerDown` | Scheduler 停止 | critical | `domain-health-monitor` | + +**監控 → 告警 → 自動修復 完整流程圖**: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 完整監控與自動修復架構 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 監控層 告警層 修復層 │ +│ ─────── ─────── ─────── │ +│ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────────────┐ │ +│ │Prometheus│ ──────▶│Alertmgr │ ──Telegram──▶│ Cron 自動修復 │ │ +│ │ 指標收集 │ │告警路由 │ │ master-auto- │ │ +│ └─────────┘ └─────────┘ │ repair.sh │ │ +│ │ │ └────────┬────────┘ │ +│ ▼ ▼ │ │ +│ PrometheusRule webhook (預留) ▼ │ +│ (告警規則) └── n8n 整合 ┌────────────┐ │ +│ │ oom-handler │ │ +│ ┌─────────┐ ┌─────────┐ │ postgres- │ │ +│ │域名健康 │ ──────▶│Telegram │ ◀─────────────│ repair │ │ +│ │監控腳本 │ │告警通知 │ │ auto- │ │ +│ └─────────┘ └─────────┘ │ rollback │ │ +│ (每5分鐘Cron) └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +**n8n 工作流程更新** (2026-02-13 20:30): + +- 重新匯入所有 27+ 個工作流程到 n8n +- 停用使用 `executeCommand` 節點的工作流程 (n8n 2.6.4 不支援) +- 停用的工作流程改用 Cron 自動修復機制代替: + - UAT 自動修復 → Cron: domain-health-monitor.sh + - Clawdbot 健康監控 → Cron: 已整合 + +**Git 提交記錄** (2026-02-13 21:00): + +``` +feat: 完整監控告警與自動修復系統 + +新增自動修復腳本 (scripts/auto-repair/): +- oom-handler.sh: OOM 自動增加記憶體 +- postgres-repair.sh: PostgreSQL 自動維護 +- auto-rollback.sh: 健康檢查失敗自動回滾 +- master-auto-repair.sh: 主協調腳本 + +新增 Prometheus 告警規則: +- k8s/monitoring/complete-alerting-rules.yaml +- 14 個告警規則覆蓋所有核心服務 +- 每個告警都有對應的自動修復標籤 +``` + +**已提交至 Git 版控**: ✅ + +--- + +### 2026-02-09:域名健康監控與服務修復 + +#### 1. 新增域名健康監控腳本 + +**腳本**: `scripts/domain-health-monitor.sh` +**Cron**: 每 5 分鐘執行 + +**功能**: +- 監控 8 個核心域名健康狀態 +- 自動發送 Telegram 告警 +- 嘗試自動修復(重啟容器/K8s Pod) +- 等待 30 秒驗證修復結果 + +#### 2. 更新 Nginx 配置 + +**檔案**: `docker/nginx/monitor-clean.conf` + +**變更**: +- 移除 Harbor 相關配置(Harbor 已廢棄) +- 新增 Docker Registry 反向代理(port 5002) +- 更新 Prometheus 使用 K8s ClusterIP (10.43.25.78:9090) +- 更新 Alertmanager 使用 K8s ClusterIP (10.43.79.187:9093) + +#### 3. Docker 服務修復 + +**n8n 容器重建**: +- 問題:Task Runner 403 錯誤 + 端口綁定問題 +- 修復:添加 `N8N_RUNNERS_DISABLED=true` 環境變數 +- 重建命令: +```bash +docker run -d --name momo-n8n --restart unless-stopped \ + -p 5678:5678 \ + -e N8N_BASIC_AUTH_USER=admin \ + -e N8N_BASIC_AUTH_PASSWORD=Wooo_N8n_2026 \ + -e N8N_RUNNERS_DISABLED=true \ + -v n8n_data:/home/node/.n8n \ + n8nio/n8n:latest +``` + +**Superset 容器修復**: +- 問題:Docker 重啟後容器停止 +- 修復:`cd /home/wooo/momo_pro_system/docker/superset && docker compose up -d` + +#### 4. 當前服務狀態 (2026-02-09 驗證) + +**正常運作的服務**: + +| 服務 | URL | 狀態碼 | +|------|-----|-------| +| EwoooC App (mo) | https://mo.wooo.work/health | 200 ✅ | +| EwoooC App (momo) | https://momo.wooo.work/health | 200 ✅ | +| Monitor 首頁 | https://monitor.wooo.work/ | 200 ✅ | +| Superset | https://monitor.wooo.work/superset/login/ | 200 ✅ | +| Metabase | https://monitor.wooo.work/metabase/ | 200 ✅ | +| n8n | http://192.168.0.110:5678/ | 200 ✅ | +| Registry | https://registry.wooo.work/v2/ | 401 ✅ | +| K8s Grafana | https://monitor.wooo.work/k8s-grafana/ | 302 ✅ | +| Prometheus | https://monitor.wooo.work/prometheus/ | 302 ✅ | +| Alertmanager | https://monitor.wooo.work/alertmanager/ | 200 ✅ | + +**未部署的服務**: +- Nextcloud: 無容器 +- Grist: 無容器 +- Docker Grafana: 無容器(使用 K8s Grafana) +- Portainer: 無容器 +- Loki: 無容器 +- cAdvisor: 無容器 +- Blackbox Exporter: 無容器 + +**已知問題**: +- `harbor-core` 容器卡在 restart loop(需 root 權限移除) +- `cattle-cluster-agent` CrashLoopBackOff(Rancher 相關,不影響核心服務) + +--- + +### 2026-02-08:Superset 子路徑代理修復 + +#### 1. 修復 Superset 雙重前綴問題 + +**問題**:訪問 `/superset/` 會跳轉到 `/superset/superset/welcome/` + +**根本原因**: +- Superset 內部 Flask blueprints 已使用 `/superset/...` 路由 +- `superset_config.py` 中 `x_prefix=1` 會再次添加前綴 +- Nginx `proxy_redirect / /superset/;` 也會重複添加 + +**修復方案**: +1. `superset_config.py`: 設置 `x_prefix=0` 禁用從 X-Forwarded-Prefix 讀取前綴 +2. `nginx-superset.conf`: 使用智能正則表達式保留已有 `/superset/` 前綴 + ```nginx + proxy_redirect /superset/ /superset/; + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + ``` + +#### 2. 修復 Superset 登入重定向問題 + +**問題**:訪問 `/superset/login/` 後跳轉到 `/login/` → 404 + +**根本原因**: +- Superset JavaScript bootstrap 資料中 `user_login_url: "/login/"` 沒有前綴 +- 瀏覽器直接訪問 `/login/` 時 Nginx 無法匹配到 Superset + +**修復方案**:在 Nginx 添加多個重定向規則 +```nginx +location = /login/ { + return 302 /superset/login/; +} +location = /logout/ { + return 302 /superset/logout/; +} +location ^~ /lang/ { + return 302 /superset$request_uri; +} +location ^~ /users/ { + return 302 /superset$request_uri; +} +``` + +**修改檔案**: +- `docker/superset/superset_config.py` - 設置 `x_prefix=0` +- `docker/superset/nginx-superset.conf` - 更新代理規則和重定向 + +**驗證結果**: +| 路徑 | 狀態 | +|------|------| +| `/superset/` | ✅ 302 → `/superset/welcome/` | +| `/superset/login/` | ✅ 200 (登入頁面) | +| `/login/` | ✅ 302 → `/superset/login/` | +| `/logout/` | ✅ 302 → `/superset/logout/` | + +--- + +### 2026-02-07:CI/CD Dashboard + 移除 Harbor + +#### 1. Harbor 完全移除,改用 Docker Registry + +**變更原因**:Harbor 資源佔用大、穩定性問題,改用輕量級 Docker Registry + +| 項目 | 舊配置 | 新配置 | +|------|--------|--------| +| Registry URL | `harbor.wooo.work` | `registry.wooo.work` | +| Port | 5050 | 5002 (內部) → Nginx 反向代理 | +| 認證 | Harbor 內建 | Nginx Basic Auth | +| 映像路徑 | `harbor.wooo.work/wooo/momo-pro-system:latest` | `registry.wooo.work/wooo/momo-pro-system:latest` | + +**新增/修改檔案**: +- `docker/registry/docker-compose.yml` - Docker Registry 配置 +- `docker/registry/config.yml` - Registry 儲存配置 +- `config/nginx/sites-available/registry` - Nginx 反向代理配置 + +#### 2. CI/CD Dashboard 完整版 + +**URL**: `https://mo.wooo.work/cicd` + +**功能**: +- 即時 Pipeline 流程視覺化(Test → Build → Deploy) +- UAT/PROD 環境健康狀態卡片 +- Pod 狀態監控(透過 SSH 查詢) +- 快速操作按鈕(部署 UAT/PROD、回滾) +- 成功率統計、今日部署次數 +- 10 秒自動刷新 + +**新增檔案**: +- `routes/cicd_routes.py` - CI/CD API 路由 (1354 行) +- `templates/cicd_dashboard.html` - Dashboard 前端頁面 + +**API 端點**: +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/cicd/status` | GET | 取得完整 CI/CD 狀態 | +| `/api/cicd/pipelines` | GET | 取得最近 Pipeline 列表 | +| `/api/cicd/environments` | GET | 取得所有環境狀態 | +| `/api/cicd/deploy/` | POST | 觸發部署 | +| `/api/cicd/rollback/` | POST | 回滾部署 | + +#### 3. CI/CD UAT 自動部署(歷史紀錄) + +> **2026-04-18 備註**: 當時的 CI/CD 建構在 GitLab CI 之上,GitLab 已撤除,以下內容僅作歷史參考。 + +**當時流程**:push main → UAT 自動部署 + +**當時 Pipeline 階段**: +``` +push main + │ + ├─ test (pytest) + ├─ security-scan (Bandit) + │ + ├─ build (docker build + push to registry.wooo.work) + │ + └─ deploy-uat (SSH → git pull → kubectl restart) +``` + +#### 4. 監控告警標籤 + +**Alertmanager (Prometheus) 告警模板**: +``` +🏢 環境: UAT (mo.wooo.work) +``` + +**n8n 頁面健康監控**: +- UAT 監控:`n8n-workflows/17-page-health-monitor.json` + - 告警標題:`🔴 *EwoooC 頁面異常*` + - 標籤:`(mo.wooo.work / momo.wooo.work)` + +#### 5. wooo 用戶 kubectl 無密碼訪問設定 + +```bash +# 設定 kubeconfig +sudo cp /etc/rancher/k3s/k3s.yaml /home/wooo/.kube/config +sudo chown wooo:wooo /home/wooo/.kube/config + +# 加入 bashrc +echo 'export KUBECONFIG=/home/wooo/.kube/config' >> ~/.bashrc +``` + +**修改檔案清單**: +- `app.py` - 註冊 CI/CD Blueprint +- `routes/cicd_routes.py` - 新增 +- `templates/cicd_dashboard.html` - 新增 +- `n8n-workflows/17-page-health-monitor.json` - 環境標籤 +- `k8s/monitoring/values-prometheus.yaml` - Alertmanager 環境標籤 + +#### 6. PostgreSQL 監控部署 (新增) + +**部署內容**: +- `k8s/monitoring/postgres-exporter.yaml` - PostgreSQL Exporter Deployment + ServiceMonitor +- `k8s/monitoring/postgres-alerting-rules.yaml` - 資料庫告警規則 + +**監控指標**: +| 指標 | 閾值 | 告警等級 | +|------|------|----------| +| 連線數 | > 80 | Warning | +| 連線數 | > 95 | Critical | +| 資料庫停機 | pg_up == 0 | Critical | +| 資料庫大小 | > 5GB | Warning | +| 資料庫大小 | > 8GB | Critical | +| 死鎖 | > 0 | Warning | +| 表膨脹 (Dead Tuples) | > 100,000 | Warning | + +#### 7. n8n 工作流程匯入完成 (22個) + +**原有工作流程 (20個)**: +- 系統監控:磁碟空間、SSL 證書、CI/CD 通知、備份、爬蟲、K8s Pod、Harbor +- 定期報告:每日系統報告、每週業績、月初提醒 +- AI 監控:Ollama 健康、K8s 健康、慢查詢 + +**新增工作流程 (2個)**: +- `17-uat-health-monitor.json` - UAT 健康監控 +- `18-postgres-health-monitor.json` - PostgreSQL 資料庫監控 + +**工作流程匯入方式**: +```bash +docker exec momo-n8n n8n import:workflow --input=/home/node/n8n-workflows/.json +``` + +#### 8. 集中式監控架構 + +**架構決策**:所有監控告警統一由 UAT 主機管理 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UAT 主機 (192.168.0.110) │ +│ 統一監控與排程中心 │ +├─────────────────────────────────────────────────────────────────┤ +│ Prometheus → Alertmanager → Telegram │ +│ n8n 工作流程 │ +│ • 監控 EwoooC App (mo.wooo.work / momo.wooo.work) │ +│ • PostgreSQL 健康檢查 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**分工原則**: +| 類型 | 工具 | 位置 | +|------|------|------| +| 監控告警 | Prometheus + n8n | UAT 集中管理 | +| 業務排程 | momo-scheduler | K8s 內執行 | + +--- + +### 2026-02-02:監控系統修復與安全加固 + +**修復項目**: + +#### 1. K8s Scheduler CrashLoopBackOff 修復 +- **問題**:momo-scheduler Pod 重啟 387 次,持續 CrashLoopBackOff +- **原因**:`run_scheduler.py` 檔案遺失 +- **解決**:建立新的 `run_scheduler.py` 獨立排程腳本 +- **新增檔案**:`run_scheduler.py` + +```python +# run_scheduler.py 排程任務 +- 每小時:主站爬蟲、EDM 爬蟲 +- 每 6 小時:購物節爬蟲 +- 每 30 分鐘:Google Drive 自動匯入、網頁白頁監控 +``` + +#### 2. vendor-stockout 500 錯誤修復 +- **問題**:訪問 `/vendor-stockout/` 返回 Internal Server Error +- **原因**:模板位置錯誤 (`vendor_stockout_index.html` 應為 `templates/vendor_stockout/index.html`) +- **解決**:建立 `templates/vendor_stockout/` 目錄並移動所有模板 + +``` +templates/vendor_stockout/ +├── index.html +├── list.html +├── history.html +├── import.html +├── send_email.html +└── vendor_management.html +``` + +#### 3. Alertmanager 無法發送 Telegram 告警修復 +- **問題**:Alertmanager 日誌持續報錯 `can't evaluate field StartsAt in type *template.Data` +- **原因**:Telegram 訊息模板錯誤,`.StartsAt` 放在 `{{ range .Alerts }}` 區塊外 +- **解決**:修正模板,將 `.StartsAt` 移入 range 區塊內 +- **修改檔案**:`k8s/monitoring/values-prometheus.yaml` + +```yaml +# 修正前(錯誤) +{{ range .Alerts }}...{{ end }} +⏰ 時間: {{ .StartsAt.Local.Format "..." }} # 錯誤:在 range 外 + +# 修正後(正確) +{{ range .Alerts }} +... +⏰ 時間: {{ .StartsAt.Local.Format "..." }} # 正確:在 range 內 +{{ end }} +``` + +#### 4. n8n 配置損壞修復 +- **問題**:n8n 容器持續重啟,配置檔案損壞 +- **解決**:重新寫入正確的 JSON 配置 + +#### 5. 伺服器安全加固 + +**192.168.0.188 (Ollama Server)**: +- ✅ 安裝 Fail2Ban (SSH 3 次失敗封鎖 1 小時) +- ✅ 安裝 UFW 防火牆 +- ✅ AI 服務限制內網存取 (192.168.0.0/24) + +**192.168.0.110 (UAT Server)**: +- ✅ Fail2Ban 已運行 (確認配置正確) +- ✅ SSH + Nginx 雙重保護 + +**UFW 規則 (192.168.0.188)**: +| 端口 | 服務 | 存取範圍 | +|------|------|---------| +| 22 | SSH | 公開 | +| 80/443 | HTTP/HTTPS | 公開 | +| 11434 | Ollama API | 僅內網 | +| 3000 | Open WebUI | 僅內網 | +| 5678 | n8n | 僅內網 | +| 8080 | SearXNG | 僅內網 | + +**監控系統現況**: +``` +Prometheus (抓取指標) → PrometheusRule (告警規則) → Alertmanager → Telegram Bot +``` + +目前告警規則: +- PodOOMKilled, PodRestartTooMany, PodNotReady, PodPending +- HighMemoryUsage, MemoryNearLimit, HighCPUUsage +- PostgresDown, PVCSpaceLow +- MomoAppDown, HighHTTP5xxRate +- NodeMemoryLow, NodeDiskLow, NodeNotReady + +--- + +### 2026-01-31:系統重開機自動啟動機制 + +**問題發現**: +- 伺服器重開機後 Registry 容器未正常啟動 (Exit 128) +- K8s Pod 顯示 `ImagePullBackOff` 無法拉取映像 +- mo.wooo.work 返回 502 Bad Gateway +- 系統負載過高 (load average 112) + +**根本原因**: +1. Registry 容器重開機後未自動啟動 +2. Supabase 不必要服務佔用大量資源 +3. 沒有統一的服務啟動順序管理 + +**解決方案**: +1. 建立 `scripts/tools/system_startup.sh` 統一啟動腳本 +2. 建立 `momo-startup.service` systemd 服務 +3. 啟動順序:Docker → 停止 Supabase 多餘服務 → Registry → K8s → 監控 +4. 每個服務啟動後進行健康檢查 +5. 啟動完成後發送 Telegram 通知 + +**新增檔案**: +- `scripts/tools/system_startup.sh` - 系統啟動腳本 +- `scripts/tools/momo-startup.service` - systemd 服務定義 +- `deploy/lib/registry.sh` - Registry 管理函數庫 +- `deploy/lib/k8s.sh` - K8s 管理函數庫 +- `deploy/lib/monitoring.sh` - 監控與通知函數庫 +- `deploy/lib/systemd.sh` - Systemd 服務管理函數庫 + +**部署步驟**: +```bash +# 1. 上傳腳本 +scp scripts/tools/system_startup.sh wooo@192.168.0.110:/home/wooo/momo_pro_system/scripts/tools/ + +# 2. 安裝 systemd 服務 +sudo cp /tmp/momo-startup.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable momo-startup.service +``` + +**驗證方式**: +```bash +# 確認服務已啟用 +systemctl is-enabled momo-startup.service # 應返回 enabled + +# 查看啟動日誌 +sudo journalctl -u momo-startup.service +``` + +--- + +### 2026-01-28:502 Bad Gateway 排查與自動修復 + +**問題發現**: +- mo.wooo.work 返回 502 Bad Gateway +- Nginx 無法連接到後端 Docker 容器 + +**排查過程**: +1. 檢查 Docker 容器狀態 → 顯示 healthy +2. 測試外部端口 `curl 127.0.0.1:5001` → Connection reset by peer +3. 測試容器內部 `docker exec curl` → HTTP 200 正常 +4. 診斷結論:Docker 網路轉發失效 + +**解決方案**: +```bash +sudo systemctl restart docker +``` + +**新增功能**: +- 新增自動化監控腳本 `scripts/docker_health_monitor.sh` +- 每 5 分鐘自動檢查服務健康狀態 +- 檢測到 Docker 網路問題時自動修復 +- 發送 Telegram 告警通知 + +**修改檔案**: +- `CLAUDE.md` - 新增問題 #4, #5 和自動修復文檔 +- `scripts/docker_health_monitor.sh` - Docker 網路監控腳本 +- `scripts/registry_health_monitor.sh` - Registry 服務監控腳本 +- `k8s/04-momo-app.yaml` - 改用 registry.wooo.work 域名 +- `k8s/05-scheduler.yaml` - 改用 registry.wooo.work 域名 + +--- + +### 2026-01-28:Registry 服務中斷與 K8s 映像拉取修復 + +**問題發現**: +- Google Drive 自動匯入功能停止 +- K8s Pods 顯示 `ImagePullBackOff` + +**根本原因**: +1. Docker Registry 服務停止 +2. Registry 端口綁定 127.0.0.1:5000(安全配置) +3. K8s 嘗試從 192.168.0.110:5000 拉取映像失敗 + +**解決方案**: +1. 啟動 Registry: `cd /home/wooo/devops/registry && docker compose up -d` +2. K8s 配置改用 HTTPS 域名 `registry.wooo.work` +3. 重建 registry secret 使用新域名 +4. 重啟 K8s Deployments + +**預防措施**: +- 建立 Registry 健康監控腳本 +- 加入 cron 排程自動檢查 +- UAT crontab - 新增排程任務 + +--- + +### 2026-01-28:當日業績匯入 OOM 問題修復 + +**問題發現**: +- 自動匯入通知發送成功,但 `/daily_sales` 頁面顯示「資料表為空」 +- 匯入任務卡在 60% 進度 + +**排查過程**: +1. 檢查 `import_jobs` 表發現任務狀態為 `importing`,進度 60% +2. 查看 Docker 日誌發現 `Worker was sent SIGKILL! Perhaps out of memory?` +3. 確認 OOM 導致匯入進程被殺死 + +**解決方案**: +1. 找到暫存檔案 `/app/data/temp/即時業績_當日.xlsx`(匯入中途失敗但已下載) +2. 手動觸發匯入: + ```python + from services.import_service import import_service + import os + file_path = '/app/data/temp/即時業績_當日.xlsx' + job_id = import_service.create_import_job('daily_sales', None, '即時業績_當日.xlsx', os.path.getsize(file_path)) + import_service.process_daily_sales_import(job_id, file_path) + ``` +3. 成功匯入 19,099 筆資料(2026-01-01 ~ 2026-01-27) + +**資料流向說明(重要)**: +``` +Excel 檔案 → import_service.process_daily_sales_import() + │ + ├→ daily_sales_snapshot (主資料表,77欄位,含 snapshot_date) + │ + └→ realtime_sales_monthly (副本,28欄位,供業績分析儀表板) +``` +- **正確方向**:`daily_sales_snapshot` → `realtime_sales_monthly`(同步寫入) +- **禁止反向操作**:不可從 `realtime_sales_monthly` 同步回 `daily_sales_snapshot` + +**修改檔案**: +- `services/import_service.py` - 新增日期範圍追蹤 +- `scheduler.py` - 增強匯入通知格式 +- `scripts/google_drive_monitor.sh` - Google Drive 認證監控 +- `scripts/system_health_monitor.sh` - 統一健康監控腳本 + +--- + +### 2026-01-28:Ollama 502 Bad Gateway 修復與監控機制 + +**問題發現**: +- `http://192.168.0.188/` 返回 502 Bad Gateway +- Ollama API `http://192.168.0.188:11434/api/tags` 正常回應 + +**診斷過程**: +1. 檢查 nginx 進程 → 正常運行 +2. 檢查 nginx error.log → `recv() failed (104: Unknown error)` 指向 `127.0.0.1:3000` +3. 發現 nginx 代理到 **Open WebUI** 容器 (port 3000),非直接代理 Ollama +4. 檢查 Open WebUI 容器 → 容器內部正常,但外部無法連接 +5. 診斷結論:**Docker 端口映射失效** + +**解決方案**: +```bash +ssh ollama@192.168.0.188 +sudo systemctl restart docker +# 等待 60 秒後所有容器恢復 +``` + +**新增監控機制**: +- **監控腳本**: `scripts/ollama_health_monitor.sh` +- **n8n 工作流程**: `n8n-workflows/14-ollama-health-monitor.json` +- **排程**: 每 5 分鐘執行 (cron) +- **自動修復**: + - Open WebUI 502 → 重啟 Docker daemon + - Open WebUI 容器卡住 → 重啟容器 + - Nginx 停止 → 重啟 nginx + - Ollama 停止 → 重啟 Ollama + +**部署到 192.168.0.188**: +```bash +# 1. 上傳監控腳本 +scp scripts/ollama_health_monitor.sh ollama@192.168.0.188:/home/ollama/scripts/ + +# 2. 設定 cron +*/5 * * * * /home/ollama/scripts/ollama_health_monitor.sh >> /var/log/ollama_health_monitor.log 2>&1 + +# 3. 設定 sudoers(無密碼重啟) +ollama ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart docker, /usr/bin/systemctl restart nginx, /usr/bin/systemctl restart ollama +``` + +**修改檔案**: +- `scripts/ollama_health_monitor.sh` - 新增 Ollama/Open WebUI 監控腳本 +- `n8n-workflows/14-ollama-health-monitor.json` - 新增 n8n 工作流程 +- `CLAUDE.md` - 更新 Ollama 監控文檔 + +--- + +### 2026-01-25:DevSecOps 安全強化 + WireGuard VPN + +**Registry 安全配置**: +- 端口 5000 改為僅綁定 127.0.0.1 (內部) +- 外部訪問改用 `registry.wooo.work` (HTTPS) +- CI/CD 腳本更新使用域名 +- 減少攻擊面 + +**WireGuard VPN (WG-Easy)**: +- 部署於 Kali Linux (192.168.0.112) +- 管理介面:`http://192.168.0.112:51821` +- 外部端點:`114.32.151.246:51820/udp` +- QR Code 掃描連線 + +**CI/CD 安全掃描(已退役,待新方案重建)**: +- 曾在 GitLab CI 上設置 `security-code-scan`(每次 Push 執行 Bandit)與 `container-security-scan`(每週 Trivy 容器弱點掃描) +- 掃描結果自動發送 Telegram 通知 +- 隨 GitLab 撤除而停用,後續需在新 CI/CD 平台重建 + +**SSL/TLS 安全審核 (SSLyze)**: +- mo.wooo.work 掃描結果:✅ 全部通過 +- SSL 2.0/3.0, TLS 1.0/1.1 已禁用 +- TLS 1.2/1.3 啟用強加密套件 +- HSTS 已啟用,無 Heartbleed/CRIME 弱點 + +**Trivy 容器掃描結果**: +- 掃描 `192.168.0.110:5050/wooo/momo-pro-system:latest` +- HIGH: 56 (Debian 基礎映像) +- CRITICAL: 0 +- Python 套件: 0 弱點 + +**Kali Linux DevSecOps 工作站 (192.168.0.112)**: +- 建立集中式安全掃描環境 +- 部署 `port_monitor.py`:Nmap 端口監控,每小時執行 +- 部署 `code_security_scan.py`:Bandit 程式碼掃描,每日執行 +- 建立端口基線:9 個端口 (192.168.0.110: 7, 192.168.0.188: 2) +- 程式碼掃描結果:16 HIGH, 65 MEDIUM, 72 LOW + +--- + +### 2026-01-24:K8s 配置優化 + Rancher 部署 + +**K8s Google Drive 認證修復**: +- 問題:K8s Secret 為唯讀,OAuth Token 無法刷新 +- 解法:使用 initContainer 將認證複製到 emptyDir(可寫入) +- 修改檔案:`k8s/04-momo-app.yaml`、`k8s/05-scheduler.yaml` +- 新增 Secret:`google-drive-credentials` + +**Ollama AI 配置更新**: +- Host 更新為 `http://192.168.0.188:11434`(原本錯誤指向 192.168.0.110) +- 模型更新為 `llama3:70b-instruct-q2_K` +- 修改檔案:`k8s/02-configmap.yaml` + +**n8n 白頁修復**: +- 問題:子路徑 `/n8n/` 顯示空白頁面 +- 解法:新增環境變數 `N8N_PATH_PREFIX=/n8n` +- 修改檔案:UAT `docker-compose.yml` + +**Rancher 部署 + K3s 匯入**: +- 安裝 Rancher v2.13.1 via Docker +- URL:`https://192.168.0.110:8443` +- K3s 集群 `momo-k3s` 已成功匯入 +- Cluster ID:`c-52mz8` +- 修改檔案:`k8s/nginx/monitor.conf`(新增 `/rancher/` 代理) + +--- + +### 2026-01-24:K8s 遷移完成 + +**重大變更**: +- ✅ **K3s 部署完成** - 版本 v1.34.3+k3s1 +- ✅ **PostgreSQL 資料遷移完成** - 資料庫: `momo_analytics` +- ✅ **VM Nginx 配置更新** - 指向 K8s ClusterIP + +**K8s 資源**: +| 資源 | 說明 | +|------|------| +| momo-app | Flask 應用 Deployment | +| momo-postgres | PostgreSQL StatefulSet | +| momo-scheduler | 爬蟲排程 Deployment | +| momo-data-pvc | 應用資料 PVC (5Gi) | +| postgres-pvc | 資料庫 PVC (10Gi) | + +**網路架構**: +``` +用戶 → mo.wooo.work:443 → VM Nginx (SSL) → K8s ClusterIP 10.43.238.49:80 → momo-app +``` + +**備份位置**: +- `/home/wooo/backups/pre-k8s-migration/` +- 回滾腳本: `quick_rollback.sh` + +--- + +### 2026-01-22:CI/CD 初版部署(已退役) + +> **2026-04-18 備註**: 原本以 GitLab CE + Docker Registry + Watchtower 為核心的 CI/CD 架構已撤除,此段僅作歷史紀錄。Docker Registry 與 Watchtower 仍保留在 UAT 運作。 + +**當時架構重點**: +- 自建 Git 伺服器 + CI/CD 平台(已撤除) +- Docker Registry: 私有 Container Registry(保留,位於 registry.wooo.work) +- Watchtower: 自動偵測 Registry 映像更新並部署(保留) +- 部署位置: UAT Server (192.168.0.110) + +**當時 CI/CD 流程**: +``` +git push → CI 測試 → 構建 Docker Image → 推送到 Registry → Watchtower 自動更新容器 +``` + +--- + +### 2026-01-22:PostgreSQL 相容性修復 + +**問題**:`sales_analysis` 頁面的「星期幾」和「時段」篩選功能在 PostgreSQL 環境下失效 + +**原因**:SQLite 使用 `strftime('%w', timestamp)` 取得星期幾,PostgreSQL 使用 `EXTRACT(dow FROM timestamp)` + +**修復內容**: +1. 更新 `app.py` 中的 `get_sales_table_data()` 函數 +2. 根據 `DATABASE_TYPE` 動態選擇正確的 SQL 語法 +3. 修復 SQLite 日期字串格式問題(PostgreSQL 返回 `datetime` 物件) + +**修改檔案**: +- `app.py` (約 line 2800-2900) +- `config.py` - 新增 `DATABASE_TYPE` 變數 + +**驗證**:API `/api/sales_table?dow=1&hour=14` 在 PostgreSQL 環境正常運作 + +--- + +### 2026-01-23:商品看板效能優化 + +**問題**:商品看板首頁載入緩慢(約 3-4 秒) + +**診斷結果**: +- 資料庫有 5,510 商品、609,173 價格記錄 +- `price_records` 表缺少 `product_id` 索引 +- 多個 gunicorn worker 同時重建快取 + +**優化措施**: + +#### 1. PostgreSQL 索引優化 +```sql +CREATE INDEX CONCURRENTLY idx_price_records_product_id ON price_records(product_id); +CREATE INDEX CONCURRENTLY idx_price_records_product_timestamp ON price_records(product_id, timestamp DESC); +CREATE INDEX idx_products_status ON products(status); +``` + +#### 2. 跨進程快取鎖定(FileLock) +- 將 `threading.Lock()` 替換為 `fcntl.flock()` 檔案鎖 +- 解決多 gunicorn worker 同時重建快取的問題 +- 鎖定檔案位置:`data/.dashboard_cache.lock` + +**修改檔案**: +- `routes/dashboard_routes.py` - 新增 `FileLock` 類別,更新 `get_full_dashboard_data()` 函數 + +**效能改善**: +| 情境 | 優化前 | 優化後 | +|------|--------|--------| +| 快取命中 | ~1 秒 | ~1 秒 | +| 快取重建(單一 worker)| ~3-4 秒 | ~3-4 秒 | +| 快取重建(多 worker)| 每個各 3-4 秒 | 只有一個重建 | + +--- + +### FileLock 實作細節 + +```python +import fcntl + +class FileLock: + """簡單的檔案鎖,用於 gunicorn 多進程環境""" + def __init__(self, lock_file): + self.lock_file = lock_file + self.fd = None + + def acquire(self, blocking=True): + try: + self.fd = open(self.lock_file, 'w') + if blocking: + fcntl.flock(self.fd, fcntl.LOCK_EX) + else: + fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except (IOError, OSError): + if self.fd: + self.fd.close() + self.fd = None + return False + + def release(self): + if self.fd: + fcntl.flock(self.fd, fcntl.LOCK_UN) + self.fd.close() + self.fd = None +``` + +**使用方式**: +```python +_DASHBOARD_FILE_LOCK = FileLock('/path/to/data/.dashboard_cache.lock') + +# 取得鎖(非阻塞) +if not _DASHBOARD_FILE_LOCK.acquire(blocking=False): + # 其他 worker 正在重建,等待 + _DASHBOARD_FILE_LOCK.acquire() + _DASHBOARD_FILE_LOCK.release() + return cached_data + +try: + # 重建快取... +finally: + _DASHBOARD_FILE_LOCK.release() +``` + +--- + +## 資料庫表結構 + +### 核心業務表 +- `daily_sales_snapshot` - 每日銷售快照 (snapshot_date 欄位) +- `realtime_sales_monthly` - 業績分析儀表板資料 (日期 欄位) +- `products` - 商品資料 +- `price_records` - 價格記錄 + +### 廠商缺貨系統 +- `vendor_stockout` - 缺貨商品清單 +- `vendor_list` - 廠商資料 +- `vendor_emails` - 廠商郵件地址 +- `email_send_log` - 郵件發送記錄 + +### 系統表 +- `users` - 用戶帳號 +- `permissions` - 權限定義 +- `user_permissions` - 用戶權限關聯 +- `import_jobs` - 匯入任務記錄 +- `import_config` - 匯入設定 + +--- + +## 部署指令(Docker-on-188,見 ADR-008) + +### SSH 進入 188(必經 110 跳板) +```bash +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 +``` + +### 同步 Python 檔案到 188(volume mount,即時生效) +```bash +# 經 110 跳板 +scp -o ProxyJump=wooo@192.168.0.110 app.py ollama@192.168.0.188:/home/ollama/momo-pro/ +scp -o ProxyJump=wooo@192.168.0.110 -r services/ ollama@192.168.0.188:/home/ollama/momo-pro/ +``` + +### 重啟主應用(volume mount 後) +```bash +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" +``` + +### 重建映像(新增套件 / Dockerfile 變更) +```bash +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "cd /home/ollama/momo-pro && docker compose build momo-app && docker compose up -d momo-app" +``` + +### 查看日誌 +```bash +# momo-pro-system +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker logs -f momo-pro-system --tail 100" + +# scheduler +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker logs -f momo-scheduler --tail 100" + +# PostgreSQL (pgvector) +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker logs -f momo-db --tail 100" +``` + +### 進入容器 Shell +```bash +# 進入主應用 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker exec -it momo-pro-system /bin/bash" + +# 進入 PostgreSQL(pgvector/pg14) +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker exec -it momo-db psql -U momo -d momo_analytics" +``` + +--- + +# 待辦:細粒度權限系統實作計畫 + +## 需求總結 + +將現有的三級角色權限系統 (admin/manager/user) 升級為細粒度權限配置系統: +- **保留角色概念**:作為「預設權限模板」,新增用戶時快速套用 +- **權限粒度**:分為「查看」和「操作」兩層 +- **不需要權限群組功能** + +--- + +## 現有系統分析 + +### 已有的基礎設施 + +| 檔案 | 說明 | +|------|-----| +| `database/user_models.py` | User 和 LoginHistory 模型已存在 | +| `services/user_service.py` | 用戶 CRUD 服務已存在 | +| `services/password_service.py` | 密碼服務已存在 | +| `routes/user_routes.py` | 用戶管理路由已存在 | +| `templates/user_management.html` | 用戶管理頁面已存在 | +| `auth.py` | login_required, role_required 裝飾器已存在 | + +### 需要擴展的部分 + +- User 模型需新增 permissions 欄位或關聯表 +- 新增 Permission 模型和 UserPermission 關聯表 +- 新增 permission_required 裝飾器 +- 更新用戶管理介面,增加權限勾選區塊 +- 更新導航列,根據權限動態顯示選單 + +--- + +## 資料庫設計 + +### 新增表: permissions (權限定義表) + +```python +class Permission(db.Model): + """權限定義表 - 系統預設,不可刪除""" + __tablename__ = 'permissions' + + id = Column(Integer, primary_key=True) + code = Column(String(50), unique=True, nullable=False) # 如 'dashboard.view' + name = Column(String(100), nullable=False) # 顯示名稱 '查看首頁看板' + category = Column(String(50), nullable=False) # 分類 '首頁/看板' + description = Column(String(200)) # 詳細說明 + sort_order = Column(Integer, default=0) # 排序順序 +``` + +### 新增表: user_permissions (用戶權限關聯表) + +```python +class UserPermission(db.Model): + """用戶權限關聯表""" + __tablename__ = 'user_permissions' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + permission_code = Column(String(50), nullable=False) + granted_by = Column(Integer, ForeignKey('users.id')) + granted_at = Column(DateTime, default=datetime.utcnow) + + # 複合唯一索引 + __table_args__ = ( + UniqueConstraint('user_id', 'permission_code', name='uq_user_permission'), + ) +``` + +--- + +## 權限清單定義 + +### 首頁/看板 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `dashboard.view` | 查看首頁看板 | 訪問 `/` 首頁 | +| `dashboard.export` | 匯出看板資料 | 匯出商品資料 Excel | + +### 報表 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `report.daily_sales.view` | 查看每日銷售 | 訪問 `/daily_sales` | +| `report.daily_sales.export` | 匯出每日銷售 | 匯出每日銷售 Excel | +| `report.monthly_summary.view` | 查看月度總結 | 訪問 `/monthly_summary_analysis` | +| `report.monthly_summary.import` | 匯入月度資料 | 匯入月度總結 Excel | +| `report.sales_analysis.view` | 查看銷售分析 | 訪問 `/sales_analysis` | +| `report.growth_analysis.view` | 查看成長分析 | 訪問 `/growth_analysis` | +| `report.abc_analysis.view` | 查看 ABC 分析 | 訪問 `/abc_analysis/detail` | + +### 活動看板 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `edm.view` | 查看 EDM 看板 | 訪問 `/edm` | +| `edm.trigger` | 觸發 EDM 爬蟲 | 手動觸發爬蟲按鈕 | +| `festival.view` | 查看節慶看板 | 訪問 `/festival` | +| `festival.trigger` | 觸發節慶爬蟲 | 手動觸發爬蟲按鈕 | + +### 廠商缺貨 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `vendor.index.view` | 查看廠商缺貨首頁 | 訪問 `/vendor_stockout/` | +| `vendor.import` | 匯入缺貨資料 | 匯入缺貨 Excel | +| `vendor.list.view` | 查看缺貨清單 | 訪問 `/vendor_stockout/list` | +| `vendor.list.edit` | 編輯缺貨資料 | 編輯/刪除缺貨記錄 | +| `vendor.management.view` | 查看廠商管理 | 訪問 `/vendor_stockout/vendor-management` | +| `vendor.management.edit` | 管理廠商資料 | 新增/編輯/刪除廠商 | +| `vendor.email.view` | 查看郵件發送 | 訪問 `/vendor_stockout/send-email` | +| `vendor.email.send` | 發送廠商郵件 | 發送缺貨通知郵件 | +| `vendor.history.view` | 查看歷史記錄 | 訪問 `/vendor_stockout/history` | + +### 匯入 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `import.auto.view` | 查看自動匯入 | 訪問 `/auto_import` | +| `import.auto.manage` | 管理匯入任務 | 新增/編輯/刪除任務 | +| `import.manual` | 手動匯入資料 | 系統設定頁匯入按鈕 | + +### 系統 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `system.settings.view` | 查看系統設定 | 訪問 `/settings` | +| `system.settings.edit` | 修改系統設定 | 儲存設定變更 | +| `system.advanced.view` | 查看進階設定 | 訪問 `/system_settings` | +| `system.advanced.edit` | 修改進階設定 | 分類管理等 | +| `system.logs.view` | 查看系統日誌 | 訪問 `/logs` | +| `system.crawler.view` | 查看爬蟲管理 | 訪問 `/crawler_management` | +| `system.crawler.manage` | 管理爬蟲設定 | 修改爬蟲設定 | +| `system.backup` | 備份資料庫 | 執行備份操作 | +| `system.users.view` | 查看用戶管理 | 訪問 `/user_management` | +| `system.users.manage` | 管理用戶帳號 | 新增/編輯/刪除用戶 | + +### 其他 +| 權限代碼 | 名稱 | 說明 | +|---------|------|------| +| `brand_assets.view` | 查看品牌素材 | 訪問 `/brand_assets` | + +**總計: 31 個權限項目** + +--- + +## 角色預設權限模板 + +### admin (管理員) +- 全部 31 個權限 + +### manager (管理者) +- 所有 `.view` 權限 +- `dashboard.export` +- `report.daily_sales.export` +- `report.monthly_summary.import` +- `vendor.import`, `vendor.list.edit`, `vendor.management.edit`, `vendor.email.send` +- `import.auto.manage`, `import.manual` +- `system.settings.edit` +- **排除**: `system.users.*`, `system.backup`, `system.advanced.edit`, `system.crawler.manage` + +### user (一般用戶) +- `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` + +--- + +## 修改檔案清單 + +### 新增檔案 + +| 檔案 | 說明 | +|------|-----| +| `database/permission_models.py` | Permission 和 UserPermission 模型 | +| `services/permission_service.py` | 權限 CRUD 服務、角色模板應用 | +| `templates/user_permissions.html` | 用戶權限編輯頁面 (獨立頁面或 Modal) | + +### 修改檔案 + +| 檔案 | 修改內容 | +|------|---------| +| `auth.py` | 新增 `permission_required` 裝飾器、更新 `get_current_user()` | +| `routes/user_routes.py` | 新增權限 CRUD API | +| `templates/user_management.html` | 新增「編輯權限」按鈕 | +| `app.py` | 註冊權限 context_processor、初始化權限資料 | +| `database/manager.py` | 新增權限表初始化 | + +### 需更新權限檢查的路由檔案 + +| 檔案 | 說明 | +|------|-----| +| `routes/dashboard_routes.py` | 首頁看板 | +| `routes/daily_sales_routes.py` | 每日銷售 | +| `routes/monthly_routes.py` | 月度總結 | +| `routes/sales_routes.py` | 銷售分析、成長分析 | +| `routes/edm_routes.py` | EDM、節慶看板 | +| `routes/system_routes.py` | 系統設定、日誌 | +| `vendor_routes.py` | 廠商缺貨相關 | +| `auto_import_routes.py` | 自動匯入 | +| `crawler_management_routes.py` | 爬蟲管理 | + +--- + +## 實作步驟 + +### Phase 1: 資料庫模型 (預計 1 步) + +1. 建立 `database/permission_models.py` + - Permission 模型 + - UserPermission 模型 + - 權限常數定義 (PERMISSIONS 字典) + - 角色預設權限模板 (ROLE_DEFAULT_PERMISSIONS) + +2. 更新 `database/manager.py` + - 在 `init_db()` 中初始化權限表資料 + +### Phase 2: 服務層 (預計 1 步) + +1. 建立 `services/permission_service.py` + - `get_all_permissions()` - 取得所有權限定義 (分類顯示) + - `get_user_permissions(user_id)` - 取得用戶權限列表 + - `set_user_permissions(user_id, permission_codes, granted_by)` - 設定用戶權限 + - `apply_role_template(user_id, role)` - 套用角色預設權限 + - `has_permission(user_id, permission_code)` - 檢查是否有權限 + - `has_any_permission(user_id, *permission_codes)` - 檢查是否有任一權限 + +### Phase 3: 認證與裝飾器 (預計 1 步) + +1. 更新 `auth.py` + - 新增 `permission_required(*permissions)` 裝飾器 + - 更新 `get_current_user()` 增加 permissions 欄位 + - 登入時載入用戶權限到 session (或每次查詢) + +### Phase 4: API 路由 (預計 1 步) + +1. 更新 `routes/user_routes.py` + - `GET /api/permissions` - 取得所有權限定義 + - `GET /api/users//permissions` - 取得用戶權限 + - `PUT /api/users//permissions` - 更新用戶權限 + - `POST /api/users//apply_role_template` - 套用角色模板 + +### Phase 5: 前端介面 (預計 1 步) + +1. 建立用戶權限編輯介面 + - 可在 `user_management.html` 新增 Modal 或獨立頁面 + - 按分類顯示權限勾選框 + - 「套用角色模板」下拉選單 + - 「全選」/「清除」按鈕 + +### Phase 6: 套用權限檢查 (預計 2-3 步) + +1. 更新各路由檔案,將 `@role_required` 改為 `@permission_required` +2. 更新導航列模板,根據權限動態顯示選單項目 +3. 更新頁面內的操作按鈕,根據權限顯示/隱藏 + +### Phase 7: 測試驗證 (預計 1 步) + +1. 建立測試用戶並配置不同權限 +2. 驗證各頁面和功能的權限控制 +3. 確認角色模板套用正確 + +--- + +## 權限裝飾器設計 + +```python +def permission_required(*permissions): + """權限檢查裝飾器 + + 使用範例: + @permission_required('dashboard.view') + @permission_required('vendor.list.view', 'vendor.list.edit') + """ + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('logged_in'): + return redirect(url_for('login')) + + user_id = session.get('user_id') + user_role = session.get('role') + + # admin 擁有所有權限 + if user_role == 'admin': + return f(*args, **kwargs) + + # 檢查用戶是否有任一所需權限 + from services.permission_service import PermissionService + if not PermissionService.has_any_permission(user_id, *permissions): + abort(403) + + return f(*args, **kwargs) + return decorated_function + return decorator +``` + +--- + +## 用戶權限編輯介面設計 + +``` +┌─────────────────────────────────────────────────┐ +│ 編輯權限: peter (Peter Chen) │ +├─────────────────────────────────────────────────┤ +│ 快速設定: [套用角色模板 ▼] [全選] [清除] │ +├─────────────────────────────────────────────────┤ +│ │ +│ 📊 首頁/看板 │ +│ ├─ ☑ 查看首頁看板 │ +│ └─ ☐ 匯出看板資料 │ +│ │ +│ 📈 報表 │ +│ ├─ ☑ 查看每日銷售 │ +│ ├─ ☐ 匯出每日銷售 │ +│ ├─ ☑ 查看月度總結 │ +│ ├─ ☐ 匯入月度資料 │ +│ ├─ ☑ 查看銷售分析 │ +│ ├─ ☑ 查看成長分析 │ +│ └─ ☐ 查看 ABC 分析 │ +│ │ +│ 📣 活動看板 │ +│ ├─ ☑ 查看 EDM 看板 │ +│ ├─ ☐ 觸發 EDM 爬蟲 │ +│ ├─ ☑ 查看節慶看板 │ +│ └─ ☐ 觸發節慶爬蟲 │ +│ │ +│ 🏭 廠商缺貨 │ +│ ├─ ☐ 查看廠商缺貨首頁 │ +│ ├─ ☐ 匯入缺貨資料 │ +│ ├─ ☐ 查看缺貨清單 │ +│ ├─ ☐ 編輯缺貨資料 │ +│ ├─ ☐ 查看廠商管理 │ +│ ├─ ☐ 管理廠商資料 │ +│ ├─ ☐ 查看郵件發送 │ +│ ├─ ☐ 發送廠商郵件 │ +│ └─ ☐ 查看歷史記錄 │ +│ │ +│ 📥 匯入 │ +│ ├─ ☐ 查看自動匯入 │ +│ ├─ ☐ 管理匯入任務 │ +│ └─ ☐ 手動匯入資料 │ +│ │ +│ ⚙️ 系統 │ +│ ├─ ☐ 查看系統設定 │ +│ ├─ ☐ 修改系統設定 │ +│ ├─ ☐ 查看進階設定 │ +│ ├─ ☐ 修改進階設定 │ +│ ├─ ☐ 查看系統日誌 │ +│ ├─ ☐ 查看爬蟲管理 │ +│ ├─ ☐ 管理爬蟲設定 │ +│ ├─ ☐ 備份資料庫 │ +│ ├─ ☐ 查看用戶管理 │ +│ └─ ☐ 管理用戶帳號 │ +│ │ +│ 📦 其他 │ +│ └─ ☑ 查看品牌素材 │ +│ │ +├─────────────────────────────────────────────────┤ +│ [取消] [儲存權限] │ +└─────────────────────────────────────────────────┘ +``` + +--- + +## 導航列動態顯示 + +更新 `templates/components/_navbar.html`,根據權限決定顯示哪些選單項目: + +```jinja2 +{% if has_permission('dashboard.view') %} +首頁 +{% endif %} + +{% if has_permission('report.daily_sales.view') %} +每日銷售 +{% endif %} + +{# 廠商缺貨子選單 - 有任一權限就顯示 #} +{% if has_any_permission('vendor.index.view', 'vendor.list.view', 'vendor.management.view') %} + +{% endif %} +``` + +--- + +## 驗證計畫 + +1. **資料庫測試** + - 確認 permissions 和 user_permissions 表正確建立 + - 確認初始權限資料正確載入 + +2. **API 測試** + - 取得權限列表 API + - 更新用戶權限 API + - 套用角色模板 API + +3. **權限控制測試** + - 建立測試用戶,僅給予 `dashboard.view` 和 `report.daily_sales.view` + - 確認可以訪問首頁和每日銷售 + - 確認無法訪問廠商缺貨、系統設定等頁面 (403) + - 確認導航列只顯示有權限的選單 + +4. **角色模板測試** + - 套用 manager 模板,確認權限正確 + - 套用 user 模板,確認權限正確 + +--- + +## 關鍵檔案路徑 + +- 新增: `database/permission_models.py` +- 新增: `services/permission_service.py` +- 修改: `auth.py` (新增裝飾器) +- 修改: `routes/user_routes.py` (新增 API) +- 修改: `templates/user_management.html` (新增權限編輯) +- 修改: `app.py` (context_processor) +- 修改: 各路由檔案 (套用權限檢查) + +--- + +# 專案憲法 (CONSTITUTION.md) + +> 本文件定義專案開發的核心準則與不可違反的規範 +> **建立日期**: 2026-01-12 +> **當前版本**: V9.4 + +--- + +## 第一章:資料庫與模型層規範 + +### 第 1 條:商品 ID 命名規範(絕對禁止違反) +- ✅ **正確**: 使用 `product.i_code` 作為商品唯一識別碼 +- ❌ **禁止**: 使用 `product.momo_id`(此屬性不存在) + +### 第 2 條:時間戳處理規範(絕對禁止違反) +- ✅ **正確**: 使用 `datetime.now(TAIPEI_TZ).replace(tzinfo=None)` +- ❌ **禁止**: 直接使用 `datetime.now()` 或保留 tzinfo +- **理由**: SQLite 不支援時區感知的 datetime + +### 第 3 條:價格變動邏輯(絕對禁止違反) +- ✅ **正確**: 比對「今日最新價格」與「今日之前的最後一筆價格」 +- ❌ **禁止**: 僅查詢「昨天 00:00-23:59」的價格記錄 + +### 第 4 條:查詢效能優化(強制要求) +- ✅ **正確**: 使用批次查詢(如 `yesterday_prices_map`) +- ❌ **禁止**: N+1 查詢模式 + +--- + +## 第二章:爬蟲與資料採集規範 + +### 第 5 條:商品圖片 URL 構造(絕對禁止違反) +- ✅ **正確**: 使用 CDN 直接構造 + ```python + image_url = f"https://m.momoshop.com.tw/moscdn/goods/{i_code}_m.webp" + ``` +- ❌ **禁止**: 使用複雜的 DOM 查詢 + +### 第 6 條:爬蟲頻率與禮貌性(強制要求) +- ✅ **正確**: 每次請求間隔至少 1 秒 +- ❌ **禁止**: 高頻率無間隔爬取 + +--- + +## 第三章:API 設計規範 + +### 第 8 條:API 邏輯一致性(絕對禁止違反) +- ✅ **正確**: API 的資料處理邏輯必須與儀表板**完全一致** +- ❌ **禁止**: API 與前端使用不同的計算邏輯 + +### 第 9 條:API 錯誤處理(強制要求) +- ✅ **正確**: 所有 API 必須使用 try-except-finally 結構 +- ✅ **正確**: 錯誤時返回 JSON 格式: `{'products': []}, 500` +- ❌ **禁止**: 返回 HTML 錯誤頁面或純文字錯誤 + +--- + +## 第四章:前端 UI/UX 規範 + +### 第 11 條:設計系統色彩(絕對禁止違反) +- ✅ **主題色**: 紫色漸變 `#667eea` → `#764ba2` +- ✅ **漲價**: 紅色 `#dc3545` / `#ff6b6b` +- ✅ **降價**: 綠色 `#28a745` / `#51cf66` + +### 第 12 條:響應式設計(強制要求) +- ✅ **正確**: 所有頁面必須支援手機版(< 768px) +- ✅ **正確**: 使用 Bootstrap 5.3.3 + +--- + +## 第五章:系統架構規範 + +### 第 15 條:服務端口(絕對禁止違反) +- ✅ **正確**: Flask 使用 **Port 80** +- ❌ **禁止**: 使用 5888 或其他端口(已廢棄) + +### 第 16 條:資料庫路徑(絕對禁止違反) +- ✅ **正確**: `data/momo_database.db` + +### 第 17 條:時區設定(絕對禁止違反) +- ✅ **正確**: 使用 `TAIPEI_TZ = pytz.timezone('Asia/Taipei')` + +### 第 18 條:日誌系統(強制要求) +- ✅ **正確**: 使用 `sys_log.info()` / `sys_log.error()` 記錄關鍵操作 +- ❌ **禁止**: 使用 `print()` 輸出日誌 + +--- + +## 第十章:安全性規範 + +### 第 31 條:敏感資訊保護(絕對禁止違反) +- ✅ **正確**: 所有 API Token、密碼存放於 `config.py` +- ❌ **禁止**: 硬編碼敏感資訊於程式碼中 + +### 第 32 條:SQL 注入防護(絕對禁止違反) +- ✅ **正確**: 使用 SQLAlchemy ORM 的參數化查詢 +- ❌ **禁止**: 使用字串拼接建構 SQL 查詢 + +--- + +## 附錄 A:常見錯誤與解決方案 + +| 錯誤 | 原因 | 解決 | +|-----|------|------| +| 'Product' object has no attribute 'momo_id' | 使用錯誤的屬性名稱 | 改用 `product.i_code` | +| API 返回空資料 | 價格比對邏輯錯誤(只查昨天) | 使用「今日之前最後一筆」邏輯 | +| 時間戳比對失敗 | 時區感知 datetime 與 naive datetime 混用 | 使用 `.replace(tzinfo=None)` 統一為 naive | + +--- + +# 專案憲法 - 擴充版 (PROJECT_CONSTITUTION.md) + +**版本:** 1.3 +**最後更新:** 2026-01-14 + +--- + +## 第一章:溝通規範 + +### 第 1 條:語言使用 +- **所有溝通一律使用繁體中文** +- 包含:程式碼註解、文檔說明、Commit 訊息、錯誤訊息、日誌輸出 + +--- + +## 第二章:安全政策 + +### 第 4 條:敏感資訊管理 +- **禁止在程式碼中硬編碼任何敏感資訊** +- 所有憑證、API 金鑰、密碼必須使用環境變數(`.env`) + +### 第 5 條:密碼安全 +- 所有密碼必須使用 `pbkdf2:sha256` 雜湊儲存 +- 密碼長度至少 8 個字元 +- 登入失敗 5 次後鎖定帳號 5 分鐘 + +### 第 7 條:CSRF 防護 +- 所有 POST/PUT/DELETE/PATCH 請求必須包含 CSRF token +- HTML 表單使用: `` +- AJAX 請求使用: `'X-CSRFToken': getCSRFToken()` + +--- + +## 第四章:數據爬取規範 + +### 第 13 條:爬蟲程式碼穩定性原則 +- **爬蟲程式碼屬於核心業務邏輯,修改時必須格外謹慎** +- 修改前必須備份現有可運作的版本 +- 修改後必須驗證所有爬蟲任務正常執行 + +### 第 14 條:爬蟲選擇器維護 +- **CSS 選擇器和 XPath 是脆弱的依賴** +- 修改選擇器前必須記錄修改原因 +- 保留舊選擇器作為註解備份 + +### 第 15 條:爬蟲錯誤處理 +- **所有爬蟲函數必須包含完整的錯誤處理** +- 必須處理:網路連線失敗、頁面載入超時、元素找不到、資料格式異常、反爬蟲機制觸發 + +--- + +## 第六章:部署與維運 + +### 第 27 條:環境管理規範(2026-04-18 依 ADR-008 改寫) + +**環境分層**: + +| 環境 | 位置 | 用途 | +|------|------|------| +| 開發環境 | `/Users/ooo/Library/Mobile Documents/com~apple~CloudDocs/momo-pro-system/` (macOS,iCloud) | 程式碼開發、快速測試 | +| 正式環境 | `/home/ollama/momo-pro/` 於 `192.168.0.188`(Docker Compose) | 生產服務、24/7 運行 | + +**嚴格禁止**: +- ❌ 直接在 188 生產環境隨意改檔案(應走 scp 同步 SOP) +- ❌ 跳過本地測試直接部署到 188 +- ❌ 將 `.env` 或資料庫密碼檔上傳到公開 Git + +### 第 28 條:變更前強制備份原則 ⚠️ + +**核心原則**: +- **所有涉及嚴重影響的變更、修改操作,變更前必須先進行完整備份** + +### 第 29 條:部署強制規範(2026-04-18 依 ADR-008 改寫)⚠️ + +**核心原則**: +- **188 正式環境為 Docker Compose + volume mount,目前無 Git 版控(本地 Mac 有),CI/CD 方案待定** +- **程式碼變更必先於本地測試,再以 scp(經 110 跳板)同步到 `ollama@188:~/momo-pro/`** +- **重大結構變更同步記入 ADR + CLAUDE.md 更新記錄** + +**官方唯一部署流程**: +1. 本地 Mac 修改程式碼 + 本地測試通過 +2. 經 110 跳板 scp 到 188: + ```bash + scp -o ProxyJump=wooo@192.168.0.110 <檔案> ollama@192.168.0.188:/home/ollama/momo-pro/<目標> + ``` +3. 依檔案類型處理: + - Python 檔案(app.py/services/routes/templates/config.py/database/) → volume mount 即時生效,只需 `docker restart momo-pro-system` + - 新增套件 / Dockerfile 變更 → `docker compose build momo-app && docker compose up -d momo-app` + - SQL schema 變更 → `docker exec -i momo-db psql -U momo -d momo_analytics < migration.sql` + +**禁止行為**: +- ❌ 直接在 188 上用 `vim` 改線上檔案(繞過 scp + 本地審視) +- ❌ 用 `docker cp` 改容器內檔案(volume mount 下無意義且混亂) +- ❌ 把未測試的 commit scp 上去 +- ❌ 在 110 做 `kubectl *` 類操作(本系統無 K8s) +- ❌ 修改 `~/momo_pro_system/` 於 110(該路徑已空置,不是運行目錄) + +**唯一例外**: +- 熱修復(Hotfix)若時間緊迫可直接在 188 改,但事後必須把 diff 補回本地並記入 CLAUDE.md 更新記錄 + +**違反後果**: +- 版本混亂(本地與 188 漂移)、無從追蹤 +- 容器重啟後可能丟失(若改了非 mount 的目錄) +- 未來導入 Git/CI 時衝突 + +**適用範圍**: +1. 資料庫結構變更(ALTER TABLE, DROP, CREATE) +2. 大量資料修改或刪除(影響 >100 筆) +3. 系統配置檔案修改 +4. 爬蟲核心邏輯修改 +5. 認證和安全模組修改 +6. 生產環境程式碼更新 +7. **K8s ConfigMap 環境變數變更** +8. **K8s Deployment YAML 變更** +9. **Nginx 配置變更** + +**備份命名規範**: +``` +資料庫:momo_backup_YYYYMMDD_HHMMSS.db +程式碼:momo_code_backup_YYYYMMDD_HHMMSS.tar.gz +``` + +### 第 30 條:所有變更必須入 Git 版控(絕對禁止違反)⚠️ + +**核心原則**: +- **任何對專案的修改,無論大小,都必須提交到 Git 版本控制** +- **禁止只在伺服器端修改而不同步到 Git** + +**適用範圍**: +1. 程式碼變更(Python, JavaScript, HTML, CSS) +2. Docker/K8s 配置變更(docker-compose.yml, Deployment YAML) +3. n8n 工作流變更(n8n-workflows/*.json) +4. Nginx 配置變更 +5. 安全性修復 +6. 環境變數變更(ConfigMap) +7. 任何系統配置修改 + +**強制流程**: +```bash +# 1. 本地修改或從伺服器同步變更 +# 2. 提交到 Git +git add +git commit -m "描述性訊息" + +# 3. 推送到遠端 Git 版控(remote 名稱依新 CI/CD 方案而定) +git push main +``` + +**Claude 助手責任**: +- 每次完成修改任務後,必須主動詢問或執行 Git 提交 +- 若在伺服器端直接修改,必須將變更同步回本地並提交 +- 禁止回覆「已完成」但未提交到 Git 的情況 + +**違反後果**: +- 本地與伺服器版本不一致 +- 下次 CI/CD 部署會覆蓋未提交的變更 +- 無法追蹤變更歷史和回滾 + +### 第 31 條:端口安全規範(絕對禁止違反)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **所有內部服務端口必須綁定到 127.0.0.1,禁止暴露到公網 (0.0.0.0)** +- **只有必要的公開服務才能綁定到 0.0.0.0** + +**強制本地綁定的服務**: + +| 服務 | 端口 | 正確配置 | 風險等級 | +|------|------|----------|----------| +| PostgreSQL | 5432 | `127.0.0.1:5432:5432` | 🔴 極高 | +| Portainer | 9000/9443 | `127.0.0.1:9000:9000` | 🔴 極高 | +| Rancher | 8880/8443 | `127.0.0.1:8880:80` | 🔴 極高 | +| MOMO App | 5001 | `127.0.0.1:5001:80` | 🟠 高 | +| Prometheus | 9090 | `127.0.0.1:9090:9090` | 🟠 高 | +| Alertmanager | 9093 | `127.0.0.1:9093:9093` | 🟠 高 | +| Node Exporter | 9100 | `127.0.0.1:9100:9100` | 🟡 中 | +| Postgres Exporter | 9187 | `127.0.0.1:9187:9187` | 🟡 中 | +| Loki | 3100 | `127.0.0.1:3100:3100` | 🟡 中 | +| cAdvisor | 8080 | `127.0.0.1:8080:8080` | 🟡 中 | + +**允許公開的服務**: +- Port 80/443 (Nginx - HTTPS 反向代理) +- Port 22 (SSH - 需要密鑰認證) +- Port 5000 (Registry - 需要認證) + +**docker-compose.yml 配置範例**: +```yaml +# ❌ 錯誤 - 暴露到公網 +ports: + - "5432:5432" + +# ✅ 正確 - 僅本地連線 +ports: + - "127.0.0.1:5432:5432" +``` + +**存取內部服務方式**: +```bash +# SSH Tunnel 存取 Portainer +ssh -L 9000:127.0.0.1:9000 wooo@192.168.0.110 +# 然後瀏覽器開啟 http://localhost:9000 +``` + +### 第 32 條:Nginx 安全配置規範(絕對禁止違反)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **必須隱藏伺服器版本資訊** +- **必須設定安全 Headers** +- **必須透過 HTTPS 反向代理,禁止直接存取後端服務** + +**強制配置項目**: + +```nginx +server { + # 1. 隱藏 Nginx 版本 + server_tokens off; + + # 2. 安全 Headers(必須全部加入) + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 3. HSTS - 強制 HTTPS(防止中間人攻擊) + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # 3. 反向代理設定 + location / { + proxy_pass http://127.0.0.1:5001; # 後端只綁定本地 + + # 4. 隱藏後端版本資訊 + proxy_hide_header X-Powered-By; + proxy_hide_header Server; + } +} +``` + +**Rate Limiting 配置**(防止暴力掃描和 DDoS): + +```nginx +# 在 nginx.conf 的 http {} 區塊中 +limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=login:10m rate=3r/s; +limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + +# 在 server {} 區塊中 +limit_req zone=general burst=20 nodelay; # 一般請求 +limit_conn conn_limit 50; # 最大同時連線數 + +# 登入頁面更嚴格 +location /login { + limit_req zone=login burst=5 nodelay; # 每秒 3 個請求 +} +``` + +**禁止行為**: +- ❌ 暴露 Nginx 版本號 (`nginx/1.x.x`) +- ❌ 暴露後端框架版本 (`gunicorn`, `Python`) +- ❌ 允許直接存取後端端口 (5001) +- ❌ 使用 HTTP 傳輸登入憑證 +- ❌ 無限制接受請求(易遭 DDoS) + +**驗證指令**: +```bash +# 檢查是否隱藏版本 +curl -sI https://mo.wooo.work | grep -i server + +# 應該只顯示 "server: nginx" 不帶版本號 +``` + +### 第 33 條:資料庫安全規範(絕對禁止違反)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **資料庫端口絕對禁止暴露到公網** +- **必須使用強密碼** +- **連線必須透過內部網路或 SSH Tunnel** + +**PostgreSQL 安全配置**: +```yaml +# docker-compose.yml +postgres: + ports: + - "127.0.0.1:5432:5432" # 僅本地連線 + environment: + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} # 從環境變數讀取 +``` + +**禁止行為**: +- ❌ 資料庫密碼寫死在程式碼中 +- ❌ 使用弱密碼(如 `postgres`, `123456`) +- ❌ 允許遠端直接連線資料庫 +- ❌ 在錯誤訊息中暴露資料庫結構 + +### 第 34 條:容器管理平台安全規範(絕對禁止違反)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **Portainer、Rancher 等容器管理平台屬於最高權限入口** +- **必須限制為本地連線,透過 SSH Tunnel 存取** +- **必須使用強密碼和雙因素認證(如支援)** + +**風險說明**: +- 攻擊者一旦獲得容器管理平台存取權,可以: + - 查看所有容器的環境變數(包含密碼) + - 執行任意容器命令 + - 部署惡意容器 + - 讀取資料庫內容 + - 完全控制整個系統 + +**強制配置**: +```yaml +# Portainer +ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9443:9443" +``` + +```bash +# Rancher(docker run 方式) +docker run -d --name rancher \ + -p 127.0.0.1:8880:80 \ + -p 127.0.0.1:8443:443 \ + rancher/rancher:latest +``` + +--- + +### 第 35 條:SSH 安全規範(絕對禁止違反)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **SSH 服務是伺服器的最重要入口,必須嚴格保護** +- **必須安裝 Fail2Ban 防止暴力破解攻擊** +- **建議使用 SSH Key 認證取代密碼認證** + +**Fail2Ban 強制配置** (`/etc/fail2ban/jail.local`): +```ini +[DEFAULT] +bantime = 3600 # 封鎖 1 小時 +findtime = 600 # 10 分鐘內 +maxretry = 3 # 最多 3 次失敗 +ignoreip = 127.0.0.1/8 192.168.0.0/24 # 白名單 + +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 3 +``` + +**常用指令**: +```bash +# 查看封鎖狀態 +sudo fail2ban-client status sshd + +# 手動解封 IP +sudo fail2ban-client set sshd unbanip + +# 查看日誌 +sudo tail -f /var/log/fail2ban.log +``` + +--- + +### 第 36 條:持續性安全監控規範(強制要求)⚠️ + +> **2026-01-25 Kali 滲透測試後新增** + +**核心原則**: +- **安全不是一次性工作,必須持續監控** +- **所有安全事件必須有日誌和告警機制** +- **定期執行滲透測試,發現新漏洞立即修復** + +**強制監控項目**: + +| 監控項目 | 工具 | 告警方式 | +|---------|------|---------| +| SSH 暴力破解 | Fail2Ban + Prometheus Exporter | Grafana 儀表板 | +| 端口掃描偵測 | CI/CD Nmap 掃描(待新 CI/CD 方案重建) | Telegram 通知 | +| 系統資源 | Prometheus + Node Exporter | Grafana 告警 | +| 容器狀態 | cAdvisor + Prometheus | Grafana 告警 | +| 網站可用性 | Blackbox Exporter | Telegram 通知 | + +**Fail2Ban Prometheus Exporter**: +```bash +# 服務位置 +/usr/local/bin/fail2ban_exporter.py +systemctl status fail2ban-exporter + +# Prometheus 指標 +fail2ban_currently_banned{jail="sshd"} +fail2ban_total_banned{jail="sshd"} +``` + +**每週自動安全掃描(待重建)**: +```yaml +# 原本在 GitLab CI 上以排程(每週一 09:00)執行 security-scan job +# 掃描:nmap -Pn --top-ports 100 192.168.0.110 +# 通知:Telegram +# GitLab 已撤除,排程掃描需在新 CI/CD 方案或 cron 上重建 +``` + +**發現漏洞處理流程**: +1. 立即評估風險等級(極高/高/中/低) +2. 極高風險:1 小時內修復 +3. 高風險:24 小時內修復 +4. 中/低風險:1 週內修復 +5. 修復後更新憲法和 CLAUDE.md +6. 提交到 Git 版控 + +--- + +### 第 37 條:程式碼安全掃描規範(強制要求)⚠️ + +> **2026-01-25 新增** + +**核心原則**: +- **每次 Push 必須執行自動化程式碼安全掃描** +- **使用 Bandit 工具檢測 Python 常見安全漏洞** +- **高危漏洞必須立即修復後才能部署** + +**掃描工具**: +| 工具 | 用途 | 說明 | +|------|------|------| +| Bandit | Python 安全掃描 | OWASP Top 10、SQL 注入、XSS、密碼硬編碼等 | + +**CI/CD Job**: `security-code-scan`(原 GitLab CI Job,待新 CI/CD 方案重建) + +**漏洞嚴重度與處理**: +| 嚴重度 | 圖示 | 處理方式 | 通知 | +|--------|------|---------|------| +| 高危 (HIGH) | 🔴 | 必須修復後才能合併 | Telegram 警告 | +| 中危 (MEDIUM) | 🟠 | 應在 24 小時內修復 | Telegram 提醒 | +| 低危 (LOW) | 🟡 | 可選擇修復 | 僅記錄 | + +**常見檢測項目**: +- `B101` - assert 在生產環境中使用 +- `B105` - 硬編碼密碼 +- `B303` - 使用 MD5 或 SHA1 +- `B320` - XML 外部實體注入 (XXE) +- `B501` - 禁用 SSL 驗證 +- `B608` - SQL 注入 + +**Bandit 報告位置**:(GitLab 已撤除,待新 CI/CD 方案定案後更新路徑) + +--- + +## 🔒 資安修復記錄 + +> **2026-01-25 Kali Linux 滲透測試修復** + +| 漏洞 | 風險等級 | 修復方式 | 狀態 | +|------|----------|----------|------| +| PostgreSQL 5432 暴露公網 | 🔴 極高 | 改為 127.0.0.1:5432 | ✅ 已修復 | +| Portainer 9000/9443 暴露公網 | 🔴 極高 | 改為 127.0.0.1 | ✅ 已修復 | +| Rancher 8880/8443 暴露公網 | 🔴 極高 | 改為 127.0.0.1 | ✅ 已修復 | +| pgAdmin 8088 暴露公網 | 🔴 極高 | 改為 127.0.0.1:8088 | ✅ 已修復 | +| MOMO App 5001 暴露公網 | 🟠 高 | 改為 127.0.0.1,透過 Nginx 代理 | ✅ 已修復 | +| Prometheus 9090 暴露公網 | 🟠 高 | 改為 127.0.0.1:9090 | ✅ 已修復 | +| Grafana 3000 暴露公網 | 🟠 高 | 改為 127.0.0.1,透過 mon.wooo.work | ✅ 已修復 | +| cAdvisor 8080 無認證暴露 | 🟠 高 | 改為 127.0.0.1:8080 | ✅ 已修復 | +| n8n 5678 暴露 API Keys/Sentry | 🔴 極高 | 改為 127.0.0.1:5678 | ✅ 已修復 | +| Registry 5000 HTTP 公開存取 | 🟠 高 | 改為 127.0.0.1:5000,透過 HTTPS | ✅ 已修復 | +| Nginx 暴露版本資訊 | 🟡 中 | server_tokens off | ✅ 已修復 | +| 後端暴露 Gunicorn/Python 版本 | 🟡 中 | proxy_hide_header | ✅ 已修復 | +| 缺少安全 Headers | 🟡 中 | 新增 X-Content-Type-Options 等 | ✅ 已修復 | +| Nextcloud 8081 暴露公網 | 🟠 高 | 改為 127.0.0.1:8081,透過 Nginx 代理 | ✅ 已修復 | +| SSH 22 暴力破解風險 | 🟠 高 | 安裝 Fail2Ban (3次失敗封鎖1小時) | ✅ 已修復 | +| Node Exporter 9100 洩漏硬體資訊 | 🟡 中 | 改為 127.0.0.1:9100 | ✅ 已修復 | +| SSL/TLS 未啟用 HSTS | 🟡 中 | 新增 Strict-Transport-Security 標頭 | ✅ 已修復 | +| 無請求速率限制 (DDoS 風險) | 🟡 中 | 新增 Rate Limiting (10r/s + burst 20) | ✅ 已修復 | + +--- + +## 🛡️ 安全監控機制 + +### Fail2Ban 監控 (Prometheus + Grafana) + +**Exporter 位置**: `/usr/local/bin/fail2ban_exporter.py` +**服務**: `systemctl status fail2ban-exporter` +**端口**: `0.0.0.0:9191` (內部使用) + +**可用指標**: +```promql +fail2ban_currently_failed{jail="sshd"} # 目前失敗嘗試數 +fail2ban_total_failed{jail="sshd"} # 歷史總失敗數 +fail2ban_currently_banned{jail="sshd"} # 目前封鎖 IP 數 +fail2ban_total_banned{jail="sshd"} # 歷史總封鎖數 +``` + +### 每週自動安全掃描 (Nmap) — 待新 CI/CD 方案重建 + +> 原本建構在 GitLab CI 排程之上(每週一 09:00 執行 `security-scan` job,掃描 PostgreSQL/MySQL/MongoDB/Redis 等常見危險端口並發送 Telegram 通知)。GitLab 撤除後此排程掃描已停用,需在新 CI/CD 方案或 cron 上重建。 + +### 程式碼安全掃描 (Bandit) — 待新 CI/CD 方案重建 + +> 原本建構在 GitLab CI 上(`security-code-scan` job,每次 Push 和手動觸發;Bandit 掃描全部 Python 檔案,排除 tests/, venv/, node_modules/;結果依嚴重度發 Telegram 通知)。GitLab 撤除後此掃描需在新 CI/CD 方案上重建。 + +### 容器映像檔安全掃描 (Trivy) — 待新 CI/CD 方案重建 + +> 原本建構在 GitLab CI 上(`container-security-scan` job,每週一排程 + 手動觸發;Trivy 掃描 Debian 基礎映像與 Python 套件 CVE,僅報告 HIGH/CRITICAL 等級並發送 Telegram 通知)。GitLab 撤除後此掃描需在新 CI/CD 方案上重建。 + +--- + +## 快速檢查清單 + +### ✅ 新功能開發檢查 +- [ ] 程式碼和註解使用繁體中文 +- [ ] 無硬編碼敏感資訊 +- [ ] 所有輸入經過驗證 +- [ ] POST 請求包含 CSRF token +- [ ] 錯誤處理完整 +- [ ] 日誌記錄完整 + +### 🕷️ 爬蟲修改檢查 +- [ ] 備份現有可運作版本 +- [ ] 記錄修改原因 +- [ ] 保留舊選擇器作為註解 +- [ ] 測試新選擇器正確性 +- [ ] 驗證資料完整性 +- [ ] 更新爬蟲文檔 +- [ ] 監控 24 小時確保穩定 + +--- + +# 開發測試部署流程(2026-04-18 依 ADR-008 改寫) + +**版本:** 3.0 +**制定日期:** 2026-04-18 + +--- + +## 環境架構 + +| 環境 | 位置 | 用途 | 網址 | +|------|------|------|------| +| **開發環境** | `/Users/ooo/.../momo-pro-system/` (macOS + iCloud) | 本地開發 | `http://127.0.0.1:5000` | +| **正式環境** | `/home/ollama/momo-pro/` 於 `ollama@192.168.0.188`(Docker Compose) | 生產服務 | `https://mo.wooo.work` / `https://momo.wooo.work` | + +--- + +## 完整開發部署流程 + +### 階段 1:本地開發 +```bash +cd "/Users/ooo/Library/Mobile Documents/com~apple~CloudDocs/momo-pro-system" +source venv/bin/activate +python app.py +``` + +### 階段 2:本地測試 +```bash +./run_security_tests.sh # 若有 +# 手動測試主要流程 / UI +``` + +### 階段 3:同步到 188(經 110 跳板) +```bash +# 單檔 Python +scp -o ProxyJump=wooo@192.168.0.110 app.py ollama@192.168.0.188:/home/ollama/momo-pro/ + +# 整個目錄(routes / services / templates 等) +scp -o ProxyJump=wooo@192.168.0.110 -r routes/ ollama@192.168.0.188:/home/ollama/momo-pro/ +``` + +### 階段 4:重啟容器(volume mount 後) +```bash +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" +``` +若新增套件或改 Dockerfile: +```bash +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "cd /home/ollama/momo-pro && docker compose build momo-app && docker compose up -d momo-app" +``` + +### 階段 5:驗證部署 +```bash +# 容器狀態 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker ps --filter name=momo" + +# 應用日誌 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker logs -f momo-pro-system --tail 50" + +# 對外健康檢查 +curl -s https://mo.wooo.work/health +curl -s https://momo.wooo.work/health +``` + +--- + +## 常用命令速查 + +### Docker 管理(188) +```bash +# 跳板登入 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 + +# 容器清單 +docker ps + +# 即時日誌 +docker logs -f momo-pro-system --tail 100 + +# 進入容器 +docker exec -it momo-pro-system /bin/bash + +# 重啟(volume mount 更新後) +docker restart momo-pro-system + +# 重建映像 +cd /home/ollama/momo-pro && docker compose build momo-app && docker compose up -d momo-app +``` + +--- + +## 回滾流程 + +### 方法:以檔案版本回滾(因為是 volume mount,無需 K8s rollout) +```bash +# 在本地 Mac 用 git 找回舊版本 +git log -- app.py +git show :app.py > /tmp/app.py.old + +# 推回 188 +scp -o ProxyJump=wooo@192.168.0.110 /tmp/app.py.old ollama@192.168.0.188:/home/ollama/momo-pro/app.py +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" +``` + +--- + +# Google Drive API 設定指南 (GOOGLE_DRIVE_SETUP.md) + +## 功能說明 + +系統會: +1. **每 30 分鐘**自動檢查 Google Drive 指定資料夾 +2. **自動下載** Excel 檔案到本地 +3. **自動匯入**資料到資料庫 +4. **自動刪除** Google Drive 上的原始檔案 + +--- + +## 目前認證狀態 + +| 項目 | 值 | +|-----|-----| +| Google Cloud 專案 | `wooo-481204` | +| OAuth Client ID | `132823079326-h9cvj5eahigm8hp9q0b7t5rk77bhu3gp.apps.googleusercontent.com` | +| 目前認證帳號 | `owen.tsai@gmail.com` (BA O) | +| 目標資料夾 | `當日業績匯入` | +| 檔案匹配模式 | `即時業績_當日` | +| 匯入後移至 | `當日業績匯入/已匯入` | + +✅ **狀態**: Google Drive 自動匯入已正常運作(2026-01-24 驗證) + +--- + +## K8s 環境設定(當前使用) + +### 認證檔案位置 +K8s Scheduler Pod 使用以下架構掛載 Google 認證: + +``` +┌─────────────────────────────────────────────────────────┐ +│ K8s Secret: google-drive-credentials │ +│ ├── google_credentials.json (OAuth 設定) │ +│ └── google_token.pickle (已授權的 Token) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ initContainer 複製到 +┌─────────────────────────────────────────────────────────┐ +│ emptyDir: /app/config/ (可讀寫) │ +│ ├── google_credentials.json │ +│ └── google_token.pickle │ +└─────────────────────────────────────────────────────────┘ +``` + +### 相關 K8s 檔案 +- `k8s/08-google-drive-secret.yaml` - Google 認證 Secret +- `k8s/05-scheduler.yaml` - Scheduler Deployment(含 initContainer) + +### 更換 Google 帳號步驟 + +1. **本地重新認證**(取得新的 token.pickle) + ```bash + cd /Users/ogt/momo-pro-system + # 刪除舊的 token + rm -f config/google_token.pickle + # 重新認證(會開啟瀏覽器) + python3 -c " + from services.google_drive_service import drive_service + drive_service.authenticate() + " + ``` + +2. **更新 K8s Secret** + ```bash + # 將新的 token 編碼為 base64 + CREDENTIALS_B64=$(base64 -i config/google_credentials.json) + TOKEN_B64=$(base64 -i config/google_token.pickle) + + # 更新 k8s/08-google-drive-secret.yaml + # 將 base64 值更新到 data 區段 + + # 套用新的 Secret + kubectl apply -f k8s/08-google-drive-secret.yaml + + # 重啟 Scheduler(讓 initContainer 重新複製) + kubectl rollout restart deployment/momo-scheduler -n momo + ``` + +3. **驗證認證** + ```bash + kubectl exec -n momo deploy/momo-scheduler -c scheduler -- \ + sh -c 'cd /app && python -c " + from services.google_drive_service import drive_service + drive_service.authenticate() + service = drive_service.service + about = service.about().get(fields=\"user\").execute() + print(\"認證帳號:\", about[\"user\"][\"emailAddress\"]) + "' + ``` + +--- + +## 設定步驟摘要(本地/Docker 環境) + +### 1. 建立 Google Cloud 專案 +- 前往 [Google Cloud Console](https://console.cloud.google.com/) +- 建立新專案(例如:`momo-auto-import`) + +### 2. 啟用 Google Drive API +- 前往「API 和服務」→「程式庫」 +- 搜尋並啟用「Google Drive API」 + +### 3. 建立 OAuth 2.0 憑證 +- 前往「API 和服務」→「憑證」 +- 建立 OAuth 用戶端 ID(桌面應用程式) +- 下載 JSON 並放到 `config/google_credentials.json` + +### 4. 首次認證 +```bash +python3 -c " +from services.google_drive_service import drive_service +drive_service.authenticate() +" +``` + +### 5. 設定 Google Drive 資料夾 +**必須**在認證帳號的 Google Drive 建立以下結構: +``` +我的雲端硬碟/ +└── 業績報表/ + └── 當日業績/ + └── 即時業績_當日.xlsx ← 放置要匯入的檔案 +``` + +--- + +## 常見問題 + +| 問題 | 解決方法 | +|-----|---------| +| 認證失敗,找不到認證檔案 | 確認 `config/google_credentials.json` 存在 | +| 找不到 Google Drive 資料夾 | 確認路徑正確(區分大小寫),確認用正確的 Google 帳號 | +| 重新授權 | 刪除 `config/google_token.pickle` 並重新認證 | +| K8s 認證錯帳號 | 依照「更換 Google 帳號步驟」重新設定 | + +--- + +# Docker 部署指南(2026-04-18 依 ADR-008 改寫) + +## 部署架構 + +``` +本機 (Mac/iCloud) 188 伺服器 (ollama@192.168.0.188) +┌──────────────────────┐ scp 經110 ┌──────────────────────────────┐ +│ 開發環境 │ 跳板 │ Docker Compose (V12.0) │ +│ momo-pro-system/ │ ────────────► │ ┌──────────────────────────┐│ +│ │ │ │ momo-pro-system ││ +│ app.py / services/ │ │ │ Gunicorn 4 workers :80 ││ +│ routes/ templates/ │ │ │ (host 127.0.0.1:5003) ││ +│ database/ config.py │ │ └──────────────────────────┘│ +└──────────────────────┘ │ momo-db (pgvector/pg14) │ + │ momo-scheduler / openclaw │ + └──────────────────────────────┘ +``` + +--- + +## 同步檔案到 188 + +```bash +# 單檔 +scp -o ProxyJump=wooo@192.168.0.110 app.py ollama@192.168.0.188:/home/ollama/momo-pro/ + +# 整個目錄 +scp -o ProxyJump=wooo@192.168.0.110 -r services ollama@192.168.0.188:/home/ollama/momo-pro/ +scp -o ProxyJump=wooo@192.168.0.110 -r routes ollama@192.168.0.188:/home/ollama/momo-pro/ +scp -o ProxyJump=wooo@192.168.0.110 -r templates ollama@192.168.0.188:/home/ollama/momo-pro/ +``` + +--- + +## 重啟 / 重建 + +```bash +# Python 檔案熱更新(最常用) +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 "docker restart momo-pro-system" + +# 新套件 / Dockerfile 變更 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 \ + "cd /home/ollama/momo-pro && docker compose build momo-app && docker compose up -d momo-app" + +# 完整重啟所有 momo-* 容器 +ssh -J wooo@192.168.0.110 ollama@192.168.0.188 \ + "cd /home/ollama/momo-pro && docker compose restart momo-app momo-scheduler" +``` + +--- + +## Volume Mount 檔案清單(這些 host 目錄會即時反映到容器) + +**ro(唯讀)掛載 — 改完 scp + docker restart 即生效**: +- `app.py`, `scheduler.py`, `config.py`, `auth.py` +- `services/`, `routes/`, `database/`, `templates/` + +**rw(讀寫)掛載 — 容器與 host 雙向**: +- `config/`, `data/`, `logs/`, `backups/` + +**未掛載(修改 host 無效)**: +- `docs/`(CLAUDE.md / ADR 為人工參考,不影響 runtime) +- `static/`, `web/`(編譯進 image,改動需重建) + +--- + +# app.py 重構計畫 (docs/APP_REFACTOR_PLAN.md) + +## 現況分析 + +- **總行數**: 7,253 行 +- **路由數量**: 49 個 +- **函數數量**: 約 70 個 + +### 最大函數(需優先拆分) + +| 排名 | 函數名稱 | 行數 | 建議模組 | +|------|----------|------|----------| +| 1 | `sales_analysis()` | 1,020 行 | sales_routes.py | +| 2 | `get_monthly_summary_data()` | 388 行 | monthly_routes.py | +| 3 | `import_excel()` | 288 行 | import_routes.py | +| 4 | `get_sales_table_data()` | 267 行 | sales_routes.py | +| 5 | `edm_dashboard()` | 244 行 | edm_routes.py | + +--- + +## 目標目錄結構 + +``` +momo-pro/ # (188: /home/ollama/momo-pro/) +├── app.py # 主應用程式 (精簡後約 500 行) +├── routes/ # 🆕 路由模組目錄 +│ ├── __init__.py # Blueprint 註冊中心 +│ ├── dashboard_routes.py # 商品看板 (首頁) +│ ├── sales_routes.py # 業績分析相關 +│ ├── daily_sales_routes.py # 當日業績相關 +│ ├── monthly_routes.py # 月結分析相關 +│ ├── edm_routes.py # EDM 儀表板 +│ ├── export_routes.py # 匯出功能 +│ ├── import_routes.py # 匯入功能 +│ ├── system_routes.py # 系統管理 +│ └── api_routes.py # 通用 API +├── services/ # 服務層 +│ ├── cache_service.py # 🆕 快取服務 +│ ├── sales_service.py # 🆕 業績計算服務 +│ └── ... +└── utils/ # 工具函數 + ├── validators.py # 🆕 驗證函數 + └── ... +``` + +--- + +## 重構執行步驟 + +| 順序 | 模組 | 複雜度 | +|------|------|--------| +| 1 | system_routes.py | 低 | +| 2 | api_routes.py | 低 | +| 3 | export_routes.py | 中 | +| 4 | import_routes.py | 中 | +| 5 | edm_routes.py | 中 | +| 6 | monthly_routes.py | 中 | +| 7 | dashboard_routes.py | 高 | +| 8 | daily_sales_routes.py | 高 | +| 9 | sales_routes.py | 高 | + +--- + +## 預期效益 + +| 指標 | 重構前 | 重構後 | +|------|--------|--------| +| app.py 行數 | 7,253 | ~500 | +| 最大函數行數 | 1,020 | ~200 | +| 模組數量 | 1 | 10 | +| 可測試性 | 低 | 高 | + +--- + +# Google Drive 自動匯入功能 (AUTO_IMPORT_README.md) + +## 功能概述 + +- 🔄 **自動化流程**:每 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` - 自動匯入管理頁面 + +--- + +## API 端點 + +| 端點 | 方法 | 說明 | +|-----|------|------| +| `/api/import_jobs` | GET | 查詢任務清單 | +| `/api/import_jobs/{id}` | GET | 查詢單一任務 | +| `/api/import_config` | GET/POST | 取得/設定配置 | +| `/api/test_drive_connection` | POST | 測試連接 | +| `/api/list_drive_files` | POST | 列出檔案 | +| `/api/manual_import` | POST | 手動觸發匯入 | + +--- + +## 資料表結構 + +### import_jobs(匯入任務) +- `id`, `job_type`, `status`, `drive_file_id`, `drive_file_name` +- `progress_percent`, `current_step`, `total_rows`, `success_rows` +- `created_at`, `started_at`, `completed_at`, `error_message` + +### import_config(匯入配置) +- `config_key`, `config_value`, `config_type`, `description` + +--- + +## 工作流程 + +``` +Google Drive 新檔案上傳 + ↓ +排程任務執行 (每 30 分鐘) + ↓ +建立匯入任務 (import_jobs) + ↓ +下載到本地 (data/temp/) + ↓ +解析 Excel 並匯入資料庫 + ↓ +刪除 Drive 原檔 + ↓ +任務完成,更新狀態記錄 +``` + +--- + +## 故障排除 + +| 問題 | 解決方法 | +|-----|---------| +| 認證失敗 | 確認 `config/google_credentials.json` 存在 | +| 找不到資料夾 | 確認路徑正確(區分大小寫) | +| 檔案沒有被刪除 | 檢查任務狀態是否為「已完成」 | +| 匯入失敗 | 檢查 Excel 格式是否正確 | + +--- + +# 一鍵部署自動化腳本 (2026-01-26 新增) + +## 功能概述 + +完整的部署自動化系統,支援將 MOMO Pro System 一鍵部署到新的 VM 環境。 + +### 支援的部署模式 + +| 模式 | 說明 | 使用場景 | +|------|------|---------| +| **本地部署** | 在當前主機執行部署 | 開發/測試環境 | +| **SSH 遠端部署** | 透過 SSH 部署到遠端主機 | UAT/正式環境 | +| **匯出部署包** | 生成可攜式 tar.gz 壓縮包 | 無網路直連環境 | + +--- + +## 檔案結構 + +``` +deploy/ +├── deploy.sh # 主入口腳本 +├── README.md # 部署說明文檔 +├── configs/ +│ └── .env.template # 環境變數模板 +└── lib/ + ├── common.sh # 通用函數 (日誌、進度條) + ├── check.sh # 環境檢查 (Docker、記憶體、端口) + ├── config.sh # 配置生成 (互動式 + 模板) + ├── docker.sh # Docker 操作 (構建、啟動、同步) + ├── database.sh # 資料庫備份/還原 (完整+CSV) + ├── ssl.sh # SSL 憑證 (Let's Encrypt) + └── health.sh # 健康檢查 (容器、端點、資料庫) +``` + +--- + +## 常用命令 + +### 部署 + +```bash +# 本地部署(互動式配置) +./deploy/deploy.sh deploy + +# 使用自訂配置部署 +./deploy/deploy.sh -e .env.production deploy + +# SSH 遠端部署 +./deploy/deploy.sh --ssh -h 192.168.1.100 -u root deploy + +# 匯出部署包(含資料) +./deploy/deploy.sh --export --with-data +``` + +### 備份與還原 + +```bash +# 完整備份(資料庫 + 配置 + n8n 工作流) +./deploy/deploy.sh backup +# 輸出: backups/momo_backup_20260126_143000.tar.gz + +# 從備份還原 +./deploy/deploy.sh -b backups/momo_backup_20260126.tar.gz restore +``` + +### SSL 憑證 + +```bash +# 申請 Let's Encrypt 憑證 +./deploy/deploy.sh -d momo.example.com ssl + +# 健康檢查 +./deploy/deploy.sh health +``` + +--- + +## 命令選項 + +| 選項 | 說明 | +|------|------| +| `--local` | 本地部署(預設) | +| `--ssh` | SSH 遠端部署 | +| `--export` | 匯出部署包 | +| `-h, --host` | SSH 目標主機 | +| `-u, --user` | SSH 用戶名 | +| `-p, --path` | 遠端部署路徑 | +| `-e, --env-file` | 自訂環境變數檔案 | +| `-d, --domain` | 域名(SSL 用) | +| `-b, --backup` | 備份檔案路徑 | +| `--no-monitoring` | 不部署監控服務 | +| `--with-data` | 包含資料庫備份 | +| `-y, --yes` | 跳過確認提示 | + +--- + +## 資料庫備份特點 + +- **完整 SQL 備份**: 結構 + 資料 (pg_dump) +- **CSV 分表匯出**: 每個資料表獨立 CSV 檔案,方便檢視 +- **備份摘要報告**: 資料表行數、大小統計 +- **自動壓縮**: 生成 `.tar.gz` 壓縮包 + +### 備份內容 + +``` +backups/momo_backup_20260126_143000/ +├── database/ +│ ├── postgres_full_20260126_143000.sql # 完整 SQL +│ ├── backup_summary.txt # 備份摘要 +│ └── tables/ +│ ├── users.csv +│ ├── products.csv +│ ├── daily_sales_snapshot.csv +│ └── ... # 所有資料表 +├── configs/ +│ ├── .env +│ ├── docker-compose.yml +│ └── config/ +└── n8n/ + └── *.json # 所有工作流程 +``` + +--- + +## 環境需求 + +### 硬體需求 + +| 項目 | 最低需求 | 建議配置 | +|------|----------|----------| +| CPU | 4 核心 | 8 核心 | +| RAM | 8 GB | 16 GB | +| 硬碟 | 50 GB SSD | 100 GB SSD | + +### 端口需求 + +| 端口 | 服務 | 必要 | +|------|------|------| +| 5001 | Flask App | ✓ | +| 5432 | PostgreSQL | ✓ | +| 80/443 | Nginx | ✓ | +| 3000 | Grafana | 選填 | +| 9090 | Prometheus | 選填 | +| 5678 | n8n | 選填 | + +--- + +# PostgreSQL 慢查詢監控 (2026-01-26 新增) + +## 功能概述 + +新增 PostgreSQL 慢查詢監控 API 及 n8n 自動修復工作流程。 + +### API 端點 + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/system/db/slow_queries` | GET | 取得慢查詢列表 | +| `/api/system/db/optimize` | POST | 執行 VACUUM ANALYZE | +| `/api/system/db/stats` | GET | 資料庫統計資訊 | +| `/api/system/monitor/overview` | GET | 監控總覽(整合所有狀態) | + +### n8n 工作流程 + +**名稱**: `PostgreSQL 慢查詢監控 (含自動修復)` +**檔案**: `n8n-workflows/13-slow-query-monitor.json` + +**工作流程**: +1. 每 15 分鐘執行 +2. 呼叫 `/api/system/db/slow_queries` 取得慢查詢 +3. 分析是否需要優化(≥3 個慢查詢或重複模式) +4. 發送 Telegram 告警 +5. 自動執行 VACUUM ANALYZE(如需要) +6. 通知優化結果 + +### 慢查詢檢測邏輯 + +```javascript +// 閾值設定 +const threshold = 5; // 秒 + +// 觸發優化條件 +if (slowQueries.length >= 3) { + needsOptimize = true; +} + +// 重複慢查詢檢測(可能需要索引) +if (repeatedSlowQueries.length > 0) { + needsOptimize = true; +} +``` + +--- + +## Monitor 頁面更新 + +**檔案**: `docker/nginx/html/monitor-index.html` + +新增功能: +- 即時服務狀態面板(Registry、Grafana、Prometheus、n8n、MOMO App、Database) +- Prometheus 告警顯示面板 +- n8n 工作流程狀態面板 +- 每 30 秒自動刷新 + +--- + +# n8n 監控工作流程清單 + +| 檔案 | 名稱 | 排程 | 功能 | +|------|------|------|------| +| `11-registry-health-monitor.json` | Registry 健康監控 | 每 10 分鐘 | Docker Registry 健康檢查 | +| `12-google-drive-import-monitor.json` | Google Drive 匯入監控 | 每 30 分鐘 | 自動匯入失敗告警 | +| `13-slow-query-monitor.json` | PostgreSQL 慢查詢監控 | 每 15 分鐘 | 慢查詢告警 + 自動 VACUUM | + +--- + +# CI/CD 與一鍵部署自動化 (2026-02-06 新增) + +## 概述 + +完整的部署自動化系統,包含: +- **CI/CD 流程**: GitLab 已撤除,新方案待定 +- **環境自動安裝**: 在全新主機上自動安裝所有必要套件 +- **一鍵完整部署**: 環境安裝 + 應用部署一次完成 + +--- + +## CI/CD 流程(待定) + +> **2026-04-18 備註**: 原 GitLab CI/CD(含 `.gitlab-ci.yml`、`.gitlab-ci-simple.yml`)已隨 GitLab 撤除而退役。下列 Pipeline 骨架保留作為新 CI/CD 方案的參考需求。 + +### 預期階段骨架 + +``` +git push main (remote 待定) + │ + ▼ +╔═══════════════════════════════════════╗ +║ CI/CD Pipeline (待新方案定案) ║ +╠═══════════════════════════════════════╣ +║ Stage: test ║ +║ • pytest 測試 (允許失敗) ║ +╠═══════════════════════════════════════╣ +║ Stage: deploy ║ +║ • rsync 同步程式碼到 K3s 主機 ║ +║ • docker build (在 K3s 主機建置) ║ +║ • k3s ctr images import (匯入映像) ║ +║ • kubectl rollout restart (重啟) ║ +║ • 健康檢查 ║ +║ • Telegram 通知 ║ +╚═══════════════════════════════════════╝ +``` + +### 關鍵特點(設計需求) + +| 特點 | 說明 | +|------|------| +| **不依賴 Registry** | 本地建置 + K3s containerd 直接匯入(可選) | +| **自動健康檢查** | 部署後自動驗證服務狀態 | +| **Telegram 通知** | 部署成功/失敗即時通知 | +| **自動回滾** | 部署失敗可快速回滾 | + +### 相關檔案 + +| 檔案 | 說明 | +|------|------| +| `scripts/deploy/build-and-deploy.sh` | 快速部署腳本(手動執行) | +| `scripts/verify-cicd.sh` | CI/CD 驗證腳本(原 GitLab CI 用,待改寫) | + +--- + +## 一鍵部署到新主機 + +### 完整部署命令 + +```bash +# 1. 複製專案到新主機 +scp -r momo-pro-system root@新主機IP:/opt/ + +# 2. SSH 到新主機執行完整部署 +ssh root@新主機IP +cd /opt/momo-pro-system +sudo ./deploy/scripts/full-deploy.sh --domain mo.example.com --ssl +``` + +**一個命令完成:環境安裝 + K8s 部署 + Nginx + SSL + 監控 + 自動啟動** + +### 部署流程 + +``` +執行 full-deploy.sh + │ + ▼ +Phase 1: 環境安裝 ───────────────────────────────────────────── + • 檢測 OS (Ubuntu/Debian) + • 安裝基礎套件 (curl, git, vim, htop, jq...) + • 安裝 Docker + Docker Compose + • 安裝 K3s + Helm + • 安裝 Nginx + 安全配置 + • 安裝 Certbot (Let's Encrypt) + • 設定 UFW 防火牆 + • 設定 Fail2Ban + • 系統優化 (核心參數、檔案限制) + │ + ▼ +Phase 2: K8s 配置 ───────────────────────────────────────────── + • 建立 momo namespace + • 部署 Secrets + ConfigMap + • 部署 PostgreSQL StatefulSet + • 部署 momo-app Deployment + • 部署 momo-scheduler Deployment + │ + ▼ +Phase 3: 映像建置 ───────────────────────────────────────────── + • docker build -t momo-pro-system:local + • docker save | k3s ctr images import + • kubectl rollout restart + │ + ▼ +Phase 4-5: 網路配置 ─────────────────────────────────────────── + • Nginx 反向代理配置 + • Let's Encrypt SSL 證書 + │ + ▼ +Phase 6: 監控系統 ───────────────────────────────────────────── + • Prometheus + Grafana (Helm) + • Alertmanager 告警 + │ + ▼ +Phase 7-8: 自動化 ───────────────────────────────────────────── + • systemd 開機自動啟動 + • 健康檢查 + • Telegram 通知 +``` + +--- + +## 自動安裝的套件清單 + +### 基礎套件 + +| 套件 | 用途 | +|------|------| +| curl, wget | 檔案下載 | +| git | 版本控制 | +| vim, htop, iotop | 編輯器與系統監控 | +| jq | JSON 處理 | +| rsync | 檔案同步 | +| unzip, tar, gzip | 壓縮解壓縮 | +| net-tools, dnsutils | 網路工具 | +| build-essential | 編譯工具 | +| cron, logrotate | 排程與日誌管理 | + +### 容器與 Kubernetes + +| 套件 | 版本 | 用途 | +|------|------|------| +| Docker CE | 最新 | 容器運行環境 | +| Docker Compose | Plugin | 多容器編排 | +| K3s | 最新 | 輕量級 Kubernetes | +| Helm | v3 | K8s 套件管理 | + +### Python + +| 套件 | 用途 | +|------|------| +| python3 | Python 執行環境 | +| pip | Python 套件管理 | +| venv | 虛擬環境 | + +### Web 伺服器與 SSL + +| 套件 | 用途 | +|------|------| +| Nginx | 反向代理 + 負載均衡 | +| Certbot | Let's Encrypt SSL 自動證書 | + +### 安全 + +| 套件 | 用途 | +|------|------| +| Fail2Ban | 防暴力破解 (SSH 3次失敗封鎖1小時) | +| UFW | 防火牆 | + +### 監控 + +| 套件 | 用途 | +|------|------| +| Node Exporter | 主機指標收集 | +| Prometheus | 指標儲存與查詢 (Helm) | +| Grafana | 監控儀表板 (Helm) | + +### 資料庫 + +| 套件 | 用途 | +|------|------| +| postgresql-client | PostgreSQL 客戶端工具 | + +--- + +## 部署腳本說明 + +### 1. 環境安裝腳本 + +**檔案**: `deploy/scripts/setup-environment.sh` + +只安裝環境,不部署應用: + +```bash +sudo ./deploy/scripts/setup-environment.sh [選項] + +選項: + --user 部署用戶(預設: wooo) + --domain 域名 + --no-docker 不安裝 Docker + --no-k3s 不安裝 K3s + --no-nginx 不安裝 Nginx + --no-certbot 不安裝 Certbot + --no-monitoring 不安裝監控工具 + --no-firewall 不設定防火牆 +``` + +### 2. 完整部署腳本 + +**檔案**: `deploy/scripts/full-deploy.sh` + +環境安裝 + 應用部署: + +```bash +sudo ./deploy/scripts/full-deploy.sh [選項] + +選項: + --user 部署用戶(預設: wooo) + --domain 域名 + --ssl 設定 SSL 證書 + --skip-env 跳過環境安裝(環境已準備好時使用) +``` + +### 3. 快速部署腳本 + +**檔案**: `scripts/deploy/build-and-deploy.sh` + +日常更新用(環境已準備好): + +```bash +./scripts/deploy/build-and-deploy.sh + +# 功能: +# 1. 本地 Docker 建置 +# 2. 映像傳輸到 K3s 主機 +# 3. 匯入 K3s containerd +# 4. 重啟服務 +# 5. 健康檢查 +# 6. Telegram 通知 +``` + +### 4. CI/CD 驗證腳本 + +**檔案**: `scripts/verify-cicd.sh` + +驗證 CI/CD 流程完整性: + +```bash +./scripts/verify-cicd.sh + +# 驗證項目: +# 1. 本地環境 (Docker, Git, SSH) +# 2. SSH 連線 (K3s 主機) +# 3. K8s 服務狀態 +# 4. 應用健康檢查 +# 5. CI/CD 檔案完整性 +# 6. 自動啟動設定 +# 7. 端對端部署測試 +# 8. 監控系統 +``` + +--- + +## 日常更新流程 + +環境已安裝後,日常更新只需: + +```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 +``` + +--- + +## 系統需求 + +### 硬體需求 + +| 項目 | 最低需求 | 建議配置 | +|------|----------|----------| +| CPU | 2 核心 | 4+ 核心 | +| RAM | 4 GB | 8+ GB | +| 硬碟 | 30 GB | 50+ GB SSD | + +### 支援的作業系統 + +| OS | 版本 | 狀態 | +|------|------|------| +| Ubuntu | 22.04, 24.04 | ✅ 完全支援 | +| Debian | 11, 12 | ✅ 完全支援 | + +### 防火牆規則 (自動設定) + +| 端口 | 服務 | 存取範圍 | +|------|------|---------| +| 22 | SSH | 公開 | +| 80 | HTTP | 公開 | +| 443 | HTTPS | 公開 | +| 6443 | K3s API | 僅內網 | + +--- + +## 部署檔案清單 + +``` +deploy/ +├── deploy.sh # 進階部署主腳本 +├── README.md # 部署說明文檔 +├── QUICK_START.md # 快速部署指南 +├── configs/ +│ └── .env.template # 環境變數模板 +├── lib/ +│ ├── common.sh # 通用函數 +│ ├── check.sh # 環境檢查 +│ ├── config.sh # 配置生成 +│ ├── docker.sh # Docker 操作 +│ ├── database.sh # 資料庫備份還原 +│ ├── ssl.sh # SSL 憑證 +│ ├── health.sh # 健康檢查 +│ ├── k8s.sh # K8s 管理 +│ ├── monitoring.sh # 監控部署 +│ ├── systemd.sh # Systemd 服務 +│ └── registry.sh # Registry 管理 +└── scripts/ + ├── setup-environment.sh # 🆕 環境自動安裝 + ├── full-deploy.sh # 🆕 完整一鍵部署 + └── setup-firewall.sh # 防火牆設定 + +scripts/ +├── deploy/ +│ └── build-and-deploy.sh # 快速部署腳本 +├── verify-cicd.sh # 🆕 CI/CD 驗證腳本 +└── tools/ + ├── system_startup_complete.sh # 系統啟動腳本 + └── momo-startup-complete.service # systemd 服務 +``` + +--- + +# 🚀 WOOO AIOps - 智能運維 SaaS 產品 (2026-02-13 新增) + +## 產品概覽 + +> **WOOO AIOps** 是基於 MOMO Pro System 監控與自動修復經驗打造的 SaaS 產品, +> 讓新網站能夠**即時接入**監控、告警、自動修復與一鍵部署功能。 + +| 項目 | 說明 | +|------|------| +| 產品名稱 | WOOO AIOps (AI Operations Platform) | +| 目標客戶 | 需要快速建立監控的中小型網站 | +| 核心價值 | 10 分鐘內完成監控 + 自動修復配置 | +| 技術棧 | FastAPI + React/Next.js + K8s + Prometheus | + +### 產品特點 + +| 功能 | 說明 | +|------|------| +| **一鍵部署** | 支援 FastAPI、Flask、Express、Next.js 等主流框架 | +| **智能監控** | 自動整合 Prometheus + Grafana | +| **AI 告警** | 基於歷史資料的智能告警 | +| **自動修復** | 重啟、擴容、回滾、記憶體調整等 8 種修復策略 | +| **多租戶** | 完整的用戶隔離與計費系統 | + +--- + +## 架構圖 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WOOO AIOps Platform │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Web Portal (React/Next.js) │ +│ ├── Dashboard # 總覽儀表板 │ +│ ├── Apps Management # 應用管理 │ +│ ├── Monitoring # 監控中心 │ +│ ├── Repairs History # 修復記錄 │ +│ └── Settings # 系統設定 │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ FastAPI Backend │ │ +│ │ /api/auth/* # 認證 (JWT) │ │ +│ │ /api/apps/* # 應用 CRUD │ │ +│ │ /api/deployments/* # 部署管理 │ │ +│ │ /api/monitoring/* # 監控資料 │ │ +│ │ /api/repairs/* # 修復記錄 │ │ +│ │ /api/users/* # 用戶管理 │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ Deploy Engine │ │ Monitor Engine│ │ Repair Engine │ │ +│ │ │ │ │ │ │ │ +│ │ • Template │ │ • Prometheus │ │ • 8 種策略 │ │ +│ │ Renderer │ │ Client │ │ • 自動觸發 │ │ +│ │ • K8s Client │ │ • Alertmanager│ │ • 執行追蹤 │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ Kubernetes Cluster │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ User App A │ │ User App B │ │ User App C │ │ │ +│ │ │ (FastAPI) │ │ (Next.js) │ │ (Express) │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Prometheus │ │ Grafana │ │Alertmanager │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 目錄結構 + +``` +aiops-core/ +├── README.md # 產品說明文檔 +├── requirements.txt # Python 依賴 +│ +├── api/ # FastAPI 後端 +│ ├── main.py # FastAPI 入口 +│ └── routers/ +│ ├── auth.py # JWT 認證 +│ ├── apps.py # 應用 CRUD +│ ├── deployments.py # 部署管理 +│ ├── monitoring.py # 監控 API +│ ├── repairs.py # 修復記錄 +│ └── users.py # 用戶管理 +│ +├── database/ # 資料庫層 +│ ├── models.py # SQLAlchemy 模型 +│ └── session.py # 資料庫連線 +│ +├── deploy_engine/ # 部署引擎 +│ ├── deploy_service.py # 部署服務 +│ ├── template_renderer.py # Jinja2 模板渲染 +│ └── k8s_client.py # kubectl 封裝 +│ +├── 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 模板 (Jinja2) +│ ├── base/ +│ │ ├── namespace.yaml.j2 +│ │ ├── service.yaml.j2 +│ │ └── ingress.yaml.j2 +│ └── frameworks/ +│ ├── fastapi/deployment.yaml.j2 +│ ├── flask/deployment.yaml.j2 +│ ├── express/deployment.yaml.j2 +│ └── nextjs/deployment.yaml.j2 +│ +└── web/ # React/Next.js 前端 + ├── package.json + ├── tailwind.config.js + └── src/ + ├── lib/api.ts # API 客戶端 + └── pages/ + ├── index.tsx # Dashboard + ├── monitoring.tsx # 監控中心 + ├── repairs.tsx # 修復記錄 + └── apps/ + ├── index.tsx # 應用列表 + └── new.tsx # 創建應用精靈 +``` + +--- + +## 資料庫模型 + +### 核心表 + +| 表名 | 說明 | 主要欄位 | +|------|------|---------| +| `users` | 用戶帳號 | id, email, password_hash, plan, company | +| `apps` | 應用配置 | id, user_id, name, framework, status, domain | +| `deployments` | 部署記錄 | id, app_id, version, status, started_at | +| `alerts` | 告警記錄 | id, app_id, name, severity, status | +| `repairs` | 修復記錄 | id, app_id, action, status, attempt | +| `api_keys` | API 金鑰 | id, user_id, key_hash, scopes | + +### 列舉類型 + +```python +class PlanType(str, Enum): + FREE = "free" # 最多 2 個應用 + STARTER = "starter" # 最多 5 個應用 + PRO = "pro" # 最多 20 個應用 + ENTERPRISE = "enterprise" # 無限制 + +class Framework(str, Enum): + FASTAPI = "fastapi" + FLASK = "flask" + EXPRESS = "express" + NEXTJS = "nextjs" + DJANGO = "django" + NESTJS = "nestjs" + +class AppStatus(str, Enum): + PENDING = "pending" + DEPLOYING = "deploying" + RUNNING = "running" + STOPPED = "stopped" + ERROR = "error" + +class RepairAction(str, Enum): + RESTART = "restart" + SCALE_UP = "scale_up" + SCALE_DOWN = "scale_down" + ROLLBACK = "rollback" + INCREASE_MEMORY = "increase_memory" + INCREASE_CPU = "increase_cpu" + VACUUM_DB = "vacuum_db" + CLEAR_CACHE = "clear_cache" +``` + +--- + +## API 端點 + +### 認證 API + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/auth/login` | POST | 登入取得 JWT | +| `/api/auth/register` | POST | 註冊新用戶 | +| `/api/auth/refresh` | POST | 刷新 Token | +| `/api/auth/me` | GET | 取得當前用戶 | + +### 應用 API + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/apps` | GET | 列出所有應用 | +| `/api/apps` | POST | 創建新應用 | +| `/api/apps/{id}` | GET | 取得應用詳情 | +| `/api/apps/{id}` | PUT | 更新應用 | +| `/api/apps/{id}` | DELETE | 刪除應用 | +| `/api/apps/{id}/start` | POST | 啟動應用 | +| `/api/apps/{id}/stop` | POST | 停止應用 | +| `/api/apps/{id}/restart` | POST | 重啟應用 | + +### 部署 API + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/deployments` | GET | 列出部署記錄 | +| `/api/deployments` | POST | 觸發新部署 | +| `/api/deployments/{id}` | GET | 部署詳情 | +| `/api/deployments/{id}/cancel` | POST | 取消部署 | +| `/api/deployments/{id}/rollback` | POST | 回滾到此版本 | + +### 監控 API + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/monitoring/dashboard` | GET | 總覽資料 | +| `/api/monitoring/apps/{id}/metrics` | GET | 應用指標 | +| `/api/monitoring/apps/{id}/health` | GET | 健康狀態 | +| `/api/monitoring/alerts` | GET | 告警列表 | + +### 修復 API + +| 端點 | 方法 | 說明 | +|------|------|------| +| `/api/repairs` | GET | 修復記錄列表 | +| `/api/repairs/stats` | GET | 修復統計 | +| `/api/repairs/apps/{id}/trigger` | POST | 手動觸發修復 | + +--- + +## 自動修復策略 + +### 告警到修復動作映射 + +| 告警類型 | 修復動作 | 說明 | +|---------|---------|------| +| `HighCpuUsage` | scale_up | CPU > 80%,自動擴容 | +| `HighMemoryUsage` | increase_memory | 記憶體 > 85%,增加限制 | +| `PodCrashLoopBackOff` | restart | Pod 崩潰,嘗試重啟 | +| `HighErrorRate` | rollback | 錯誤率 > 5%,回滾版本 | +| `PodOOMKilled` | increase_memory | OOM,增加 50% 記憶體 | +| `HighResponseTime` | scale_up | P95 > 2s,擴容 | +| `DatabaseHighConnections` | vacuum_db | 連線 > 80%,執行 VACUUM | +| `DatabaseDeadlock` | kill_query | 發現死鎖,終止查詢 | + +### 修復執行流程 + +``` +告警觸發 + │ + ▼ +repair_service.process_alert() + │ + ├─ 檢查是否符合自動修復條件 + │ + ├─ 建立 Repair 記錄 + │ + ├─ repair_executor.execute() + │ ├─ restart: kubectl rollout restart + │ ├─ scale_up: kubectl scale --replicas +1 + │ ├─ rollback: kubectl rollout undo + │ ├─ increase_memory: kubectl patch +50% + │ ├─ vacuum_db: VACUUM ANALYZE + │ └─ kill_query: pg_terminate_backend() + │ + └─ 更新修復狀態 + 發送通知 +``` + +--- + +## 支援的框架 + +### Python 框架 + +| 框架 | 版本 | 預設配置 | +|------|------|---------| +| FastAPI | 0.100+ | Gunicorn + Uvicorn, Port 8000 | +| Flask | 2.0+ | Gunicorn, Port 5000 | +| Django | 4.0+ | Gunicorn, Port 8000 | + +### JavaScript 框架 + +| 框架 | 版本 | 預設配置 | +|------|------|---------| +| Express | 4.18+ | Node.js, Port 3000 | +| Next.js | 14+ | Node.js, Port 3000 | +| NestJS | 10+ | Node.js, Port 3000 | + +### K8s 模板變數 + +| 變數 | 說明 | 範例 | +|------|------|------| +| `app.name` | 應用名稱 | `my-api` | +| `app.namespace` | K8s 命名空間 | `user-123-my-api` | +| `app.framework` | 框架類型 | `fastapi` | +| `app.replicas` | 副本數 | `2` | +| `app.port` | 容器端口 | `8000` | +| `app.domain` | 自訂域名 | `api.example.com` | +| `resources.cpu_request` | CPU 請求 | `100m` | +| `resources.memory_request` | 記憶體請求 | `256Mi` | + +--- + +## 運行方式 + +### 後端 (FastAPI) + +```bash +cd aiops-core + +# 安裝依賴 +pip install -r requirements.txt + +# 設定環境變數 +export DATABASE_URL="postgresql://user:pass@localhost/aiops" +export JWT_SECRET="your-secret-key" +export PROMETHEUS_URL="http://prometheus:9090" +export ALERTMANAGER_URL="http://alertmanager:9093" + +# 啟動服務 +uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload +``` + +### 前端 (Next.js) + +```bash +cd aiops-core/web + +# 安裝依賴 +npm install + +# 設定環境變數 +echo 'NEXT_PUBLIC_API_URL=http://localhost:8000/api' > .env.local + +# 開發模式 +npm run dev + +# 生產建置 +npm run build +npm start +``` + +--- + +## 開發狀態 + +### 已完成 ✅ + +| 模組 | 檔案數 | 狀態 | +|------|--------|------| +| Deploy Engine | 4 | ✅ 完成 | +| Monitor Engine | 4 | ✅ 完成 | +| Repair Engine | 4 | ✅ 完成 | +| FastAPI Backend | 8 | ✅ 完成 | +| Database Models | 3 | ✅ 完成 | +| K8s Templates | 7 | ✅ 完成 | +| Web Portal | 8 | ✅ 完成 | +| **總計** | **38** | **✅** | + +### 待開發 ⏳ + +| 功能 | 優先級 | 說明 | +|------|--------|------| +| 計費系統整合 | 高 | Stripe/金流整合 | +| 多租戶隔離 | 高 | K8s namespace 隔離 | +| 自訂告警規則 | 中 | 用戶自定義閾值 | +| Slack/Discord 通知 | 中 | 多渠道告警 | +| 日誌聚合 | 中 | Loki 整合 | +| 效能分析 | 低 | APM 功能 | + +--- + +## 更新記錄 + +- **2026-02-13**: + - 🆕 新增 WOOO AIOps 產品核心模組 (38 個檔案) + - 🆕 Deploy Engine: 支援 4 種框架的 K8s 部署 + - 🆕 Monitor Engine: Prometheus + Grafana + Alertmanager 整合 + - 🆕 Repair Engine: 8 種自動修復策略 + - 🆕 FastAPI Backend: 6 個 API 路由模組 + - 🆕 Web Portal: Dashboard、應用管理、監控、修復記錄頁面 + - 🆕 Database Models: User、App、Deployment、Alert、Repair + +- **2026-02-06**: + - 新增 CI/CD 完整驗證腳本 (`scripts/verify-cicd.sh`) + - 新增環境自動安裝腳本 (`deploy/scripts/setup-environment.sh`) + - 新增完整一鍵部署腳本 (`deploy/scripts/full-deploy.sh`) + - 新增快速部署腳本 (`scripts/deploy/build-and-deploy.sh`) + - K8s 優化配置 (`k8s/optimized/`) + - 完整 K8s 配置檔案 (`k8s/00-namespace.yaml`, `k8s/01-secrets.yaml`, `k8s/03-postgres.yaml`) + - 系統自動啟動機制 (systemd) + - 完整部署文檔更新 + +- **2026-01-26**: + - 新增一鍵部署自動化腳本系統 + - 新增 PostgreSQL 慢查詢監控 API + - 新增 n8n 慢查詢自動修復工作流程 + - 更新 Monitor 頁面整合所有監控狀態 + +--- + +## ⚖️ 第十三章:AI 架構完整憲法(2026-04-18 加入 + 統帥批准) + +### 第 40 條:AI 學習數據雙寫規範(絕對禁止違反) +- ✅ **正確**:所有 AI 產出(PPT 洞察、競品分析、對話記錄)必須**雙寫** PostgreSQL `ai_insights` + pgvector embedding +- ❌ **禁止**:只寫 DB 不寫 KM(RAG 無法語意搜尋) +- ❌ **禁止**:只寫 KM 不寫 DB(精確 period/sku 查詢無法命中) +- **理由**:DB 是精準命中,KM 是語意搜尋,兩者互補缺一不可(ADR-007) +- **實作**:NemoTron `store_insight` tool call 觸發雙寫;Hermes 非同步補 embedding + +### 第 41 條:架構決策 ADR 規範(強制要求) +- ✅ **正確**:任何重大架構決策(引入新依賴、改資料庫 schema、改 AI 路由邏輯)必須建立 `docs/adr/ADR-XXX-*.md` +- ✅ **正確**:ADR 建立後同步更新 `docs/adr/README.md` 索引 + 相關 SOT + Memory +- ❌ **禁止**:口頭決策不留文字記錄 +- **格式**:Context → Decision → Alternatives Considered → Consequences +- **目錄**:`docs/adr/`(2026-04-18 已建立 ADR-001 ~ ADR-007) + +### 第 42 條:Memory 跨 Session 持久化規範(強制要求) +- ✅ **正確**:重大協作 context、統帥偏好、技術決策必須寫入 Memory 目錄 +- ✅ **目錄**:`~/.claude/projects/.../memory/` +- ✅ **索引**:`MEMORY.md` 必須保持最新索引 +- ❌ **禁止**:依賴 Claude 對話 session 記憶(必有 context window 截斷問題) +- **類型分類**:`user`(統帥偏好)、`feedback`(決策回饋)、`project`(專案技術)、`reference`(地圖參考) + +### 第 43 條:Embedding 必須本地化到 Hermes(強制要求) +- ✅ **正確**:使用 `bge-m3`(或 `nomic-embed-text`)掛載在 `192.168.0.111` Ollama +- ❌ **禁止**:呼叫外部 Embedding API(成本 + 數據隱私雙重問題) +- **維度**:`vector(1024)`(ADR-003) +- **理由**:統帥明確要求「Hermes 機器徹底包辦所有苦力運算,把數據隱私與成本降到絕對的零」 + +### 第 44 條:NemoTron 配額耗盡 Fallback 鏈(強制要求) +- ✅ **正確**:`nemoton_dispatcher_service.py` 必須有 try-except,HTTP 429 → 立刻切換 `_hermes_rule_fallback()` +- ❌ **禁止**:配額耗盡時讓告警管線中斷(雙 11 等節慶高峰時風險最高) +- **標記**:降級模式告警需帶 `🟡` 前綴,讓統帥識別(ADR-004) + +### 第 45 條:KM 品質分數時間衰減(強制要求) +- ✅ **公式**:`effective_score = base_score × e^(-decay_rate × days_passed)` +- **預設 decay_rate**:`0.005`;`decay_exempt=True` 用於結構性/憲法類知識 +- **理由**:確保 RAG 優先抓取最新適用洞察,避免歷史偏誤(ADR-005) + +--- + +> **📌 遊戲規則全文見第零章(文件最頂部)。本章條款是第零章的 AI 專項細化。** + +## 🏷️ 專案正名記錄 + +- **2026-04-18**:正式更名為 **EwoooC**(ADR-006) +- Docker image tag 維持 `momo-pro-system`(延後處理) +- Domain 維持 `mo.wooo.work` 與 `momo.wooo.work`(皆指向 UAT) diff --git a/CONSTITUTION.md b/CONSTITUTION.md new file mode 100644 index 0000000..071eaae --- /dev/null +++ b/CONSTITUTION.md @@ -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(統帥批准,加入第十三、十四章) diff --git a/DEPLOYMENT_WORKFLOW.md b/DEPLOYMENT_WORKFLOW.md new file mode 100644 index 0000000..fd0c1af --- /dev/null +++ b/DEPLOYMENT_WORKFLOW.md @@ -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): 初版發布,定義完整開發測試部署流程 diff --git a/DEPLOY_README.md b/DEPLOY_README.md new file mode 100644 index 0000000..2f6dc72 --- /dev/null +++ b/DEPLOY_README.md @@ -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 開始,需要時再遷移到其他方案。** diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e06a296 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/GOOGLE_DRIVE_SETUP.md b/GOOGLE_DRIVE_SETUP.md new file mode 100644 index 0000000..1d3d02d --- /dev/null +++ b/GOOGLE_DRIVE_SETUP.md @@ -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 分鐘自動檢查並匯入新的業績檔案。 diff --git a/PROJECT_CONSTITUTION.md b/PROJECT_CONSTITUTION.md new file mode 100644 index 0000000..a9ec03d --- /dev/null +++ b/PROJECT_CONSTITUTION.md @@ -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: `` +- 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): 初版發布,定義核心規範 diff --git a/SECURITY_FIX_SUMMARY.md b/SECURITY_FIX_SUMMARY.md new file mode 100644 index 0000000..0258fc9 --- /dev/null +++ b/SECURITY_FIX_SUMMARY.md @@ -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 表單添加 `` + - `login.html` (第 142 行) - 登入表單 + +3. **AJAX 請求防護** + - 在所有 HTML 模板的 `` 添加 CSRF meta tag: + ```html + + ``` + - 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/` 路由 + +**防護機制:** + +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%) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt new file mode 100644 index 0000000..097b8e5 --- /dev/null +++ b/TODO_NEXT_STEPS.txt @@ -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 匯出。 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c66a0b2 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# Services package \ No newline at end of file diff --git a/abc_analysis_detail.html b/abc_analysis_detail.html new file mode 100644 index 0000000..5a901af --- /dev/null +++ b/abc_analysis_detail.html @@ -0,0 +1,196 @@ + + + + + + + ABC 分析詳情 - {{ info.title }} + + + + + + + {% include 'components/_navbar.html' %} + +
+
+
+

+ {{ info.title }} +

+ {{ info.desc }} +
+
+ +
+ 補貨係數 x + + +
+ + + + 匯出此類別報表 + + +
+
+ +
+
+ + + + + {% if cols.pid %}{% endif %} + + {% if cols.brand %}{% endif %} + {% if cols.vendor %}{% endif %} + {% if cols.cat %}{% endif %} + {% if cols.cost or cols.profit %}{% endif %} + {% if cols.qty %}{% endif %} + {% if cols.qty %}{% endif %} + {% if cols.qty %}{% endif %} + + + + + + {% for item in items %} + + + {% if cols.pid %} + + {% endif %} + + {% if cols.brand %} + + {% endif %} + {% if cols.vendor %} + + {% endif %} + {% if cols.cat %} + + {% endif %} + {% if cols.cost or cols.profit %} + + {% endif %} + {% if cols.qty %} + + {% endif %} + {% if cols.qty %} + + {% endif %} + {% if cols.qty %} + + {% endif %} + + + + {% endfor %} + +
排名商品ID商品名稱品牌廠商名稱分類毛利率平均單價銷售數量建議補貨 (x{{ current_factor }})銷售金額累積營收佔比
{{ loop.index }}{{ item[cols.pid] }} +
+ {{ item[cols.name] }} +
+
{{ item[cols.brand] }}{{ item[cols.vendor] }}{{ item[cols.cat] }} + {% set margin = item['calculated_margin_rate'] %} + + {{ "{:.1f}%".format(margin) }} + + ${{ "{:,.0f}".format(item['avg_unit_price']) }}{{ "{:,.0f}".format(item[cols.qty]) }} + {% if item['suggested_restock'] > 0 %} + {{ "{:,.0f}".format(item['suggested_restock']) }} + {% else %} + 建議清倉 + {% endif %} + + ${{ "{:,.0f}".format(item[cols.amount]) }} + + {{ "{:.2f}%".format(item['cumulative_pct']) }} +
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/aiops-core/README.md b/aiops-core/README.md new file mode 100644 index 0000000..380be21 --- /dev/null +++ b/aiops-core/README.md @@ -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. diff --git a/aiops-core/requirements.txt b/aiops-core/requirements.txt new file mode 100644 index 0000000..70c5709 --- /dev/null +++ b/aiops-core/requirements.txt @@ -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 diff --git a/app.py b/app.py new file mode 100644 index 0000000..9c5e72d --- /dev/null +++ b/app.py @@ -0,0 +1,7345 @@ +# ================= TODO LIST (待辦事項 - 重開機後請依序執行) ================= +# 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。 +# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。 +# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。 +# 4. [備份] 確認系統運作正常後,執行系統備份。 +# ======================================================================= + +import os +import sys +import time +import threading +import math +import json +import hashlib +import shutil +import zipfile +import re +import io # V-New: 用於 Excel 匯出 +import traceback # V-Fix: 用於錯誤追蹤 +from datetime import datetime, timedelta, timezone + +# ================= 🔧 1. 環境與路徑鎖定 ================= +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# 確保專案根目錄在 sys.path 的最前面,優先讀取本地模組 +sys.path.insert(0, BASE_DIR) + +# 自動檢核並建立必要目錄 +try: + for folder in ['database', 'services', 'crawler', 'logs', 'data', 'web/templates', 'web/static']: + folder_path = os.path.join(BASE_DIR, folder) + if not os.path.exists(folder_path): + os.makedirs(folder_path) + + # 僅針對 Python 套件目錄建立 __init__.py + if 'web' not in folder: + init_file = os.path.join(folder_path, '__init__.py') + if not os.path.exists(init_file): + with open(init_file, 'w') as f: pass +except OSError as e: + print(f"❌ 系統初始化失敗: 無法建立目錄或檔案 (磁碟可能已滿) - {e}") + +# ================= 🔧 2. 核心模組導入 ================= +try: + from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, send_from_directory, flash, session + from werkzeug.utils import secure_filename + from pyngrok import ngrok, conf + import schedule + from sqlalchemy import desc, and_, func, text, literal, case + from sqlalchemy import inspect # V-New: 用於檢查資料表是否存在 + from sqlalchemy.orm import joinedload + import pandas as pd # type: ignore + from pandas.api.types import is_numeric_dtype # type: ignore + import numpy as np # type: ignore # V-Opt: 引入 numpy 進行向量化運算加速 + + # 導入自定義模組 + try: + from scheduler import run_momo_task, run_edm_task, run_festival_task, run_auto_import_task, run_whitepage_check, run_competitor_price_feeder_task + from database.manager import DatabaseManager + from database.models import Product, PriceRecord, MonthlySummaryAnalysis + from database.edm_models import PromoProduct + except ImportError as e: + print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。") + sys.exit(1) + + from services.logger_manager import SystemLogger + from services.exporter import Exporter # 🚩 導入匯出模組 +except ImportError as e: + print(f"❌ 關鍵套件導入失敗: {e}") + sys.exit(1) + +# ================= 🔧 3. 系統核心配置 ================= +# 從 config.py 匯入必要的設定 +from config import EXCEL_EXPORT_DIR, DATABASE_TYPE + +sys_log = SystemLogger("Web_Server").get_logger() + +# 🚩 V-Opt: 全域資料快取 (用於加速業績分析) +_SALES_DF_CACHE = {} # 已棄用,保留相容性 +_SALES_PROCESSED_CACHE = {} # V-Opt: 處理後資料快取 +_SALES_CACHE_MAX_ENTRIES = 10 # V-Opt (2026-01-23): 快取最大條目數 +_SALES_CACHE_TTL = 600 # V-Opt (2026-01-23): 快取有效期 10 分鐘 + + +def _cleanup_sales_cache(): + """清理過期和過多的快取條目""" + global _SALES_PROCESSED_CACHE + current_time = time.time() + + # 1. 清理過期條目 + expired_keys = [ + k for k, v in _SALES_PROCESSED_CACHE.items() + if v.get('time') and current_time - v['time'] > _SALES_CACHE_TTL + ] + for k in expired_keys: + del _SALES_PROCESSED_CACHE[k] + + # 2. 如果仍超過限制,刪除最舊的條目 + if len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES: + sorted_items = sorted( + [(k, v.get('time', 0)) for k, v in _SALES_PROCESSED_CACHE.items()], + key=lambda x: x[1] + ) + # 保留最新的 _SALES_CACHE_MAX_ENTRIES 條 + keys_to_delete = [k for k, _ in sorted_items[:-_SALES_CACHE_MAX_ENTRIES]] + for k in keys_to_delete: + del _SALES_PROCESSED_CACHE[k] + + if expired_keys or len(_SALES_PROCESSED_CACHE) > _SALES_CACHE_MAX_ENTRIES - 2: + sys_log.debug(f"[Cache] 清理快取: 移除 {len(expired_keys)} 條過期, 剩餘 {len(_SALES_PROCESSED_CACHE)} 條") + +# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入) +_DASHBOARD_DATA_CACHE = { + 'consolidated_data': None, # get_consolidated_data() 結果 + 'consolidated_timestamp': None, # 快取時間戳記 + 'stats_data': None, # 統計資料 + 'stats_timestamp': None # 統計資料時間戳記 +} +_DASHBOARD_CACHE_TTL = 300 # 快取有效期 5 分鐘(秒) + +# 🚩 檢查磁碟空間 (V9.52 新增) +try: + total, used, free = shutil.disk_usage(BASE_DIR) + if free < 200 * 1024 * 1024: # 小於 200MB + sys_log.critical(f"[System] [DISK_CHECK] 🚨 嚴重警告: 磁碟空間極低 | Free: {free // (1024*1024)} MB") + elif free < 1024 * 1024 * 1024: # 小於 1GB + sys_log.warning(f"[System] [DISK_CHECK] ⚠️ 警告: 磁碟空間不足 1GB | Free: {free // (1024*1024)} MB") +except Exception as e: + sys_log.error(f"無法檢測磁碟空間: {e}") + +# 🚩 系統版本定義 (備份與顯示用) +# 🚩 2026-04-19 V10.3: 技術債清零 — Migration 010/011、retry queue 持久化、 +# NemoTron store_insight 雙寫、import 前置欄位防禦、時間衰減 RAG +SYSTEM_VERSION = "V10.3" + +# ========================================== +# 🔒 SQL Injection 防護函數 +# ========================================== + +# 允許的資料表白名單 +ALLOWED_TABLES = { + 'realtime_sales_monthly', + 'daily_sales_snapshot', + 'products', + 'price_records', + 'promo_products', + 'edm_products', + 'festival_products' +} + +def validate_table_name(table_name): + """ + 驗證資料表名稱,防止 SQL Injection + + Args: + table_name: 要驗證的資料表名稱 + + Returns: + str: 驗證通過的表名 + + Raises: + ValueError: 表名不在白名單中 + """ + # 移除空白字符 + table_name = str(table_name).strip() + + # 檢查是否為空 + if not table_name: + raise ValueError("表名不能為空") + + # 檢查是否包含危險字符 + if not re.match(r'^[a-zA-Z0-9_]+$', table_name): + raise ValueError(f"表名包含非法字符: {table_name}") + + # 檢查是否在白名單中 + if table_name not in ALLOWED_TABLES: + # 對於動態表名(從檔名生成),允許但記錄警告 + sys_log.warning(f"[Security] 表名不在白名單中: {table_name}") + # 至少確保沒有 SQL 關鍵字 + sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'UNION', 'WHERE', 'FROM'] + if any(keyword in table_name.upper() for keyword in sql_keywords): + raise ValueError(f"表名包含 SQL 關鍵字: {table_name}") + + return table_name + +def validate_column_names(column_names): + """ + 驗證欄位名稱列表,防止 SQL Injection + + Args: + column_names: 欄位名稱列表 + + Returns: + list: 驗證通過的欄位名稱列表 + + Raises: + ValueError: 欄位名稱包含非法字符 + """ + if isinstance(column_names, str): + column_names = [column_names] + + validated = [] + for col in column_names: + col = str(col).strip() + # 允許中文、英文、數字、底線 + if not re.match(r'^[\w\u4e00-\u9fff]+$', col): + raise ValueError(f"欄位名稱包含非法字符: {col}") + validated.append(col) + + return validated + +def safe_read_sql(table_name, columns=None, engine=None, where_clause=None): + """ + 安全的 SQL 查詢函數,防止 SQL Injection + + Args: + table_name: 資料表名稱 + columns: 欄位列表,None 表示 * + engine: SQLAlchemy engine + where_clause: WHERE 子句(僅支持簡單條件) + + Returns: + DataFrame: 查詢結果 + """ + from sqlalchemy import text, MetaData, Table + + # 驗證表名 + table_name = validate_table_name(table_name) + + # 驗證欄位名 + if columns: + columns = validate_column_names(columns) + col_str = ', '.join([f'"{col}"' for col in columns]) + else: + col_str = '*' + + # 使用 SQLAlchemy 的參數化查詢 + # 注意:表名和欄位名不能參數化,所以必須先驗證 + try: + query = f'SELECT {col_str} FROM "{table_name}"' + if where_clause: + query += f' WHERE {where_clause}' + + return pd.read_sql(text(query), engine) + except Exception as e: + sys_log.error(f"[Security] SQL 查詢失敗: {e}") + raise + +# ========================================== +# 🔒 路徑遍歷防護函數 +# ========================================== + +from pathlib import Path + +def safe_join(base, *paths): + """ + 安全的路徑拼接,防止路徑遍歷攻擊 + + Args: + base: 基礎目錄(絕對路徑) + *paths: 子路徑組件 + + Returns: + Path: 安全的完整路徑 + + Raises: + ValueError: 偵測到路徑遍歷嘗試 + """ + # 確保 base 是絕對路徑 + base = Path(base).resolve() + + # 檢查路徑組件中是否包含危險字符 + for path_component in paths: + path_str = str(path_component) + + # 阻擋包含 Windows 反斜線的路徑 + if '\\' in path_str: + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (Windows 反斜線) | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許使用反斜線") + + # 阻擋包含連續點的路徑 (如 ...., ....//) + if '..' in path_str.replace('\\', '/'): + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (雙點) | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許使用 '..'") + + # 拼接並解析完整路徑 + full_path = (base / Path(*paths)).resolve() + + # 驗證最終路徑必須在基礎目錄內 + try: + full_path.relative_to(base) + except ValueError: + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許存取基礎目錄外的檔案") + + return full_path + +# ========================================== +# 🔒 檔案上傳安全驗證 +# ========================================== + +# 允許的檔案副檔名與 MIME types +ALLOWED_UPLOAD_EXTENSIONS = {'xlsx', 'xls', 'csv'} +ALLOWED_MIME_TYPES = { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'application/vnd.ms-excel', # .xls + 'text/csv', # .csv + 'application/octet-stream' # CSV sometimes detected as this +} + +def secure_filename_unicode(filename): + """ + 支援中文的安全檔案名稱清理 + + Args: + filename: 原始檔案名稱 + + Returns: + str: 清理後的安全檔案名稱 + """ + import re + import unicodedata + + # 正規化 Unicode 字元 + filename = unicodedata.normalize('NFKC', filename) + + # 移除危險字元但保留中文、英文、數字、空格、括號、底線、連字號 + # 允許的字元: 中文、英文字母、數字、空格、括號、底線、連字號、點 + safe_chars = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\(\)_\-\.]', '', filename) + + # 將多個空格替換為單一空格 + safe_chars = re.sub(r'\s+', ' ', safe_chars) + + # 移除前後空格 + safe_chars = safe_chars.strip() + + return safe_chars + +def allowed_file(filename): + """ + 檢查檔案副檔名是否在白名單中 + + Args: + filename: 檔案名稱 + + Returns: + bool: 是否允許上傳 + """ + if not filename or '.' not in filename: + return False + + # 分割檔名和副檔名 + parts = filename.rsplit('.', 1) + if len(parts) != 2: + return False + + basename, ext = parts + + # 拒絕純副檔名檔案(如 .xlsx) + if not basename or basename.strip() == '': + return False + + # 檢查副檔名是否在白名單中 + return ext.lower() in ALLOWED_UPLOAD_EXTENSIONS + +def validate_upload_file(file): + """ + 完整的檔案上傳驗證(副檔名、檔案名稱清理) + + Args: + file: Flask request.files 物件 + + Returns: + tuple: (is_valid, error_message, safe_filename) + """ + # 檢查檔案是否存在 + if not file or file.filename == '': + return False, '未選擇檔案', None + + original_filename = file.filename + + # 1. 在清理前先檢查路徑遍歷攻擊 + # 檢查連續的雙點(路徑遍歷) + if '..' in original_filename: + sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(雙點): {original_filename}") + return False, '檔案名稱包含非法字元', None + + # 檢查絕對路徑或目錄分隔符(在檔名中間) + # 允許檔名開頭沒有分隔符,且允許 HTML 標籤中的斜線(不在路徑位置) + import os + if os.path.sep in original_filename or (os.path.altsep and os.path.altsep in original_filename): + # 進一步檢查是否真的是路徑分隔(而不是 HTML 標籤等) + # 如果檔名以 / 或 \ 開頭,或包含 ./ 或 .\\ 模式,則為路徑遍歷 + if original_filename.startswith(('/','\\')) or './' in original_filename or '.\\' in original_filename: + sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(路徑分隔符): {original_filename}") + return False, '檔案名稱包含非法字元', None + + # 2. 檔案名稱清理(使用支援中文的版本) + safe_name = secure_filename_unicode(original_filename) + if not safe_name: + return False, '檔案名稱不合法', None + + # 3. 副檔名驗證 + if not allowed_file(safe_name): + return False, f'不支援的檔案格式,僅允許: {", ".join(ALLOWED_UPLOAD_EXTENSIONS)}', None + + # 4. 檔案大小驗證(由 Flask MAX_CONTENT_LENGTH 自動處理,此處記錄) + # Flask 會在超過大小時自動拋出 413 錯誤 + + return True, None, safe_name + +# 🚩 資料庫結構自動修復 (V9.53 新增) +def repair_database_schema(): + db = DatabaseManager() + engine = db.engine + from sqlalchemy import inspect, text + from config import DATABASE_TYPE + try: + # 🚩 V9.96: 啟用 SQLite WAL 模式以解決 database is locked 問題 (僅 SQLite) + if DATABASE_TYPE == 'sqlite': + with engine.connect() as conn: + # 啟用 WAL 模式 (Write-Ahead Logging) + conn.execute(text("PRAGMA journal_mode=WAL")) + conn.commit() + sys_log.info("[Database] [WAL] ✅ SQLite WAL 模式已啟用 | 提升並發寫入效能") + else: + sys_log.info(f"[Database] ✅ 使用 {DATABASE_TYPE.upper()} 資料庫") + + inspector = inspect(engine) + # V9.70: 檢查 products 表 + if 'products' in inspector.get_table_names(): + product_columns = [c['name'] for c in inspector.get_columns('products')] + if 'image_url' not in product_columns: + sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 image_url 欄位 | 正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE products ADD COLUMN image_url TEXT")) + conn.commit() + sys_log.info("[Database] [Schema] ✅ products.image_url 欄位修復完成") + + if 'created_at' not in product_columns: + sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 created_at 欄位 | 正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE products ADD COLUMN created_at DATETIME")) + conn.execute(text("UPDATE products SET created_at = updated_at WHERE created_at IS NULL")) + conn.commit() + sys_log.info("[Database] [Schema] ✅ products.created_at 欄位修復完成") + + if 'promo_products' in inspector.get_table_names(): + columns = [c['name'] for c in inspector.get_columns('promo_products')] + if 'url' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT")) + conn.commit() + sys_log.info("✅ url 欄位修復完成") + if 'image_url' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 image_url 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN image_url TEXT")) + conn.commit() + sys_log.info("✅ image_url 欄位修復完成") + if 'previous_price' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 previous_price 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN previous_price INTEGER")) + conn.commit() + sys_log.info("✅ previous_price 欄位修復完成") + if 'session_time_text' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 session_time_text 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN session_time_text TEXT")) + conn.commit() + sys_log.info("✅ session_time_text 欄位修復完成") + if 'remain_qty' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 remain_qty 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN remain_qty INTEGER")) + conn.commit() + sys_log.info("✅ remain_qty 欄位修復完成") + if 'discount_text' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 discount_text 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN discount_text TEXT")) + conn.commit() + sys_log.info("✅ discount_text 欄位修復完成") + if 'page_type' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 page_type 欄位,正在自動修復...") + with engine.connect() as conn: + # 將既有資料預設為 'edm' + conn.execute(text("ALTER TABLE promo_products ADD COLUMN page_type TEXT DEFAULT 'edm'")) + conn.commit() + sys_log.info("✅ page_type 欄位修復完成") + except Exception as e: + sys_log.error(f"[Database] [Schema] ❌ 資料庫修復失敗 | Error: {e}") + +# 從環境變數讀取 NGROK_AUTH_TOKEN(如果未設定則使用原值,但會發出警告) +NGROK_AUTH_TOKEN = os.getenv('NGROK_AUTH_TOKEN', '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF') +if NGROK_AUTH_TOKEN == '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF': + sys_log.warning("[Security] ⚠️ 使用預設 NGROK_AUTH_TOKEN,請設定環境變數") +conf.get_default().auth_token = NGROK_AUTH_TOKEN + +TEMPLATE_DIR = BASE_DIR # 修正:根據檔案結構,模板位於根目錄 +TEMPLATE_DIR_NEW = os.path.join(BASE_DIR, 'templates') # 新模板路徑(模組化) +STATIC_DIR = os.path.join(BASE_DIR, 'web/static') + +# 檢查關鍵模板是否存在 +if not os.path.exists(os.path.join(BASE_DIR, 'dashboard.html')): + sys_log.warning(f"[Web] [Template] ⚠️ 警告: 找不到 dashboard.html | Path: {TEMPLATE_DIR}") + +app = Flask(__name__, + template_folder=TEMPLATE_DIR, + static_folder=STATIC_DIR) + +# 設定多路徑模板載入器(同時支援根目錄和 templates/ 目錄) +from jinja2 import FileSystemLoader, ChoiceLoader +app.jinja_loader = ChoiceLoader([ + FileSystemLoader(TEMPLATE_DIR_NEW), # templates/ 目錄優先 + FileSystemLoader(TEMPLATE_DIR), # 根目錄備用 +]) + +# ========================================== +# 🔒 Flask 安全配置 +# ========================================== + +# 從 config.py 導入 SECRET_KEY +from config import SECRET_KEY + +# 基本配置 +app.config['SECRET_KEY'] = SECRET_KEY + +# Session 安全配置 +app.config['SESSION_COOKIE_HTTPONLY'] = True # 防止 JavaScript 存取 cookie(防 XSS) +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防止 CSRF 攻擊 +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) # Session 有效期 24 小時(延長避免長時間閒置斷線) + +# 如果使用 HTTPS,啟用 SECURE cookie(本地開發時應設為 False) +# 注意:如果您的系統部署在 HTTPS 環境,請將 .env 中的 USE_HTTPS 設為 true +USE_HTTPS = os.getenv('USE_HTTPS', 'false').lower() == 'true' +if USE_HTTPS: + app.config['SESSION_COOKIE_SECURE'] = True + sys_log.info("[Security] ✅ HTTPS 模式已啟用,Session cookie 僅透過 HTTPS 傳輸") +else: + app.config['SESSION_COOKIE_SECURE'] = False + sys_log.warning("[Security] ⚠️ HTTP 模式(開發環境),Session cookie 未強制 HTTPS") + +# 檔案上傳大小限制(10MB) +# V-New: 提高檔案上傳大小限制 (從 10MB 提高到 100MB) +app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 + +sys_log.info("[Security] ✅ Flask 安全配置已載入") +sys_log.info(f"[Security] • Session 有效期: 2 小時") +sys_log.info(f"[Security] • 檔案上傳限制: 10 MB") +sys_log.info(f"[Security] • CSRF 防護: SameSite=Lax") +sys_log.info(f"[Security] • XSS 防護: HttpOnly=True") + +# ========================================== +# 🔒 CSRF 防護配置 +# ========================================== + +from flask_wtf.csrf import CSRFProtect + +csrf = CSRFProtect(app) +sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)") + +# ========================================== +# 🔧 Blueprint 註冊 - 廠商缺貨系統 +# ========================================== +from routes.vendor_routes import vendor_bp +app.register_blueprint(vendor_bp) +sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - Google Drive 自動匯入 +# ========================================== +from routes.auto_import_routes import auto_import_bp +app.register_blueprint(auto_import_bp) +csrf.exempt(auto_import_bp) +sys_log.info("[Blueprint] ✅ Google Drive 自動匯入 Blueprint 已註冊 (CSRF 已豁免)") + +# ========================================== +# 🔧 Blueprint 註冊 - 爬蟲管理系統 +# ========================================== +from routes.crawler_management_routes import crawler_bp +app.register_blueprint(crawler_bp) +sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - AI 智慧文案系統 +# ========================================== +from routes.ai_routes import ai_bp +app.register_blueprint(ai_bp) +csrf.exempt(ai_bp) # ICAIM API 使用內部呼叫,不需要 CSRF +sys_log.info("[Blueprint] ✅ AI 智慧文案系統 Blueprint 已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - 趨勢資料系統 +# ========================================== +from routes.trend_routes import trend_bp +app.register_blueprint(trend_bp) +sys_log.info("[Blueprint] ✅ 趨勢資料系統 Blueprint 已註冊") + +# ========================================== +# 🔒 Auth 路由註冊 - 登入/登出 +# ========================================== +from auth import init_auth_routes +init_auth_routes(app) +sys_log.info("[Auth] ✅ 登入/登出路由已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - 用戶管理系統 +# ========================================== +from routes.user_routes import user_bp +app.register_blueprint(user_bp) +sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊") + +# ========================================== +# 🚨 Blueprint 註冊 - 系統告警 +# ========================================== +from routes.alert_routes import alert_bp +app.register_blueprint(alert_bp) +csrf.exempt(alert_bp) +sys_log.info("[Blueprint] ✅ 系統告警 Blueprint 已註冊 (CSRF 已豁免)") + +# ========================================== +# 系統管理路由 Blueprint +# ========================================== +from routes.system_routes import system_bp +app.register_blueprint(system_bp) +csrf.exempt(system_bp) # n8n API 需要豁免 CSRF +sys_log.info("[Blueprint] ✅ 系統管理 Blueprint 已註冊 (CSRF 已豁免)") + +# ========================================== +# 通知模板管理 Blueprint +# ========================================== +from routes.notification_routes import notification_bp +app.register_blueprint(notification_bp) +csrf.exempt(notification_bp) # n8n API 需要豁免 CSRF +sys_log.info("[Blueprint] ✅ 通知模板管理 Blueprint 已註冊") + +# ========================================== +# Bot API Blueprint (Clawdbot 整合) +# ========================================== +from routes.bot_api_routes import bot_api_bp +app.register_blueprint(bot_api_bp) +csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF +sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊") + +# CI/CD Dashboard Blueprint +from routes.cicd_routes import cicd_bp +app.register_blueprint(cicd_bp) +csrf.exempt(cicd_bp) # CI/CD API 不需要 CSRF +sys_log.info("[Blueprint] ✅ CI/CD Dashboard Blueprint 已註冊") + +# [2026-04-18 台北] OpenClaw Bot Blueprint — 修復 /menu 啞巴 (/bot/telegram/webhook 404) +# 原因:routes/openclaw_bot_routes.py 有 5000+ 行完整 telegram bot handler,但 app.py 從未 register +# 效果:Telegram 送進來的 update (包含 /menu) 能被正確接收與處理 +try: + from routes.openclaw_bot_routes import openclaw_bot_bp + app.register_blueprint(openclaw_bot_bp) + csrf.exempt(openclaw_bot_bp) # Telegram webhook 不需要 CSRF + sys_log.info("[Blueprint] ✅ OpenClaw Bot Blueprint 已註冊 (Telegram /menu 復活)") +except Exception as _e: + sys_log.error(f"[Blueprint] ❌ OpenClaw Bot Blueprint 註冊失敗: {_e}") + +# V-Fix: 註冊 slugify 函數供模板使用,解決 'slugify is undefined' 錯誤 +def slugify(text): + if not text: return "" + return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "") + +LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') +public_url = "服務啟動中..." + +# 🚩 時區設定:台北時間 (UTC+8) +TAIPEI_TZ = timezone(timedelta(hours=8)) + +# ========================================== +# 🔧 全域模板變數注入 (Context Processor) +# ========================================== +from config import METABASE_URL, GRIST_URL + +@app.context_processor +def inject_global_vars(): + """注入全域變數到所有模板""" + return { + 'metabase_url': METABASE_URL, + 'grist_url': GRIST_URL, + 'datetime_now': datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), + } + +sys_log.info("[Template] ✅ 全域模板變數已注入 (metabase_url, grist_url)") + +# ================= 🛠️ V9.72: 分類設定管理核心 ================= +CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json') + +def load_categories(): + """從 JSON 檔案載入分類列表""" + try: + with open(CATEGORIES_JSON_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + +def save_categories(categories): + """將分類列表儲存到 JSON 檔案""" + with open(CATEGORIES_JSON_PATH, 'w', encoding='utf-8') as f: + json.dump(categories, f, ensure_ascii=False, indent=4) + +def load_scheduler_stats(): + """讀取排程統計資料""" + stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json') + if os.path.exists(stats_path): + try: + with open(stats_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (IOError, json.JSONDecodeError): + return {} + return {} + +# ================= 🛠️ 數據處理核心 (封裝) ================= + +def get_color_for_string(s): + """為字串生成一個穩定且美觀的 HSL 顏色""" + if not s: return "hsl(0, 0%, 85%)" # 預設灰色 + # 使用 md5 hash 確保顏色穩定,並映射到 HSL 色彩空間以獲得柔和色彩 + hash_val = int(hashlib.md5(s.encode('utf-8'), usedforsecurity=False).hexdigest(), 16) + hue = hash_val % 360 + return f"hsl({hue}, 60%, 88%)" + +def extract_snapshot_date_from_filename(filename): + """從檔名提取日期:即時業績_當日_20260111.xlsx → 2026-01-11""" + match = re.search(r'(\d{8})', filename) + if match: + date_str = match.group(1) # '20260111' + try: + # 轉換為 YYYY-MM-DD 格式 + year = date_str[:4] + month = date_str[4:6] + day = date_str[6:8] + return f"{year}-{month}-{day}" + except: + return None + return None + +@app.template_filter('number_format') +def number_format_filter(value): + """V9.61: 將數字格式化,加上千分位符號。""" + if isinstance(value, (int, float)): + return "{:,.0f}".format(value) + return value + +# V-Refactor: 將 find_col 移至全域,方便多個函式共用 +def find_col(df_cols, keywords): + """從欄位列表中,根據關鍵字列表找出最匹配的欄位名稱""" + # V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商') + for k in keywords: + for col in df_cols: + if k in str(col): + return col + return None + +def get_consolidated_data(): + """🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)""" + global _DASHBOARD_DATA_CACHE + + # V-New: 檢查快取是否有效 + now = datetime.now(TAIPEI_TZ) + if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and + _DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None): + cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp']) + if cache_age < _DASHBOARD_CACHE_TTL: + sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒") + return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start'] + + sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫") + + db = DatabaseManager() + session = db.get_session() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + seven_days_ago = today_start - timedelta(days=7) + thirty_days_ago = today_start - timedelta(days=30) + + try: + # Query 1: Get the latest price record for every product. This is our main list of items. + latest_price_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + latest_records = session.query(PriceRecord).options( + joinedload(PriceRecord.product) + ).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all() + + product_ids = [r.product_id for r in latest_records] + if not product_ids: + session.close() # 提前關閉連線 + return [], today_start + + # Query 2: Get yesterday's closing prices for all products in one go + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + # Query 3: Get specific historical price points (7 days ago and 30 days ago) + # Instead of fetching ALL history, we fetch only the records closest to the target dates. + # This is a significant optimization. + + # Helper to get price map for a specific date (start of day) + def get_price_map_before(target_date): + subq = session.query( + PriceRecord.product_id, + func.max(PriceRecord.timestamp).label('max_ts') + ).filter( + PriceRecord.product_id.in_(product_ids), + PriceRecord.timestamp < target_date + ).group_by(PriceRecord.product_id).subquery() + + q = session.query(PriceRecord.product_id, PriceRecord.price).join( + subq, + and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts) + ) + return {pid: price for pid, price in q} + + prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago + prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago + + # Query 4: Get TODAY's records only (for sparkline/intraday change) + today_records_q = session.query(PriceRecord).filter( + PriceRecord.product_id.in_(product_ids), + PriceRecord.timestamp >= today_start + ).order_by(PriceRecord.product_id, PriceRecord.timestamp).all() + + today_map = {} + for r in today_records_q: + if r.product_id not in today_map: today_map[r.product_id] = [] + today_map[r.product_id].append(r) + + # Final Assembly (in-memory, no more DB queries) + unique_items = [] + for r in latest_records: + pid = r.product_id + + # 7d/30d stats + price_7d = prices_7d_ago_map.get(pid) + price_30d = prices_30d_ago_map.get(pid) + + stats_7d_diff = r.price - price_7d if price_7d is not None else 0 + stats_30d_diff = r.price - price_30d if price_30d is not None else 0 + + # Today's stats + today_records = today_map.get(pid, []) + today_diff = 0 + today_changes = [] + if len(today_records) > 1: + today_diff = today_records[-1].price - today_records[0].price + + # Yesterday diff + y_price = yesterday_prices_map.get(pid) + yesterday_diff = r.price - y_price if y_price is not None else 0 + + status = "NONE" + if yesterday_diff > 0: + status = "PRICE_UP" + elif yesterday_diff < 0: + status = "PRICE_DOWN" + + # Today's changes details + last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price) + for tr in today_records: + if tr.price != last_p: + diff = tr.price - last_p + today_changes.append({ + 'time': tr.timestamp.strftime('%H:%M'), + 'price': tr.price, + 'diff': diff + }) + last_p = tr.price + + unique_items.append({ + 'record': r, + 'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff}, + 'yesterday_diff': yesterday_diff, + 'today_changes': today_changes, + 'status': status + }) + + # V-New: 更新快取 + _DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items + _DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp() + _DASHBOARD_DATA_CACHE['today_start'] = today_start + sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)}") + + return unique_items, today_start + finally: + session.close() + +def get_dashboard_stats(): + """計算看板統計數據 (供通知使用)""" + db = DatabaseManager() + session = db.get_session() + try: + unique_items, today_start = get_consolidated_data() + today_start_db = today_start.replace(tzinfo=None) + + # 1. 漲跌 + increase_count = sum(1 for item in unique_items if item['yesterday_diff'] > 0) + decrease_count = sum(1 for item in unique_items if item['yesterday_diff'] < 0) + + # 2. 今日新增 (使用與 index 路由相同的邏輯) + new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db) + new_product_ids = {r[0] for r in new_pids_query.all()} + new_count = len(new_product_ids) + + # 3. 今日下架 + today_delisted_count = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start_db + ).count() + + return {'new': new_count, 'up': increase_count, 'down': decrease_count, 'delisted': today_delisted_count} + except Exception as e: + sys_log.error(f"[Stats] ❌ 計算統計失敗: {e}") + return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0} + finally: + session.close() + +# ================= 🛣️ 4. Flask 路由 ================= + +# Session 自動續期機制 +@app.before_request +def refresh_session(): + """ + 在每次請求時自動刷新 Session,避免長時間閒置後突然斷線 + 只要用戶有任何操作,Session 就會自動延長 + """ + if session.get('logged_in'): + session.modified = True # 標記 Session 已修改,觸發 Cookie 更新 + +@app.route('/health') +def health_check(): + """健康檢查端點 - 供 Nginx 和 Docker healthcheck 使用""" + try: + # 簡單檢查資料庫連線 + from config import DATABASE_TYPE + return jsonify({ + 'status': 'healthy', + 'database': DATABASE_TYPE, + 'version': SYSTEM_VERSION + }), 200 + except Exception as e: + return jsonify({ + 'status': 'unhealthy', + 'error': str(e) + }), 500 + + +@app.route('/metrics') +def prometheus_metrics(): + """Prometheus 指標端點 - 供 Prometheus 抓取監控資料""" + try: + from prometheus_client import generate_latest, CONTENT_TYPE_LATEST, Counter, Gauge, CollectorRegistry + from config import DATABASE_TYPE + + # 建立獨立的 registry 以避免重複註冊 + registry = CollectorRegistry() + + # 應用程式資訊 + app_info = Gauge('momo_app_info', '應用程式資訊', ['version', 'database_type'], registry=registry) + app_info.labels(version=SYSTEM_VERSION, database_type=DATABASE_TYPE).set(1) + + # 應用程式健康狀態 (1=健康, 0=不健康) + app_health = Gauge('momo_app_health', '應用程式健康狀態', registry=registry) + + # 資料庫連線狀態 + db_status = Gauge('momo_database_up', '資料庫連線狀態', registry=registry) + + try: + db = DatabaseManager() + with db.engine.connect() as conn: + conn.execute(text("SELECT 1")) + db_status.set(1) + app_health.set(1) + except Exception: + db_status.set(0) + app_health.set(0) + + # 資料庫記錄數 + try: + db = DatabaseManager() + session = db.get_session() + + # 商品數量 + product_count = Gauge('momo_products_total', '商品總數', registry=registry) + product_count.set(session.query(Product).count()) + + # 價格記錄數量 + price_record_count = Gauge('momo_price_records_total', '價格記錄總數', registry=registry) + price_record_count.set(session.query(PriceRecord).count()) + + # 業績資料筆數 + from database.realtime_sales_models import RealtimeSalesMonthly + sales_count = Gauge('momo_sales_records_total', '業績資料總數', registry=registry) + sales_count.set(session.query(RealtimeSalesMonthly).count()) + + session.close() + except Exception as e: + sys_log.warning(f"[Metrics] 無法取得資料庫統計: {e}") + + # 返回 Prometheus 格式 + from flask import Response + return Response(generate_latest(registry), mimetype=CONTENT_TYPE_LATEST) + + except ImportError: + # prometheus_client 未安裝時的備用方案 + metrics_text = """# HELP momo_app_health 應用程式健康狀態 +# TYPE momo_app_health gauge +momo_app_health 1 +# HELP momo_app_info 應用程式資訊 +# TYPE momo_app_info gauge +momo_app_info{version="9.4",database_type="postgresql"} 1 +""" + from flask import Response + return Response(metrics_text, mimetype='text/plain; charset=utf-8') + except Exception as e: + sys_log.error(f"[Metrics] 指標生成錯誤: {e}") + from flask import Response + return Response(f"# Error: {e}\n", mimetype='text/plain; charset=utf-8'), 500 + + +@app.route('/') +def index(): + db = DatabaseManager() + + session = db.get_session() + page = request.args.get('page', 1, type=int) + category_filter = request.args.get('category', 'all') + sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序 + filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted) + order = request.args.get('order', 'desc') + search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字 + per_page = 50 + + # 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較) + # 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較 + now_taipei = datetime.now(TAIPEI_TZ) + today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + try: + # 🚩 1. 使用封裝函式獲取數據 + unique_items, today_start = get_consolidated_data() + + # --- 計算今日漲跌統計 --- + increase_items = [item for item in unique_items if item['yesterday_diff'] > 0] + decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0] + + # --- V-New: 取得所有分類並加上筆數統計 --- + cat_counts = {} + for item in unique_items: + c = item['record'].product.category + if c: + cat_counts[c] = cat_counts.get(c, 0) + 1 + + all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())] + + # V-Fix: 預先計算今日新增的商品 ID (不依賴 Product.created_at) + new_product_ids = set() + try: + # 找出最早一筆價格紀錄是在今天的商品 + new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db) + new_product_ids = {r[0] for r in new_pids_query.all()} + except Exception: pass + + # --- 看板統計數據 --- + total_products_history = session.query(Product).count() + today_new_products = session.query(func.count(Product.id)).filter( + Product.id.in_( + session.query(PriceRecord.product_id) + .group_by(PriceRecord.product_id) + .having(func.min(PriceRecord.timestamp) >= today_start_db) + ) + ).scalar() + total_price_records = session.query(PriceRecord).count() + today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count() + + # 🚩 新增:今日下架商品統計 (狀態為 INACTIVE 且 最後更新時間 >= 今天零點) + today_delisted_query = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start_db + ) + raw_delisted_items = today_delisted_query.all() + today_delisted_count = len(raw_delisted_items) + + # 🚩 V-Opt: 為下架商品補上最後價格(優化:一次查詢取得所有價格,避免 N+1 問題) + today_delisted_items = [] + if raw_delisted_items: + # 取得所有下架商品的 ID + delisted_ids = [p.id for p in raw_delisted_items] + + # 一次性查詢所有下架商品的最後價格 + last_prices_subq = session.query( + PriceRecord.product_id, + func.max(PriceRecord.id).label('max_id') + ).filter( + PriceRecord.product_id.in_(delisted_ids) + ).group_by(PriceRecord.product_id).subquery() + + last_prices_q = session.query( + PriceRecord.product_id, + PriceRecord.price + ).join( + last_prices_subq, + PriceRecord.id == last_prices_subq.c.max_id + ) + + # 建立 product_id -> price 的映射 + price_map = {pid: price for pid, price in last_prices_q} + + # 組合結果 + for p in raw_delisted_items: + price = price_map.get(p.id, 0) + today_delisted_items.append({'product': p, 'last_price': price}) + + # ========== V9.2: 新增 KPI 計算 ========== + + # 1. 平均漲跌幅 + avg_increase = sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0 + avg_decrease = sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0 + + # 2. 今日活躍度(有價格變動的商品百分比) + active_count = len(increase_items) + len(decrease_items) + activity_rate = (active_count / total_products_history * 100) if total_products_history > 0 else 0 + + # 3. 最大變動(絕對值最大的價格變動) + max_change_item = None + max_change_value = 0 + for item in unique_items: + if abs(item['yesterday_diff']) > abs(max_change_value): + max_change_value = item['yesterday_diff'] + max_change_item = item + + # 4. 週增長 (過去 7 天新增的商品數) + week_ago_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7) + week_ago_db = week_ago_db.replace(tzinfo=None) + week_new_products = session.query(func.count(Product.id)).filter( + Product.id.in_( + session.query(PriceRecord.product_id) + .group_by(PriceRecord.product_id) + .having(func.min(PriceRecord.timestamp) >= week_ago_db) + ) + ).scalar() or 0 + + # 5. 價格穩定商品數(7 天內無變價)- V9.3 效能優化版 + seven_days_ago = now_taipei - timedelta(days=7) + seven_days_ago_db = seven_days_ago.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + # 使用 GROUP BY 一次性統計所有商品的不同價格數量(避免 N+1 查詢) + try: + stable_count = session.query(PriceRecord.product_id).filter( + PriceRecord.timestamp >= seven_days_ago_db + ).group_by(PriceRecord.product_id).having( + func.count(func.distinct(PriceRecord.price)) == 1 + ).count() + except Exception: + stable_count = 0 + + # 6. 最活躍分類(今日變動商品數最多的分類) + category_activity = {} + for item in increase_items + decrease_items: + cat = item['record'].product.category + if cat: + category_activity[cat] = category_activity.get(cat, 0) + 1 + + most_active_category = None + most_active_count = 0 + if category_activity: + most_active_category = max(category_activity.items(), key=lambda x: x[1]) + most_active_count = most_active_category[1] + most_active_category = most_active_category[0] + + # 🚩 讀取系統狀態 (用於紅綠燈顯示) + system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"} + status_path = os.path.join(BASE_DIR, 'data/system_status.json') + if os.path.exists(status_path): + try: + with open(status_path, 'r', encoding='utf-8') as f: + system_status = json.load(f) + except: pass + + # --- 取得所有分類用於篩選器 --- + # (已在上方取得) + + # 🚩 2. 後端篩選 (Server-side Filtering) + scheduler_stats = load_scheduler_stats() + + # V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors + if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict): + scheduler_stats['momo_task'] = [scheduler_stats['momo_task']] + if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict): + scheduler_stats['edm_task'] = [scheduler_stats['edm_task']] + + filtered_items = [] + + # 0. 先處理搜尋 (若有) + if search_query: + search_lower = search_query.lower() + # V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code + base_items = [ + item for item in unique_items + if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or + (item['record'].product.i_code and search_lower in str(item['record'].product.i_code)) + ] + else: + base_items = unique_items + + # A. 先處理狀態篩選 (漲/跌/下架) + if filter_type == 'increase': + filtered_items = [i for i in base_items if i in increase_items] + elif filter_type == 'decrease': + filtered_items = [i for i in base_items if i in decrease_items] + elif filter_type == 'new': + # V-New: 新上架篩選 (今日新增的商品) + filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids] + elif filter_type == 'delisted': + # 特殊處理:將下架商品轉換為列表格式以便顯示 + for item in today_delisted_items: + # 模擬 record 物件結構 + class MockRecord: + def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at + + if not search_query or search_query.lower() in item['product'].name.lower(): + filtered_items.append({ + 'record': MockRecord(item['product'], item['last_price']), + 'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構 + 'yesterday_diff': 0, + 'today_changes': [], # 確保結構一致 + 'status': 'DELISTED' # 新增狀態 + }) + else: + # B. 若無狀態篩選,則處理分類篩選 + if category_filter != 'all': + # V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水" + real_category = category_filter + if "(" in category_filter and "筆)" in category_filter: + real_category = category_filter.rsplit(" (", 1)[0] + filtered_items = [item for item in base_items if item['record'].product.category == real_category] + else: + filtered_items = base_items + + # 🚩 3. 後端排序 (Server-side Sorting) + reverse = (order == 'desc') + def get_sort_key(item): + # 處理 None 值,確保排序時不會出錯 + def safe_get(value, default=0): + return default if value is None else value + + if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0)) + if sort_by == 'category': return safe_get(item['record'].product.category, '') + if sort_by == 'name': return safe_get(item['record'].product.name, '') + if sort_by == 'price': return safe_get(item['record'].price, 0) + if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動 + if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0) + if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0) + return item['record'].timestamp # 預設 + + sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse) + + # 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行 + total_items = len(sorted_items) + total_pages = math.ceil(total_items / per_page) + + start_idx = (page - 1) * per_page + paged_items = sorted_items[start_idx : start_idx + per_page] + + # V-Fix: 為前端準備安全的 created_at 屬性 + for item in paged_items: + item['safe_created_at'] = getattr(item['record'].product, 'created_at', None) + + # 🚩 5. 為當前頁面項目添加顏色 + for item in paged_items: + category_name = item['record'].product.category + item['category_color'] = get_color_for_string(category_name) + + return render_template('dashboard.html', + total_products=total_products_history, + today_new_products=today_new_products, + total_price_records=total_price_records, + cnt_increase=len(increase_items), + cnt_decrease=len(decrease_items), # 傳遞跌價數 + today_delisted_count=today_delisted_count, + today_delisted_items=today_delisted_items, + system_status=system_status, + items=paged_items, + categories=all_categories, + current_page=page, + total_pages=total_pages, # V-New: 傳遞總項目數 + total_items=total_items, + datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間 + today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期 + public_url=public_url, + current_category=category_filter, + current_filter=filter_type, # 傳遞當前篩選狀態 + search_query=search_query, # 傳遞搜尋關鍵字 + current_sort=sort_by, + current_order=order, + scheduler_stats=scheduler_stats, + # V9.2: 新增 KPI 數據 + avg_increase=avg_increase, + avg_decrease=avg_decrease, + activity_rate=activity_rate, + active_count=active_count, + max_change_item=max_change_item, + max_change_value=max_change_value, + week_new_products=week_new_products, + stable_count=stable_count, + most_active_category=most_active_category, + most_active_count=most_active_count) + except Exception as e: + sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}") + return f"系統維護中,錯誤詳情:{e}" + finally: + session.close() + +@app.route('/settings') +def settings(): + """分類設定頁面""" + categories = load_categories() + return render_template('settings.html', + categories=categories, + public_url=public_url, + system_version=SYSTEM_VERSION) + +@app.route('/system_settings') +def system_settings_page(): + """系統設定與匯入頁面""" + return render_template('system_settings.html', system_version=SYSTEM_VERSION) + +@app.route('/api/categories', methods=['POST']) +def add_category(): + """API: 新增分類""" + name = request.form.get('name') + url = request.form.get('url') + if not name or not url: + return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400 + + categories = load_categories() + new_id = int(time.time() * 1000) # 使用時間戳作為簡易唯一 ID + categories.append({'id': new_id, 'name': name, 'url': url}) + save_categories(categories) + + return jsonify({"status": "success", "message": "分類新增成功"}) + +@app.route('/api/categories/', methods=['PUT']) +def update_category(category_id): + """API: 更新分類""" + name = request.form.get('name') + url = request.form.get('url') + if not name or not url: + return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400 + + categories = load_categories() + category_found = False + for cat in categories: + if cat.get('id') == category_id: + cat['name'] = name + cat['url'] = url + category_found = True + break + + if not category_found: + return jsonify({"status": "error", "message": "找不到指定的分類 ID"}), 404 + + save_categories(categories) + return jsonify({"status": "success", "message": "分類更新成功"}) + +@app.route('/api/categories/', methods=['DELETE']) +def delete_category(category_id): + """API: 刪除分類""" + categories = [cat for cat in load_categories() if cat.get('id') != category_id] + save_categories(categories) + return jsonify({"status": "success", "message": "分類刪除成功"}) + +@app.route('/api/test_url', methods=['POST']) +def test_url(): + """API: 測試網址是否有效""" + try: + data = request.get_json() + url = data.get('url') + if not url: + return jsonify({"status": "error", "message": "網址不能為空"}), 400 + + import requests + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + # 設定 10 秒超時,避免卡住 + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return jsonify({"status": "success", "message": f"✅ 連結有效 (Status: 200)"}) + else: + return jsonify({"status": "warning", "message": f"⚠️ 連結回應異常 (Status: {response.status_code})"}) + + except Exception as e: + return jsonify({"status": "error", "message": f"❌ 連線失敗: {str(e)}"}), 500 + + +@app.route('/brand_assets') +def brand_assets(): + """顯示品牌資產庫""" + return render_template('brand_assets.html') + +@app.route('/edm') +def edm_dashboard(): + """🚩 新增:MOMO 限時搶購 (EDM) 專屬儀表板""" + db = DatabaseManager() + session = db.get_session() + + # V-New: 排序參數 + sort_by = request.args.get('sort_by', 'default') + order = request.args.get('order', 'desc') + + try: + # 1. 基礎統計 + # 取得最後更新時間 + last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料" + + # 🚩 V9.29 新增:取得最新的活動時間文字 + latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購" + + # 2. 查詢資料 (V9.44: 只顯示最新批次的資料) + # 找出最新的 batch_id + latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + current_batch_id = latest_batch[0] if latest_batch else None + + # 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次 + # 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失 + subq = session.query( + func.max(PromoProduct.id).label('max_id') + ).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery() + + latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all() + + # 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品 + items_in_batch = [] + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0) + + for item in latest_records: + # V9.60: 隱藏自然結束的時段商品 + # V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查 + # V-Fix: 確保時區比較一致 + item_crawled_at = item.crawled_at + if item_crawled_at and item_crawled_at.tzinfo is None: + # V-Fix: 使用 replace 而非 localize (datetime.timezone 不支援 localize 方法) + item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ) + + if item.status_change == 'SLOT_END' and item_crawled_at < today_start: + continue + + # V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示 + if item.status_change == 'DELISTED' and item_crawled_at < today_start: + continue + items_in_batch.append(item) + + # V9.45: 按時段分組 + grouped_items = {} + for item in items_in_batch: + if item.time_slot not in grouped_items: + grouped_items[item.time_slot] = [] + grouped_items[item.time_slot].append(item) + + # 按時段鍵值排序 (e.g., 00:00, 07:00, ...) + sorted_grouped_items = dict(sorted(grouped_items.items())) + + # V9.45: 決定預設顯示的頁籤 + def get_current_time_slot(): + hour = datetime.now(TAIPEI_TZ).hour + available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22] + current_slot_hour = 0 + for s in available_slots: + if hour >= s: + current_slot_hour = s + return f"{current_slot_hour:02d}:00" + + active_tab = get_current_time_slot() + if active_tab not in sorted_grouped_items and sorted_grouped_items: + active_tab = next(iter(sorted_grouped_items)) + + # V-New: 計算在架天數與總銷量 + all_icodes_in_batch = [item.i_code for item in items_in_batch] + product_categories = {} + days_on_shelf_map = {} + total_sold_map = {} + + if all_icodes_in_batch: + # 從主商品表 (products) 查詢這些 i_code 對應的分類 + main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all() + product_categories = {p.i_code: p.category for p in main_products} + + # 計算上架天數 (days_on_shelf) + # V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite + from sqlalchemy import cast, Date + days_on_shelf_q = session.query( + PromoProduct.i_code, + func.count(func.distinct(cast(PromoProduct.crawled_at, Date))) + ).filter( # V-New: 增加 page_type 過濾 + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.page_type == 'edm' + ).group_by(PromoProduct.i_code).all() + days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q} + + # 計算總銷量 + # 1. 找出每個商品第一次有庫存紀錄的 ID + first_qty_subq = session.query( + PromoProduct.i_code, + func.min(PromoProduct.id).label('min_id') + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.remain_qty.isnot(None), + PromoProduct.page_type == 'edm' + ).group_by(PromoProduct.i_code).subquery() + + # 2. 根據 ID 取得當時的庫存 + first_qty_records = session.query( + PromoProduct.i_code, PromoProduct.remain_qty + ).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all() + first_qty_map = {r[0]: r[1] for r in first_qty_records} + + # 3. 計算總銷量 (初始庫存 - 當前庫存) + for item in items_in_batch: + # 確保該商品有初始庫存紀錄,且當前庫存也存在 + if item.i_code in first_qty_map and item.remain_qty is not None: + initial_qty = first_qty_map[item.i_code] + current_qty = item.remain_qty + # 只有在初始庫存大於當前庫存時才計算,避免負數 + if initial_qty > current_qty: + total_sold_map[item.i_code] = initial_qty - current_qty + + # V-Fix: 修正 NameError: name 'history_map' is not defined + # 準備銷售歷程資料 + history_map = {} + if all_icodes_in_batch: + all_history_records = session.query( + PromoProduct.i_code, + PromoProduct.time_slot, + PromoProduct.remain_qty, + PromoProduct.crawled_at + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.crawled_at >= today_start + ).order_by(PromoProduct.crawled_at).all() + + for rec in all_history_records: + key = (rec.i_code, rec.time_slot) + if key not in history_map: + history_map[key] = [] + + if rec.remain_qty is not None: + if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty): + history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty}) + + # 將查到的分類資訊附加到每個 item 物件上 + for item in items_in_batch: + item.main_category = product_categories.get(item.i_code) + if item.main_category: + item.category_color = get_color_for_string(item.main_category) + # V-New: 附加在架天數與總銷量 + item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1) + item.total_sold = total_sold_map.get(item.i_code, 0) + # V-New: Attach quantity history + item.qty_history = history_map.get((item.i_code, item.time_slot), []) + + # V9.46: 排序邏輯優化 (中文註解) + # 排序規則: + # 1. 有貼標 (main_category 存在) 的商品優先 + # 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之 + # 3. 已下架的商品再次之 + # 4. 最後按價格由高到低排序 + reverse = (order == 'desc') + for time_slot in sorted_grouped_items: + if sort_by == 'name': + sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse) + elif sort_by == 'remain_qty': + # 將 None 視為 -1,確保排序時在最下方 + sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse) + elif sort_by == 'price': + sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse) + else: # 預設排序 + sorted_grouped_items[time_slot].sort(key=lambda x: ( + 1 if x.main_category else 0, + 2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0), + x.price if x.price is not None else -1 + ), reverse=True) + + # 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」 + # V-New: 重構時段統計邏輯,確保統計所有今日異動 + slot_stats = {} + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + # 1. 取得今日所有異動紀錄 + today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all() + + # 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的) + slots_from_changes = {rec.time_slot for rec in today_change_records} + slots_from_display = set(sorted_grouped_items.keys()) + all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display))) + + # 3. 初始化所有相關時段的統計數據 + for slot in all_relevant_slots: + slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0} + + # 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄) + for rec in today_change_records: + if rec.time_slot in slot_stats: + if rec.status_change == 'NEW': + slot_stats[rec.time_slot]['new'] += 1 + elif rec.status_change == 'PRICE_UP': + slot_stats[rec.time_slot]['up'] += 1 + elif rec.status_change == 'PRICE_DOWN': + slot_stats[rec.time_slot]['down'] += 1 + elif rec.status_change in ['DELISTED', 'SLOT_END']: + slot_stats[rec.time_slot]['delisted_last_run'] += 1 + + # 5. 計算在架與下架總數 (從當前顯示的商品快照) + for slot, items in sorted_grouped_items.items(): + if slot in slot_stats: + on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END']) + delisted_total_count = len(items) - on_shelf_count + slot_stats[slot]['on_shelf'] = on_shelf_count + slot_stats[slot]['delisted_total'] = delisted_total_count + + # V-New: 建立儀表板頁籤 + promo_pages = [ + {'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, + {'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'} + ] + + scheduler_stats = load_scheduler_stats() + + return render_template('edm_dashboard.html', + promo_pages=promo_pages, + current_promo_page='edm', + page_title='MOMO 限時搶購', + grouped_items=sorted_grouped_items, + slot_stats=slot_stats, + total_edm_products=len(items_in_batch), + last_update=last_update_str, + activity_time=activity_time, + active_tab=active_tab, + current_batch_id=current_batch_id, + public_url=public_url, + scheduler_stats=scheduler_stats, + current_sort=sort_by, + current_order=order, + slugify=slugify) + except Exception as e: + sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}") + return f"系統錯誤: {e}" + finally: + session.close() + +@app.route('/festival') +def festival_dashboard(): + """🚩 新增:1.1 狂歡購物節專屬儀表板""" + db = DatabaseManager() + session = db.get_session() + + PAGE_TYPE = "festival" + PAGE_NAME = "1.1狂歡購物節" + + sort_by = request.args.get('sort_by', 'default') + order = request.args.get('order', 'desc') + + try: + # 1. 基礎統計 + last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first() + last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料" + + latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first() + activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME + + # 2. 查詢資料 + subq = session.query( + func.max(PromoProduct.id).label('max_id') + ).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery() + + latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all() + + items_in_batch = [] + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + for item in latest_records: + if item.status_change == 'SLOT_END' and item.crawled_at < today_start: + continue + if item.status_change == 'DELISTED' and item.crawled_at < today_start: + continue + items_in_batch.append(item) + + # 此頁面使用區塊標題作為分組依據 + grouped_items = {} + for item in items_in_batch: + if item.time_slot not in grouped_items: + grouped_items[item.time_slot] = [] + grouped_items[item.time_slot].append(item) + + sorted_grouped_items = dict(sorted(grouped_items.items())) + + # 預設顯示第一個頁籤 + active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else "" + + all_icodes_in_batch = [item.i_code for item in items_in_batch] + product_categories = {} + days_on_shelf_map = {} + total_sold_map = {} + + if all_icodes_in_batch: + main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all() + product_categories = {p.i_code: p.category for p in main_products} + + # V-Fix: 使用 CAST 轉換為 DATE,兼容 PostgreSQL 和 SQLite + from sqlalchemy import cast, Date + days_on_shelf_q = session.query( + PromoProduct.i_code, + func.count(func.distinct(cast(PromoProduct.crawled_at, Date))) + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.page_type == PAGE_TYPE + ).group_by(PromoProduct.i_code).all() + days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q} + + # 將查到的分類資訊附加到每個 item 物件上 + for item in items_in_batch: + item.main_category = product_categories.get(item.i_code) + if item.main_category: + item.category_color = get_color_for_string(item.main_category) + item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1) + # V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯 + item.total_sold = 0 + item.qty_history = [] + + # 排序邏輯 + reverse = (order == 'desc') + for time_slot in sorted_grouped_items: + if sort_by == 'name': + sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse) + elif sort_by == 'price': + sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse) + else: # 預設排序 + sorted_grouped_items[time_slot].sort(key=lambda x: ( + 1 if x.main_category else 0, + 2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0), + x.price if x.price is not None else -1 + ), reverse=True) + + # 時段統計 + slot_stats = {} + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all() + + slots_from_changes = {rec.time_slot for rec in today_change_records} + slots_from_display = set(sorted_grouped_items.keys()) + all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display))) + + for slot in all_relevant_slots: + slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0} + + for rec in today_change_records: + if rec.time_slot in slot_stats: + if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1 + elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1 + elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1 + elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1 + + for slot, items in sorted_grouped_items.items(): + if slot in slot_stats: + on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END']) + delisted_total_count = len(items) - on_shelf_count + slot_stats[slot]['on_shelf'] = on_shelf_count + slot_stats[slot]['delisted_total'] = delisted_total_count + + scheduler_stats = load_scheduler_stats() + + # 建立儀表板頁籤 + promo_pages = [ + {'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, + {'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'} + ] + + # 注意:這裡我們重複使用 edm_dashboard.html 範本 + # 您需要建立一個它的複本,命名為 festival.html + return render_template('edm_dashboard.html', + promo_pages=promo_pages, + current_promo_page='festival', + page_title=PAGE_NAME, + grouped_items=sorted_grouped_items, + slot_stats=slot_stats, + total_edm_products=len(items_in_batch), + last_update=last_update_str, + activity_time=activity_time, + active_tab=active_tab, + public_url=public_url, + scheduler_stats=scheduler_stats, + current_sort=sort_by, + current_order=order, + slugify=slugify) + except Exception as e: + sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}") + return f"系統錯誤: {e}" + finally: + session.close() + +@app.route('/api/export/all_categories') +def export_all_categories(): + """🚩 需求 A:處理全分類報表匯出請求""" + try: + sys_log.info("📊 執行全分類 CSV 數據導出...") + + # 1. 獲取與看板一致的整合數據 + items, _ = get_consolidated_data() + + # 2. 呼叫匯出服務 + exporter = Exporter() + file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet + + if file_path: + # 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題 + abs_file_path = os.path.abspath(file_path) + + if os.path.exists(abs_file_path): + sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}") + return send_file(abs_file_path, as_attachment=True) + + return "匯出失敗:資料庫內尚無足夠數據", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}") + return f"匯出失敗,錯誤詳情:{e}", 500 + +# 🚩 V9.90: 新增 Excel 匯出路由 +@app.route('/api/export/excel/all') +def export_excel_all(): + try: + items, _ = get_consolidated_data() + exporter = Exporter() + file_path = exporter.generate_all_products_excel(items) + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "匯出失敗", 500 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/changes') +def export_excel_changes(): + try: + items, _ = get_consolidated_data() + increase = [i for i in items if i['yesterday_diff'] > 0] + decrease = [i for i in items if i['yesterday_diff'] < 0] + + exporter = Exporter() + file_path = exporter.generate_changes_excel(increase, decrease) + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "匯出失敗", 500 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/delisted') +def export_excel_delisted(): + db = DatabaseManager() + session = db.get_session() + try: + _, today_start = get_consolidated_data() + today_delisted_query = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start.replace(tzinfo=None) + ) + raw_items = today_delisted_query.all() + delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items] + + exporter = Exporter() + file_path = exporter.generate_delisted_excel(delisted_items) + return send_file(file_path, as_attachment=True) + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}") + return f"匯出失敗: {e}", 500 + finally: + session.close() + +@app.route('/api/export/price_changes') +def export_price_changes(): + """V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯""" + import openpyxl + from openpyxl.styles import Font, Alignment, PatternFill + + filter_type = request.args.get('type', '') + filter_category = request.args.get('category', '') + + try: + db = DatabaseManager() + session = db.get_session() + + # 使用與 /api/price_change_details 相同的邏輯 + now_taipei = datetime.now(TAIPEI_TZ) + today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + # 基礎查詢:取得所有商品的最新記錄 + latest_records_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + query = session.query(PriceRecord, Product).join( + latest_records_subq, + PriceRecord.id == latest_records_subq.c.max_id + ).join(Product, PriceRecord.product_id == Product.id) + + # 一次性查詢所有商品的「今日之前最後價格」 + product_ids = [r[0] for r in session.query(PriceRecord.product_id).join( + latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id + ).all()] + + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + products = [] + + # 根據 filter_type 篩選 + if filter_type == 'increase': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price > old_price: + products.append((product, record, old_price)) + + elif filter_type == 'decrease': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price < old_price: + products.append((product, record, old_price)) + + elif filter_type == 'delisted': + today_delisted = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start + ).all() + for product in today_delisted: + last_record = session.query(PriceRecord).filter( + PriceRecord.product_id == product.id + ).order_by(PriceRecord.timestamp.desc()).first() + if last_record: + products.append((product, last_record, last_record.price)) + + elif filter_type == 'active': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + elif filter_type == 'category' and filter_category: + for record, product in query.filter(Product.category == filter_category).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + else: + # 預設:所有變動商品 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + session.close() + + if not products: + return "無符合條件的商品資料", 404 + + # 建立 Excel + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "價格變動明細" + + # 標題列 + headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址'] + ws.append(headers) + + # 設定標題列樣式 + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_font = Font(bold=True, color='FFFFFF') + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 填充資料 + for product, record, old_price in products: + change = record.price - old_price + change_pct = (change / old_price * 100) if old_price > 0 else 0 + ws.append([ + product.i_code, + product.name, + product.category or '未分類', + old_price, + record.price, + change, + f"{change_pct:.2f}%", + record.timestamp.strftime('%Y-%m-%d %H:%M'), + product.url + ]) + + # 調整欄寬 + ws.column_dimensions['A'].width = 12 + ws.column_dimensions['B'].width = 40 + ws.column_dimensions['C'].width = 15 + ws.column_dimensions['D'].width = 12 + ws.column_dimensions['E'].width = 12 + ws.column_dimensions['F'].width = 12 + ws.column_dimensions['G'].width = 12 + ws.column_dimensions['H'].width = 18 + ws.column_dimensions['I'].width = 50 + + # 儲存檔案 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx" + filepath = os.path.join(EXCEL_EXPORT_DIR, filename) + + os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True) + wb.save(filepath) + + return send_file(filepath, as_attachment=True, download_name=filename) + + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/low_prices') +def export_low_prices(): + """🚩 新增:匯出歷史低價商品""" + try: + exporter = Exporter() + file_path = exporter.generate_low_price_report() + + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "目前無歷史低價商品", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/changes') +def export_changes(): + """🚩 需求:匯出篩選後的資料 (漲/跌/下架)""" + filter_type = request.args.get('type') + exporter = Exporter() + file_path = None + + try: + unique_items, today_start = get_consolidated_data() + + if filter_type == 'increase': + target_items = [i for i in unique_items if i['yesterday_diff'] > 0] + file_path = exporter.generate_custom_report(target_items, "今日漲價商品") + elif filter_type == 'decrease': + target_items = [i for i in unique_items if i['yesterday_diff'] < 0] + file_path = exporter.generate_custom_report(target_items, "今日跌價商品") + elif filter_type == 'delisted': + db = DatabaseManager() + session = db.get_session() + try: + today_start_naive = today_start.replace(tzinfo=None) + today_delisted_query = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start_naive + ) + raw_delisted_items = today_delisted_query.all() + + delisted_items_with_price = [] + + # 定義模擬物件 (移至迴圈外以提升效率) + class MockRecord: + def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at + + for p in raw_delisted_items: + last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first() + price = last_rec.price if last_rec else 0 + delisted_items_with_price.append({'product': p, 'last_price': price}) + + file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品") + finally: + session.close() + + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/abc') +def export_abc_analysis(): + """API: 匯出 ABC 分析報表 (Excel)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取) + df = None + cols_map = {} + + if table_name in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[table_name] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400 + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位 + + # 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + target_df = df.copy() # 複製一份以免修改到快取 + + # 重新計算 Top N 分類 (用於 '其他' 篩選) + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + # 3. 執行 ABC 分析與匯出 + if col_amount and not target_df.empty: + # V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致 + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_category: agg_rules[col_category] = 'first' + if col_vendor: agg_rules[col_vendor] = 'first' + if col_brand: agg_rules[col_brand] = 'first' + if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID + + # 執行聚合 + df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index() + + # 重新計算聚合後的毛利率 + if col_profit: + df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['calculated_margin_rate'] = 0.0 + df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # 排序與 ABC 分類 + target_df = df_agg.sort_values(by=col_amount, ascending=False) + target_df['cumulative_revenue'] = target_df[col_amount].cumsum() + total_revenue = target_df[col_amount].sum() + target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100 + + conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)] + choices = ['A', 'B'] + target_df['ABC_Class'] = np.select(conditions, choices, default='C') + + # V-New: 支援依類別篩選匯出 (例如只匯出 A 類) + filter_class = request.args.get('class') + if filter_class: + target_df = target_df[target_df['ABC_Class'] == filter_class] + + # V-New: 計算平均單價 (Avg Unit Price) + if col_qty: + target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0) + + # V-New: 計算建議補貨量 (支援自訂係數) + if col_qty: + custom_factor = request.args.get('factor') + if custom_factor: + try: + factor = float(custom_factor) + # 若有指定係數,則全體套用 (通常用於單一類別匯出) + target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int) + except: + # 格式錯誤則回退至預設邏輯 + conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')] + choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2] + target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int) + else: + # 預設邏輯 (A=1.5, B=1.2, C=0) + conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')] + choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2] + target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int) + + # 整理匯出欄位 + export_cols = [] + header_map = {} + if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID + if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱' + if col_category: export_cols.append(col_category); header_map[col_category] = '分類' + if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌' + if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商' + export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類' + if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額' + if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量' + # V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代) + if 'avg_unit_price' in target_df.columns: + export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價' + if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本' + if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利' + if 'calculated_margin_rate' in target_df.columns: + export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)' + if 'suggested_restock' in target_df.columns: + export_cols.append('suggested_restock') + header_map['suggested_restock'] = '建議補貨量' + + export_df = target_df[export_cols].rename(columns=header_map) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='ABC分析') + output.seek(0) + + filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_" + return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"ABC Export Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/vendor') +def export_vendor_analysis(): + """API: 匯出廠商獲利能力排行 (Excel)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 嘗試從快取讀取資料 + df = None + cols_map = {} + + if table_name in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[table_name] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + # V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料 + params = {k: v for k, v in request.args.items()} + flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning') + return redirect(url_for('sales_analysis', **params)) + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + + if not col_vendor: + return "無法識別廠商欄位,無法匯出。", 400 + + # 2. 篩選資料 (複製 sales_analysis 的篩選邏輯) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + target_df = df.copy() + + # Top N 分類處理 + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + # 3. 執行廠商聚合 + if col_amount and not target_df.empty: + agg_dict = {col_amount: 'sum', col_name: 'nunique'} + if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP + if col_profit: + agg_dict[col_profit] = 'sum' + elif col_cost: + agg_dict[col_cost] = 'sum' + + vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index() + + if col_profit: + vendor_group['total_profit'] = vendor_group[col_profit] + elif col_cost: + vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost] + else: + vendor_group['total_profit'] = 0 + + # V-Fix: 計算營收佔比 (Share %) + total_vendor_revenue = vendor_group[col_amount].sum() + vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100) + + vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0) + + # V-Fix: 計算平均客單價 (ASP) + if col_qty: + vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0) + + vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0) + vendor_group = vendor_group.sort_values(by=col_amount, ascending=False) + + # V-Fix: 更新匯出欄位以匹配儀表板 + export_cols = [col_vendor, col_amount, 'revenue_share'] + header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'} + + if col_qty: + export_cols.extend([col_qty, 'asp']) + header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'}) + + export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue']) + header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'}) + + export_df = vendor_group[export_cols].rename(columns=header_map) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='廠商排行') + output.seek(0) + return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"Vendor Export Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/abc_analysis/detail') +def abc_analysis_detail(): + """ABC 分析詳細報表頁面""" + try: + target_class = request.args.get('class', 'A') # 預設 A 類 + table_name = 'realtime_sales_monthly' + + # 1. 生成與主頁面一致的 cache_key + data_range_months = int(request.args.get('data_range', '0') or '0') + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 2. 使用共用篩選函式取得資料 + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + # V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值 + if err and table_name in _SALES_PROCESSED_CACHE: + target_df, cols_map, err = _get_filtered_sales_data(table_name) + + if err: + # V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面 + return f''' + + + + + 數據加載中 - WOOO TECH + + + +
+
+

數據準備中

+

正在自動重新加載數據,請稍後...

+ +
+ + + ''', 200 + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') + + + # 3. 執行 ABC 分類 + items = [] + total_revenue = 0 + if col_amount and not target_df.empty: + # V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」 + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_category: agg_rules[col_category] = 'first' + if col_vendor: agg_rules[col_vendor] = 'first' + if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌 + if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID + if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) + + df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index() + + # 重新計算聚合後的毛利率 + if col_profit: + df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['calculated_margin_rate'] = 0.0 + df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # 執行 ABC 排序與計算 + df_agg = df_agg.sort_values(by=col_amount, ascending=False) + df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum() + total_revenue = df_agg[col_amount].sum() + df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100 + + conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)] + choices = ['A', 'B'] + df_agg['ABC_Class'] = np.select(conditions, choices, default='C') + + # 4. 篩選特定類別 + class_df = df_agg[df_agg['ABC_Class'] == target_class].copy() + + # V-New: 計算平均單價與庫存建議 + if col_qty: + class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0) + + # V-New: 處理動態補貨係數 + custom_factor = request.args.get('factor') + current_factor = 0.0 + + if custom_factor: + try: + current_factor = float(custom_factor) + except: + current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) + else: + current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) + + class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int) + + items = class_df.to_dict('records') + + # 準備標題與描述 + class_info = { + 'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'}, + 'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'}, + 'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'} + } + info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'}) + + # 計算 DataTables 預設排序欄位 (銷售金額) 的索引 + # 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount + sort_col_index = 1 # Rank + if col_pid: sort_col_index += 1 + sort_col_index += 1 # Name + if col_brand: sort_col_index += 1 + if col_vendor: sort_col_index += 1 + if col_category: sort_col_index += 1 + if col_cost or col_profit: sort_col_index += 1 + if col_qty: sort_col_index += 3 + # 此時 sort_col_index 即為 Amount 欄位的索引 + + return render_template('abc_analysis_detail.html', + items=items, + info=info, + target_class=target_class, + current_factor=current_factor, # V-New: 傳遞當前係數 + total_revenue=total_revenue, + sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引 + cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, + 'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid}, + # 傳遞當前查詢參數以供匯出連結使用 + query_string=request.query_string.decode()) + + except Exception as e: + sys_log.error(f"ABC Detail Error: {e}") + return f"系統錯誤: {e}" + +@app.route('/logs') +def show_logs(): + return render_template('logs.html') + +@app.route('/api/run_task', methods=['POST']) +def trigger_task(): + try: + client_ip = request.remote_addr + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}") + scheduled_job_wrapper() + return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/run_edm_task', methods=['POST']) +def trigger_edm_task(): + """🚩 新增:手動觸發 EDM 爬蟲任務""" + try: + target_lpn = "O1K5FBOqsvN" # 預設活動代碼 + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}") + + # V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯 + import importlib + import scheduler + importlib.reload(scheduler) + + # 使用執行緒啟動,避免卡住 Web Server + task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,)) + task_thread.daemon = True + task_thread.start() + + return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/run_festival_task', methods=['POST']) +def trigger_festival_task(): + """🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務""" + try: + target_lpn = "O7ylWfihYUM" + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}") + + # 使用執行緒啟動,避免卡住 Web Server + task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,)) + task_thread.daemon = True + task_thread.start() + + return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/trigger_momo_notification', methods=['POST']) +def trigger_momo_notification(): + """🚩 新增:手動觸發商品看板通知""" + try: + # 強制重載通知模組 + import importlib + import scheduler + import services.notification_manager + importlib.reload(scheduler) + importlib.reload(services.notification_manager) + from services.notification_manager import NotificationManager + + # 1. 取得統計數據 + stats = get_dashboard_stats() + + # 2. 截取儀表板畫面 + dashboard_url = "http://127.0.0.1/" + screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard") + + # 3. 發送通知 + notifier = NotificationManager() + sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知") + notifier.send_momo_report(stats, screenshot_path) + + return jsonify({"status": "success", "message": "已發送商品看板通知"}) + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/trigger_edm_notification', methods=['POST']) +def trigger_edm_notification(): + """🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)""" + try: + # V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料) + import importlib + import config + import services.notification_manager + import services.edm_notifier # V-New: 導入新的通知模組 + importlib.reload(config) + importlib.reload(services.notification_manager) + importlib.reload(services.edm_notifier) + + db = DatabaseManager() + session = db.get_session() + try: + # V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長 + # 1. 找出最新的 batch_id + latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + + if not latest_batch_tuple: + return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400 + + latest_batch_id = latest_batch_tuple[0] + + # 2. 取得最新批次的所有異動商品 + products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all() + + if not products: + return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200 + + # V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案 + screenshot_path = None + try: + filename = f"edm_{latest_batch_id}.png" + potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename) + if os.path.exists(potential_path): + screenshot_path = potential_path + except Exception: pass + + from services.edm_notifier import EdmNotifier + notifier = EdmNotifier() + sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}") + notifier.send_edm_report(products, screenshot_path) + + return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"}) + finally: + session.close() + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/test_notification', methods=['POST']) +def test_notification(): + """🚩 新增:測試訊息通知功能""" + try: + from services.notification_manager import NotificationManager + import config + import requests + notifier = NotificationManager() + + # --- 🕵️‍♂️ V9.13 更新:Messaging API 診斷邏輯 --- + sys_log.info("[Web] [Notification] 🕵️‍♂️ 執行手動通知發送測試 (Line/Telegram/Email)...") + + token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None) + target_id = getattr(config, 'LINE_GROUP_ID', None) + + if token and target_id: + sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}") + sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}") + + # 2. 嘗試直接發送請求 + try: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "to": target_id, + "messages": [ + { + "type": "text", + "text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!" + } + ] + } + + sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...") + resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10) + + sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}") + sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}") + + if resp.status_code != 200: + return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400 + except Exception as req_err: + sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}") + return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500 + else: + sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)") + return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400 + + # 🚩 V9.14 修改:呼叫真實的日報發送邏輯 + notifier.send_daily_report() + + return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"}) + except ImportError: + return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500 + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500 + +@app.route('/api/logs') +def get_logs_api(): + if os.path.exists(LOG_FILE_PATH): + try: + with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f: + return jsonify({"logs": "".join(f.readlines()[-60:])}) + except Exception as e: + sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}") + return jsonify({"logs": "讀取日誌異常"}) + return jsonify({"logs": "等待系統啟動中..."}) + +# 🚩 V9.82: 新增歷史價格 API +@app.route('/api/history/') +def get_price_history(product_id): + """API: 取得商品過去 180 天的價格歷史""" + db = DatabaseManager() + session = db.get_session() + try: + # 計算 180 天前的日期 + start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180) + + records = session.query(PriceRecord).filter( + PriceRecord.product_id == product_id, + PriceRecord.timestamp >= start_date + ).order_by(PriceRecord.timestamp).all() + + data = [{ + 't': r.timestamp.strftime('%Y-%m-%d %H:%M'), + 'p': r.price + } for r in records] + + return jsonify(data) + except Exception as e: + sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}") + return jsonify([]), 500 + finally: + session.close() + +@app.route('/api/price_change_details') +def get_price_change_details(): + """API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯""" + filter_type = request.args.get('type', '') + filter_category = request.args.get('category', '') + filter_product_id = request.args.get('product_id', '') + + db = DatabaseManager() + session = db.get_session() + try: + # 取得今日起始時間 + now_taipei = datetime.now(TAIPEI_TZ) + today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + # 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯) + latest_records_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + query = session.query(PriceRecord, Product).join( + latest_records_subq, + PriceRecord.id == latest_records_subq.c.max_id + ).join(Product, PriceRecord.product_id == Product.id) + + # 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map) + product_ids = [r[0] for r in session.query(PriceRecord.product_id).join( + latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id + ).all()] + + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + # 根據 filter_type 進行篩選 + products = [] + + if filter_type == 'increase': + # 漲價商品 - 比對今日最新價格與今日之前的最後價格 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price > old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'decrease': + # 降價商品 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price < old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'delisted': + # 下架商品 (今日狀態為 INACTIVE 且今天更新的) + today_delisted = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start + ).all() + + for product in today_delisted: + last_record = session.query(PriceRecord).filter( + PriceRecord.product_id == product.id + ).order_by(PriceRecord.timestamp.desc()).first() + + if last_record: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': last_record.price, + 'current_price': 0, + 'change': 0, + 'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'active': + # 活躍商品 (今日有價格變動的) + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'category' and filter_category: + # 特定分類的變動商品 + for record, product in query.filter(Product.category == filter_category).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'max_change' and filter_product_id: + # 最大變動商品 - 只顯示指定的單一商品 + for record, product in query.filter(Product.i_code == filter_product_id).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + break # 只需要一件商品 + + return jsonify({'products': products}) + + except Exception as e: + sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}") + return jsonify({'products': []}), 500 + finally: + session.close() + +@app.route('/api/backup', methods=['POST']) +def trigger_backup(): + """API: 觸發系統完整備份""" + # Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入 + try: + sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...") + backup_dir = os.path.join(BASE_DIR, 'backups') + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M') + zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip" + zip_filepath = os.path.join(backup_dir, zip_filename) + + with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(BASE_DIR): + # 排除不必要的目錄 + dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']] + + for file in files: + if file == zip_filename: continue # 跳過正在寫入的檔案 + if file.endswith('.pyc') or file.endswith('.DS_Store'): continue + + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, BASE_DIR) + zipf.write(file_path, arcname) + + sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}") + + # V-New: 回傳下載連結 + download_url = url_for('download_backup', filename=zip_filename) + + return jsonify({ + "status": "success", + "message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...", + "download_url": download_url + }) + except Exception as e: + sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/backup/download/') +def download_backup(filename): + """ + API: 下載備份檔案(已加入路徑遍歷防護) + """ + try: + backup_dir = os.path.join(BASE_DIR, 'backups') + # 使用 safe_join 驗證路徑,防止路徑遍歷攻擊 + safe_path = safe_join(backup_dir, filename) + + # 確保檔案存在 + if not safe_path.exists(): + sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}") + return jsonify({'error': '檔案不存在'}), 404 + + # 確保是檔案而非目錄 + if not safe_path.is_file(): + sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}") + return jsonify({'error': '非法路徑'}), 400 + + return send_from_directory(backup_dir, safe_path.name, as_attachment=True) + + except ValueError as e: + # safe_join 偵測到路徑遍歷嘗試 + sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}") + return jsonify({'error': '非法路徑'}), 400 + except Exception as e: + sys_log.error(f"[System] 下載備份失敗 | Error: {e}") + return jsonify({'error': '下載失敗'}), 500 + +@app.route('/api/import_excel', methods=['POST']) +def import_excel(): + """ + API: 匯入 Excel/CSV 並自動建表 + 已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理) + """ + try: + # 1. 檢查是否有上傳檔案 + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400 + + file = request.files['file'] + + # 2. 使用安全驗證函數 + is_valid, error_msg, safe_name = validate_upload_file(file) + if not is_valid: + sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}") + return jsonify({'status': 'error', 'message': error_msg}), 400 + + sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}") + + # 3. 根據副檔名讀取檔案 + df = None + filename_lower = safe_name.lower() + + if filename_lower.endswith(('.xlsx', '.xls')): + try: + df = pd.read_excel(file, engine='openpyxl', dtype=str) + except Exception as e: + return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + elif filename_lower.endswith('.csv'): + try: + # V-New: 嘗試用多種編碼讀取 CSV + try: + df = pd.read_csv(file, dtype=str) + except UnicodeDecodeError: + file.seek(0) # 重置文件指針 + df = pd.read_csv(file, encoding='big5', dtype=str) + except Exception as e: + return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500 + else: + # 理論上不會到這裡,因為 validate_upload_file 已經檢查過 + return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400 + + if df is None: + return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500 + + # V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理) + sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理") + + # V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確 + # df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns] + + # V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題) + # sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...") + + # 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算) + # numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收', + # 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue'] + + # for col in df.columns: + # # 判斷是否為強制數值欄位 + # is_force_numeric = any(k in col for k in numeric_keywords) + + # if df[col].dtype == 'object': + # if is_force_numeric: + # # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0) + # # 先移除千分位逗號等非數值字符 + # cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True) + # converted_series = pd.to_numeric(cleaned_series, errors='coerce') + # df[col] = converted_series.fillna(0) + # sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)") + # else: + # # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字) + # # 直接嘗試轉換,不移除文字 + # converted_series = pd.to_numeric(df[col], errors='coerce') + + # # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的) + # original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '') + # converted_valid_mask = converted_series.notna() + # loss_count = (original_valid_mask & ~converted_valid_mask).sum() + + # if loss_count == 0: + # # 如果沒有資料損失 (代表全是數字或空值),才轉換 + # df[col] = converted_series + # else: + # # 有資料損失 (例如包含 'F'),保留為文字 + # sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')") + + # 識別檔案類型 + is_daily_sales = '即時業績' in file.filename and '當日' in file.filename + is_sales_report = '即時業績' in file.filename and '全月' in file.filename + + if is_daily_sales: + table_name = 'daily_sales_snapshot' + + # V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date + date_col = None + for possible_col in ['日期', '訂單日期', '交易日期', 'Date']: + if possible_col in df.columns: + date_col = possible_col + break + + if date_col: + # 使用 Excel 內的日期欄位作為 snapshot_date + sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期") + + # 將日期欄位轉換為標準格式 YYYY-MM-DD + df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d') + + # 移除無效日期的資料 + invalid_count = df['snapshot_date'].isna().sum() + if invalid_count > 0: + sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除") + df = df.dropna(subset=['snapshot_date']) + + unique_dates = df['snapshot_date'].nunique() + sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期") + else: + # 備用方案:從檔名提取日期 + snapshot_date = extract_snapshot_date_from_filename(file.filename) + if not snapshot_date: + return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400 + df['snapshot_date'] = snapshot_date + sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}") + elif is_sales_report: + table_name = 'realtime_sales_monthly' + else: + filename_no_ext = os.path.splitext(file.filename)[0] + table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_') + + if not table_name: table_name = f"import_{int(time.time())}" + + db = DatabaseManager() + engine = db.engine + + # V-Debug: 顯示實際寫入的資料庫路徑 + sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}") + + if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']: + try: + # V-Fix: 實作自動去重邏輯 (Deduplication) + # 1. 檢查資料表是否存在 + inspector = inspect(engine) + if not inspector.has_table(table_name): + sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}") + df.to_sql(table_name, con=engine, if_exists='replace', index=False) + rows_imported = len(df) + message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。' + else: + sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...") + + # 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重) + try: + # 嘗試根據 incoming df 的日期範圍來過濾現有資料 + filter_clause = "" + if '日期' in df.columns: + # V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對 + # 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式 + temp_dates = pd.to_datetime(df['日期'], errors='coerce') + unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique() + + if len(unique_dates) > 0: + date_list = "', '".join([str(d) for d in unique_dates]) + filter_clause = f" WHERE 日期 IN ('{date_list}')" + sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個日期相關的現有資料 (範例: {unique_dates[0]})") + elif 'snapshot_date' in df.columns: + unique_dates = df['snapshot_date'].dropna().unique() + if len(unique_dates) > 0: + date_list = "', '".join([str(d) for d in unique_dates]) + filter_clause = f" WHERE snapshot_date IN ('{date_list}')" + sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個快照日期相關的現有資料") + + if filter_clause: + # V-Debug: 顯示實際執行的 SQL (限開發者日誌) + # sys_log.debug(f"[Web] [Import] SQL Filter: SELECT * FROM {table_name}{filter_clause}") + df_existing = pd.read_sql(f"SELECT * FROM {table_name}{filter_clause}", con=engine) + else: + # 備用方案:若無日期欄位,仍讀取全表 + sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)") + df_existing = safe_read_sql(table_name, engine=engine) + + except Exception as e: + sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。") + df_existing = pd.DataFrame() + + rows_to_write = df + + if not df_existing.empty: + # 3. 執行比對 (找出共有欄位) + common_cols = list(set(df.columns) & set(df_existing.columns)) + + # 針對 daily_sales_snapshot 使用特定去重鍵 + if table_name == 'daily_sales_snapshot': + # 優先使用 snapshot_date + 訂單編號 + if 'snapshot_date' in common_cols and '訂單編號' in common_cols: + common_cols = ['snapshot_date', '訂單編號'] + sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號") + elif 'snapshot_date' in common_cols: + # 備用方案:使用所有共有欄位 + sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)") + + if common_cols: + # 轉換為字串以確保比對準確 (處理 NaN 與型別差異) + # V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題 + def normalize_series(s): + return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True) + + df_str = df[common_cols].apply(normalize_series).fillna('') + existing_str = df_existing[common_cols].apply(normalize_series).fillna('') + + # 移除 df_existing 中的重複項 (優化 merge 效能) + existing_str = existing_str.drop_duplicates() + + # 使用 merge 找出 df 中已存在的資料 + merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True) + + # 只保留 'left_only' 的資料 (即新資料) + rows_to_write = df[merged['_merge'] == 'left_only'] + + duplicates_count = len(df) - len(rows_to_write) + sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。") + + # 4. 寫入新資料 + if not rows_to_write.empty: + rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False) + rows_imported = len(rows_to_write) + message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。' + else: + rows_imported = 0 + message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。' + + # V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料 + # 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新 + if table_name in _SALES_DF_CACHE: + del _SALES_DF_CACHE[table_name] + sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}") + + # V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取) + cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)] + for cache_key in cache_keys_to_delete: + del _SALES_PROCESSED_CACHE[cache_key] + sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}") + + return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name}) + + except Exception as de: + sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}") + return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500 + else: + # 對於非業績報表,維持覆蓋邏輯 + sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}") + df.to_sql(table_name, con=engine, if_exists='replace', index=False) + + if table_name in _SALES_DF_CACHE: + del _SALES_DF_CACHE[table_name] + sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}") + + # V-Opt: 清除所有相關的處理後快取 + cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)] + for cache_key in cache_keys_to_delete: + del _SALES_PROCESSED_CACHE[cache_key] + sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}") + + return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', 'rows': len(df), 'table': table_name}) + + except Exception as e: + sys_log.error(f"[Web] [Import] ❌ 檔案匯入發生嚴重錯誤 | Error: {str(e)}") + return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500 + +@app.route('/api/import/monthly_summary', methods=['POST']) +def import_monthly_summary(): + """API: 匯入月份總表數據分析""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400 + + file = request.files['file'] + is_valid, error_msg, safe_name = validate_upload_file(file) + if not is_valid: + sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}") + return jsonify({'status': 'error', 'message': error_msg}), 400 + + # 讀取 Excel + try: + df = pd.read_excel(file, engine='openpyxl') + except Exception as e: + return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + + if df.empty: + return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400 + + # 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位) + mapping = { + '年': 'year', '月': 'month', '商品部': 'department', '3C百貨': 'category_3c', + '處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name', + '商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id', + '廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price', + '銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa', + '毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa', + '折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa', + '折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa', + '其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa', + '點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa', + '點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa', + '銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa', + '轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa' + } + + # 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入) + current_cols = df.columns.tolist() + import_mapping = {k: v for k, v in mapping.items() if k in current_cols} + + if len(import_mapping) < 5: # 至少要有幾個維度 + return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400 + + # 重新命名與清理資料 + target_df = df[list(import_mapping.keys())].rename(columns=import_mapping) + + # 轉換數值欄位,填補 NaN + numeric_cols = [v for k, v in import_mapping.items() if v not in [ + 'department', 'category_3c', 'division', 'section', 'area_id', 'area_name', + 'pm_name', 'brand_name', 'vendor_name', 'trade_type' + ]] + for col in numeric_cols: + target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0) + + # 寫入資料庫 - 優化效能版本 (Phase 9 Optimization) + db = DatabaseManager() + engine = db.engine + + try: + # 取得要匯入的年月份,用於先行刪除重複資料 + years_months = target_df[['year', 'month']].drop_duplicates() + + with engine.begin() as conn: + # 1. 刪除該月份舊資料 (Transaction 開始) + for _, row in years_months.iterrows(): + conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"), + {'y': int(row['year']), 'm': int(row['month'])}) + + # 2. 批量寫入 (使用 multi 方法加速,SQLite chunksize 建議 2000 避免參數過多) + # 比照 realtime_sales_monthly 的優化方式 + target_df.to_sql('monthly_summary_analysis', + con=conn, + if_exists='append', + index=False, + chunksize=2000, + method='multi') + + except Exception as e: + sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}") + raise e + + + sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}") + return jsonify({ + 'status': 'success', + 'message': f'成功匯入 {len(target_df)} 筆分析數據。', + 'rows': len(target_df) + }) + + except Exception as e: + sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}") + return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500 + +@app.route('/monthly_summary_analysis') +def monthly_summary_analysis_page(): + """月份總表數據分析展示頁 (Phase 9)""" + return render_template('monthly_summary_analysis.html', + datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), + system_version=SYSTEM_VERSION) + +@app.route('/api/monthly_summary_data') +def get_monthly_summary_data(): + """API: 取得月份總表數據與分析指標 (Phase 9)""" + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + division = request.args.get('division') + pm_name = request.args.get('pm_name') + brand_name = request.args.get('brand_name') + vendor_name = request.args.get('vendor') + area_name = request.args.get('area_name') + trade_type = request.args.get('trade_type') + limit = request.args.get('limit', default=1000, type=int) + + # DEBUG LOGGING + import logging + debug_logger = logging.getLogger('app') + debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}") + + db = DatabaseManager() + session = db.get_session() + try: + # 基礎查詢 + query = session.query(MonthlySummaryAnalysis) + + # 套用過濾 + if year: query = query.filter(MonthlySummaryAnalysis.year == year) + if month: query = query.filter(MonthlySummaryAnalysis.month == month) + if division: query = query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + query = query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + # 取得統計數據 (KPIs) + kpi_query = session.query( + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'), + func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'), + func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'), + func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'), + func.sum(MonthlySummaryAnalysis.views_curr).label('total_views') + ) + + # 同樣套用過濾到 KPI + if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year) + if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month) + if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + kpi_res = kpi_query.one() + + # 取得總筆數與月數 + total_rows = session.query(func.count(MonthlySummaryAnalysis.id)) + total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct() + + if year: + total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year) + total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year) + if month: + total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month) + + total_rows = total_rows.scalar() + total_months = total_months_query.count() + + # 取得趨勢數據 (按月加總) + trend_query = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + + if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + # 取得排行榜 (Top 10 Brands) + rank_query = session.query( + MonthlySummaryAnalysis.brand_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).group_by(MonthlySummaryAnalysis.brand_name) + + if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year) + if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month) + if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + rank_query = rank_query.order_by(desc('sales')).limit(10) + + # 取得明細資料 + rows_query = query.order_by( + MonthlySummaryAnalysis.year.desc(), + MonthlySummaryAnalysis.month.desc(), + MonthlySummaryAnalysis.sales_amt_curr.desc() + ).limit(limit) + + # --- 📊 V-New: 進階分析子查詢 (Phase 17) --- + def apply_filters(q, ignore_year=False): + if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year) + if month: q = q.filter(MonthlySummaryAnalysis.month == month) + if division: q = q.filter(MonthlySummaryAnalysis.division == division) + if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + q = q.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type) + return q + + # 廠商排行 + # 廠商排行 (Top 20, 分年度) + # 廠商排行 (Top 20, 分年度) + vendor_rank_q = session.query( + MonthlySummaryAnalysis.vendor_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'), + ).group_by(MonthlySummaryAnalysis.vendor_name) + + vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True) + vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20) + + # 分類分佈 (按 Division, Top 12, 分年度) + div_dist_q = session.query( + MonthlySummaryAnalysis.division, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by(MonthlySummaryAnalysis.division) + + div_dist_q = apply_filters(div_dist_q, ignore_year=True) + div_dist_q = div_dist_q.order_by(desc('sales')).limit(12) + + # 價格帶貢獻 (分年度) + price_cont_q = session.query( + case( + (MonthlySummaryAnalysis.unit_price < 500, '0-499'), + (MonthlySummaryAnalysis.unit_price < 1000, '500-999'), + (MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'), + (MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'), + (MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'), + else_='10,000+' + ).label('price_range'), + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by('price_range') + price_cont_q = apply_filters(price_cont_q, ignore_year=True) + + # BCG 矩陣 (品牌 x 區域) + bcg_q = session.query( + MonthlySummaryAnalysis.brand_name, + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'), + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit') + ).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\ + .having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0) + + bcg_q = apply_filters(bcg_q) + bcg_q = bcg_q.order_by(desc('sales')).limit(100) + + # 熱力圖 (月份 x 分類) + # 熱力圖 (月份 x 分類) + # 為了保持一致性,這裡我們應該只取 Top 12 的 Division + # 先取得 Top 12 Division 的名稱列表 + top_12_divs = [r.division for r in div_dist_q.all()] + + heatmap_q = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + MonthlySummaryAnalysis.division, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).filter(MonthlySummaryAnalysis.division.in_(top_12_divs))\ + .group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.division)\ + .order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + heatmap_q = apply_filters(heatmap_q, ignore_year=True) + + # Highlights (Top 3) + def get_highlights_q(metric_col): + q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val')) + q = apply_filters(q) + q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3) + return q + + rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr) + profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr) + vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr) + + # 區域排行 + area_rank_q = session.query( + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by(MonthlySummaryAnalysis.area_name) + + area_rank_q = apply_filters(area_rank_q, ignore_year=True) + area_rank_q = area_rank_q.order_by(desc('sales')) + + # 年度對比趨勢 (需要包含本期與去年同期) + # 年度對比趨勢 (需要包含本期與去年同期) + yoy_trend_q = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'), + func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa') + ) + yoy_trend_q = apply_filters(yoy_trend_q) + yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + + rows = [] + for r in rows_query.all(): + rows.append({ + 'year': r.year, + 'month': r.month, + 'division': r.division, + 'pm_name': r.pm_name, + 'area_name': r.area_name, + 'brand_name': r.brand_name, + 'vendor_name': r.vendor_name, + 'trade_type': r.trade_type, + 'sales_amt_curr': r.sales_amt_curr, + 'sales_amt_yoa': r.sales_amt_yoa, + 'sales_vol_curr': r.sales_vol_curr, + 'profit_amt_curr': r.profit_amt_curr, + 'views_curr': r.views_curr + }) + + # 取得不重複的維度列表 + years_list = [r[0] for r in session.query(MonthlySummaryAnalysis.year).distinct().all()] + months_list = [r[0] for r in session.query(MonthlySummaryAnalysis.month).distinct().all()] + divisions_list = [r[0] for r in session.query(MonthlySummaryAnalysis.division).distinct().all() if r[0]] + pms_list = [r[0] for r in session.query(MonthlySummaryAnalysis.pm_name).distinct().all() if r[0]] + areas_list = [r[0] for r in session.query(MonthlySummaryAnalysis.area_name).distinct().all() if r[0]] + vendors_list = [r[0] for r in session.query(MonthlySummaryAnalysis.vendor_name).distinct().all() if r[0]] + trades_list = [r[0] for r in session.query(MonthlySummaryAnalysis.trade_type).distinct().all() if r[0]] + # DEBUG LOGGING FOR RESULTS + debug_logger.info(f"🔍 [API Debug] Result Counts: Area={len(area_rank_q.all())}, Vendor={len(vendor_rank_q.all())}, Div={len(div_dist_q.all())}, Price={len(price_cont_q.all())}") + + return jsonify({ + 'status': 'success', + 'total_rows': total_rows, + 'total_months': total_months, + 'kpis': { + 'sales': int(kpi_res.total_sales or 0), + 'sales_prev': int(kpi_res.total_sales_prev or 0), + 'sales_yoa': int(kpi_res.total_sales_yoa or 0), + 'profit': int(kpi_res.total_profit or 0), + 'vol': int(kpi_res.total_vol or 0), + 'views': int(kpi_res.total_views or 0), + 'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0 + }, + 'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_query.all()], + 'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_q.all()], + 'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()], + 'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_query.all()], + 'area_ranking': [ + { + 'name': r.area_name, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in area_rank_q.all() + ], + 'vendor_ranking': [ + { + 'name': r.vendor_name, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0), + 'profit': int(r.profit or 0), + 'profit_2024': int(r.profit_2024 or 0), + 'profit_2025': int(r.profit_2025 or 0), + 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0 + } + for r in vendor_rank_q.all() + ], + 'division_dist': [ + { + 'name': r.division, + 'value': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in div_dist_q.all() + ], + 'price_contribution': [ + { + 'range': r.price_range, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in price_cont_q.all() + ], + 'bcg_data': [ + {'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)} + for r in bcg_q.all() + ], + 'heatmap_data': [ + {'month': f"{r.year}-{r.month:02d}", 'category': r.division, 'sales': int(r.sales or 0)} + for r in heatmap_q.all() + ], + 'highlights': { + 'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_q.all()], + 'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_q.all()], + 'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_q.all()] + }, + 'filters': { + 'years': sorted(years_list, reverse=True), + 'months': sorted(months_list), + 'divisions': sorted(divisions_list), + 'pms': sorted(pms_list), + 'areas': sorted(areas_list), + 'vendors': sorted(vendors_list), + 'trades': sorted(trades_list) + }, + 'rows': rows + }) + + + + except Exception as e: + sys_log.error(f"取得月份總表數據失敗: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + finally: + session.close() + + + +# ================= 📊 V-New: 業績分析報表 ================= + + +def _get_filtered_sales_data(cache_key): + """ + 🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選 + 回傳: (target_df, cols_map, error_message) + 參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m") + """ + db = DatabaseManager() + + # 1. 檢查資料表與快取 + df = None + cols_map = {} + + if cache_key in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[cache_key] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + # V-Fix: 增加自動重載邏輯,如果快取不存在,試圖從資料庫載入 + sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...") + try: + # V-Fix: 從 cache_key 提取 table_name + # 格式: realtime_sales_monthly_3m 或 realtime_sales_monthly_custom_2025-01-01_2025-01-31 + if "_custom_" in cache_key: + table_name = cache_key.split('_custom_')[0] # realtime_sales_monthly + else: + # 移除最後的 _Xm 部分 + parts = cache_key.rsplit('_', 1) + table_name = parts[0] if len(parts) > 1 else 'realtime_sales_monthly' + + # 判斷是自訂區間還是標配區間 + if "_custom_" in cache_key: + # 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31 + parts = cache_key.split('_custom_') + dates = parts[1].split('_') + start_d, end_d = dates[0], dates[1] + # 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射) + result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d) + else: + # 格式: realtime_sales_monthly_1m + months = int(cache_key.split('_')[-1].replace('m', '') or '1') + result_df, result_cols = db.get_sales_data(table_name=table_name, months=months) + + if result_df is not None and not result_df.empty: + # V-Fix (2026-01-23): 補回所有日期維度欄位供後續篩選 (_dow, _hour, _month_str) + if '日期' in result_df.columns: + # 先轉換為 datetime + result_df['_parsed_date'] = pd.to_datetime(result_df['日期'], errors='coerce') + result_df['_month_str'] = result_df['_parsed_date'].dt.strftime('%Y-%m') + result_df['_dow'] = result_df['_parsed_date'].dt.dayofweek + + # 小時需要從「時間」欄位提取 + if '時間' in result_df.columns: + result_df['_hour'] = pd.to_datetime(result_df['時間'], format='%H:%M:%S', errors='coerce').dt.hour + else: + result_df['_hour'] = 0 # 如果沒有時間欄位,預設為 0 + + # 清理臨時欄位 + result_df.drop(columns=['_parsed_date'], inplace=True, errors='ignore') + + # 自動存入快取 + _SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()} + df = result_df + cols_map = result_cols + sys_log.info(f"[Sales Analysis] ✅ 快取成功自動重載 | 筆數: {len(df)}") + else: + return None, None, "資料庫無可用資料,請確認匯入狀態" + except Exception as ex: + sys_log.error(f"[Sales Analysis] 🚨 自動重載失敗: {ex}") + return None, None, f"快取失效且無法重載: {ex}" + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_date = cols_map.get('date') + col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位 + + # 2. 取得篩選參數 + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + # 3. 執行篩選 + target_df = df + + # Top N 分類處理 (用於 '其他' 篩選) + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + # 注意:這裡為了效能,簡單重算一次 Top N,或可考慮也快取起來 + cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + + if col_price: + if min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + return target_df, cols_map, None + +@app.route('/sales_analysis') +def sales_analysis(): + """業績分析報表頁面""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 檢查資料表是否存在 + inspector = inspect(db.engine) + if table_name not in inspector.get_table_names(): + return render_template('sales_analysis.html', + error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。", + table_name=table_name, + selected_metric='amount', + no_filter=False, + data_range_months=0, + start_date='', + end_date='', + total_records=0, + db_data_range='') + + # V-New: 查詢資料庫的資料期間範圍 + db_data_range = '' + try: + # 取得日期欄位的最小值和最大值 + from sqlalchemy import text + date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}") + # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 + with db.engine.connect() as conn: + result = conn.execute(date_query).fetchone() + if result and result[0] and result[1]: + min_date = result[0] + max_date = result[1] + # 格式化為 YYYY年MM月 格式 + if isinstance(min_date, str): + from datetime import datetime + try: + min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d') + max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d') + db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月" + except: + db_data_range = f"{min_date} ~ {max_date}" + else: + db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月" + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}") + + # V-New: 取得篩選參數 + data_range_param = request.args.get('data_range', '') # 不再設預設值 + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + # V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面 + if not data_range_param and not start_date and not end_date: + sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件") + + # V-Fix: 即使在引導頁面,也要提供下拉選單選項 + # V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊 + preview_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 1000", db.engine) + preview_categories = [] + preview_brands = [] + preview_vendors = [] + preview_activities = [] + preview_payments = [] + preview_months = [] # V-New: 新增月份列表 + + if not preview_df.empty: + cols = preview_df.columns.tolist() + def find_col(keywords): + for k in keywords: + for col in cols: + if k in str(col): return col + return None + + col_category = find_col(['館別', '商品館', '分類', 'Category']) + col_brand = find_col(['品牌', 'Brand']) + col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) + # V-Fix: 優先匹配具體的活動欄位名稱 + col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign']) + col_payment = find_col(['付款', 'Payment', 'Pay']) + # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) + col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) + col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) + + # V-Fix: 篩選掉空字串,只保留有效數據 + if col_category: + preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_brand: + preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_vendor: + preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_activity: + preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_payment: + preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()]) + + # V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份) + if col_date_part: + try: + from sqlalchemy import text + with db.engine.connect() as conn: + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month + FROM {table_name} + WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != '' + ORDER BY month + """)).fetchall() + preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])] + sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份") + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}") + pass + + # 傳遞必要的變數以避免模板錯誤 + selected_metric = request.args.get('metric', 'amount') + # 建立空的數據結構 + empty_data = {'labels': [], 'chart_values': [], 'values': [], 'metric_label': ''} + return render_template('sales_analysis.html', + no_filter=True, + table_name=table_name, + selected_metric=selected_metric, + total_records=0, + items=[], + kpi={'revenue': 0, 'qty': 0, 'count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0}, + insights={}, + abc_stats={}, + vendor_stats=[], + seasonality_data={'datasets': [], 'yLabels': [], 'xLabels': []}, + bar_data=empty_data, + cat_data=empty_data, + price_dist_data=empty_data, + scatter_data=[], + bcg_data=[], + dow_data=empty_data, + hourly_data=empty_data, + monthly_data=empty_data, + weekly_data=empty_data, + heatmap_data=[], + treemap_data=[], + cols={'name': True, 'amount': True, 'qty': True, 'cat': True, 'date': True, + 'cost': True, 'profit': True, 'vendor': True, 'brand': True, + 'return_qty': True, 'pid': True}, + all_categories=preview_categories, all_brands=preview_brands, all_vendors=preview_vendors, + all_activities=preview_activities, all_payments=preview_payments, all_months=preview_months, + selected_category='all', selected_brand='all', selected_vendor='all', + selected_activity='all', selected_payment='all', selected_dow='all', + selected_hour='all', selected_month='all', + keyword='', min_price='', max_price='', min_margin='', max_margin='', + data_range_months=0, start_date='', end_date='', + db_data_range=db_data_range, + marketing_data=None) + + # 解析 data_range_months(有篩選時才處理) + data_range_months = int(data_range_param or '0') + + # V-New: 如果有自訂日期區間,則優先使用 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 2. 讀取與處理資料 (V-Opt: 使用二級快取機制 Raw -> Processed) + df = None + cols_map = {} + + # A. 優先檢查是否已有處理好的快取 (最快) + if cache_key in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[cache_key] + df = cache_data['df'] + cols_map = cache_data['cols'] + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_date = cols_map.get('date') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_pid = cols_map.get('pid') # V-New: 取得 PID 欄位 + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_return_qty = cols_map.get('return_qty') + + cached_pie_data = cache_data.get('pie_data', {'labels': [], 'chart_values': []}) # V-Opt: 讀取圓餅圖快取 + else: + # B. 若無處理後快取,則從 Raw Cache 或 DB 讀取並處理 + # V-Opt: 加入日期範圍篩選以減少記憶體使用 + # (data_range_months 已在上方定義) + + # 先讀取小樣本以識別日期欄位 + sample_df = pd.read_sql(f"SELECT * FROM {table_name} LIMIT 100", db.engine) + if sample_df.empty: + return render_template('sales_analysis.html', + error="資料表為空,請重新匯入。", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None) + + # 自動識別日期欄位(V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字) + sample_cols = sample_df.columns.tolist() + date_col_name = None + for col in sample_cols: + if any(keyword in str(col) for keyword in ['日期', '交易日期', 'Date', '訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']): + date_col_name = col + break + + # 根據是否有日期欄位決定查詢方式 + if date_col_name: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + # 有自訂日期區間 - 使用 BETWEEN 或單邊範圍 + if start_date and end_date: + sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" BETWEEN '{start_date_slash}' AND '{end_date_slash}'" + sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date} (DB格式: {start_date_slash} ~ {end_date_slash})") + elif start_date: + sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{start_date_slash}'" + sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date} (DB格式: {start_date_slash})") + else: # only end_date + sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" <= '{end_date_slash}'" + sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date} (DB格式: {end_date_slash})") + elif data_range_months > 0: + # 使用相對日期範圍(最近N個月) + # V-Fix: 使用斜線格式以匹配資料庫格式 + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + sql_query = f"SELECT * FROM {table_name} WHERE \"{date_col_name}\" >= '{cutoff_date}'" + sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月 (>= {cutoff_date})") + else: + # data_range_months == 0,載入全部資料 + sql_query = f"SELECT * FROM {table_name}" + sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)") + else: + # 無日期欄位 - 載入全部 + sql_query = f"SELECT * FROM {table_name}" + sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料") + + # V-Opt (2026-01-23): 優先使用 PostgreSQL 聚合視圖 (mv_sales_summary) + # 聚合視圖已預先計算:資料量 -20%, 大小 -73%, 欄位類型已轉換 + from sqlalchemy import text as sql_text + from config import DATABASE_TYPE + + use_materialized_view = False + if DATABASE_TYPE == 'postgresql': + # 檢查聚合視圖是否存在 + try: + with db.engine.connect() as conn: + check_mv = conn.execute(sql_text( + "SELECT EXISTS (SELECT 1 FROM pg_matviews WHERE matviewname = 'mv_sales_summary')" + )).fetchone() + use_materialized_view = check_mv[0] if check_mv else False + except: + use_materialized_view = False + + if use_materialized_view: + # 使用聚合視圖 - 欄位已標準化為英文 + sys_log.info(f"[Sales Analysis] 📊 使用 PostgreSQL 聚合視圖 (mv_sales_summary)") + + # 構建日期篩選條件 + mv_where = "" + if start_date or end_date: + if start_date and end_date: + mv_where = f"WHERE sale_date BETWEEN '{start_date}' AND '{end_date}'" + elif start_date: + mv_where = f"WHERE sale_date >= '{start_date}'" + else: + mv_where = f"WHERE sale_date <= '{end_date}'" + elif data_range_months > 0: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + cutoff = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y-%m-%d') + mv_where = f"WHERE sale_date >= '{cutoff}'" + + mv_query = f""" + SELECT + sale_date as "日期", + product_id as "商品ID", + product_name as "商品名稱", + category as "商品館", + brand as "品牌", + vendor_name as "廠商名稱", + payment as "付款", + total_revenue as "總業績", + total_qty as "數量", + total_cost as "總成本", + order_count + FROM mv_sales_summary + {mv_where} + """ + df = pd.read_sql(mv_query, db.engine) + sys_log.info(f"[Sales Analysis] 📊 聚合視圖載入完成: {len(df):,} 筆記錄") + else: + # 原始邏輯:使用原始表 + sys_log.info(f"[Sales Analysis] 📊 使用原始表載入...") + df = pd.read_sql(sql_query, db.engine) + sys_log.info(f"[Sales Analysis] 📊 載入完成: {len(df):,} 筆記錄") + + # 聚合模式標記 + is_aggregated_mode = use_materialized_view + + # V-Opt: 不再快取完整 DataFrame 到 _SALES_DF_CACHE (避免記憶體累積) + # 改用輕量級處理後快取 (_SALES_PROCESSED_CACHE) + + if df.empty: + return render_template('sales_analysis.html', + error="資料表為空,請重新匯入。", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None) + + # 3. 自動識別關鍵欄位 (模糊比對) + cols = df.columns.tolist() + def find_col(keywords): + # V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商') + for k in keywords: + for col in cols: + if k in str(col): return col + return None + + col_name = find_col(['商品名稱', '品名', 'Name', 'Product']) + col_pid = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) # V-New: 偵測商品ID欄位 + + # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) + col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) + col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) + + col_brand = find_col(['品牌', 'Brand']) # V-New: 品牌欄位 + col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Opt: 優先抓取名稱 + col_activity = find_col(['活動', '折扣', 'Activity', 'Campaign', 'Promotion', '專案']) # V-New: 活動欄位 + col_payment = find_col(['付款方式', 'Payment', 'Pay']) # V-New: 付款方式欄位 + col_price = find_col(['單價', 'Price', '價格', 'Avg Price']) # V-New: 嘗試尋找單價欄位 + col_cost = find_col(['成本', 'Cost', '進價', 'Cost Price', 'Wholesale']) # V-New: 成本欄位 + col_profit = find_col(['毛利', 'Profit', '利潤']) # V-New: 直接尋找毛利欄位 (若有) + col_return_qty = find_col(['退貨數量', 'Return Qty', '退貨']) # V-New: 退貨欄位 + col_amount = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total']) + col_qty = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity']) + col_category = find_col(['館別', '分類', 'Category']) + + if not col_name or not col_amount: + return render_template('sales_analysis.html', + error=f"無法自動識別關鍵欄位 (需包含 '名稱' 與 '金額')。偵測到的欄位: {cols}", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None) + + # 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次) + # 確保金額與數量是數字 + df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) + if col_qty: + df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) + + if col_cost: + df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) + + if col_profit: + df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) + + if col_return_qty: + df[col_return_qty] = pd.to_numeric(df[col_return_qty], errors='coerce').fillna(0) + + # V-Fix: 智慧日期時間合併邏輯 (聚合模式下跳過) + col_date = None + if not is_aggregated_mode: + if col_date_part and col_time_part: + # 兩者都有,嘗試合併 + try: + df['combined_dt'] = pd.to_datetime(df[col_date_part].astype(str) + ' ' + df[col_time_part].astype(str), errors='coerce') + col_date = 'combined_dt' + except: + # 合併失敗,退回使用時間欄位 (假設包含日期) 或日期欄位 + col_date = col_time_part or col_date_part + elif col_time_part: + # 只有時間欄位 (可能包含日期) + df[col_time_part] = pd.to_datetime(df[col_time_part], errors='coerce') + col_date = col_time_part + elif col_date_part: + # 只有日期欄位 + df[col_date_part] = pd.to_datetime(df[col_date_part], errors='coerce') + col_date = col_date_part + + # V-New: 若無明確單價欄位,則自動計算 (金額 / 數量) + if not col_price and col_amount and col_qty: + col_price = 'calculated_price' + # V-Opt: 使用 numpy 向量化運算加速 (取代 apply) + df[col_price] = np.where(df[col_qty] > 0, df[col_amount] / df[col_qty], 0) + + if col_price: + df[col_price] = pd.to_numeric(df[col_price], errors='coerce').fillna(0) + + # V-New: 預先計算毛利率 (Margin Rate) 用於篩選 + # 邏輯: (毛利 / 金額) * 100 + col_margin_rate = 'calculated_margin_rate' + with np.errstate(divide='ignore', invalid='ignore'): + if col_profit: + df[col_margin_rate] = (df[col_profit] / df[col_amount]) * 100 + elif col_cost: + df[col_margin_rate] = ((df[col_amount] - df[col_cost]) / df[col_amount]) * 100 + else: + df[col_margin_rate] = 0.0 + # 處理無限大與 NaN (轉為 0) + df[col_margin_rate] = df[col_margin_rate].replace([np.inf, -np.inf, np.nan], 0) + + # === V-Opt: 效能優化預計算 (V9.98) === + # 1. 日期維度 (加速篩選與聚合,避免重複呼叫 .dt 存取器) + # 聚合模式下跳過日期維度計算 + if col_date and not is_aggregated_mode: + df['_dow'] = df[col_date].dt.dayofweek + df['_hour'] = df[col_date].dt.hour + df['_week'] = df[col_date].dt.strftime('%G-W%V') + df['_month_str'] = df[col_date].dt.strftime('%Y-%m') # V-New: 月份維度 (YYYY-MM) + + # 2. 毛利額 (加速 Top 3 分析,避免 runtime 計算) + if col_profit: + df['calculated_profit'] = df[col_profit] + elif col_cost: + df['calculated_profit'] = df[col_amount] - df[col_cost] + else: + df['calculated_profit'] = 0.0 + + # 3. 全站分類圓餅圖 (已移至下方使用 target_df 計算) + + + # 建立/更新處理後快取 + cache_entry = { + 'df': df, + 'cols': { + 'name': col_name, 'date': col_date, 'amount': col_amount, + 'qty': col_qty, 'category': col_category, 'brand': col_brand, + 'vendor': col_vendor, 'activity': col_activity, 'payment': col_payment, + 'price': col_price, 'cost': col_cost, 'profit': col_profit, + 'return_qty': col_return_qty, + 'pid': col_pid # V-New: 儲存商品ID欄位 + }, + 'pid': col_pid # V-New: 儲存商品ID欄位 + } + _SALES_PROCESSED_CACHE[cache_key] = cache_entry + # V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用 + _SALES_PROCESSED_CACHE[table_name] = cache_entry + # V-Opt (2026-01-23): 定期清理過期快取 + _cleanup_sales_cache() + + # 🚩 V-Opt: 使用共用篩選函式 + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + if err: + # V-Fix: 若快取失效,重新導向自己以觸發重新讀取(保留所有查詢參數) + params = {k: v for k, v in request.args.items()} + return redirect(url_for('sales_analysis', **params)) + + # 重新取得變數 (因為 _get_filtered_sales_data 內部使用了 cols_map) + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_return_qty = cols_map.get('return_qty') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') + + # V-Fix: 準備前端需要的下拉選單資料 + # V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取 + # 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小 + all_categories = [] + all_brands = [] + all_vendors = [] + all_activities = [] + all_payments = [] + all_months = [] + + try: + from sqlalchemy import text + # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 + with db.engine.connect() as conn: + # 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能) + # V-Fix: 使用單引號空字串,兼容 PostgreSQL + if col_category: + sql = f"SELECT DISTINCT \"{col_category}\" FROM {table_name} WHERE \"{col_category}\" IS NOT NULL AND \"{col_category}\" <> ''" + result = conn.execute(text(sql)).fetchall() + all_categories = sorted([str(row[0]) for row in result if row[0]]) + + if col_brand: + sql = f"SELECT DISTINCT \"{col_brand}\" FROM {table_name} WHERE \"{col_brand}\" IS NOT NULL AND \"{col_brand}\" <> ''" + result = conn.execute(text(sql)).fetchall() + all_brands = sorted([str(row[0]) for row in result if row[0]]) + + if col_vendor: + sql = f"SELECT DISTINCT \"{col_vendor}\" FROM {table_name} WHERE \"{col_vendor}\" IS NOT NULL AND \"{col_vendor}\" <> ''" + result = conn.execute(text(sql)).fetchall() + all_vendors = sorted([str(row[0]) for row in result if row[0]]) + + if col_activity: + sql = f"SELECT DISTINCT \"{col_activity}\" FROM {table_name} WHERE \"{col_activity}\" IS NOT NULL AND \"{col_activity}\" <> ''" + result = conn.execute(text(sql)).fetchall() + all_activities = sorted([str(row[0]) for row in result if row[0]]) + + if col_payment: + sql = f"SELECT DISTINCT \"{col_payment}\" FROM {table_name} WHERE \"{col_payment}\" IS NOT NULL AND \"{col_payment}\" <> ''" + result = conn.execute(text(sql)).fetchall() + all_payments = sorted([str(row[0]) for row in result if row[0]]) + + # V-Fix: 從數據庫提取所有月份(格式:YYYY-MM) + if col_date: + # 從日期欄位提取月份(支援多種日期欄位名稱) + date_fields = ['日期', '訂單日期', '時間'] + for field in date_fields: + try: + # V-Fix: 使用 substr 提取年月部分,並將斜線替換為橫線 + # 數據庫格式: "2025/07/01" -> 提取前7個字符 "2025/07" -> 替換斜線 "2025-07" + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month + FROM {table_name} + WHERE \"{field}\" IS NOT NULL AND \"{field}\" != '' + ORDER BY month + """)).fetchall() + if result and len(result) > 0: + all_months = [row[0] for row in result if row[0] and '-' in str(row[0])] + if all_months: # 如果成功提取到月份,就使用這個欄位 + sys_log.info(f"[Sales Analysis] 從欄位 {field} 提取到 {len(all_months)} 個月份: {all_months}") + break + except Exception as ex: + sys_log.warning(f"[Sales Analysis] 從欄位 {field} 提取月份失敗: {ex}") + continue + except Exception as e: + sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}") + # 如果查詢失敗,回退到從快取讀取 + if cache_key in _SALES_PROCESSED_CACHE: + original_df = _SALES_PROCESSED_CACHE[cache_key]['df'] + elif table_name in _SALES_PROCESSED_CACHE: + original_df = _SALES_PROCESSED_CACHE[table_name]['df'] + else: + original_df = pd.DataFrame() + + if not original_df.empty: + all_categories = sorted(original_df[col_category].dropna().astype(str).unique().tolist()) if col_category else [] + all_brands = sorted(original_df[col_brand].dropna().astype(str).unique().tolist()) if col_brand else [] + all_vendors = sorted(original_df[col_vendor].dropna().astype(str).unique().tolist()) if col_vendor else [] + all_activities = sorted(original_df[col_activity].dropna().astype(str).unique().tolist()) if col_activity else [] + all_payments = sorted(original_df[col_payment].dropna().astype(str).unique().tolist()) if col_payment else [] + all_months = sorted(original_df['_month_str'].dropna().unique().tolist()) if col_date and '_month_str' in original_df.columns else [] + + # 取得前端參數供模板回填 + selected_category = request.args.get('category', 'all') + selected_metric = request.args.get('metric', 'amount') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + # 決定排序欄位 + sort_col = col_amount + if selected_metric == 'qty' and col_qty: + sort_col = col_qty + + target_df = target_df.sort_values(by=sort_col, ascending=False) + + # 📊 KPI 計算 (針對篩選後的資料) + total_revenue = float(target_df[col_amount].sum()) + total_qty = float(target_df[col_qty].sum()) if col_qty else 0 + total_count = int(len(target_df)) # 訂單筆數 + # V-Fix 2026-01-15: SKU 數應計算唯一商品數,而非記錄筆數 + sku_count = int(target_df[col_name].nunique()) if col_name else total_count + + # V-New: 成本與毛利計算 + total_cost = float(target_df[col_cost].sum()) if col_cost else 0 + if col_profit: + gross_margin = float(target_df[col_profit].sum()) + else: + gross_margin = total_revenue - total_cost + + gross_margin_rate = (gross_margin / total_revenue * 100) if total_revenue > 0 else 0 + + avg_price = total_revenue / total_qty if total_qty > 0 else 0 + + # 📊 V-New: 商業洞察 (Top 3 Analysis) + insights = { + 'rev_cats': [], 'rev_prods': [], + 'margin_cats': [], 'margin_prods': [], + 'qty_cats': [], 'qty_prods': [] + } + + # Helper function to get top 3 + # Helper function to get top 3 + def get_top_3(groupby_col, metric_col, is_margin=False, is_qty=False): + if not groupby_col or not metric_col: return [] + + # V-Opt: 直接使用 target_df 與預計算欄位,避免 copy() 與 assign() + target_metric = metric_col + if is_margin: + target_metric = 'calculated_profit' + + try: + # 直接聚合並取前3名 + # V-Fix 2026-01-15: 若 groupby_col 是 list (例如 [PID, Name]),結果 index 會是 MultiIndex + grouped = target_df.groupby(groupby_col)[target_metric].sum() + + def get_name(k): + # 如果是 Tuple (MultiIndex),通常最後一個是 Name,取之 + return str(k[-1]) if isinstance(k, tuple) else str(k) + + return [{'name': get_name(k), 'value': float(v)} for k, v in grouped.nlargest(3).items() if v > 0] + except Exception: + return [] + + insights['rev_cats'] = get_top_3(col_category, col_amount) + # V-Fix: 商品聚合改用 [PID, Name] 避免同名不同ID商品被合併 + product_groupby = [col_pid, col_name] if col_pid else col_name + insights['rev_prods'] = get_top_3(product_groupby, col_amount) + insights['qty_cats'] = get_top_3(col_category, col_qty, is_qty=True) + insights['qty_prods'] = get_top_3(product_groupby, col_qty, is_qty=True) + + if col_cost or col_profit: + insights['margin_cats'] = get_top_3(col_category, col_amount, is_margin=True) + insights['margin_prods'] = get_top_3(product_groupby, col_amount, is_margin=True) + + # 📊 V-Opt: 改為橫向長條圖數據 (Top 20) + top_chart = target_df.head(20) + bar_data = { + 'labels': [str(n)[:20] + '...' if len(str(n)) > 20 else str(n) for n in top_chart[col_name]], # 稍微放寬長度限制 + 'chart_values': [float(x) for x in top_chart[sort_col]], + 'metric_label': '銷售金額 ($)' if selected_metric == 'amount' else '銷售數量' + } + + # 📋 V-Opt: 列表資料改為 AJAX 載入,這裡只傳空列表以加快初始渲染 + table_items = [] + + # 準備類別圓餅圖資料 + # V-Fix: 使用 target_df (篩選後資料) 動態計算 + cat_data = {'labels': [], 'chart_values': []} + if col_category and not target_df.empty: + cat_group_all = target_df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) + TOP_N_CATS = 12 + if len(cat_group_all) > TOP_N_CATS: + top_cats = cat_group_all.head(TOP_N_CATS) + other_val = cat_group_all.iloc[TOP_N_CATS:].sum() + cat_data['labels'] = [str(x) for x in top_cats.index.tolist()] + ['其他'] + cat_data['chart_values'] = [float(x) for x in top_cats.tolist()] + [float(other_val)] + else: + cat_data['labels'] = [str(x) for x in cat_group_all.index.tolist()] + cat_data['chart_values'] = [float(x) for x in cat_group_all.tolist()] + + # 📊 V-New: 價格帶分析 (Price Range Analysis) + price_dist_data = {'labels': [], 'chart_values': []} + if col_price and not target_df.empty: + # 定義價格區間 (0-500, 500-1000, 1000-2000, 2000-5000, 5000-10000, 10000+) + bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')] + labels = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+'] + + # V-Opt: 使用 pd.cut 進行分組,但不修改 target_df (避免污染快取) + # right=False 表示包含左邊界,例如 500 在 500-999 這一組 + price_bins = pd.cut(target_df[col_price], bins=bins, labels=labels, right=False) + + # 統計各區間的「銷售金額」貢獻 (直接使用外部 Series 進行 groupby) + range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum() + + price_dist_data['labels'] = labels + price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in labels] + + # 📊 V-New: 價格 vs 銷量 散佈圖 (Scatter Plot) + scatter_data = [] + if col_price and col_qty and not target_df.empty: + # 取前 300 筆主要商品,避免圖表過於密集導致瀏覽器卡頓 + scatter_source = target_df.head(300) + for _, row in scatter_source.iterrows(): + # V-Fix (2026-01-23): 處理 NaN 值 + price_val = row[col_price] if pd.notna(row[col_price]) else 0 + qty_val = row[col_qty] if pd.notna(row[col_qty]) else 0 + amt_val = row[col_amount] if pd.notna(row[col_amount]) else 0 + scatter_data.append({ + 'x': float(price_val), + 'y': float(qty_val), + 'name': str(row[col_name]) if pd.notna(row[col_name]) else '', + 'amt': float(amt_val) # 用於 tooltip 顯示金額 + }) + + # 📊 V-New: BCG 矩陣分析 (BCG Matrix) + # X軸: 銷量 (Qty), Y軸: 毛利率 (Margin %) + bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}} + # V-Fix: 確保 calculated_margin_rate 欄位存在 + if col_qty and (col_cost or col_profit) and not target_df.empty and 'calculated_margin_rate' in target_df.columns: + # 1. 計算閾值 (使用中位數,避免極端值影響) + # 過濾掉銷量為 0 的商品,避免干擾閾值計算 + active_products = target_df[target_df[col_qty] > 0] + if not active_products.empty and 'calculated_margin_rate' in active_products.columns: + median_qty = active_products[col_qty].median() + median_margin = active_products['calculated_margin_rate'].median() + + # 若中位數為 0 (例如大部分商品沒銷量),則給一個預設值以利顯示 + if median_qty == 0: median_qty = 1 + + bcg_data['thresholds'] = {'x': float(median_qty), 'y': float(median_margin)} + + # 2. 分類商品 (四象限) + # Stars (明星): High Qty, High Margin + stars = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] >= median_margin)] + # Cows (金牛): High Qty, Low Margin + cows = active_products[(active_products[col_qty] >= median_qty) & (active_products['calculated_margin_rate'] < median_margin)] + # Questions (問題): Low Qty, High Margin + questions = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] >= median_margin)] + # Dogs (瘦狗): Low Qty, Low Margin + dogs = active_products[(active_products[col_qty] < median_qty) & (active_products['calculated_margin_rate'] < median_margin)] + + def format_bcg_points(df_segment): + # 限制點數,避免前端卡頓 (各象限最多 100 點) + return [{'x': float(row[col_qty]), 'y': float(row['calculated_margin_rate']), 'name': str(row[col_name]), 'amt': float(row[col_amount])} for _, row in df_segment.head(100).iterrows()] + + bcg_data['datasets'] = [ + {'label': '明星商品 (Stars)', 'data': format_bcg_points(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'}, # Yellow + {'label': '金牛商品 (Cows)', 'data': format_bcg_points(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'}, # Green + {'label': '問題商品 (Questions)', 'data': format_bcg_points(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'}, # Blue + {'label': '瘦狗商品 (Dogs)', 'data': format_bcg_points(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'} # Grey + ] + + # 📊 V-New: 時間維度分析 (Time Analysis) + dow_data = {'labels': ['週一', '週二', '週三', '週四', '週五', '週六', '週日'], 'chart_values': [0]*7} + hourly_data = {'labels': [f"{i:02d}:00" for i in range(24)], 'chart_values': [0]*24} + weekly_data = {'labels': [], 'chart_values': []} # V-New: 每週趨勢 + monthly_data = {'labels': [], 'chart_values': []} # V-New: 每月趨勢 + heatmap_data = [] # V-New: 多維度熱力圖 (Day x Hour) + treemap_data = [] # V-New: 板塊圖數據 + + if col_date: + # 過濾掉日期無效的資料 + # V-Opt: 使用預計算欄位進行分組,速度更快 + if not target_df.empty: + # 1. 星期分析 (Day of Week) + dow_group = target_df.groupby('_dow')[col_amount].sum() + for day, val in dow_group.items(): + if not np.isnan(day): + dow_data['chart_values'][int(day)] = float(val) + + # 2. 小時分析 (Hourly) + hour_group = target_df.groupby('_hour')[col_amount].sum() + for hour, val in hour_group.items(): + if not np.isnan(hour): + hourly_data['chart_values'][int(hour)] = float(val) + + # 3. 每月趨勢 (Monthly Trend) - V-New + month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index() + monthly_data['labels'] = month_group.index.tolist() + # V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗 + monthly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in month_group.tolist()] + + # 3. 每週趨勢 (Weekly Trend) - V-New + week_group = target_df.groupby('_week')[col_amount].sum().sort_index() + # V-Opt: 解除 12 週限制,顯示完整年度趨勢 (因應一年份數據需求) + weekly_data['labels'] = week_group.index.tolist() + # V-Fix (2026-01-23): 處理 NaN 值避免 JSON 序列化失敗 + weekly_data['chart_values'] = [float(x) if not np.isnan(x) else 0 for x in week_group.tolist()] + + # 4. 多維度熱力圖 (Day x Hour) - V-Fix: 確保數據完整性 + dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum() + # V-Opt: 正規化氣泡大小 (Normalize Bubble Size) 以提升可讀性 + max_val = dh_group.max() if not dh_group.empty else 1 + + for (day, hour), val in dh_group.items(): + # V-Fix (2026-01-23): 處理 NaN 值 + if np.isnan(val): + val = 0 + # 將數值映射到 3~25px 的半徑範圍,確保視覺可辨識 + radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0 + heatmap_data.append({ + 'x': int(hour), # X軸: 小時 (0-23) + 'y': int(day), # Y軸: 星期 (0-6) + 'r': float(radius) if not np.isnan(radius) else 0, # V-Adj: 正規化後半徑 + 'v': float(val) # 實際數值 (用於 Tooltip) + }) + + # 📊 V-New: 板塊圖 (Treemap) 數據準備 + # 結構: Root -> Category -> Product (Top 5 per cat) + if col_category and col_name and col_amount and not target_df.empty: + # V-Opt: 優化聚合邏輯,先聚合再篩選,避免在迴圈中重複過濾大表 + # 1. 先聚合 Category + Product (大幅減少資料量) + cat_prod_group = target_df.groupby([col_category, col_name])[col_amount].sum().reset_index() + + # 2. 找出前 10 大分類 + top_cats = cat_prod_group.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() + + # 3. 針對前 10 大分類,各取前 5 大商品 + for cat in top_cats: + if not cat: continue + # 在縮減後的資料中篩選,速度極快 + cat_subset = cat_prod_group[cat_prod_group[col_category] == cat] + top_prods = cat_subset.nlargest(5, col_amount) + + for _, row in top_prods.iterrows(): + # V-Fix (2026-01-23): 處理 NaN 值 + amount_val = row[col_amount] + if pd.isna(amount_val): + amount_val = 0 + treemap_data.append({ + 'category': str(cat), + 'product': str(row[col_name]) if pd.notna(row[col_name]) else '', + 'value': float(amount_val), + 'color': get_color_for_string(str(cat)) # V-Fix: 增加顏色參數,確保與分類顏色一致且清晰 + }) + + # 📊 V-New: ABC 分析 (Pareto Analysis) - TODO #8 + # A類: 累積營收 0-80% (核心商品) + # B類: 累積營收 80-95% (次要商品) + # C類: 累積營收 95-100% (長尾商品) + abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}} + + if not target_df.empty and col_amount: + # 使用 numpy 加速累積計算 + sorted_rev = target_df[col_amount].values # 已在上方排序過 + cumsum_rev = np.cumsum(sorted_rev) + total_rev_abc = cumsum_rev[-1] if len(cumsum_rev) > 0 else 0 + + if total_rev_abc > 0: + pct_cumsum = cumsum_rev / total_rev_abc * 100 + + # 找出分界點索引 + idx_a = np.searchsorted(pct_cumsum, 80) + idx_b = np.searchsorted(pct_cumsum, 95) + + # A類: 0 ~ idx_a + count_a = idx_a + 1 + rev_a = cumsum_rev[idx_a] if idx_a < len(cumsum_rev) else total_rev_abc + + # B類: idx_a+1 ~ idx_b + count_b = max(0, idx_b - idx_a) + rev_b = (cumsum_rev[idx_b] - cumsum_rev[idx_a]) if idx_b < len(cumsum_rev) else (total_rev_abc - cumsum_rev[idx_a]) + + # C類: idx_b+1 ~ end + count_c = max(0, len(cumsum_rev) - 1 - idx_b) + rev_c = total_rev_abc - cumsum_rev[idx_b] if idx_b < len(cumsum_rev) else 0 + + abc_stats['A'] = {'count': int(count_a), 'revenue': float(rev_a), 'pct_rev': float(rev_a/total_rev_abc*100), 'pct_sku': float(count_a/total_count*100)} + abc_stats['B'] = {'count': int(count_b), 'revenue': float(rev_b), 'pct_rev': float(rev_b/total_rev_abc*100), 'pct_sku': float(count_b/total_count*100)} + abc_stats['C'] = {'count': int(count_c), 'revenue': float(rev_c), 'pct_rev': float(rev_c/total_rev_abc*100), 'pct_sku': float(count_c/total_count*100)} + + # 📊 V-New: 廠商獲利能力排行 (Vendor Profitability) - TODO #9 + vendor_stats = [] + if col_vendor and col_amount and not target_df.empty: + # Group by vendor + agg_dict = {col_amount: 'sum', col_name: 'nunique'} # nunique 計算不重複商品數 (SKU) + if col_qty: agg_dict[col_qty] = 'sum' # V-New: 累加銷量 + if col_profit: + agg_dict[col_profit] = 'sum' + elif col_cost: + agg_dict[col_cost] = 'sum' + + # 使用 groupby 聚合 + vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index() + + # 計算毛利與毛利率 + if col_profit: + vendor_group['total_profit'] = vendor_group[col_profit] + elif col_cost: + vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost] + else: + vendor_group['total_profit'] = 0 + + # 計算營收佔比 (Share %) + total_vendor_revenue = vendor_group[col_amount].sum() + if total_vendor_revenue > 0: + vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100) + else: + vendor_group['revenue_share'] = 0.0 + + # 避免除以零 + vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0) + + # 計算平均客單價 (ASP) + if col_qty: + vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0) + + # 排序:預設按總業績降序 + vendor_group = vendor_group.sort_values(by=col_amount, ascending=False) + + # 格式化輸出 (Top 100) + for _, row in vendor_group.head(100).iterrows(): + vendor_stats.append({ + 'name': str(row[col_vendor]), + 'revenue': float(row[col_amount]), + 'share': float(row['revenue_share']), # V-New + 'qty': float(row[col_qty]) if col_qty else 0, # V-New + 'asp': float(row.get('asp', 0)), # V-New + 'profit': float(row['total_profit']), + 'margin_rate': float(row['margin_rate']), + 'sku_count': int(row[col_name]) + }) + + # 📊 V-New: 淡旺季熱力圖 (Seasonality Analysis) - TODO #10 + seasonality_data = None + if col_date and col_category and col_amount and not target_df.empty: + # 1. 取得前 10 大分類 (避免圖表過大) + # 使用 target_df (受篩選影響),這樣可以看特定品牌下的分類季節性 + top_cats_season = target_df.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() + + # 2. 聚合數據 (Month x Category) + season_group = target_df[target_df[col_category].isin(top_cats_season)].groupby(['_month_str', col_category])[col_amount].sum().reset_index() + + # 3. 轉換為 Bubble Chart 格式 + # X軸: 月份 (需解析 _month_str 取得順序) + # Y軸: 分類 (使用 top_cats_season 的索引) + + # 取得所有月份並排序 + all_months_sorted = sorted(target_df['_month_str'].unique()) + month_map = {m: i for i, m in enumerate(all_months_sorted)} + cat_map = {c: i for i, c in enumerate(top_cats_season)} + + points = [] + max_val_season = season_group[col_amount].max() if not season_group.empty else 1 + + for _, row in season_group.iterrows(): + m_str = row['_month_str'] + cat = row[col_category] + val = row[col_amount] + + if m_str in month_map and cat in cat_map: + # 正規化大小 (3~25px) + radius = 3 + (math.sqrt(val) / math.sqrt(max_val_season)) * 25 if val > 0 else 0 + points.append({ + 'x': month_map[m_str], + 'y': cat_map[cat], + 'r': radius, + 'v': float(val), + 'm': m_str, + 'c': cat + }) + + seasonality_data = { + 'datasets': [{ + 'label': '淡旺季熱點', + 'data': points, + # 顏色將在前端動態生成 + }], + 'yLabels': top_cats_season, + 'xLabels': all_months_sorted + } + + # 📊 V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Campaign Contribution) + marketing_data = None + if not target_df.empty: + marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric) + + return render_template('sales_analysis.html', + marketing_data=marketing_data, # V-New: 傳遞行銷活動數據 + items=table_items, + kpi={ + 'revenue': total_revenue, + 'qty': total_qty, + 'count': total_count, + 'sku_count': sku_count, # V-Fix 2026-01-15: 唯一商品數 + 'cost': total_cost, + 'gross_margin': gross_margin, + 'gross_margin_rate': gross_margin_rate, + 'avg_price': avg_price + }, + insights=insights, + abc_stats=abc_stats, # V-New: 傳遞 ABC 分析數據 + vendor_stats=vendor_stats, # V-New: 傳遞廠商排行數據 + seasonality_data=seasonality_data, # V-New: 傳遞淡旺季數據 + bar_data=bar_data, + cat_data=cat_data, + price_dist_data=price_dist_data, + scatter_data=scatter_data, + bcg_data=bcg_data, # V-New: 傳遞 BCG 數據 + dow_data=dow_data, + hourly_data=hourly_data, + monthly_data=monthly_data, + weekly_data=weekly_data, + heatmap_data=heatmap_data, + treemap_data=treemap_data, + all_categories=all_categories, + all_brands=all_brands, all_vendors=all_vendors, all_activities=all_activities, all_payments=all_payments, + all_months=all_months, # V-New: 傳遞月份列表 + selected_category=selected_category, + selected_brand=selected_brand, selected_vendor=selected_vendor, + selected_activity=selected_activity, selected_payment=selected_payment, + selected_dow=selected_dow, selected_hour=selected_hour, + selected_month=selected_month, + selected_metric=selected_metric, + keyword=keyword, min_price=min_price, max_price=max_price, + min_margin=min_margin, max_margin=max_margin, + cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, 'date': col_date, 'cost': col_cost, 'profit': col_profit, 'vendor': col_vendor, 'brand': col_brand, 'return_qty': col_return_qty, 'pid': col_pid}, + table_name=table_name, + data_range_months=data_range_months, + start_date=start_date, # V-New: 傳遞自訂開始日期 + end_date=end_date, # V-New: 傳遞自訂結束日期 + total_records=len(df), + db_data_range=db_data_range) # V-New: 傳遞資料庫資料期間 + + except Exception as e: + sys_log.error(f"Sales Analysis Error: {e}") + import traceback + traceback.print_exc() + # 提供完整的變數以避免模板錯誤 + return render_template('sales_analysis.html', + error=f"系統發生錯誤: {str(e)}", + marketing_data=None, + insights=None, + abc_stats=None, + vendor_stats=None, + seasonality_data=None, + scatter_data=None, + bcg_data=None, + dow_data=None, + hourly_data=None, + monthly_data=None, + weekly_data=None, + heatmap_data=None, + treemap_data=None, + all_categories=[], + all_brands=[], all_vendors=[], all_activities=[], all_payments=[], + all_months=[], + selected_category='all', + selected_brand='all', selected_vendor='all', + selected_activity='all', selected_payment='all', + selected_dow='all', selected_hour='all', + selected_month='all', + selected_metric=request.args.get('metric', 'amount'), + keyword='', min_price='', max_price='', + min_margin='', max_margin='', + cols={}, + table_name='realtime_sales_monthly', + no_filter=False, + data_range_months=int(request.args.get('data_range', '0') or '0'), + start_date=request.args.get('start_date', ''), + end_date=request.args.get('end_date', ''), + total_records=0, + db_data_range='') + +# V-Opt: API 層級快取 (減少重複查詢) +_TABLE_DATA_CACHE = {} +_TABLE_DATA_CACHE_TTL = 60 # 快取 60 秒 + +@app.route('/api/sales_analysis/table_data') +def get_sales_table_data(): + """API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化""" + try: + import hashlib + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + # V-Opt: 產生查詢快取 key (根據所有篩選條件) + cache_params = request.args.to_dict() + cache_key = hashlib.md5(str(sorted(cache_params.items())).encode(), usedforsecurity=False).hexdigest() + + # V-Opt: 檢查快取 + if cache_key in _TABLE_DATA_CACHE: + cached = _TABLE_DATA_CACHE[cache_key] + if time.time() - cached['time'] < _TABLE_DATA_CACHE_TTL: + sys_log.debug(f"[API] Table Data: 使用快取 (key={cache_key[:8]})") + return jsonify(cached['data']) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + + # V-Fix: 取得所有篩選參數 + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選 + vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選 + activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選 + payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選 + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') # 星期篩選 + hour_filter = request.args.get('hour', 'all') # 小時篩選 + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 + col_name = cols_map.get('name') or '商品名稱' + col_pid = cols_map.get('pid') or '商品ID' + col_brand = cols_map.get('brand') or '品牌' + col_vendor = cols_map.get('vendor') or '廠商名稱' + col_category = cols_map.get('category') or '商品館' + col_amount = cols_map.get('amount') or '總業績' + col_qty = cols_map.get('qty') or '數量' + col_cost = cols_map.get('cost') or '總成本' + col_return_qty = cols_map.get('return_qty') or '退貨數量' + + # V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集 + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + # V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期) + if start_date and end_date: + date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')""" + elif start_date: + date_filter = f"""AND ("日期" >= '{start_date_slash}')""" + else: # only end_date + date_filter = f"""AND ("日期" <= '{end_date_slash}')""" + elif data_range_months > 0: + # V-Fix: 使用斜線格式以匹配資料庫格式 + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + # V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期) + date_filter = f"""AND ("日期" >= '{cutoff_date}')""" + + # V-Fix: 建立其他篩選條件 + additional_filters = [] + + # 分類篩選 + if category_filter and category_filter != 'all' and col_category: + additional_filters.append(f""""{col_category}" = '{category_filter}'""") + + # V-Fix: 品牌篩選 + if brand_filter and brand_filter != 'all' and col_brand: + additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") + + # V-Fix: 廠商篩選 + if vendor_filter and vendor_filter != 'all' and col_vendor: + additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") + + # V-Fix: 活動篩選 + col_activity = cols_map.get('activity') + if activity_filter and activity_filter != 'all' and col_activity: + additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") + + # V-Fix: 付款方式篩選 + col_payment = cols_map.get('payment') + if payment_filter and payment_filter != 'all' and col_payment: + additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") + + # 月份篩選 + if month_filter and month_filter != 'all': + # V-Fix: 月份格式例如 "2025-01",但資料庫可能使用斜線格式 "2025/01" + # 只使用「日期」欄位(「訂單日期」是固定文字,「時間」只包含時間) + month_filter_slash = month_filter.replace('-', '/') # "2025-01" -> "2025/01" + # 同時匹配橫線和斜線格式 + additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") + + # 星期篩選 (需要從日期計算) + if dow_filter and dow_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 + # Pandas dt.dayofweek: 0=Monday, 6=Sunday + pandas_dow = int(dow_filter) + if DATABASE_TYPE == 'postgresql': + # PostgreSQL: EXTRACT(DOW FROM date) 0=Sunday, 6=Saturday + # Pandas 0(Mon) -> PostgreSQL 1(Mon), Pandas 6(Sun) -> PostgreSQL 0(Sun) + pg_dow = (pandas_dow + 1) % 7 + # 日期格式可能是 2025/01/01,需要轉換為 YYYY-MM-DD + additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") + else: + # SQLite: strftime('%w', date) 0=Sunday, 6=Saturday + sqlite_dow = str((pandas_dow + 1) % 7) + additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") + + # 小時篩選 (需要從時間欄位提取) + if hour_filter and hour_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite 兩種資料庫 + hour_val = int(hour_filter) + if DATABASE_TYPE == 'postgresql': + # PostgreSQL: 使用 SUBSTRING 或 CAST + additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") + else: + # SQLite: 使用 substr + additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") + + # 關鍵字篩選 + if keyword: + keyword_escaped = keyword.replace("'", "''") # SQL 注入防護 + keyword_conditions = [] + if col_name: + keyword_conditions.append(f""""{col_name}" LIKE '%{keyword_escaped}%'""") + if col_pid: + keyword_conditions.append(f""""{col_pid}" LIKE '%{keyword_escaped}%'""") + if col_brand: + keyword_conditions.append(f""""{col_brand}" LIKE '%{keyword_escaped}%'""") + if col_vendor: + keyword_conditions.append(f""""{col_vendor}" LIKE '%{keyword_escaped}%'""") + if keyword_conditions: + additional_filters.append(f"({' OR '.join(keyword_conditions)})") + + # V-New: 價格區間篩選 (Price Range) + if (min_price_str or max_price_str) and col_qty and col_amount: + # 假設單價 = 總業績 / 數量 (防止除以零) + price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: + additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}") + if max_price_str: + additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}") + + # V-New: 毛利率區間篩選 (Margin Range) + if (min_margin_str or max_margin_str) and col_amount: + # 計算毛利額 SQL + if col_profit: + profit_cal_sql = f'"{col_profit}"' + elif col_cost: + profit_cal_sql = f'("{col_amount}" - "{col_cost}")' + else: + profit_cal_sql = "0" + + # 計算毛利率 SQL: (毛利 / 業績) * 100 + margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))' + + if min_margin_str: + additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}") + if max_margin_str: + additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}") + + # 組合所有篩選條件 + all_filters = date_filter + if additional_filters: + all_filters += " AND " + " AND ".join(additional_filters) + + # SQL 聚合查詢 - 直接在資料庫層級完成聚合 + # V-Fix: 使用動態欄位名稱 + group_by_cols = [] + if col_pid: group_by_cols.append(f'"{col_pid}"') + if col_name: group_by_cols.append(f'"{col_name}"') + if col_brand: group_by_cols.append(f'"{col_brand}"') + if col_vendor: group_by_cols.append(f'"{col_vendor}"') + if col_category: group_by_cols.append(f'"{col_category}"') + group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"' + + sql_query = f""" + SELECT + {f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"}, + {f'"{col_name}" as name' if col_name else "'未知' as name"}, + {f'"{col_brand}" as brand' if col_brand else "'' as brand"}, + {f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"}, + {f'"{col_category}" as category' if col_category else "'' as category"}, + {f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'}, + {f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'}, + {f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'}, + {f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'}, + COUNT(*) as order_count + FROM {table_name} + WHERE 1=1 {all_filters} + GROUP BY {group_by_clause} + ORDER BY amount DESC + LIMIT 300 + """ + + df_agg = pd.read_sql(sql_query, db.engine) + sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})") + + if df_agg.empty: + return jsonify({'data': []}) + + # 計算衍生欄位 + df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0) + df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0) + df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0) + df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0) + + # V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選) + if min_price_str: + try: + min_price = float(min_price_str) + df_agg = df_agg[df_agg['avg_price'] >= min_price] + except ValueError: + pass + + if max_price_str: + try: + max_price = float(max_price_str) + df_agg = df_agg[df_agg['avg_price'] <= max_price] + except ValueError: + pass + + # V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選) + if min_margin_str: + try: + min_margin = float(min_margin_str) + df_agg = df_agg[df_agg['margin_rate'] >= min_margin] + except ValueError: + pass + + if max_margin_str: + try: + max_margin = float(max_margin_str) + df_agg = df_agg[df_agg['margin_rate'] <= max_margin] + except ValueError: + pass + + # 重新排序並限制到 300 筆 (減少前端渲染負擔) + df_agg = df_agg.sort_values('amount', ascending=False).head(300) + + # V-Opt: 使用向量化操作取代逐列迴圈 + df_agg['rank'] = range(1, len(df_agg) + 1) + df_agg['month_str'] = '' # SQL聚合模式不需要月份字串 + + # 重新命名欄位以符合前端格式 + result_df = df_agg.rename(columns={ + 'product_id': 'product_id', + 'name': 'name', + 'brand': 'brand', + 'vendor': 'vendor', + 'category': 'category', + 'margin_rate': 'margin_rate', + 'avg_price': 'avg_price', + 'return_rate': 'return_rate', + 'qty': 'qty', + 'amount': 'amount' + }) + + # 選擇需要的欄位並轉換為字典列表 + columns = ['rank', 'product_id', 'name', 'brand', 'vendor', 'category', + 'margin_rate', 'month_str', 'avg_price', 'return_rate', 'qty', 'amount'] + + # V-Fix (2026-01-23): 確保所有數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 + numeric_cols = ['margin_rate', 'avg_price', 'return_rate', 'qty', 'amount'] + for col in numeric_cols: + if col in result_df.columns: + result_df[col] = result_df[col].replace([np.inf, -np.inf], 0).fillna(0) + + # V-Fix (2026-01-23): 確保字串欄位無 None,避免 JSON 序列化失敗 + string_cols = ['product_id', 'name', 'brand', 'vendor', 'category', 'month_str'] + for col in string_cols: + if col in result_df.columns: + result_df[col] = result_df[col].fillna('').astype(str) + + data = result_df[columns].to_dict('records') + + response_data = {'data': data} + + # V-Opt: 儲存到快取 + _TABLE_DATA_CACHE[cache_key] = {'data': response_data, 'time': time.time()} + + # V-Opt: 清理過期快取 (保留最近 50 個) + if len(_TABLE_DATA_CACHE) > 50: + sorted_keys = sorted(_TABLE_DATA_CACHE.keys(), + key=lambda k: _TABLE_DATA_CACHE[k]['time']) + for old_key in sorted_keys[:-50]: + del _TABLE_DATA_CACHE[old_key] + + return jsonify(response_data) + + except Exception as e: + sys_log.error(f"[API] Table Data Error: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +# V-Old: 保留舊版本以防需要回滾 +@app.route('/api/sales_analysis/table_data_pandas') +def get_sales_table_data_pandas(): + """API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)""" + try: + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1')) + cache_key = f"{table_name}_{data_range_months}m" + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + if err or target_df is None: + sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料") + return jsonify({'data': []}) + + if target_df.empty: + return jsonify({'data': []}) + + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_category = cols_map.get('category') + col_vendor = cols_map.get('vendor') + col_date = cols_map.get('date') + col_brand = cols_map.get('brand') + col_return_qty = cols_map.get('return_qty') + + selected_metric = request.args.get('metric', 'amount') + + # 執行聚合 (V-Opt: 多維度聚合,增加精確度) + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_return_qty: agg_rules[col_return_qty] = 'sum' + if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) + + # Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性) + group_cols = [col_name] + if col_brand: group_cols.append(col_brand) + if col_vendor: group_cols.append(col_vendor) + if col_category: group_cols.append(col_category) + + df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index() + + # 計算毛利率 + if col_profit: + df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['agg_margin_rate'] = 0.0 + df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # V-New: 計算平均單價與退貨率 + if col_qty: + df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0) + if col_return_qty: + df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0) + + # 排序 + sort_col_agg = col_amount + if selected_metric == 'qty' and col_qty: + sort_col_agg = col_qty + + df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆 + + # 轉換為 DataTables 需要的格式 + data = [] + for i, row in enumerate(df_agg.to_dict('records')): + data.append({ + 'rank': i + 1, + 'name': row.get(col_name, ''), + 'brand': row.get(col_brand, ''), + 'vendor': row.get(col_vendor, ''), + 'category': row.get(col_category, ''), + 'margin_rate': row.get('agg_margin_rate', 0), + 'month_str': row.get('_month_str', ''), + 'avg_price': row.get('avg_price', 0), + 'return_rate': row.get('return_rate', 0), + 'qty': row.get(col_qty, 0), + 'amount': row.get(col_amount, 0) + }) + + return jsonify({'data': data}) + + except Exception as e: + sys_log.error(f"Table Data API Error: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/export/excel/seasonality_detail') +def export_seasonality_detail(): + """API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)""" + try: + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + # V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name + if err and table_name in _SALES_PROCESSED_CACHE: + target_df, cols_map, err = _get_filtered_sales_data(table_name) + + if err: return f"匯出失敗: {err}", 400 + + # 取得額外參數 + target_month = request.args.get('target_month') + target_category = request.args.get('target_category') + + if not target_month or not target_category: + return "缺少必要參數 (month, category)", 400 + + col_category = cols_map.get('category') + + # 進一步篩選 + export_df = target_df[ + (target_df['_month_str'] == target_month) & + (target_df[col_category] == target_category) + ] + + if export_df.empty: + return "該月份與分類無資料", 404 + + # 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容) + import io + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='明細') + output.seek(0) + + filename = f"Seasonality_{target_category}_{target_month}.xlsx" + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + except Exception as e: + sys_log.error(f"Seasonality Export Error: {e}") + return f"匯出失敗: {e}", 500 + +# ================= 💎 V-New: Top 3 Highlights 詳細列表 API ================= +@app.route('/api/sales_analysis/top_detail') +def get_top_detail(): + """API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)""" + try: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + top_type = request.args.get('type', 'revenue') # revenue/margin/quantity + metric = request.args.get('metric', 'amount') # amount/profit/qty + view_type = request.args.get('view', 'product') # product/category + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 + col_name = cols_map.get('name') or '商品名稱' + col_brand = cols_map.get('brand') or '品牌' + col_vendor = cols_map.get('vendor') or '廠商名稱' + col_category = cols_map.get('category') or '商品館' + col_amount = cols_map.get('amount') or '總業績' + col_qty = cols_map.get('qty') or '數量' + col_cost = cols_map.get('cost') or '總成本' + col_profit = cols_map.get('profit') # 可以為 None + col_activity = cols_map.get('activity') or '活動名稱' + col_payment = cols_map.get('payment') or '付款方式' + + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + if start_date and end_date: + date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" + elif start_date: + date_filter = f"""AND "日期" >= '{start_date_slash}'""" + else: # only end_date + date_filter = f"""AND "日期" <= '{end_date_slash}'""" + elif data_range_months > 0: + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + date_filter = f"""AND "日期" >= '{cutoff_date}'""" + + # V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致) + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') + vendor_filter = request.args.get('vendor', 'all') + activity_filter = request.args.get('activity', 'all') + payment_filter = request.args.get('payment', 'all') + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') + hour_filter = request.args.get('hour', 'all') + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + additional_filters = [] + + if category_filter and category_filter != 'all': + additional_filters.append(f""""{col_category}" = '{category_filter}'""") + if brand_filter and brand_filter != 'all': + additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") + if vendor_filter and vendor_filter != 'all': + additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") + if activity_filter and activity_filter != 'all': + additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") + if payment_filter and payment_filter != 'all': + additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") + + # 時間維度 + if month_filter and month_filter != 'all': + month_filter_slash = month_filter.replace('-', '/') + # 使用「日期」欄位 (這似乎是系統內部固定欄位,不需 dynamic map,除非資料表結構也變了) + # 假設 "日期" 是固定欄位 + additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") + + if dow_filter and dow_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) + pandas_dow = int(dow_filter) + if DATABASE_TYPE == 'postgresql': + pg_dow = (pandas_dow + 1) % 7 + additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") + else: + sqlite_dow = str((pandas_dow + 1) % 7) + additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") + + if hour_filter and hour_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (top_detail API) + hour_val = int(hour_filter) + if DATABASE_TYPE == 'postgresql': + additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") + else: + additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") + + # 關鍵字 + if keyword: + keyword_escaped = keyword.replace("'", "''") + k_conds = [] + for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: + k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") + additional_filters.append(f"({' OR '.join(k_conds)})") + + if (min_price_str or max_price_str): + price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") + if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") + + if (min_margin_str or max_margin_str): + if col_profit: + profit_sql = f'"{col_profit}"' + else: + profit_sql = f'("{col_amount}" - "{col_cost}")' + + margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' + if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") + if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") + + if additional_filters: + date_filter += " AND " + " AND ".join(additional_filters) + + # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) + if col_profit: + profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' + else: + profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' + + # 根據檢視類型和指標建立 SQL 查詢 + if view_type == 'category': + # 分類排行 + if metric == 'qty': + sql_query = f""" + SELECT + "{col_category}" as name, + SUM(CAST("{col_qty}" AS REAL)) as value + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{col_category}" as name, + {profit_select_sql} as value, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as margin_rate + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + else: # amount + sql_query = f""" + SELECT + "{col_category}" as name, + SUM(CAST("{col_amount}" AS REAL)) as value + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + else: + # 商品排行(包含商品ID) + pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位 + if metric == 'qty': + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + SUM(CAST("{col_qty}" AS REAL)) as value + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + {profit_select_sql} as value, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as margin_rate + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + else: # amount + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + SUM(CAST("{col_amount}" AS REAL)) as value + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + + # 執行查詢 + df = pd.read_sql(sql_query, db.engine) + sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料") + + if df.empty: + return jsonify({'items': []}) + + # V-Fix (2026-01-23): 確保數值欄位無 NaN/Infinity,避免 JSON 序列化失敗 + numeric_cols = ['value', 'margin_rate'] + for col in numeric_cols: + if col in df.columns: + df[col] = df[col].replace([np.inf, -np.inf], 0).fillna(0) + + # V-Fix (2026-01-23): 確保字串欄位無 None + string_cols = ['product_id', 'name', 'brand', 'vendor', 'category'] + for col in string_cols: + if col in df.columns: + df[col] = df[col].fillna('').astype(str) + + # 轉換為 JSON + items = df.to_dict('records') + return jsonify({'items': items}) + + except Exception as e: + sys_log.error(f"[API] Top Detail Error: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@app.route('/api/sales_analysis/export_top_detail') +def export_top_detail(): + """API: 匯出 Top N 詳細列表為 Excel""" + try: + from datetime import datetime, timedelta, timezone + import io + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + top_type = request.args.get('type', 'revenue') + metric = request.args.get('metric', 'amount') + view_type = request.args.get('view', 'product') + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + # V-Fix (2026-01-23): 使用 or 確保不會得到 None 值 + col_name = cols_map.get('name') or '商品名稱' + col_brand = cols_map.get('brand') or '品牌' + col_vendor = cols_map.get('vendor') or '廠商名稱' + col_category = cols_map.get('category') or '商品館' + col_amount = cols_map.get('amount') or '總業績' + col_qty = cols_map.get('qty') or '數量' + col_cost = cols_map.get('cost') or '總成本' + col_profit = cols_map.get('profit') # 可以為 None + col_activity = cols_map.get('activity') or '活動名稱' + col_payment = cols_map.get('payment') or '付款方式' + + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + if start_date and end_date: + date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" + elif start_date: + date_filter = f"""AND "日期" >= '{start_date_slash}'""" + else: # only end_date + date_filter = f"""AND "日期" <= '{end_date_slash}'""" + elif data_range_months > 0: + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + date_filter = f"""AND "日期" >= '{cutoff_date}'""" + + # V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致) + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') + vendor_filter = request.args.get('vendor', 'all') + activity_filter = request.args.get('activity', 'all') + payment_filter = request.args.get('payment', 'all') + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') + hour_filter = request.args.get('hour', 'all') + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + additional_filters = [] + + if category_filter and category_filter != 'all': + additional_filters.append(f""""{col_category}" = '{category_filter}'""") + if brand_filter and brand_filter != 'all': + additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") + if vendor_filter and vendor_filter != 'all': + additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") + if activity_filter and activity_filter != 'all': + additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") + if payment_filter and payment_filter != 'all': + additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") + + if month_filter and month_filter != 'all': + month_filter_slash = month_filter.replace('-', '/') + additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") + + if dow_filter and dow_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) + pandas_dow = int(dow_filter) + if DATABASE_TYPE == 'postgresql': + pg_dow = (pandas_dow + 1) % 7 + additional_filters.append(f"""EXTRACT(DOW FROM TO_DATE(REPLACE("日期", '/', '-'), 'YYYY-MM-DD')) = {pg_dow}""") + else: + sqlite_dow = str((pandas_dow + 1) % 7) + additional_filters.append(f"""strftime('%w', replace("日期", '/', '-')) = '{sqlite_dow}'""") + + if hour_filter and hour_filter != 'all': + # V-Fix (2026-01-23): 支援 PostgreSQL 和 SQLite (export API) + hour_val = int(hour_filter) + if DATABASE_TYPE == 'postgresql': + additional_filters.append(f"""CAST(SUBSTRING("時間" FROM 1 FOR 2) AS INTEGER) = {hour_val}""") + else: + additional_filters.append(f"""CAST(substr("時間", 1, 2) AS INTEGER) = {hour_val}""") + + if keyword: + keyword_escaped = keyword.replace("'", "''") + k_conds = [] + for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: + k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") + additional_filters.append(f"({' OR '.join(k_conds)})") + + if (min_price_str or max_price_str): + price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") + if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") + + if (min_margin_str or max_margin_str): + if col_profit: + profit_sql = f'"{col_profit}"' + else: + profit_sql = f'("{col_amount}" - "{col_cost}")' + + margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' + if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") + if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") + + if additional_filters: + date_filter += " AND " + " AND ".join(additional_filters) + + # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) + if col_profit: + profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' + else: + profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' + + # 根據檢視類型和指標建立 SQL 查詢(與上面相同) + if view_type == 'category': + if metric == 'qty': + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 銷售數量 DESC + LIMIT 50 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + {profit_select_sql} as 毛利金額, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as 毛利率 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 毛利金額 DESC + LIMIT 50 + """ + else: # amount + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 銷售金額 DESC + LIMIT 50 + """ + else: + # 商品排行(包含商品ID) + if metric == 'qty': + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 銷售數量 DESC + LIMIT 100 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + {profit_select_sql} as 毛利金額, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as 毛利率 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 毛利金額 DESC + LIMIT 100 + """ + else: # amount + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 銷售金額 DESC + LIMIT 100 + """ + + # 執行查詢並匯出 + df = pd.read_sql(sql_query, db.engine) + + if df.empty: + return "無資料可匯出", 400 + + # 生成 Excel + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Top排行') + output.seek(0) + + # 生成檔案名稱 + type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'} + view_names = {'product': '商品排行', 'category': '分類排行'} + filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" + + sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)") + + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + except Exception as e: + sys_log.error(f"[Export] Top Detail Error: {e}") + return f"匯出失敗: {e}", 500 + +# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) ================= +@app.route('/api/sales_analysis/yoy_comparison') +def yoy_comparison(): + """ + API: 年度對比分析 (YoY Comparison) + + 參數: + year1: 基準年 (例如 2024) + year2: 對比年 (例如 2025) + month: 月份 (可選,1-12,不帶則為全年) + metric: 指標 (revenue/qty/profit) + + 回傳: + JSON with year1 total, year2 total, growth rate, and monthly breakdown + """ + try: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + year1 = request.args.get('year1', '2024') + year2 = request.args.get('year2', '2025') + month = request.args.get('month', '') # 可選,1-12 + metric = request.args.get('metric', 'revenue') # revenue/qty/profit + + db = DatabaseManager() + + # 欄位名稱 + col_amount = '總業績' + col_qty = '數量' + col_cost = '總成本' + col_date = '日期' + + # 根據指標決定聚合欄位 + if metric == 'qty': + agg_sql = f'SUM(CAST("{col_qty}" AS REAL))' + metric_label = '銷售數量' + elif metric == 'profit': + agg_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' + metric_label = '毛利金額' + else: # revenue + agg_sql = f'SUM(CAST("{col_amount}" AS REAL))' + metric_label = '銷售金額' + + # 建立年度篩選條件 + # 日期格式為 2025/01/01 或 2025-01-01 + def build_year_filter(year, month_filter=''): + if month_filter: + month_str = month_filter.zfill(2) + return f"""("{col_date}" LIKE '{year}/{month_str}%' OR "{col_date}" LIKE '{year}-{month_str}%')""" + else: + return f"""("{col_date}" LIKE '{year}/%' OR "{col_date}" LIKE '{year}-%')""" + + year1_filter = build_year_filter(year1, month) + year2_filter = build_year_filter(year2, month) + + # 查詢年度總計 + sql_year1 = f""" + SELECT {agg_sql} as total + FROM {table_name} + WHERE {year1_filter} + """ + sql_year2 = f""" + SELECT {agg_sql} as total + FROM {table_name} + WHERE {year2_filter} + """ + + # V-Fix: SQLAlchemy 2.0 需要使用 text() 包裹 SQL 字串 + from sqlalchemy import text + result_year1 = pd.read_sql(text(sql_year1), db.engine) + result_year2 = pd.read_sql(text(sql_year2), db.engine) + + total_year1 = float(result_year1['total'].iloc[0] or 0) + total_year2 = float(result_year2['total'].iloc[0] or 0) + + # 計算成長率 + if total_year1 > 0: + growth_rate = ((total_year2 - total_year1) / total_year1) * 100 + else: + growth_rate = 0 if total_year2 == 0 else 100 + + # 月度明細 (如果沒有指定月份,則查詢 12 個月的明細) + monthly_breakdown = [] + if not month: + for m in range(1, 13): + m_str = str(m).zfill(2) + y1_filter = build_year_filter(year1, m_str) + y2_filter = build_year_filter(year2, m_str) + + sql_m1 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y1_filter}" + sql_m2 = f"SELECT {agg_sql} as total FROM {table_name} WHERE {y2_filter}" + + # V-Fix: SQLAlchemy 2.0 需要使用 text() + r1 = pd.read_sql(text(sql_m1), db.engine) + r2 = pd.read_sql(text(sql_m2), db.engine) + + v1 = float(r1['total'].iloc[0] or 0) + v2 = float(r2['total'].iloc[0] or 0) + + m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100) + + monthly_breakdown.append({ + 'month': m, + 'month_label': f'{m}月', + 'year1_value': v1, + 'year2_value': v2, + 'growth_rate': round(m_growth, 2) + }) + + response = { + 'year1': { + 'label': f'{year1}年' + (f'{month}月' if month else ''), + 'total': total_year1 + }, + 'year2': { + 'label': f'{year2}年' + (f'{month}月' if month else ''), + 'total': total_year2 + }, + 'growth_rate': round(growth_rate, 2), + 'metric': metric, + 'metric_label': metric_label, + 'monthly_breakdown': monthly_breakdown + } + + sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%)") + + return jsonify(response) + + except Exception as e: + sys_log.error(f"[YoY] Error: {e}") + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +# ================= 📈 V-New: 營運成長報表 (Growth Strategy) ================= +@app.route('/growth_analysis') +def growth_analysis(): + """營運成長策略報表 (MoM, YoY, AOV, YTD)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 檢查資料表 + inspector = inspect(db.engine) + if table_name not in inspector.get_table_names(): + # V-Fix: 使用正確的模板或回傳錯誤訊息 + return f"尚未匯入業績資料 ({table_name})", 404 + + # 2. 讀取資料 (只讀取必要欄位以提升效能,使用安全函數防止 SQL Injection) + # 根據 inspect_columns.py 結果,使用正確的中文欄位名稱 + req_cols = ['日期', '總業績', '訂單編號', '總成本'] + df = safe_read_sql(table_name, columns=req_cols, engine=db.engine) + + if df.empty: + # V-Fix: 使用正確的模板或回傳錯誤訊息 + return f"資料表 {table_name} 為空", 404 + + # 3. 資料前處理 + df['dt'] = pd.to_datetime(df['日期'], errors='coerce') + df = df.dropna(subset=['dt']) # 移除日期無效的資料 + df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0) + df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0) + df['profit'] = df['amount'] - df['cost'] + + # 4. 按月聚合統計 + # resample('MS') 會將日期對齊到月初 (Month Start) + monthly_stats = df.set_index('dt').resample('MS').agg({ + 'amount': 'sum', + 'profit': 'sum', + '訂單編號': 'nunique' # 計算不重複訂單數 + }).rename(columns={'訂單編號': 'orders'}) + + # 5. 計算衍生指標 (AOV, MoM, YoY) + monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders'] + monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100 + + # MoM (月增率) + monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100 + + # YoY (年增率) - shift(12) + monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100 + + # 填補 NaN (第一個月或無上期資料) + monthly_stats = monthly_stats.fillna(0) + + # 6. 準備圖表數據 + # 轉換索引為字串 'YYYY-MM' + labels = monthly_stats.index.strftime('%Y-%m').tolist() + + chart_data = { + 'labels': labels, + 'revenue': monthly_stats['amount'].tolist(), + 'profit': monthly_stats['profit'].tolist(), + 'orders': monthly_stats['orders'].tolist(), + 'aov': monthly_stats['aov'].round(0).tolist(), + 'mom': monthly_stats['mom'].round(2).tolist(), + 'yoy': monthly_stats['yoy'].round(2).tolist(), + 'margin_rate': monthly_stats['margin_rate'].round(1).tolist() + } + + # 7. 計算 KPI (YTD - Year to Date) + current_year = df['dt'].max().year + last_year = current_year - 1 + + ytd_mask = df['dt'].dt.year == current_year + last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) + + ytd_revenue = df.loc[ytd_mask, 'amount'].sum() + last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum() + + ytd_growth = 0 + if last_ytd_revenue > 0: + ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 + + # 近30天客單價 + last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) + recent_revenue = df.loc[last_month_mask, 'amount'].sum() + recent_orders = df.loc[last_month_mask, '訂單編號'].nunique() + recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0 + + kpi = { + 'ytd_revenue': ytd_revenue, + 'ytd_growth': ytd_growth, + 'current_year': current_year, + 'recent_aov': recent_aov, + 'total_orders': monthly_stats['orders'].sum() + } + + # V-Fix: 將模板移至根目錄,與 sales_analysis.html 一致,解決 TemplateNotFound 問題 + return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi) + + except Exception as e: + sys_log.error(f"Growth Analysis Error: {e}") + return f"系統錯誤: {e}" + +# ================= 📅 V-New: 當日業績看板 ================= + +@app.route('/daily_sales') +def daily_sales(): + """當日業績看板 (Day-over-Day 與 Week-over-Week 分析)""" + try: + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 1. 檢查資料表是否存在 + inspector = inspect(engine) + if table_name not in inspector.get_table_names(): + return render_template('daily_sales.html', + error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。", + selected_date=None, available_dates=[], current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None) + + # 2. 讀取資料(使用快取) + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + df = pd.read_sql(f"SELECT * FROM {table_name}", engine) + if df.empty: + return render_template('daily_sales.html', + error="資料表為空,請先匯入當日業績資料。", + selected_date=None, available_dates=[], current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None) + + # 3. 資料前處理(欄位識別、型別轉換) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 4. 取得可用日期列表 + available_dates = sorted(df['snapshot_date'].unique(), reverse=True) + available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates] + + # 5. 取得選擇的日期(從 URL 參數或使用最新日期) + selected_date_param = request.args.get('date') + if selected_date_param: + selected_date = pd.to_datetime(selected_date_param) + else: + selected_date = df['snapshot_date'].max() + + # 6. 取得選擇的月份(用於行事曆顯示) + selected_month_param = request.args.get('month') + if selected_month_param: + selected_month = pd.to_datetime(selected_month_param) + else: + selected_month = selected_date + + # V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期) + is_month_view = not selected_date_param and not request.args.get('month') + # 如果只有 month 參數沒有 date 參數,也是月概覽模式 + if selected_month_param and not selected_date_param: + is_month_view = True + + # 7. 計算 KPI + current_kpi = calculate_daily_kpis(df, selected_date) + dod_kpi = calculate_dod(df, selected_date) + wow_kpi = calculate_wow(df, selected_date) + + # V-New 2026-01-15: 計算月度總計 KPI + month_start = selected_month.replace(day=1) + month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1) + month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + + # V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱 + cols = month_df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名', 'Name']) + + month_kpi = { + 'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0, + 'total_cost': float(month_df[col_cost].sum()) if col_cost else 0, + 'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0, + 'total_qty': float(month_df[col_qty].sum()) if col_qty else 0, + 'sku_count': int(month_df[col_name].nunique()) if col_name else 0, + 'days_with_data': int(month_df['snapshot_date'].nunique()) + } + # 若無毛利欄位,用業績減成本計算 + if not col_profit and col_amount and col_cost: + month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost'] + # 計算月度毛利率 + if month_kpi['total_revenue'] > 0: + month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100 + else: + month_kpi['margin_rate'] = 0 + # 計算月度客單價 + if month_kpi['total_qty'] > 0: + month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty'] + else: + month_kpi['avg_price'] = 0 + + # 8. 準備圖表數據(根據選擇的日期) + chart_data = prepare_daily_charts(df, selected_date, days=30) + + # 9. 準備分類聚合列表 + # V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍 + category_list = prepare_category_summary( + df, + date_str=selected_date, + is_month_view=is_month_view, + month_start=month_start if is_month_view else None, + month_end=month_end if is_month_view else None + ) + + # 10. 準備行事曆數據 + calendar_data = prepare_calendar_data(df, selected_month) + + # 11. V-New: 準備行銷活動業績數據 + marketing_data = prepare_marketing_summary( + df, + selected_date=selected_date if not is_month_view else None, + is_month_view=is_month_view, + month_start=month_start if is_month_view else None, + month_end=month_end if is_month_view else None + ) + + # 12. 回傳模板 + return render_template('daily_sales.html', + selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date, + available_dates=available_dates_str, + current=current_kpi, + dod=dod_kpi, + wow=wow_kpi, + month_kpi=month_kpi, # V-New: 月度總計 + is_month_view=is_month_view, # V-New: 月概覽模式標誌 + chart_data=chart_data, + categories=category_list, + calendar_data=calendar_data, + marketing_data=marketing_data, # V-New: 行銷活動數據 + selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month) + + except Exception as e: + sys_log.error(f"[Web] [DailySales] Error: {e}") + import traceback + traceback.print_exc() + return render_template('daily_sales.html', + error=f"系統錯誤: {str(e)}", + selected_date=None, + available_dates=[], + current=None, + dod=None, + wow=None, + month_kpi=None, + is_month_view=False, + chart_data=None, + categories=None, + calendar_data=None, + marketing_data=None, + selected_month=None) + +@app.route('/daily_sales/export') +def export_daily_sales_category(): + """匯出當日業績分類明細為 Excel""" + try: + from datetime import datetime + import io + from flask import send_file + + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 檢查資料表是否存在 + inspector = inspect(engine) + if table_name not in inspector.get_table_names(): + return "資料表不存在", 404 + + # 讀取資料 + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + df = pd.read_sql(f"SELECT * FROM {table_name}", engine) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 取得選擇的日期 + selected_date = request.args.get('date') + if not selected_date: + available_dates = sorted(df['snapshot_date'].unique(), reverse=True) + if available_dates: + selected_date = str(available_dates[0]) + else: + return "無可用日期", 404 + + # 準備分類資料 + categories = prepare_category_summary(df, selected_date) + + if not categories: + return "無資料可匯出", 404 + + # 轉為 DataFrame + export_df = pd.DataFrame(categories) + + # 重新排列欄位順序並重新命名為中文 + column_mapping = { + 'category': '分類', + 'vendor': '廠商', + 'revenue': '總業績', + 'cost': '總成本', + 'profit': '毛利', + 'margin_rate': '毛利率(%)', + 'qty': '總銷量', + 'sku_count': 'SKU數', + 'avg_price': '平均單價' + } + + # 只保留存在的欄位 + export_columns = [col for col in column_mapping.keys() if col in export_df.columns] + export_df = export_df[export_columns] + export_df = export_df.rename(columns=column_mapping) + + # 格式化數值欄位 + for col in export_df.columns: + if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']: + export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0") + elif col == '毛利率(%)': + export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0") + + # 產生檔案名稱 + filename = f"當日業績_分類明細_{selected_date}.xlsx" + + # 寫入 Excel + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='分類業績明細') + + # 調整欄寬 + worksheet = writer.sheets['分類業績明細'] + for idx, col in enumerate(export_df.columns, 1): + max_length = max( + export_df[col].astype(str).apply(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50) + + output.seek(0) + + sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}") + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}") + import traceback + traceback.print_exc() + return f"匯出失敗: {str(e)}", 500 + +# V-New 2026-01-15: 行銷活動業績匯出 API +@app.route('/daily_sales/export_marketing') +def export_marketing_summary_excel(): + """匯出行銷活動業績明細為 Excel""" + try: + import io + from flask import send_file + + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 讀取資料 + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + df = pd.read_sql(f"SELECT * FROM {table_name}", engine) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 取得參數 + activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + selected_date = request.args.get('date') + + # 額外篩選參數 (與 sales_analysis 同步) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + keyword = request.args.get('keyword', '') + + # 決定日期範圍 + if start_date and end_date: + df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) & + (df['snapshot_date'] <= pd.to_datetime(end_date))] + date_label = f"{start_date}_{end_date}" + elif selected_date: + df = df[df['snapshot_date'] == pd.to_datetime(selected_date)] + date_label = selected_date + else: + date_label = "全部" + + # 應用額外篩選 + cols = df.columns.tolist() + col_category = find_col(cols, ['館別', '商品館', '分類', 'Category']) + col_brand = find_col(cols, ['品牌', 'Brand']) + col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) + col_name = find_col(cols, ['商品名稱', '品名']) + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + + if selected_category != 'all' and col_category: + df = df[df[col_category] == selected_category] + if selected_brand != 'all' and col_brand: + df = df[df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: + df = df[df[col_vendor] == selected_vendor] + if keyword and col_name: + df = df[df[col_name].str.contains(keyword, case=False, na=False)] + + # 定義行銷活動欄位 + marketing_cols = { + 'coupon': ('折價券活動名稱', '折價券活動'), + 'discount': ('折扣活動名稱', '折扣活動'), + 'bonus': ('滿額再折扣活動名稱', '滿額再折扣'), + 'click': ('點我再折扣', '點我再折扣') + } + + # 準備 Excel 輸出 + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # 如果是 all,循環所有類型 + types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click'] + + summary_rows = [] + + for t in types_to_export: + if t not in marketing_cols: continue + col_internal, sheet_label = marketing_cols[t] + if col_internal not in df.columns: + continue + + # 聚合數據 + # V-Fix: 排除空值和 0 + m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)] + + if m_df.empty: + continue + + grouped = m_df.groupby(col_internal).agg({ + col_amount: 'sum', + col_qty: 'sum', + col_name: 'count' # 訂單筆數/商品筆數 + }).reset_index() + + # 重命名 + grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數'] + grouped = grouped.sort_values(by='總業績', ascending=False) + + # 寫入工作表 + grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False) + + # 加入到總表數據 + grouped['活動類型'] = sheet_label + summary_rows.append(grouped) + + # 建立總表工作表 (如果有多個類型) + if len(summary_rows) > 1: + all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False) + all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']] + all_m_df.to_excel(writer, sheet_name='合併總表', index=False) + + output.seek(0) + output.seek(0) + + filename = f"行銷活動分析_{date_label}.xlsx" + # 處理中文檔名編碼 + from urllib.parse import quote + encoded_filename = quote(filename) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename, + conditional=True + ) + + except Exception as e: + sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}") + import traceback + traceback.print_exc() + return f"匯出失敗: {str(e)}", 500 + +def preprocess_daily_sales_data(df): + """前處理當日業績資料:欄位識別、型別轉換""" + cols = df.columns.tolist() + + # 欄位自動識別(使用現有的 find_col 函式) + col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) + + # 型別轉換 + if col_amount: + df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) + if col_cost: + df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) + if col_profit: + df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) + if col_qty: + df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) + + # 日期轉換 + df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce') + + return df + +def calculate_daily_kpis(df, date_str): + """計算單日 6 個 KPI""" + day_df = df[df['snapshot_date'] == date_str] + cols = day_df.columns.tolist() + + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名', 'Name']) + + total_revenue = float(day_df[col_amount].sum()) if col_amount else 0 + total_cost = float(day_df[col_cost].sum()) if col_cost else 0 + gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost) + total_qty = float(day_df[col_qty].sum()) if col_qty else 0 + sku_count = int(day_df[col_name].nunique()) if col_name else 0 + avg_price = total_revenue / total_qty if total_qty > 0 else 0 + + return { + 'total_revenue': total_revenue, + 'total_cost': total_cost, + 'gross_margin': gross_margin, + 'total_qty': total_qty, + 'sku_count': sku_count, + 'avg_price': avg_price + } + +def calculate_dod(df, current_date): + """計算 Day-over-Day 變化率""" + current = calculate_daily_kpis(df, current_date) + prev_date = current_date - timedelta(days=1) + + if prev_date not in df['snapshot_date'].values: + return {k: 0.0 for k in current.keys()} + + previous = calculate_daily_kpis(df, prev_date) + + dod = {} + for key in current: + if previous[key] > 0: + dod[key] = ((current[key] - previous[key]) / previous[key]) * 100 + else: + dod[key] = 0.0 + return dod + +def calculate_wow(df, current_date): + """計算 Week-over-Week 變化率""" + current = calculate_daily_kpis(df, current_date) + prev_week_date = current_date - timedelta(days=7) + + if prev_week_date not in df['snapshot_date'].values: + return {k: 0.0 for k in current.keys()} + + previous = calculate_daily_kpis(df, prev_week_date) + + wow = {} + for key in current: + if previous[key] > 0: + wow[key] = ((current[key] - previous[key]) / previous[key]) * 100 + else: + wow[key] = 0.0 + return wow + +def prepare_daily_charts(df, selected_date, days=30): + """準備 4 個圖表的數據(根據選擇的日期)""" + # 取選擇日期前 N 天的數據 + start_date = selected_date - timedelta(days=days) + df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)] + + # 按日期聚合 + cols = df_range.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', '總成本']) + col_profit = find_col(cols, ['毛利']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + # 日期聚合 + agg_dict = {} + if col_amount: + agg_dict[col_amount] = 'sum' + if col_cost: + agg_dict[col_cost] = 'sum' + if col_profit: + agg_dict[col_profit] = 'sum' + if col_qty: + agg_dict[col_qty] = 'sum' + + daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index() + + # 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算) + if col_profit and col_profit in daily_agg.columns: + daily_agg['profit'] = daily_agg[col_profit] + elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns: + daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost] + else: + daily_agg['profit'] = 0 + + # 計算客單價 + if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns: + daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0) + else: + daily_agg['avg_price'] = 0 + + # 計算 DoD (Day-over-Day) 變化率 - 多個維度 + if col_amount and col_amount in daily_agg.columns: + daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100 + if 'profit' in daily_agg.columns: + daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100 + if 'avg_price' in daily_agg.columns: + daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100 + if col_qty and col_qty in daily_agg.columns: + daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100 + + # 計算 WoW (Week-over-Week) 變化率 - 多個維度 + if col_amount and col_amount in daily_agg.columns: + daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100 + if 'profit' in daily_agg.columns: + daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100 + if 'avg_price' in daily_agg.columns: + daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100 + if col_qty and col_qty in daily_agg.columns: + daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100 + + # Top 10 商品(選擇的日期,包含廠商) + selected_df = df[df['snapshot_date'] == selected_date] + top10_labels = [] + top10_values = [] + + if col_name and col_amount: + col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) + + if col_vendor: + # 如果有廠商欄位,按商品+廠商聚合 + top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index() + top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()] + top10_values = top10_df[col_amount].tolist() + else: + # 沒有廠商欄位,只按商品聚合 + top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10) + top10_labels = top10.index.tolist() + top10_values = top10.values.tolist() + + return { + 'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [], + 'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [], + 'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [], + 'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [], + 'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [], + 'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [], + + # DoD 多維度 + 'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [], + 'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [], + 'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [], + 'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [], + + # WoW 多維度 + 'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [], + 'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [], + 'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [], + 'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [], + + 'top10_labels': top10_labels, + 'top10_values': top10_values + } + +def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None): + """準備分類聚合列表 (支援單日或月度範圍)""" + if is_month_view and month_start is not None and month_end is not None: + day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + else: + day_df = df[df['snapshot_date'] == date_str] + cols = day_df.columns.tolist() + + col_category = find_col(cols, ['館別', '分類', 'Category']) + col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) + col_amount = find_col(cols, ['銷售金額', '業績', '總業績']) + col_cost = find_col(cols, ['成本', '總成本']) + col_profit = find_col(cols, ['毛利']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + if not col_category or not col_amount: + return [] + + # 分類 + 廠商聚合 + agg_dict = {col_amount: 'sum'} + if col_cost: + agg_dict[col_cost] = 'sum' + if col_profit: + agg_dict[col_profit] = 'sum' + if col_qty: + agg_dict[col_qty] = 'sum' + if col_name: + agg_dict[col_name] = 'nunique' + + # 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合 + if col_vendor: + category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index() + else: + category_df = day_df.groupby(col_category).agg(agg_dict).reset_index() + + # 計算毛利(如果資料中沒有毛利欄位,自動計算) + if col_profit and col_profit in category_df.columns: + # 資料中有毛利欄位,直接使用 + pass + elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns: + # 資料中沒有毛利欄位,用 業績 - 成本 計算 + category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost] + col_profit = 'profit_calculated' + else: + col_profit = None + + # 計算毛利率 + if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns: + category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0) + else: + category_df['margin_rate'] = 0 + + # 計算均價 + if col_qty and col_amount: + category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0) + else: + category_df['avg_price'] = 0 + + # 重新命名欄位以便模板使用 + rename_dict = {col_category: 'category', col_amount: 'revenue'} + if col_vendor: + rename_dict[col_vendor] = 'vendor' + if col_cost: + rename_dict[col_cost] = 'cost' + if col_profit and col_profit in category_df.columns: + rename_dict[col_profit] = 'profit' + if col_qty: + rename_dict[col_qty] = 'qty' + if col_name: + rename_dict[col_name] = 'sku_count' + + category_df = category_df.rename(columns=rename_dict) + + # 確保 profit 欄位存在,如果不存在則設為 0 + if 'profit' not in category_df.columns: + category_df['profit'] = 0 + + # 轉為字典列表 + return category_df.to_dict('records') + +# V-New 2026-01-15: 行銷活動業績聚合函數 +def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'): + """ + 準備行銷活動業績貢獻數據 + 支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit) + """ + # 決定使用的數據範圍 + if is_month_view and month_start is not None and month_end is not None: + target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + elif selected_date is not None: + target_df = df[df['snapshot_date'] == selected_date] + else: + target_df = df + + if target_df.empty: + return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} + + cols = target_df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty']) + col_profit = find_col(cols, ['毛利', 'Profit', '利潤']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + + if not col_amount: + return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} + + # 定義四種行銷活動欄位 + marketing_cols = { + 'coupon': '折價券活動名稱', # 折價券活動 + 'discount': '折扣活動名稱', # 折扣活動 + 'bonus': '滿額再折扣活動名稱', # 滿額再折扣 + 'click': '點我再折扣' # 點我再折扣 + } + + result = {} + + # 確保 sort_by 欄位存在,否則退回 revenue + actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue' + + for key, col_name in marketing_cols.items(): + if col_name not in cols: + result[key] = [] + continue + + # 篩選有該行銷活動的記錄 + activity_df = target_df[ + (target_df[col_name].notna()) & + (target_df[col_name] != '') & + (target_df[col_name] != '0') & + (target_df[col_name] != 0) + ] + + if activity_df.empty: + result[key] = [] + continue + + # 聚合計算 + agg_args = { + 'revenue': (col_amount, 'sum'), + 'order_count': (col_amount, 'count') + } + if col_qty: agg_args['qty'] = (col_qty, 'sum') + if col_profit: agg_args['profit'] = (col_profit, 'sum') + + grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index() + + # 若需要手動計算毛利 (金額 - 成本) + if 'profit' not in agg_args and col_cost: + cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index() + grouped = grouped.merge(cost_agg, on=col_name) + grouped['profit'] = grouped['revenue'] - grouped[col_cost] + + grouped = grouped.rename(columns={col_name: 'name'}) + + # 動態排序 + sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue' + grouped = grouped.sort_values(sort_col, ascending=False).head(15) + + # 轉為字典列表 + records = [] + for _, row in grouped.iterrows(): + record = { + 'name': str(row['name'])[:50], + 'revenue': float(row['revenue']), + 'order_count': int(row['order_count']) + } + if 'qty' in row: record['qty'] = float(row['qty']) + if 'profit' in row: record['profit'] = float(row['profit']) + records.append(record) + + result[key] = records + + return result + + +def get_taiwan_holiday(date): + """判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)""" + year = date.year + month = date.month + day = date.day + + # 2026年台灣國定假日(根據人事行政總處公佈) + holidays_2026 = { + (1, 1): '元旦', + # 春節連假 (2/14-2/22,共9天) + (2, 14): '春節連假', + (2, 15): '小年夜', + (2, 16): '除夕', + (2, 17): '春節 (初一)', + (2, 18): '春節 (初二)', + (2, 19): '春節 (初三)', + (2, 20): '春節連假', + (2, 21): '春節連假', + (2, 22): '春節連假', + # 和平紀念日 (2/28-3/2,共3天) + (2, 28): '和平紀念日', + (3, 2): '和平紀念日補假', + # 兒童節+清明節 (4/3-4/6,共4天) + (4, 3): '兒童節補假', + (4, 4): '清明節', + (4, 5): '清明節連假', + (4, 6): '清明節補假', + # 勞動節 (5/1-5/3,共3天) + (5, 1): '勞動節', + # 端午節 (6/19-6/21,共3天) + (6, 19): '端午節', + # 中秋節+教師節 (9/25-9/28,共4天) + (9, 25): '中秋節', + (9, 28): '教師節', + # 國慶日 (10/9-10/11,共3天) + (10, 9): '國慶日補假', + (10, 10): '國慶日', + # 光復節 (10/25-10/26,共2天) + (10, 25): '臺灣光復節', + (10, 26): '光復節補假', + # 行憲紀念日 (12/25-12/27,共3天) + (12, 25): '行憲紀念日', + } + + # 2027年台灣國定假日(預先計算部分) + holidays_2027 = { + (1, 1): '元旦', + (2, 11): '春節 (除夕)', + (2, 12): '春節 (初一)', + (2, 13): '春節 (初二)', + (2, 14): '春節 (初三)', + (2, 15): '春節 (初四)', + (2, 16): '春節 (初五)', + (2, 17): '春節 (初六)', + (2, 28): '和平紀念日', + (4, 4): '清明節', + (4, 5): '清明節連假', + (6, 14): '端午節', + (9, 21): '中秋節', + (10, 10): '國慶日', + (10, 11): '國慶日連假', + } + + holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {}) + + holiday_name = holidays.get((month, day)) + return (True, holiday_name) if holiday_name else (False, None) + +def prepare_calendar_data(df, selected_month): + """準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)""" + import calendar + + # 取得該月份的年月 + year = selected_month.year + month = selected_month.month + + # 計算該月第一天和最後一天 + first_day = pd.Timestamp(year=year, month=month, day=1) + last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1]) + + # 計算行事曆顯示範圍(包含前後月份的日期以填滿週) + # 取得該月第一天是星期幾 (0=Monday, 6=Sunday) + first_weekday = first_day.weekday() + + # 計算行事曆起始日(從週一開始) + calendar_start = first_day - timedelta(days=first_weekday) + + # 計算該月最後一天是星期幾 + last_weekday = last_day.weekday() + + # 計算行事曆結束日(到週日結束) + calendar_end = last_day + timedelta(days=(6 - last_weekday)) + + # 取得該月份及前後各一天的所有資料(用於計算 DoD) + data_start = first_day - timedelta(days=1) + data_end = last_day + month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)] + + # 取得欄位 + cols = df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + # 為每一天計算 KPI + calendar_days = [] + current_date = calendar_start + + while current_date <= calendar_end: + # 取得星期(0=週一, 6=週日) + weekday = current_date.weekday() + weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] + + # 判斷是否為國定假日 + is_holiday, holiday_name = get_taiwan_holiday(current_date) + + day_data = { + 'date': current_date.strftime('%Y-%m-%d'), + 'day': current_date.day, + 'weekday': weekday_names[weekday], + 'is_weekend': weekday >= 5, # 週六或週日 + 'is_holiday': is_holiday, + 'holiday_name': holiday_name, + 'is_current_month': current_date.month == month, + 'has_data': False, + 'revenue': 0, + 'profit': 0, + 'margin_rate': 0, + 'sku_count': 0, + 'qty': 0, + 'avg_price': 0, + 'dod_percent': 0, + 'dod_direction': 'neutral' # 'up', 'down', 'neutral' + } + + # 如果該日期在當前月份範圍內,計算 KPI + if first_day <= current_date <= last_day: + day_df = month_df[month_df['snapshot_date'] == current_date] + + if not day_df.empty: + day_data['has_data'] = True + + # 計算總業績 + if col_amount: + day_data['revenue'] = float(day_df[col_amount].sum()) + + # 計算毛利(優先使用毛利欄位,否則用業績-成本計算) + if col_profit: + day_data['profit'] = float(day_df[col_profit].sum()) + elif col_cost and col_amount: + total_cost = float(day_df[col_cost].sum()) + day_data['profit'] = day_data['revenue'] - total_cost + + # 計算毛利率 + if day_data['revenue'] > 0: + day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100 + + # 計算銷量 + if col_qty: + day_data['qty'] = float(day_df[col_qty].sum()) + + # 計算客單價(總業績 / 總銷量) + if day_data['qty'] > 0: + day_data['avg_price'] = day_data['revenue'] / day_data['qty'] + + # 計算 SKU 數 + if col_name: + day_data['sku_count'] = int(day_df[col_name].nunique()) + + # 計算 DoD% + prev_date = current_date - timedelta(days=1) + prev_df = month_df[month_df['snapshot_date'] == prev_date] + + if not prev_df.empty and col_amount: + prev_revenue = float(prev_df[col_amount].sum()) + if prev_revenue > 0: + dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100 + day_data['dod_percent'] = round(dod, 1) + day_data['dod_direction'] = 'up' if dod >= 0 else 'down' + + calendar_days.append(day_data) + current_date += timedelta(days=1) + + # 組織成週結構(每週 7 天) + weeks = [] + for i in range(0, len(calendar_days), 7): + weeks.append(calendar_days[i:i+7]) + + # 計算上個月和下個月的年月 + prev_month = selected_month - pd.DateOffset(months=1) + next_month = selected_month + pd.DateOffset(months=1) + + return { + 'year': year, + 'month': month, + 'month_name': selected_month.strftime('%Y年%m月'), + 'weeks': weeks, + 'prev_month': prev_month.strftime('%Y-%m'), + 'next_month': next_month.strftime('%Y-%m') + } + +# ================= ⚙️ 5. 服務啟動邏輯 ================= + +def run_schedule(): + """在背景執行緒中運行排程""" + sys_log.info("🚀 排程服務已啟動,等待任務...") + while True: + schedule.run_pending() + time.sleep(1) + +def init_scheduler(): + """初始化排程任務(Gunicorn 模式下也會執行)""" + schedule.every(1).hours.do(run_momo_task) + schedule.every(1).hours.do(run_edm_task) + schedule.every(1).hours.do(run_festival_task) + sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務") + + schedule.every(30).minutes.do(run_auto_import_task) + sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務") + + schedule.every(30).minutes.do(run_whitepage_check) + sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務") + + schedule.every(4).hours.do(run_competitor_price_feeder_task) + sys_log.info(f"📅 已設定每 4 小時執行 PChome 競品價格抓取任務") + + # 啟動排程執行緒 + scheduler_thread = threading.Thread(target=run_schedule, daemon=True) + scheduler_thread.start() + sys_log.info("✅ 排程器已在背景執行緒中啟動") + +# V-New: 在模組載入時自動初始化排程(Gunicorn 模式下也會執行) +# 🚩 V-Fix 2026-01-14: 停用自動排程器以避免多個 gunicorn workers 重複執行任務 +# 原因:每個 worker 都會啟動排程器,導致 4x 資源消耗(4 workers × 3 爬蟲任務 = 12 Chrome 實例同時運行) +# 解決方案:改用獨立的 run_scheduler.py 或透過 Web UI 手動觸發任務 +# try: +# init_scheduler() +# except Exception as e: +# sys_log.error(f"❌ 排程器初始化失敗: {e}") +sys_log.info("ℹ️ 自動排程器已停用(避免重複執行),請使用 run_scheduler.py 或 Web UI 手動觸發") + +def start_flask(): + sys_log.info("🚀 Web 服務正在啟動於 port 80...") + app.run(host='0.0.0.0', port=80, use_reloader=False) + +def scheduled_job_wrapper(): + """執行 MOMO 爬蟲任務並發送通知""" + timestamp = datetime.now(TAIPEI_TZ).strftime('%H:%M:%S') + sys_log.info(f"⏰ [{timestamp}] 啟動背景抓取執行緒...") + + def job(): + # 1. 執行爬蟲 + run_momo_task() + + # 2. 發送通知 (僅發送今日異動) + try: + # 重新載入通知模組 + import importlib + import scheduler + import services.notification_manager + importlib.reload(scheduler) + importlib.reload(services.notification_manager) + from services.notification_manager import NotificationManager + + stats = get_dashboard_stats() + + # 只要有任何異動數據就發送通知 + if any(stats.values()): + screenshot_path = scheduler.capture_page_screenshot("http://127.0.0.1/", "momo_dashboard") + NotificationManager().send_momo_report(stats, screenshot_path) + except Exception as e: + sys_log.error(f"[Scheduler] ❌ 發送通知失敗: {e}") + + threading.Thread(target=job, daemon=True).start() + +if __name__ == "__main__": + banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} " + sys_log.info(f"{ '='*20} {banner} {'='*20}") + + # 啟動前先檢查資料庫結構 + repair_database_schema() + + # 使用生產環境域名 + public_url = "https://mo.wooo.work" + sys_log.info(f"✅ 使用固定網址: {public_url}") + + # 🚩 V9.7 將公開 URL 寫入設定檔,供其他模組使用 + try: + url_config_path = os.path.join(BASE_DIR, 'data', 'url_config.json') + with open(url_config_path, 'w') as f: + json.dump({"public_url": public_url}, f) + except Exception as file_err: + sys_log.error(f"⚠️ URL 設定檔寫入失敗 (不影響服務運行,可能磁碟已滿): {file_err}") + + web_server = threading.Thread(target=start_flask) + web_server.daemon = True + web_server.start() + + # 排程器已在模組載入時自動初始化(見 init_scheduler() 函式) + sys_log.info("ℹ️ 排程器已在全域範圍初始化完成") + + try: + while True: + time.sleep(3600) + except KeyboardInterrupt: + sys_log.info("🔌 Web 服務已關閉") + try: + ngrok.disconnect(public_url) + except Exception as e: + sys_log.info(f"ℹ️ Ngrok 關閉時無需額外操作: {e}") diff --git a/app.py.backup_login_required b/app.py.backup_login_required new file mode 100644 index 0000000..dcf0330 --- /dev/null +++ b/app.py.backup_login_required @@ -0,0 +1,7508 @@ +# ================= TODO LIST (待辦事項 - 重開機後請依序執行) ================= +# 1. [驗證] 重啟 app.py 後,重新匯入 Excel,確認「自動去重」功能是否生效 (重複匯入應顯示 0 筆新增)。 +# 2. [檢查] 前往 /sales_analysis 頁面,確認 '狀態' 欄位是否正確顯示 'F' (目前為原始匯入模式)。 +# 3. [決策] 若資料顯示正常,評估是否需要恢復「智慧資料清理」邏輯 (目前程式碼第 1160 行左右已註解)。 +# 4. [備份] 確認系統運作正常後,執行系統備份。 +# 5. [部署] UAT 驗證通過後,記得執行 `gcloud app deploy` 更新 GCP 正式環境 (Project: momo-pro-system)。 +# ======================================================================= + +import os +import sys +import time +import threading +import math +import json +import hashlib +import shutil +import zipfile +import re +import io # V-New: 用於 Excel 匯出 +from datetime import datetime, timedelta, timezone + +# ================= 🔧 1. 環境與路徑鎖定 ================= +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +# 確保專案根目錄在 sys.path 的最前面,優先讀取本地模組 +sys.path.insert(0, BASE_DIR) + +# 自動檢核並建立必要目錄 +try: + for folder in ['database', 'services', 'crawler', 'logs', 'data', 'web/templates', 'web/static']: + folder_path = os.path.join(BASE_DIR, folder) + if not os.path.exists(folder_path): + os.makedirs(folder_path) + + # 僅針對 Python 套件目錄建立 __init__.py + if 'web' not in folder: + init_file = os.path.join(folder_path, '__init__.py') + if not os.path.exists(init_file): + with open(init_file, 'w') as f: pass +except OSError as e: + print(f"❌ 系統初始化失敗: 無法建立目錄或檔案 (磁碟可能已滿) - {e}") + +# ================= 🔧 2. 核心模組導入 ================= +try: + from flask import Flask, render_template, jsonify, request, send_file, redirect, url_for, send_from_directory, flash, session + from auth import login_required, init_auth_routes # V-Fix: 使用專案自定義的 login_required (不使用 flask_login) + from werkzeug.utils import secure_filename + from pyngrok import ngrok, conf + import schedule + from sqlalchemy import desc, and_, func, text, literal, case + from sqlalchemy import inspect # V-New: 用於檢查資料表是否存在 + from sqlalchemy.orm import joinedload + import pandas as pd # type: ignore + from pandas.api.types import is_numeric_dtype # type: ignore + import numpy as np # type: ignore # V-Opt: 引入 numpy 進行向量化運算加速 + + # 導入自定義模組 + try: + from scheduler import run_momo_task, run_edm_task, run_festival_task, run_auto_import_task, run_whitepage_check + from database.manager import DatabaseManager + from database.models import Product, PriceRecord, MonthlySummaryAnalysis + from database.edm_models import PromoProduct + except ImportError as e: + print(f"❌ 專案內部檔案缺失: {e}\n請檢查 database/ 或 services/ 目錄下的 .py 檔案是否存在。") + sys.exit(1) + + from services.logger_manager import SystemLogger + from services.exporter import Exporter # 🚩 導入匯出模組 +except ImportError as e: + print(f"❌ 關鍵套件導入失敗: {e}") + sys.exit(1) + +# ================= 🔧 3. 系統核心配置 ================= +# 從 config.py 匯入必要的設定 +from config import EXCEL_EXPORT_DIR, USE_MODULAR_ROUTES + +sys_log = SystemLogger("Web_Server").get_logger() + +# 🚩 V-Opt: 全域資料快取 (用於加速業績分析) +_SALES_DF_CACHE = {} +_SALES_PROCESSED_CACHE = {} # V-Opt: 新增處理後資料快取 (二級快取) + +# 🚩 V-New: 商品看板資料快取 (用於加速首頁載入) +_DASHBOARD_DATA_CACHE = { + 'consolidated_data': None, # get_consolidated_data() 結果 + 'consolidated_timestamp': None, + 'full_data': None, # 包含統計數據的完整結果 + 'full_timestamp': None +} +_DASHBOARD_CACHE_TTL = 1800 # V-Opt: 快取有效期 30 分鐘(高規格 UAT: 126GB RAM) +_SALES_CACHE_TTL = 3600 # V-Opt: 業績分析快取有效期 60 分鐘(高規格 UAT) +_SALES_OPTIONS_CACHE = {} # V-Opt: 儲存下拉選單選項 (類別、品牌、廠商等) +_SALES_OPTIONS_TTL = 21600 # V-Opt: 6 小時有效(高規格 UAT) +_SALES_ANALYSIS_RESULT_CACHE = {} # 🚩 V-Opt: 儲存過濾後的分析結果集 (Result Cache) +_SALES_RESULT_TTL = 3600 # V-Opt: 60 分鐘有效(高規格 UAT) + +# 🚩 V-New: 慢查詢監控 (供 Prometheus 監控使用) +_SLOW_QUERY_STATS = { + 'total_queries': 0, # 總查詢數 + 'slow_queries': 0, # 慢查詢數 (>1秒) + 'very_slow_queries': 0, # 極慢查詢數 (>5秒) + 'total_query_time_ms': 0, # 總查詢時間(毫秒) + 'last_slow_query': None, # 最後一個慢查詢 + 'last_slow_query_time': None, # 最後慢查詢時間 +} +_SLOW_QUERY_THRESHOLD_MS = 1000 # 慢查詢閾值: 1秒 +_VERY_SLOW_QUERY_THRESHOLD_MS = 5000 # 極慢查詢閾值: 5秒 + +def track_query_time(query_name, duration_ms): + """追蹤查詢時間,更新慢查詢統計""" + global _SLOW_QUERY_STATS + _SLOW_QUERY_STATS['total_queries'] += 1 + _SLOW_QUERY_STATS['total_query_time_ms'] += duration_ms + + if duration_ms >= _VERY_SLOW_QUERY_THRESHOLD_MS: + _SLOW_QUERY_STATS['very_slow_queries'] += 1 + _SLOW_QUERY_STATS['slow_queries'] += 1 + _SLOW_QUERY_STATS['last_slow_query'] = query_name + _SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat() + elif duration_ms >= _SLOW_QUERY_THRESHOLD_MS: + _SLOW_QUERY_STATS['slow_queries'] += 1 + _SLOW_QUERY_STATS['last_slow_query'] = query_name + _SLOW_QUERY_STATS['last_slow_query_time'] = datetime.now(TAIPEI_TZ).isoformat() + +# 🚩 檢查磁碟空間 (V9.52 新增) +try: + total, used, free = shutil.disk_usage(BASE_DIR) + if free < 200 * 1024 * 1024: # 小於 200MB + sys_log.critical(f"[System] [DISK_CHECK] 🚨 嚴重警告: 磁碟空間極低 | Free: {free // (1024*1024)} MB") + elif free < 1024 * 1024 * 1024: # 小於 1GB + sys_log.warning(f"[System] [DISK_CHECK] ⚠️ 警告: 磁碟空間不足 1GB | Free: {free // (1024*1024)} MB") +except Exception as e: + sys_log.error(f"無法檢測磁碟空間: {e}") + +# 🚩 系統版本定義 (備份與顯示用) +# TODO: 下次進行重大更新時,記得修改此版本號 (目前 V9.4) +SYSTEM_VERSION = "V9.4" + +# ========================================== +# 🔒 SQL Injection 防護函數 +# ========================================== + +# 允許的資料表白名單 +ALLOWED_TABLES = { + 'realtime_sales_monthly', + 'daily_sales_snapshot', + 'products', + 'price_records', + 'promo_products', + 'edm_products', + 'festival_products' +} + +def validate_table_name(table_name): + """ + 驗證資料表名稱,防止 SQL Injection + + Args: + table_name: 要驗證的資料表名稱 + + Returns: + str: 驗證通過的表名 + + Raises: + ValueError: 表名不在白名單中 + """ + # 移除空白字符 + table_name = str(table_name).strip() + + # 檢查是否為空 + if not table_name: + raise ValueError("表名不能為空") + + # 檢查是否包含危險字符 + if not re.match(r'^[a-zA-Z0-9_]+$', table_name): + raise ValueError(f"表名包含非法字符: {table_name}") + + # 檢查是否在白名單中 + if table_name not in ALLOWED_TABLES: + # 對於動態表名(從檔名生成),允許但記錄警告 + sys_log.warning(f"[Security] 表名不在白名單中: {table_name}") + # 至少確保沒有 SQL 關鍵字 + sql_keywords = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'UNION', 'WHERE', 'FROM'] + if any(keyword in table_name.upper() for keyword in sql_keywords): + raise ValueError(f"表名包含 SQL 關鍵字: {table_name}") + + return table_name + +def validate_column_names(column_names): + """ + 驗證欄位名稱列表,防止 SQL Injection + + Args: + column_names: 欄位名稱列表 + + Returns: + list: 驗證通過的欄位名稱列表 + + Raises: + ValueError: 欄位名稱包含非法字符 + """ + if isinstance(column_names, str): + column_names = [column_names] + + validated = [] + for col in column_names: + col = str(col).strip() + # 允許中文、英文、數字、底線 + if not re.match(r'^[\w\u4e00-\u9fff]+$', col): + raise ValueError(f"欄位名稱包含非法字符: {col}") + validated.append(col) + + return validated + +def safe_read_sql(table_name, columns=None, engine=None, where_clause=None, limit=None, params=None): + """ + 安全的 SQL 查詢函數,防止 SQL Injection + + Args: + table_name: 資料表名稱 + columns: 欄位列表,None 表示 * + engine: SQLAlchemy engine + where_clause: WHERE 子句 + limit: 限制筆數 + params: 參數化查詢的參數字典 + + Returns: + DataFrame: 查詢結果 + """ + from sqlalchemy import text + + # 驗證表名 + table_name = validate_table_name(table_name) + + # 驗證欄位名 + if columns: + columns = validate_column_names(columns) + col_str = ', '.join([f'"{col}"' for col in columns]) + else: + col_str = '*' + + # 使用 SQLAlchemy 的參數化查詢 + # 注意:表名和欄位名不能參數化,所以必須先驗證 + try: + query = f'SELECT {col_str} FROM "{table_name}"' + if where_clause: + query += f' WHERE {where_clause}' + + if limit: + query += f' LIMIT {int(limit)}' + + return pd.read_sql(text(query), engine, params=params) + except Exception as e: + sys_log.error(f"[Security] SQL 查詢失敗: {e}") + raise + +# ========================================== +# 🔒 路徑遍歷防護函數 +# ========================================== + +from pathlib import Path + +def safe_join(base, *paths): + """ + 安全的路徑拼接,防止路徑遍歷攻擊 + + Args: + base: 基礎目錄(絕對路徑) + *paths: 子路徑組件 + + Returns: + Path: 安全的完整路徑 + + Raises: + ValueError: 偵測到路徑遍歷嘗試 + """ + # 確保 base 是絕對路徑 + base = Path(base).resolve() + + # 檢查路徑組件中是否包含危險字符 + for path_component in paths: + path_str = str(path_component) + + # 阻擋包含 Windows 反斜線的路徑 + if '\\' in path_str: + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (Windows 反斜線) | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許使用反斜線") + + # 阻擋包含連續點的路徑 (如 ...., ....//) + if '..' in path_str.replace('\\', '/'): + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 (雙點) | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許使用 '..'") + + # 拼接並解析完整路徑 + full_path = (base / Path(*paths)).resolve() + + # 驗證最終路徑必須在基礎目錄內 + try: + full_path.relative_to(base) + except ValueError: + sys_log.warning(f"[Security] 偵測到路徑遍歷嘗試 | Base: {base} | Requested: {paths}") + raise ValueError(f"路徑遍歷偵測: 不允許存取基礎目錄外的檔案") + + return full_path + +# ========================================== +# 🔒 檔案上傳安全驗證 +# ========================================== + +# 允許的檔案副檔名與 MIME types +ALLOWED_UPLOAD_EXTENSIONS = {'xlsx', 'xls', 'csv'} +ALLOWED_MIME_TYPES = { + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'application/vnd.ms-excel', # .xls + 'text/csv', # .csv + 'application/octet-stream' # CSV sometimes detected as this +} + +def secure_filename_unicode(filename): + """ + 支援中文的安全檔案名稱清理 + + Args: + filename: 原始檔案名稱 + + Returns: + str: 清理後的安全檔案名稱 + """ + import re + import unicodedata + + # 正規化 Unicode 字元 + filename = unicodedata.normalize('NFKC', filename) + + # 移除危險字元但保留中文、英文、數字、空格、括號、底線、連字號 + # 允許的字元: 中文、英文字母、數字、空格、括號、底線、連字號、點 + safe_chars = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s\(\)_\-\.]', '', filename) + + # 將多個空格替換為單一空格 + safe_chars = re.sub(r'\s+', ' ', safe_chars) + + # 移除前後空格 + safe_chars = safe_chars.strip() + + return safe_chars + +def allowed_file(filename): + """ + 檢查檔案副檔名是否在白名單中 + + Args: + filename: 檔案名稱 + + Returns: + bool: 是否允許上傳 + """ + if not filename or '.' not in filename: + return False + + # 分割檔名和副檔名 + parts = filename.rsplit('.', 1) + if len(parts) != 2: + return False + + basename, ext = parts + + # 拒絕純副檔名檔案(如 .xlsx) + if not basename or basename.strip() == '': + return False + + # 檢查副檔名是否在白名單中 + return ext.lower() in ALLOWED_UPLOAD_EXTENSIONS + +def validate_upload_file(file): + """ + 完整的檔案上傳驗證(副檔名、檔案名稱清理) + + Args: + file: Flask request.files 物件 + + Returns: + tuple: (is_valid, error_message, safe_filename) + """ + # 檢查檔案是否存在 + if not file or file.filename == '': + return False, '未選擇檔案', None + + original_filename = file.filename + + # 1. 在清理前先檢查路徑遍歷攻擊 + # 檢查連續的雙點(路徑遍歷) + if '..' in original_filename: + sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(雙點): {original_filename}") + return False, '檔案名稱包含非法字元', None + + # 檢查絕對路徑或目錄分隔符(在檔名中間) + # 允許檔名開頭沒有分隔符,且允許 HTML 標籤中的斜線(不在路徑位置) + import os + if os.path.sep in original_filename or (os.path.altsep and os.path.altsep in original_filename): + # 進一步檢查是否真的是路徑分隔(而不是 HTML 標籤等) + # 如果檔名以 / 或 \ 開頭,或包含 ./ 或 .\\ 模式,則為路徑遍歷 + if original_filename.startswith(('/','\\')) or './' in original_filename or '.\\' in original_filename: + sys_log.warning(f"[Security] 檔案上傳 - 偵測到路徑遍歷嘗試(路徑分隔符): {original_filename}") + return False, '檔案名稱包含非法字元', None + + # 2. 檔案名稱清理(使用支援中文的版本) + safe_name = secure_filename_unicode(original_filename) + if not safe_name: + return False, '檔案名稱不合法', None + + # 3. 副檔名驗證 + if not allowed_file(safe_name): + return False, f'不支援的檔案格式,僅允許: {", ".join(ALLOWED_UPLOAD_EXTENSIONS)}', None + + # 4. 檔案大小驗證(由 Flask MAX_CONTENT_LENGTH 自動處理,此處記錄) + # Flask 會在超過大小時自動拋出 413 錯誤 + + return True, None, safe_name + +# 🚩 資料庫結構自動修復 (V9.53 新增) +def repair_database_schema(): + db = DatabaseManager() + engine = db.engine + from sqlalchemy import inspect, text + try: + # 🚩 V9.96: 啟用 SQLite WAL 模式以解決 database is locked 問題 + with engine.connect() as conn: + # 啟用 WAL 模式 (Write-Ahead Logging) + conn.execute(text("PRAGMA journal_mode=WAL")) + conn.commit() + sys_log.info("[Database] [WAL] ✅ SQLite WAL 模式已啟用 | 提升並發寫入效能") + + inspector = inspect(engine) + # V9.70: 檢查 products 表 + if 'products' in inspector.get_table_names(): + product_columns = [c['name'] for c in inspector.get_columns('products')] + if 'image_url' not in product_columns: + sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 image_url 欄位 | 正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE products ADD COLUMN image_url TEXT")) + conn.commit() + sys_log.info("[Database] [Schema] ✅ products.image_url 欄位修復完成") + + if 'created_at' not in product_columns: + sys_log.warning("[Database] [Schema] ⚠️ 偵測到 products 表缺少 created_at 欄位 | 正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE products ADD COLUMN created_at DATETIME")) + conn.execute(text("UPDATE products SET created_at = updated_at WHERE created_at IS NULL")) + conn.commit() + sys_log.info("[Database] [Schema] ✅ products.created_at 欄位修復完成") + + if 'promo_products' in inspector.get_table_names(): + columns = [c['name'] for c in inspector.get_columns('promo_products')] + if 'url' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 url 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN url TEXT")) + conn.commit() + sys_log.info("✅ url 欄位修復完成") + if 'image_url' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 image_url 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN image_url TEXT")) + conn.commit() + sys_log.info("✅ image_url 欄位修復完成") + if 'previous_price' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 previous_price 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN previous_price INTEGER")) + conn.commit() + sys_log.info("✅ previous_price 欄位修復完成") + if 'session_time_text' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 session_time_text 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN session_time_text TEXT")) + conn.commit() + sys_log.info("✅ session_time_text 欄位修復完成") + if 'remain_qty' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 remain_qty 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN remain_qty INTEGER")) + conn.commit() + sys_log.info("✅ remain_qty 欄位修復完成") + if 'discount_text' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 discount_text 欄位,正在自動修復...") + with engine.connect() as conn: + conn.execute(text("ALTER TABLE promo_products ADD COLUMN discount_text TEXT")) + conn.commit() + sys_log.info("✅ discount_text 欄位修復完成") + if 'page_type' not in columns: + sys_log.warning("⚠️ 偵測到 promo_products 表缺少 page_type 欄位,正在自動修復...") + with engine.connect() as conn: + # 將既有資料預設為 'edm' + conn.execute(text("ALTER TABLE promo_products ADD COLUMN page_type TEXT DEFAULT 'edm'")) + conn.commit() + sys_log.info("✅ page_type 欄位修復完成") + except Exception as e: + sys_log.error(f"[Database] [Schema] ❌ 資料庫修復失敗 | Error: {e}") + +# 從環境變數讀取 NGROK_AUTH_TOKEN(如果未設定則使用原值,但會發出警告) +NGROK_AUTH_TOKEN = os.getenv('NGROK_AUTH_TOKEN', '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF') +if NGROK_AUTH_TOKEN == '36e27NM5V7sUJ8QxJIAAWCp7sUv_3brtcrBarYvcP3SbvFKhF': + sys_log.warning("[Security] ⚠️ 使用預設 NGROK_AUTH_TOKEN,請設定環境變數") +conf.get_default().auth_token = NGROK_AUTH_TOKEN + +TEMPLATE_DIR = BASE_DIR # 舊模板路徑(根目錄) +TEMPLATE_DIR_NEW = os.path.join(BASE_DIR, 'templates') # 新模板路徑(模組化) +TEMPLATE_DIR_WEB = os.path.join(BASE_DIR, 'web/templates') # web 子目錄模板 +STATIC_DIR = os.path.join(BASE_DIR, 'web/static') + +# 檢查關鍵模板是否存在 +if not os.path.exists(os.path.join(BASE_DIR, 'dashboard.html')): + sys_log.warning(f"[Web] [Template] ⚠️ 警告: 找不到 dashboard.html | Path: {TEMPLATE_DIR}") + +# Flask 應用程式 +# 使用 Jinja2 的多路徑載入器支援新舊兩種模板路徑 +from jinja2 import FileSystemLoader, ChoiceLoader + +app = Flask(__name__, + template_folder=TEMPLATE_DIR, + static_folder=STATIC_DIR) + +# 設定多路徑模板載入器:優先載入 templates/ 目錄,再載入根目錄與 web/templates +app.jinja_loader = ChoiceLoader([ + FileSystemLoader(TEMPLATE_DIR_NEW), # 新模板路徑優先 (templates/) + FileSystemLoader(TEMPLATE_DIR), # 舊模板路徑備用 (根目錄) + FileSystemLoader(TEMPLATE_DIR_WEB), # web 子目錄模板 (web/templates/) +]) + +# ========================================== +# 🔒 Flask 安全配置 +# ========================================== + +# 從 config.py 導入 SECRET_KEY +from config import SECRET_KEY + +# 基本配置 +app.config['SECRET_KEY'] = SECRET_KEY + +# Session 安全配置 +app.config['SESSION_COOKIE_HTTPONLY'] = True # 防止 JavaScript 存取 cookie(防 XSS) +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # 防止 CSRF 攻擊 +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=24) # Session 有效期 24 小時(延長避免長時間閒置斷線) + +# 如果使用 HTTPS,啟用 SECURE cookie(本地開發時應設為 False) +# 注意:如果您的系統部署在 HTTPS 環境,請將 .env 中的 USE_HTTPS 設為 true +USE_HTTPS = os.getenv('USE_HTTPS', 'false').lower() == 'true' +if USE_HTTPS: + app.config['SESSION_COOKIE_SECURE'] = True + sys_log.info("[Security] ✅ HTTPS 模式已啟用,Session cookie 僅透過 HTTPS 傳輸") +else: + app.config['SESSION_COOKIE_SECURE'] = False + sys_log.warning("[Security] ⚠️ HTTP 模式(開發環境),Session cookie 未強制 HTTPS") + +# 檔案上傳大小限制(10MB) +# V-New: 提高檔案上傳大小限制 (從 10MB 提高到 100MB) +app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 + +sys_log.info("[Security] ✅ Flask 安全配置已載入") +sys_log.info(f"[Security] • Session 有效期: 2 小時") +sys_log.info(f"[Security] • 檔案上傳限制: 10 MB") +sys_log.info(f"[Security] • CSRF 防護: SameSite=Lax") +sys_log.info(f"[Security] • XSS 防護: HttpOnly=True") + +# ========================================== +# 🔒 CSRF 防護配置 +# ========================================== + +from flask_wtf.csrf import CSRFProtect + +csrf = CSRFProtect(app) +sys_log.info("[Security] ✅ CSRF 防護已啟用 (Flask-WTF)") + +# ========================================== +# 🔧 Blueprint 註冊 - 廠商缺貨系統 +# ========================================== +# ========================================== +# 🔒 認證路由初始化 +# ========================================== +init_auth_routes(app) +sys_log.info("[Auth] ✅ 認證路由已註冊 (/login, /logout)") + +from vendor_routes import vendor_bp +app.register_blueprint(vendor_bp) +sys_log.info("[Blueprint] ✅ 廠商缺貨系統 Blueprint 已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - Google Drive 自動匯入 +# ========================================== +from auto_import_routes import auto_import_bp +app.register_blueprint(auto_import_bp) +csrf.exempt(auto_import_bp) +sys_log.info("[Blueprint] ✅ Google Drive 自動匯入 Blueprint 已註冊 (CSRF 已豁免)") + +# ========================================== +# 🔧 Blueprint 註冊 - 爬蟲管理系統 +# ========================================== +from crawler_management_routes import crawler_bp +app.register_blueprint(crawler_bp) +sys_log.info("[Blueprint] ✅ 爬蟲管理系統 Blueprint 已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - 重構後的路由模組 +# ========================================== +from routes import register_blueprints +register_blueprints(app) +sys_log.info("[Blueprint] ✅ 重構路由模組已註冊") + +# ========================================== +# 🔧 Blueprint 註冊 - 用戶管理系統 +# ========================================== +from routes.user_routes import user_bp +app.register_blueprint(user_bp) +sys_log.info("[Blueprint] ✅ 用戶管理系統 Blueprint 已註冊") + +# V-Fix: 註冊 slugify 函數供模板使用,解決 'slugify is undefined' 錯誤 +def slugify(text): + if not text: return "" + return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "") + +LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') +public_url = "服務啟動中..." + +# 🚩 時區設定:台北時間 (UTC+8) +TAIPEI_TZ = timezone(timedelta(hours=8)) + +# ================= 🛠️ V9.72: 分類設定管理核心 ================= +CATEGORIES_JSON_PATH = os.path.join(BASE_DIR, 'data', 'categories.json') + +def load_categories(): + """從 JSON 檔案載入分類列表""" + try: + with open(CATEGORIES_JSON_PATH, 'r', encoding='utf-8') as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return [] + +def save_categories(categories): + """將分類列表儲存到 JSON 檔案""" + with open(CATEGORIES_JSON_PATH, 'w', encoding='utf-8') as f: + json.dump(categories, f, ensure_ascii=False, indent=4) + +def load_scheduler_stats(): + """讀取排程統計資料""" + stats_path = os.path.join(BASE_DIR, 'data', 'scheduler_stats.json') + if os.path.exists(stats_path): + try: + with open(stats_path, 'r', encoding='utf-8') as f: + return json.load(f) + except (IOError, json.JSONDecodeError): + return {} + return {} + +# ================= 🛠️ 數據處理核心 (封裝) ================= + +def get_color_for_string(s): + """為字串生成一個穩定且美觀的 HSL 顏色""" + if not s: return "hsl(0, 0%, 85%)" # 預設灰色 + # 使用 md5 hash 確保顏色穩定,並映射到 HSL 色彩空間以獲得柔和色彩 + hash_val = int(hashlib.md5(s.encode('utf-8')).hexdigest(), 16) + hue = hash_val % 360 + return f"hsl({hue}, 60%, 88%)" + +def extract_snapshot_date_from_filename(filename): + """從檔名提取日期:即時業績_當日_20260111.xlsx → 2026-01-11""" + match = re.search(r'(\d{8})', filename) + if match: + date_str = match.group(1) # '20260111' + try: + # 轉換為 YYYY-MM-DD 格式 + year = date_str[:4] + month = date_str[4:6] + day = date_str[6:8] + return f"{year}-{month}-{day}" + except: + return None + return None + +@app.template_filter('number_format') +def number_format_filter(value): + """V9.61: 將數字格式化,加上千分位符號。""" + if isinstance(value, (int, float)): + return "{:,.0f}".format(value) + return value + +# V-Refactor: 將 find_col 移至全域,方便多個函式共用 +def find_col(df_cols, keywords): + """從欄位列表中,根據關鍵字列表找出最匹配的欄位名稱""" + # V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商') + for k in keywords: + for col in df_cols: + if k in str(col): + return col + return None + +def get_consolidated_data(): + """🚩 統一封裝:獲取全分類去重後的當前數據、昨日對比及差值 (V-Opt: 優化查詢效能 + 快取)""" + global _DASHBOARD_DATA_CACHE + + # V-New: 檢查快取是否有效 + now = datetime.now(TAIPEI_TZ) + if (_DASHBOARD_DATA_CACHE['consolidated_data'] is not None and + _DASHBOARD_DATA_CACHE['consolidated_timestamp'] is not None): + cache_age = (now.timestamp() - _DASHBOARD_DATA_CACHE['consolidated_timestamp']) + if cache_age < _DASHBOARD_CACHE_TTL: + sys_log.debug(f"[Dashboard] [Cache] ✅ 使用快取資料 | 快取年齡: {cache_age:.1f}秒") + return _DASHBOARD_DATA_CACHE['consolidated_data'], _DASHBOARD_DATA_CACHE['today_start'] + + sys_log.debug("[Dashboard] [Cache] 🔄 快取過期或不存在,重新查詢資料庫") + + # 🚩 V-New: 追蹤查詢時間 + query_start_time = time.time() + + db = DatabaseManager() + session = db.get_session() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + seven_days_ago = today_start - timedelta(days=7) + thirty_days_ago = today_start - timedelta(days=30) + + try: + # Query 1: Get the latest price record for every product. This is our main list of items. + latest_price_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + latest_records = session.query(PriceRecord).options( + joinedload(PriceRecord.product) + ).join(latest_price_subq, PriceRecord.id == latest_price_subq.c.max_id).all() + + product_ids = [r.product_id for r in latest_records] + if not product_ids: + session.close() # 提前關閉連線 + return [], today_start + + # Query 2: Get yesterday's closing prices for all products in one go + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + # Query 3: Get specific historical price points (7 days ago and 30 days ago) + # Instead of fetching ALL history, we fetch only the records closest to the target dates. + # This is a significant optimization. + + # Helper to get price map for a specific date (start of day) + def get_price_map_before(target_date): + subq = session.query( + PriceRecord.product_id, + func.max(PriceRecord.timestamp).label('max_ts') + ).filter( + PriceRecord.product_id.in_(product_ids), + PriceRecord.timestamp < target_date + ).group_by(PriceRecord.product_id).subquery() + + q = session.query(PriceRecord.product_id, PriceRecord.price).join( + subq, + and_(PriceRecord.product_id == subq.c.product_id, PriceRecord.timestamp == subq.c.max_ts) + ) + return {pid: price for pid, price in q} + + prices_7d_ago_map = get_price_map_before(seven_days_ago + timedelta(days=1)) # Approximate 7 days ago + prices_30d_ago_map = get_price_map_before(thirty_days_ago + timedelta(days=1)) # Approximate 30 days ago + + # Query 4: Get TODAY's records only (for sparkline/intraday change) + today_records_q = session.query(PriceRecord).filter( + PriceRecord.product_id.in_(product_ids), + PriceRecord.timestamp >= today_start + ).order_by(PriceRecord.product_id, PriceRecord.timestamp).all() + + today_map = {} + for r in today_records_q: + if r.product_id not in today_map: today_map[r.product_id] = [] + today_map[r.product_id].append(r) + + # Final Assembly (in-memory, no more DB queries) + unique_items = [] + for r in latest_records: + pid = r.product_id + + # 7d/30d stats + price_7d = prices_7d_ago_map.get(pid) + price_30d = prices_30d_ago_map.get(pid) + + stats_7d_diff = r.price - price_7d if price_7d is not None else 0 + stats_30d_diff = r.price - price_30d if price_30d is not None else 0 + + # Today's stats + today_records = today_map.get(pid, []) + today_diff = 0 + today_changes = [] + if len(today_records) > 1: + today_diff = today_records[-1].price - today_records[0].price + + # Yesterday diff + y_price = yesterday_prices_map.get(pid) + yesterday_diff = r.price - y_price if y_price is not None else 0 + + status = "NONE" + if yesterday_diff > 0: + status = "PRICE_UP" + elif yesterday_diff < 0: + status = "PRICE_DOWN" + + # Today's changes details + last_p = y_price if y_price is not None else (today_records[0].price if today_records else r.price) + for tr in today_records: + if tr.price != last_p: + diff = tr.price - last_p + today_changes.append({ + 'time': tr.timestamp.strftime('%H:%M'), + 'price': tr.price, + 'diff': diff + }) + last_p = tr.price + + unique_items.append({ + 'record': r, + 'stats': {'7d_diff': stats_7d_diff, '30d_diff': stats_30d_diff, '1d_diff': today_diff}, + 'yesterday_diff': yesterday_diff, + 'today_changes': today_changes, + 'status': status + }) + + # V-New: 更新快取 + _DASHBOARD_DATA_CACHE['consolidated_data'] = unique_items + _DASHBOARD_DATA_CACHE['consolidated_timestamp'] = now.timestamp() + _DASHBOARD_DATA_CACHE['today_start'] = today_start + + # 🚩 V-New: 追蹤查詢時間並記錄 + query_duration_ms = (time.time() - query_start_time) * 1000 + track_query_time('get_consolidated_data', query_duration_ms) + sys_log.debug(f"[Dashboard] [Cache] 💾 快取已更新 | 商品數: {len(unique_items)} | 耗時: {query_duration_ms:.0f}ms") + + return unique_items, today_start + finally: + session.close() + +def get_full_dashboard_data(): + """🚩 獲取完整的看板資料,包含快取清單與全部 KPIs (V-Opt: 深度快取)""" + global _DASHBOARD_DATA_CACHE + now = datetime.now(TAIPEI_TZ) + + # 1. 檢查完整快取是否有效 (300秒) + if _DASHBOARD_DATA_CACHE.get('full_data') and _DASHBOARD_DATA_CACHE.get('full_timestamp'): + age = now.timestamp() - _DASHBOARD_DATA_CACHE['full_timestamp'] + if age < _DASHBOARD_CACHE_TTL: + sys_log.debug(f"[Dashboard] [Cache] ✅ 使用完整看板快取 | 快取年齡: {age:.0f}秒") + return _DASHBOARD_DATA_CACHE['full_data'] + + sys_log.info("[Dashboard] [Cache] 🔄 完整快取過期,重新計算所有 KPIs 與統計數據...") + + # 🚩 V-New: 追蹤查詢時間 + query_start_time = time.time() + + # 2. 獲取基本彙總資料 + unique_items, today_start = get_consolidated_data() + today_start_db = today_start.replace(tzinfo=None) + + db = DatabaseManager() + session = db.get_session() + + try: + # A. 基礎清單統計 + increase_items = [item for item in unique_items if item['yesterday_diff'] > 0] + decrease_items = [item for item in unique_items if item['yesterday_diff'] < 0] + + # B. 分類筆數統計 + cat_counts = {} + for item in unique_items: + c = item['record'].product.category + if c: cat_counts[c] = cat_counts.get(c, 0) + 1 + all_categories = [f"{cat} ({count}筆)" for cat, count in sorted(cat_counts.items())] + + # C. 核心 KPI 統計 (資料庫查詢) + total_products_history = session.query(Product).count() + total_price_records = session.query(PriceRecord).count() + today_updates = session.query(PriceRecord).filter(PriceRecord.timestamp >= today_start_db).count() + + # 今日新增商品 ID 集合與數量 (優化查詢) + new_pids_query = session.query(PriceRecord.product_id).group_by(PriceRecord.product_id).having(func.min(PriceRecord.timestamp) >= today_start_db) + new_product_ids = {r[0] for r in new_pids_query.all()} + today_new_products = len(new_product_ids) + + # D. 今日下架商品處理 + raw_delisted_items = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start_db + ).all() + + today_delisted_items = [] + if raw_delisted_items: + delisted_ids = [p.id for p in raw_delisted_items] + # 一次性查詢最後價格 + last_prices_subq = session.query( + PriceRecord.product_id, + func.max(PriceRecord.id).label('max_id') + ).filter(PriceRecord.product_id.in_(delisted_ids)).group_by(PriceRecord.product_id).subquery() + + last_prices_q = session.query(PriceRecord.product_id, PriceRecord.price).join( + last_prices_subq, PriceRecord.id == last_prices_subq.c.max_id).all() + price_map = {pid: price for pid, price in last_prices_q} + + for p in raw_delisted_items: + today_delisted_items.append({'product': p, 'last_price': price_map.get(p.id, 0)}) + + # E. 週增長 (過去 7 天新增的商品數) + week_ago_db = (now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=7)).replace(tzinfo=None) + week_new_products = session.query(func.count(Product.id)).filter( + Product.id.in_( + session.query(PriceRecord.product_id) + .group_by(PriceRecord.product_id) + .having(func.min(PriceRecord.timestamp) >= week_ago_db) + ) + ).scalar() or 0 + + # F. 價格穩定商品數(7 天內無變價) + try: + stable_count = session.query(PriceRecord.product_id).filter( + PriceRecord.timestamp >= week_ago_db + ).group_by(PriceRecord.product_id).having( + func.count(func.distinct(PriceRecord.price)) == 1 + ).count() + except Exception: + stable_count = 0 + + # G. 最大變動計算 + max_change_item = None + max_change_value = 0 + for item in unique_items: + if abs(item['yesterday_diff']) > abs(max_change_value): + max_change_value = item['yesterday_diff'] + max_change_item = item + + # H. 最活躍分類 + category_activity = {} + for item in increase_items + decrease_items: + cat = item['record'].product.category + if cat: category_activity[cat] = category_activity.get(cat, 0) + 1 + most_active_category_item = max(category_activity.items(), key=lambda x: x[1]) if category_activity else (None, 0) + + # I. 組合結果 + full_data = { + 'unique_items': unique_items, + 'today_start': today_start, + 'today_start_db': today_start_db, + 'increase_items_all': increase_items, + 'decrease_items_all': decrease_items, + 'all_categories': all_categories, + 'new_product_ids': new_product_ids, + 'total_products_history': total_products_history, + 'total_price_records': total_price_records, + 'today_updates': today_updates, + 'today_new_products': today_new_products, + 'today_delisted_count': len(raw_delisted_items), + 'today_delisted_items': today_delisted_items, + 'max_change_item': max_change_item, + 'max_change_value': max_change_value, + 'avg_increase': sum(item['yesterday_diff'] for item in increase_items) / len(increase_items) if increase_items else 0, + 'avg_decrease': sum(item['yesterday_diff'] for item in decrease_items) / len(decrease_items) if decrease_items else 0, + 'activity_rate': (len(increase_items) + len(decrease_items)) / total_products_history * 100 if total_products_history > 0 else 0, + 'active_count': len(increase_items) + len(decrease_items), + 'week_new_products': week_new_products, + 'stable_count': stable_count, + 'most_active_category': most_active_category_item[0], + 'most_active_count': most_active_category_item[1] + } + + # 更新快取 + _DASHBOARD_DATA_CACHE['full_data'] = full_data + _DASHBOARD_DATA_CACHE['full_timestamp'] = now.timestamp() + + # 🚩 V-New: 追蹤查詢時間 + query_duration_ms = (time.time() - query_start_time) * 1000 + track_query_time('get_full_dashboard_data', query_duration_ms) + sys_log.info(f"[Dashboard] [Cache] 💾 完整看板快取已更新 | 耗時: {query_duration_ms:.0f}ms") + + return full_data + except Exception as e: + sys_log.error(f"[Dashboard] ❌ KPI 計算失敗: {e}") + import traceback + traceback.print_exc() + return None + finally: + session.close() + +def get_dashboard_stats(): + """計算看板統計數據 (供通知使用) - 改成使用快取版本""" + data = get_full_dashboard_data() + if data: + return { + 'new': data['today_new_products'], + 'up': len(data['increase_items_all']), + 'down': len(data['decrease_items_all']), + 'delisted': data['today_delisted_count'] + } + return {'new': 0, 'up': 0, 'down': 0, 'delisted': 0} + +# ================= 🛣️ 4. Flask 路由 ================= + +# Session 自動續期機制 +@app.before_request +def refresh_session(): + """ + 在每次請求時自動刷新 Session,避免長時間閒置後突然斷線 + 只要用戶有任何操作,Session 就會自動延長 + """ + if session.get('logged_in'): + session.modified = True # 標記 Session 已修改,觸發 Cookie 更新 + + +# ============================================================================= +# 全域模板變數 (Context Processor) +# ============================================================================= +@app.context_processor +def inject_global_vars(): + """ + 注入全域模板變數,供所有模板使用 + """ + import config as app_config # 區域導入避免循環引用 + from auth import get_current_user + + # 建立模擬的 current_user 對象供導航列使用 + class CurrentUser: + def __init__(self, logged_in, user_info=None): + self._logged_in = logged_in + self._user_info = user_info or {} + + @property + def is_authenticated(self): + return self._logged_in + + @property + def role(self): + return self._user_info.get('role', 'user') if self._logged_in else None + + @property + def username(self): + return self._user_info.get('username', 'User') if self._logged_in else None + + @property + def display_name(self): + return self._user_info.get('display_name', self.username) if self._logged_in else None + + @property + def user_id(self): + return self._user_info.get('user_id') if self._logged_in else None + + def is_admin(self): + return self.role == 'admin' + + def is_manager_or_above(self): + return self.role in ['admin', 'manager'] + + is_logged_in = session.get('logged_in', False) + user_info = get_current_user() if is_logged_in else None + + return { + 'metabase_url': app_config.METABASE_URL, # Metabase BI 連結 (空值時不顯示) + 'grist_url': getattr(app_config, 'GRIST_URL', ''), # Grist 連結 (空值時不顯示) + 'system_version': app_config.SYSTEM_VERSION, + 'current_user': CurrentUser(is_logged_in, user_info), # 支援多用戶角色 + } + + +# ============================================================================= +# 模板繼承測試端點 (開發用) +# ============================================================================= +@app.route('/test_template') +def test_template(): + """測試 base.html 模板繼承是否正常運作""" + return render_template('test_base.html', + datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), + active_page='test') + + +# ============================================================================= +# 健康檢查端點 (供 Docker / Load Balancer 使用) +# ============================================================================= +@app.route('/health') +def health_check(): + """ + 健康檢查端點,返回系統狀態 + - 用於 Docker HEALTHCHECK + - 用於 Nginx upstream 健康檢查 + - 用於負載均衡器健康檢查 + """ + try: + # 檢查資料庫連線 + db = DatabaseManager() + with db.get_session() as session: + session.execute(text("SELECT 1")) + + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.now(TAIPEI_TZ).isoformat(), + 'version': SYSTEM_VERSION + }), 200 + except Exception as e: + return jsonify({ + 'status': 'unhealthy', + 'error': str(e), + 'timestamp': datetime.now(TAIPEI_TZ).isoformat() + }), 503 + +# ============================================================================= +# Prometheus Metrics 端點 (供監控系統使用) +# ============================================================================= +@app.route('/metrics') +def prometheus_metrics(): + """ + Prometheus metrics endpoint - 暴露系統指標供 Prometheus 抓取 + 包含:資料庫大小、表記錄數、連線狀態等 + """ + try: + db = DatabaseManager() + metrics = [] + + # 1. 資料庫檔案大小 + db_path = os.path.join(BASE_DIR, 'data', 'momo_database.db') + if os.path.exists(db_path): + db_size = os.path.getsize(db_path) + metrics.append(f'momo_database_size_bytes{{db="main"}} {db_size}') + + # 2. WAL 檔案大小 + wal_path = db_path + '-wal' + if os.path.exists(wal_path): + wal_size = os.path.getsize(wal_path) + metrics.append(f'momo_database_wal_size_bytes{{db="main"}} {wal_size}') + else: + metrics.append(f'momo_database_wal_size_bytes{{db="main"}} 0') + + # 3. 資料表記錄數 + with db.get_session() as session: + # Products 表 + product_count = session.execute(text("SELECT COUNT(*) FROM products")).scalar() or 0 + metrics.append(f'momo_table_rows{{table="products"}} {product_count}') + + # PriceRecords 表 + price_count = session.execute(text("SELECT COUNT(*) FROM price_records")).scalar() or 0 + metrics.append(f'momo_table_rows{{table="price_records"}} {price_count}') + + # MonthlySummaryAnalysis 表 + try: + monthly_count = session.execute(text("SELECT COUNT(*) FROM monthly_summary_analysis")).scalar() or 0 + metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} {monthly_count}') + except: + metrics.append(f'momo_table_rows{{table="monthly_summary_analysis"}} 0') + + # PromoProducts 表 (EDM) + try: + promo_count = session.execute(text("SELECT COUNT(*) FROM promo_products")).scalar() or 0 + metrics.append(f'momo_table_rows{{table="promo_products"}} {promo_count}') + except: + metrics.append(f'momo_table_rows{{table="promo_products"}} 0') + + # 4. 資料庫連線狀態 + metrics.append('momo_database_up 1') + + # 5. 今日新增商品數 + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + today_products = session.execute( + text("SELECT COUNT(*) FROM products WHERE created_at >= :today"), + {'today': today_start} + ).scalar() or 0 + metrics.append(f'momo_products_today_total {today_products}') + + # 6. 今日價格變動記錄數 + today_price_records = session.execute( + text("SELECT COUNT(*) FROM price_records WHERE timestamp >= :today"), + {'today': today_start} + ).scalar() or 0 + metrics.append(f'momo_price_records_today_total {today_price_records}') + + # 7. 磁碟使用率 + total, used, free = shutil.disk_usage(BASE_DIR) + metrics.append(f'momo_disk_total_bytes {total}') + metrics.append(f'momo_disk_used_bytes {used}') + metrics.append(f'momo_disk_free_bytes {free}') + + # 8. 應用程式資訊 + metrics.append(f'momo_app_info{{version="{SYSTEM_VERSION}"}} 1') + + # 9. 慢查詢統計 + metrics.append(f'momo_query_total {_SLOW_QUERY_STATS["total_queries"]}') + metrics.append(f'momo_query_slow_total {_SLOW_QUERY_STATS["slow_queries"]}') + metrics.append(f'momo_query_very_slow_total {_SLOW_QUERY_STATS["very_slow_queries"]}') + metrics.append(f'momo_query_time_total_ms {_SLOW_QUERY_STATS["total_query_time_ms"]}') + + # 10. 計算平均查詢時間 + if _SLOW_QUERY_STATS["total_queries"] > 0: + avg_query_time = _SLOW_QUERY_STATS["total_query_time_ms"] / _SLOW_QUERY_STATS["total_queries"] + metrics.append(f'momo_query_avg_time_ms {avg_query_time:.2f}') + else: + metrics.append('momo_query_avg_time_ms 0') + + # 11. 慢查詢率 (百分比) + if _SLOW_QUERY_STATS["total_queries"] > 0: + slow_rate = (_SLOW_QUERY_STATS["slow_queries"] / _SLOW_QUERY_STATS["total_queries"]) * 100 + metrics.append(f'momo_query_slow_rate_percent {slow_rate:.2f}') + else: + metrics.append('momo_query_slow_rate_percent 0') + + # 12. SQLite 連線池狀態 (使用 PRAGMA 查詢) + with db.get_session() as session: + try: + # 查詢 SQLite 頁面統計 + page_count = session.execute(text("PRAGMA page_count")).scalar() or 0 + page_size = session.execute(text("PRAGMA page_size")).scalar() or 4096 + freelist_count = session.execute(text("PRAGMA freelist_count")).scalar() or 0 + + metrics.append(f'momo_sqlite_page_count {page_count}') + metrics.append(f'momo_sqlite_page_size {page_size}') + metrics.append(f'momo_sqlite_freelist_count {freelist_count}') + + # 碎片率 (freelist / page_count * 100) + if page_count > 0: + fragmentation = (freelist_count / page_count) * 100 + metrics.append(f'momo_sqlite_fragmentation_percent {fragmentation:.2f}') + else: + metrics.append('momo_sqlite_fragmentation_percent 0') + + # 緩存命中率 (如果啟用) + try: + cache_stats = session.execute(text("PRAGMA cache_stats")).fetchone() + if cache_stats: + metrics.append(f'momo_sqlite_cache_hit {cache_stats[0] if cache_stats[0] else 0}') + metrics.append(f'momo_sqlite_cache_miss {cache_stats[1] if len(cache_stats) > 1 else 0}') + except: + pass + + except Exception as pragma_error: + pass # PRAGMA 查詢失敗不影響其他指標 + + # 返回 Prometheus 格式 + return '\n'.join(metrics) + '\n', 200, {'Content-Type': 'text/plain; charset=utf-8'} + + except Exception as e: + # 資料庫連線失敗 + return f'momo_database_up 0\nmomo_database_error{{error="{str(e)}"}} 1\n', 200, {'Content-Type': 'text/plain; charset=utf-8'} + +@app.route('/') +def index(): + db = DatabaseManager() + + session = db.get_session() + page = request.args.get('page', 1, type=int) + category_filter = request.args.get('category', 'all') + sort_by = request.args.get('sort_by', 'timestamp') # 預設按時間排序 + filter_type = request.args.get('filter', 'all') # 🚩 新增:狀態篩選 (increase, decrease, delisted) + order = request.args.get('order', 'desc') + search_query = request.args.get('q', '').strip() # 🚩 新增:搜尋關鍵字 + per_page = 50 + + # 🚩 取得台北時間的今日起始點 (用於資料庫查詢比較) + # 注意:若資料庫內存的是 naive time (無時區),則需轉為 naive 進行比較 + now_taipei = datetime.now(TAIPEI_TZ) + today_start_db = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + try: + # 🚩 1. 使用「深度快取」獲取所有數據 (優化:不再於路由中重複計算 KPIs) + data = get_full_dashboard_data() + if not data: + return render_template('index.html', error="無法載入數據,請檢查資料庫。") + + unique_items = data['unique_items'] + today_start = data['today_start'] + today_start_db = data['today_start_db'] + increase_items = data['increase_items_all'] + decrease_items = data['decrease_items_all'] + all_categories = data['all_categories'] + new_product_ids = data['new_product_ids'] + total_products_history = data['total_products_history'] + today_new_products = data['today_new_products'] + total_price_records = data['total_price_records'] + today_updates = data['today_updates'] + today_delisted_count = data['today_delisted_count'] + today_delisted_items = data['today_delisted_items'] + max_change_item = data['max_change_item'] + max_change_value = data['max_change_value'] + avg_increase = data['avg_increase'] + avg_decrease = data['avg_decrease'] + activity_rate = data['activity_rate'] + week_new_products = data['week_new_products'] + stable_count = data['stable_count'] + most_active_category = data['most_active_category'] + most_active_count = data['most_active_count'] + active_count = data.get('active_count', 0) + + # 🚩 讀取系統狀態 (用於紅綠燈顯示) + system_status = {"status": "UNKNOWN", "message": "尚無執行紀錄", "timestamp": "-"} + status_path = os.path.join(BASE_DIR, 'data/system_status.json') + if os.path.exists(status_path): + try: + with open(status_path, 'r', encoding='utf-8') as f: + system_status = json.load(f) + except: pass + + # --- 取得所有分類用於篩選器 --- + # (已在上方取得) + + # 🚩 2. 後端篩選 (Server-side Filtering) + scheduler_stats = load_scheduler_stats() + + # V-Fix: Handle old scheduler stats format (dict) by converting to list to prevent template errors + if scheduler_stats.get('momo_task') and isinstance(scheduler_stats.get('momo_task'), dict): + scheduler_stats['momo_task'] = [scheduler_stats['momo_task']] + if scheduler_stats.get('edm_task') and isinstance(scheduler_stats.get('edm_task'), dict): + scheduler_stats['edm_task'] = [scheduler_stats['edm_task']] + + filtered_items = [] + + # 0. 先處理搜尋 (若有) + if search_query: + search_lower = search_query.lower() + # V9.81: 搜尋功能修復,支援搜尋商品名稱與 i_code + base_items = [ + item for item in unique_items + if (item['record'].product.name and search_lower in item['record'].product.name.lower()) or + (item['record'].product.i_code and search_lower in str(item['record'].product.i_code)) + ] + else: + base_items = unique_items + + # A. 先處理狀態篩選 (漲/跌/下架) + if filter_type == 'increase': + filtered_items = [i for i in base_items if i in increase_items] + elif filter_type == 'decrease': + filtered_items = [i for i in base_items if i in decrease_items] + elif filter_type == 'new': + # V-New: 新上架篩選 (今日新增的商品) + filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids] + elif filter_type == 'delisted': + # 特殊處理:將下架商品轉換為列表格式以便顯示 + for item in today_delisted_items: + # 模擬 record 物件結構 + class MockRecord: + def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at + + if not search_query or search_query.lower() in item['product'].name.lower(): + filtered_items.append({ + 'record': MockRecord(item['product'], item['last_price']), + 'stats': {'1d_diff': 0, '7d_diff': 0, '30d_diff': 0}, # 模擬 stats 結構 + 'yesterday_diff': 0, + 'today_changes': [], # 確保結構一致 + 'status': 'DELISTED' # 新增狀態 + }) + else: + # B. 若無狀態篩選,則處理分類篩選 + if category_filter != 'all': + # V-New: 處理帶有筆數的分類名稱,例如 "化妝水 (50筆)" -> "化妝水" + real_category = category_filter + if "(" in category_filter and "筆)" in category_filter: + real_category = category_filter.rsplit(" (", 1)[0] + filtered_items = [item for item in base_items if item['record'].product.category == real_category] + else: + filtered_items = base_items + + # 🚩 3. 後端排序 (Server-side Sorting) + reverse = (order == 'desc') + def get_sort_key(item): + # 處理 None 值,確保排序時不會出錯 + def safe_get(value, default=0): + return default if value is None else value + + if sort_by == 'i_code': return int(safe_get(item['record'].product.i_code, 0)) + if sort_by == 'category': return safe_get(item['record'].product.category, '') + if sort_by == 'name': return safe_get(item['record'].product.name, '') + if sort_by == 'price': return safe_get(item['record'].price, 0) + if sort_by == 'today_change': return safe_get(item['stats']['1d_diff'], 0) # 今日內波動 + if sort_by == 'yesterday_change': return safe_get(item['yesterday_diff'], 0) + if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0) + return item['record'].timestamp # 預設 + + sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse) + + # 🚩 4. 分頁 (Pagination) - 在篩選和排序之後執行 + total_items = len(sorted_items) + total_pages = math.ceil(total_items / per_page) + + start_idx = (page - 1) * per_page + paged_items = sorted_items[start_idx : start_idx + per_page] + + # V-Fix: 為前端準備安全的 created_at 屬性 + for item in paged_items: + item['safe_created_at'] = getattr(item['record'].product, 'created_at', None) + + # 🚩 5. 為當前頁面項目添加顏色 + for item in paged_items: + category_name = item['record'].product.category + item['category_color'] = get_color_for_string(category_name) + + return render_template('dashboard.html', + total_products=total_products_history, + today_new_products=today_new_products, + total_price_records=total_price_records, + cnt_increase=len(increase_items), + cnt_decrease=len(decrease_items), # 傳遞跌價數 + today_delisted_count=today_delisted_count, + today_delisted_items=today_delisted_items, + system_status=system_status, + items=paged_items, + categories=all_categories, + current_page=page, + total_pages=total_pages, # V-New: 傳遞總項目數 + total_items=total_items, + datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S'), # 顯示台北時間 + today_date=now_taipei.strftime('%Y-%m-%d'), # 傳遞今日日期 + public_url=public_url, + current_category=category_filter, + current_filter=filter_type, # 傳遞當前篩選狀態 + search_query=search_query, # 傳遞搜尋關鍵字 + current_sort=sort_by, + current_order=order, + scheduler_stats=scheduler_stats, + # V9.2: 新增 KPI 數據 + avg_increase=avg_increase, + avg_decrease=avg_decrease, + activity_rate=activity_rate, + active_count=active_count, + max_change_item=max_change_item, + max_change_value=max_change_value, + week_new_products=week_new_products, + stable_count=stable_count, + most_active_category=most_active_category, + most_active_count=most_active_count) + except Exception as e: + sys_log.error(f"[Web] [Dashboard] 🚨 渲染錯誤 | Error: {e}") + return f"系統維護中,錯誤詳情:{e}" + finally: + session.close() + +@app.route('/settings') +def settings(): + """分類設定頁面""" + categories = load_categories() + return render_template('settings.html', + categories=categories, + public_url=public_url, + system_version=SYSTEM_VERSION) + +@app.route('/system_settings') +def system_settings_page(): + """系統設定與匯入頁面""" + now_taipei = datetime.now(TAIPEI_TZ) + return render_template('system_settings.html', system_version=SYSTEM_VERSION, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S')) + +@app.route('/api/categories', methods=['POST']) +def add_category(): + """API: 新增分類""" + name = request.form.get('name') + url = request.form.get('url') + if not name or not url: + return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400 + + categories = load_categories() + new_id = int(time.time() * 1000) # 使用時間戳作為簡易唯一 ID + categories.append({'id': new_id, 'name': name, 'url': url}) + save_categories(categories) + + return jsonify({"status": "success", "message": "分類新增成功"}) + +@app.route('/api/categories/', methods=['PUT']) +def update_category(category_id): + """API: 更新分類""" + name = request.form.get('name') + url = request.form.get('url') + if not name or not url: + return jsonify({"status": "error", "message": "名稱和 URL 皆不可為空"}), 400 + + categories = load_categories() + category_found = False + for cat in categories: + if cat.get('id') == category_id: + cat['name'] = name + cat['url'] = url + category_found = True + break + + if not category_found: + return jsonify({"status": "error", "message": "找不到指定的分類 ID"}), 404 + + save_categories(categories) + return jsonify({"status": "success", "message": "分類更新成功"}) + +@app.route('/api/categories/', methods=['DELETE']) +def delete_category(category_id): + """API: 刪除分類""" + categories = [cat for cat in load_categories() if cat.get('id') != category_id] + save_categories(categories) + return jsonify({"status": "success", "message": "分類刪除成功"}) + +@app.route('/api/test_url', methods=['POST']) +def test_url(): + """API: 測試網址是否有效""" + try: + data = request.get_json() + url = data.get('url') + if not url: + return jsonify({"status": "error", "message": "網址不能為空"}), 400 + + import requests + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + } + # 設定 10 秒超時,避免卡住 + response = requests.get(url, headers=headers, timeout=10) + + if response.status_code == 200: + return jsonify({"status": "success", "message": f"✅ 連結有效 (Status: 200)"}) + else: + return jsonify({"status": "warning", "message": f"⚠️ 連結回應異常 (Status: {response.status_code})"}) + + except Exception as e: + return jsonify({"status": "error", "message": f"❌ 連線失敗: {str(e)}"}), 500 + + +@app.route('/brand_assets') +def brand_assets(): + """顯示品牌資產庫""" + return render_template('brand_assets.html') + +@app.route('/edm') +def edm_dashboard(): + """🚩 新增:MOMO 限時搶購 (EDM) 專屬儀表板""" + db = DatabaseManager() + session = db.get_session() + + # V-New: 排序參數 + sort_by = request.args.get('sort_by', 'default') + order = request.args.get('order', 'desc') + + try: + # 1. 基礎統計 + # 取得最後更新時間 + last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料" + + # 🚩 V9.29 新增:取得最新的活動時間文字 + latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + activity_time = getattr(latest_entry, 'activity_time_text', '限時搶購') if latest_entry else "限時搶購" + + # 2. 查詢資料 (V9.44: 只顯示最新批次的資料) + # 找出最新的 batch_id + latest_batch = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + current_batch_id = latest_batch[0] if latest_batch else None + + # 🚩 V9.55 修正:改為查詢「全商品的最新狀態快照」,而非僅查詢最新批次 + # 因為 Scheduler 現在只記錄異動,若只查最新批次,未變動的商品會消失 + subq = session.query( + func.max(PromoProduct.id).label('max_id') + ).filter(PromoProduct.page_type == 'edm').group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery() + + latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all() + + # 過濾顯示列表:顯示「上架中」、「本批次剛下架」或「今日結束時段」的商品 + items_in_batch = [] + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + for item in latest_records: + # V9.60: 隱藏自然結束的時段商品 + # V-New: 如果商品是今天才結束的,則依然顯示在儀表板上,方便回查 + if item.status_change == 'SLOT_END' and item.crawled_at < today_start: + continue + + # V-New: 如果是下架狀態,只有當它是「今天」下架的才顯示 + if item.status_change == 'DELISTED' and item.crawled_at < today_start: + continue + items_in_batch.append(item) + + # V9.45: 按時段分組 + grouped_items = {} + for item in items_in_batch: + if item.time_slot not in grouped_items: + grouped_items[item.time_slot] = [] + grouped_items[item.time_slot].append(item) + + # 按時段鍵值排序 (e.g., 00:00, 07:00, ...) + sorted_grouped_items = dict(sorted(grouped_items.items())) + + # V9.45: 決定預設顯示的頁籤 + def get_current_time_slot(): + hour = datetime.now(TAIPEI_TZ).hour + available_slots = sorted([int(s.split(':')[0]) for s in sorted_grouped_items.keys() if s and ':' in s]) if sorted_grouped_items else [0, 7, 11, 14, 18, 22] + current_slot_hour = 0 + for s in available_slots: + if hour >= s: + current_slot_hour = s + return f"{current_slot_hour:02d}:00" + + active_tab = get_current_time_slot() + if active_tab not in sorted_grouped_items and sorted_grouped_items: + active_tab = next(iter(sorted_grouped_items)) + + # V-New: 計算在架天數與總銷量 + all_icodes_in_batch = [item.i_code for item in items_in_batch] + product_categories = {} + days_on_shelf_map = {} + total_sold_map = {} + + if all_icodes_in_batch: + # 從主商品表 (products) 查詢這些 i_code 對應的分類 + main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all() + product_categories = {p.i_code: p.category for p in main_products} + + # 計算上架天數 (days_on_shelf) + days_on_shelf_q = session.query( + PromoProduct.i_code, + func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at))) + ).filter( # V-New: 增加 page_type 過濾 + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.page_type == 'edm' + ).group_by(PromoProduct.i_code).all() + days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q} + + # 計算總銷量 + # 1. 找出每個商品第一次有庫存紀錄的 ID + first_qty_subq = session.query( + PromoProduct.i_code, + func.min(PromoProduct.id).label('min_id') + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.remain_qty.isnot(None), + PromoProduct.page_type == 'edm' + ).group_by(PromoProduct.i_code).subquery() + + # 2. 根據 ID 取得當時的庫存 + first_qty_records = session.query( + PromoProduct.i_code, PromoProduct.remain_qty + ).join(first_qty_subq, PromoProduct.id == first_qty_subq.c.min_id).all() + first_qty_map = {r[0]: r[1] for r in first_qty_records} + + # 3. 計算總銷量 (初始庫存 - 當前庫存) + for item in items_in_batch: + # 確保該商品有初始庫存紀錄,且當前庫存也存在 + if item.i_code in first_qty_map and item.remain_qty is not None: + initial_qty = first_qty_map[item.i_code] + current_qty = item.remain_qty + # 只有在初始庫存大於當前庫存時才計算,避免負數 + if initial_qty > current_qty: + total_sold_map[item.i_code] = initial_qty - current_qty + + # V-Fix: 修正 NameError: name 'history_map' is not defined + # 準備銷售歷程資料 + history_map = {} + if all_icodes_in_batch: + all_history_records = session.query( + PromoProduct.i_code, + PromoProduct.time_slot, + PromoProduct.remain_qty, + PromoProduct.crawled_at + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.crawled_at >= today_start + ).order_by(PromoProduct.crawled_at).all() + + for rec in all_history_records: + key = (rec.i_code, rec.time_slot) + if key not in history_map: + history_map[key] = [] + + if rec.remain_qty is not None: + if not history_map[key] or (history_map[key] and history_map[key][-1]['qty'] != rec.remain_qty): + history_map[key].append({'time': rec.crawled_at.strftime('%H:%M'), 'qty': rec.remain_qty}) + + # 將查到的分類資訊附加到每個 item 物件上 + for item in items_in_batch: + item.main_category = product_categories.get(item.i_code) + if item.main_category: + item.category_color = get_color_for_string(item.main_category) + # V-New: 附加在架天數與總銷量 + item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1) + item.total_sold = total_sold_map.get(item.i_code, 0) + # V-New: Attach quantity history + item.qty_history = history_map.get((item.i_code, item.time_slot), []) + + # V9.46: 排序邏輯優化 (中文註解) + # 排序規則: + # 1. 有貼標 (main_category 存在) 的商品優先 + # 2. 有狀態變更 (NEW, 漲價, 降價) 的商品次之 + # 3. 已下架的商品再次之 + # 4. 最後按價格由高到低排序 + reverse = (order == 'desc') + for time_slot in sorted_grouped_items: + if sort_by == 'name': + sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse) + elif sort_by == 'remain_qty': + # 將 None 視為 -1,確保排序時在最下方 + sorted_grouped_items[time_slot].sort(key=lambda x: x.remain_qty if x.remain_qty is not None else -1, reverse=reverse) + elif sort_by == 'price': + sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse) + else: # 預設排序 + sorted_grouped_items[time_slot].sort(key=lambda x: ( + 1 if x.main_category else 0, + 2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0), + x.price if x.price is not None else -1 + ), reverse=True) + + # 🚩 V-Fix: 修正時段統計,使其能區分「當前狀態」與「上次異動」 + # V-New: 重構時段統計邏輯,確保統計所有今日異動 + slot_stats = {} + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + # 1. 取得今日所有異動紀錄 + today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == 'edm').all() + + # 2. 取得所有相關時段的鍵 (今日異動的 + 當前在架的) + slots_from_changes = {rec.time_slot for rec in today_change_records} + slots_from_display = set(sorted_grouped_items.keys()) + all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display))) + + # 3. 初始化所有相關時段的統計數據 + for slot in all_relevant_slots: + slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0} + + # 4. 累加新品、漲價、降價、下架的數量 (從今日歷史紀錄) + for rec in today_change_records: + if rec.time_slot in slot_stats: + if rec.status_change == 'NEW': + slot_stats[rec.time_slot]['new'] += 1 + elif rec.status_change == 'PRICE_UP': + slot_stats[rec.time_slot]['up'] += 1 + elif rec.status_change == 'PRICE_DOWN': + slot_stats[rec.time_slot]['down'] += 1 + elif rec.status_change in ['DELISTED', 'SLOT_END']: + slot_stats[rec.time_slot]['delisted_last_run'] += 1 + + # 5. 計算在架與下架總數 (從當前顯示的商品快照) + for slot, items in sorted_grouped_items.items(): + if slot in slot_stats: + on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END']) + delisted_total_count = len(items) - on_shelf_count + slot_stats[slot]['on_shelf'] = on_shelf_count + slot_stats[slot]['delisted_total'] = delisted_total_count + + # V-New: 建立儀表板頁籤 + promo_pages = [ + {'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, + {'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'} + ] + + scheduler_stats = load_scheduler_stats() + + now_taipei = datetime.now(TAIPEI_TZ) + return render_template('edm_dashboard.html', + promo_pages=promo_pages, + current_promo_page='edm', + page_title='MOMO 限時搶購', + grouped_items=sorted_grouped_items, + slot_stats=slot_stats, + total_edm_products=len(items_in_batch), + last_update=last_update_str, + activity_time=activity_time, + active_tab=active_tab, + current_batch_id=current_batch_id, + public_url=public_url, + scheduler_stats=scheduler_stats, + current_sort=sort_by, + current_order=order, + slugify=slugify, + datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S')) + except Exception as e: + sys_log.error(f"🚨 EDM Dashboard 渲染錯誤: {e}") + return f"系統錯誤: {e}" + finally: + session.close() + +@app.route('/festival') +def festival_dashboard(): + """🚩 新增:1.1 狂歡購物節專屬儀表板""" + db = DatabaseManager() + session = db.get_session() + + PAGE_TYPE = "festival" + PAGE_NAME = "1.1狂歡購物節" + + sort_by = request.args.get('sort_by', 'default') + order = request.args.get('order', 'desc') + + try: + # 1. 基礎統計 + last_update = session.query(PromoProduct.crawled_at).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first() + last_update_str = last_update[0].strftime('%Y-%m-%d %H:%M') if last_update else "尚無資料" + + latest_entry = session.query(PromoProduct).filter(PromoProduct.page_type == PAGE_TYPE).order_by(desc(PromoProduct.crawled_at)).first() + activity_time = getattr(latest_entry, 'activity_time_text', PAGE_NAME) if latest_entry else PAGE_NAME + + # 2. 查詢資料 + subq = session.query( + func.max(PromoProduct.id).label('max_id') + ).filter(PromoProduct.page_type == PAGE_TYPE).group_by(PromoProduct.i_code, PromoProduct.time_slot).subquery() + + latest_records = session.query(PromoProduct).join(subq, PromoProduct.id == subq.c.max_id).all() + + items_in_batch = [] + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + for item in latest_records: + if item.status_change == 'SLOT_END' and item.crawled_at < today_start: + continue + if item.status_change == 'DELISTED' and item.crawled_at < today_start: + continue + items_in_batch.append(item) + + # 此頁面使用區塊標題作為分組依據 + grouped_items = {} + for item in items_in_batch: + if item.time_slot not in grouped_items: + grouped_items[item.time_slot] = [] + grouped_items[item.time_slot].append(item) + + sorted_grouped_items = dict(sorted(grouped_items.items())) + + # 預設顯示第一個頁籤 + active_tab = next(iter(sorted_grouped_items)) if sorted_grouped_items else "" + + all_icodes_in_batch = [item.i_code for item in items_in_batch] + product_categories = {} + days_on_shelf_map = {} + total_sold_map = {} + + if all_icodes_in_batch: + main_products = session.query(Product.i_code, Product.category).filter(Product.i_code.in_(all_icodes_in_batch)).all() + product_categories = {p.i_code: p.category for p in main_products} + + days_on_shelf_q = session.query( + PromoProduct.i_code, + func.count(func.distinct(func.strftime('%Y-%m-%d', PromoProduct.crawled_at))) + ).filter( + PromoProduct.i_code.in_(all_icodes_in_batch), + PromoProduct.page_type == PAGE_TYPE + ).group_by(PromoProduct.i_code).all() + days_on_shelf_map = {r[0]: r[1] for r in days_on_shelf_q} + + # 將查到的分類資訊附加到每個 item 物件上 + for item in items_in_batch: + item.main_category = product_categories.get(item.i_code) + if item.main_category: + item.category_color = get_color_for_string(item.main_category) + item.days_on_shelf = days_on_shelf_map.get(item.i_code, 1) + # V-Fix: 為 festival 頁面提供預設值,避免共用模板在渲染 total_sold 和 qty_history 時出錯 + item.total_sold = 0 + item.qty_history = [] + + # 排序邏輯 + reverse = (order == 'desc') + for time_slot in sorted_grouped_items: + if sort_by == 'name': + sorted_grouped_items[time_slot].sort(key=lambda x: x.name or '', reverse=reverse) + elif sort_by == 'price': + sorted_grouped_items[time_slot].sort(key=lambda x: x.price if x.price is not None else -1, reverse=reverse) + else: # 預設排序 + sorted_grouped_items[time_slot].sort(key=lambda x: ( + 1 if x.main_category else 0, + 2 if x.status_change in ['NEW', 'PRICE_UP', 'PRICE_DOWN'] else (1 if x.status_change == 'DELISTED' else 0), + x.price if x.price is not None else -1 + ), reverse=True) + + # 時段統計 + slot_stats = {} + today_start = datetime.now(TAIPEI_TZ).replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + + today_change_records = session.query(PromoProduct).filter(PromoProduct.crawled_at >= today_start, PromoProduct.page_type == PAGE_TYPE).all() + + slots_from_changes = {rec.time_slot for rec in today_change_records} + slots_from_display = set(sorted_grouped_items.keys()) + all_relevant_slots = sorted(list(slots_from_changes.union(slots_from_display))) + + for slot in all_relevant_slots: + slot_stats[slot] = {'new': 0, 'up': 0, 'down': 0, 'delisted_last_run': 0, 'on_shelf': 0, 'delisted_total': 0} + + for rec in today_change_records: + if rec.time_slot in slot_stats: + if rec.status_change == 'NEW': slot_stats[rec.time_slot]['new'] += 1 + elif rec.status_change == 'PRICE_UP': slot_stats[rec.time_slot]['up'] += 1 + elif rec.status_change == 'PRICE_DOWN': slot_stats[rec.time_slot]['down'] += 1 + elif rec.status_change in ['DELISTED', 'SLOT_END']: slot_stats[rec.time_slot]['delisted_last_run'] += 1 + + for slot, items in sorted_grouped_items.items(): + if slot in slot_stats: + on_shelf_count = sum(1 for item in items if item.status_change not in ['DELISTED', 'SLOT_END']) + delisted_total_count = len(items) - on_shelf_count + slot_stats[slot]['on_shelf'] = on_shelf_count + slot_stats[slot]['delisted_total'] = delisted_total_count + + scheduler_stats = load_scheduler_stats() + + # 建立儀表板頁籤 + promo_pages = [ + {'url': url_for('edm_dashboard'), 'name': '限時搶購', 'id': 'edm'}, + {'url': url_for('festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'} + ] + + # 注意:這裡我們重複使用 edm_dashboard.html 範本 + # 您需要建立一個它的複本,命名為 festival.html + return render_template('edm_dashboard.html', + promo_pages=promo_pages, + current_promo_page='festival', + page_title=PAGE_NAME, + grouped_items=sorted_grouped_items, + slot_stats=slot_stats, + total_edm_products=len(items_in_batch), + last_update=last_update_str, + activity_time=activity_time, + active_tab=active_tab, + public_url=public_url, + scheduler_stats=scheduler_stats, + current_sort=sort_by, + current_order=order, + slugify=slugify) + except Exception as e: + sys_log.error(f"🚨 {PAGE_NAME} Dashboard 渲染錯誤: {e}") + return f"系統錯誤: {e}" + finally: + session.close() + +@app.route('/api/export/all_categories') +def export_all_categories(): + """🚩 需求 A:處理全分類報表匯出請求""" + try: + sys_log.info("📊 執行全分類 CSV 數據導出...") + + # 1. 獲取與看板一致的整合數據 + items, _ = get_consolidated_data() + + # 2. 呼叫匯出服務 + exporter = Exporter() + file_path = exporter.generate_all_categories_report() # 此函式內部已處理按分類分 Sheet + + if file_path: + # 🚩 強制轉為絕對路徑,解決 CWD 與 Flask Root Path 不一致導致的 404 問題 + abs_file_path = os.path.abspath(file_path) + + if os.path.exists(abs_file_path): + sys_log.info(f"✅ 報表匯出成功,準備下載: {abs_file_path}") + return send_file(abs_file_path, as_attachment=True) + + return "匯出失敗:資料庫內尚無足夠數據", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 全分類報表匯出異常 | Error: {e}") + return f"匯出失敗,錯誤詳情:{e}", 500 + +# 🚩 V9.90: 新增 Excel 匯出路由 +@app.route('/api/export/excel/all') +def export_excel_all(): + try: + items, _ = get_consolidated_data() + exporter = Exporter() + file_path = exporter.generate_all_products_excel(items) + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "匯出失敗", 500 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (All) | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/changes') +def export_excel_changes(): + try: + items, _ = get_consolidated_data() + increase = [i for i in items if i['yesterday_diff'] > 0] + decrease = [i for i in items if i['yesterday_diff'] < 0] + + exporter = Exporter() + file_path = exporter.generate_changes_excel(increase, decrease) + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "匯出失敗", 500 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Changes) | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/delisted') +def export_excel_delisted(): + db = DatabaseManager() + session = db.get_session() + try: + _, today_start = get_consolidated_data() + today_delisted_query = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start.replace(tzinfo=None) + ) + raw_items = today_delisted_query.all() + delisted_items = [{'product': p, 'last_price': (session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first().price if session.query(PriceRecord).filter_by(product_id=p.id).first() else 0)} for p in raw_items] + + exporter = Exporter() + file_path = exporter.generate_delisted_excel(delisted_items) + return send_file(file_path, as_attachment=True) + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ Excel 匯出失敗 (Delisted) | Error: {e}") + return f"匯出失敗: {e}", 500 + finally: + session.close() + +@app.route('/api/export/price_changes') +def export_price_changes(): + """V9.4 更新:匯出今日價格異動明細 (支援篩選) - 修正:改用與儀表板相同的邏輯""" + import openpyxl + from openpyxl.styles import Font, Alignment, PatternFill + + filter_type = request.args.get('type', '') + filter_category = request.args.get('category', '') + + try: + db = DatabaseManager() + session = db.get_session() + + # 使用與 /api/price_change_details 相同的邏輯 + now_taipei = datetime.now(TAIPEI_TZ) + today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + # 基礎查詢:取得所有商品的最新記錄 + latest_records_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + query = session.query(PriceRecord, Product).join( + latest_records_subq, + PriceRecord.id == latest_records_subq.c.max_id + ).join(Product, PriceRecord.product_id == Product.id) + + # 一次性查詢所有商品的「今日之前最後價格」 + product_ids = [r[0] for r in session.query(PriceRecord.product_id).join( + latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id + ).all()] + + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + products = [] + + # 根據 filter_type 篩選 + if filter_type == 'increase': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price > old_price: + products.append((product, record, old_price)) + + elif filter_type == 'decrease': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price < old_price: + products.append((product, record, old_price)) + + elif filter_type == 'delisted': + today_delisted = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start + ).all() + for product in today_delisted: + last_record = session.query(PriceRecord).filter( + PriceRecord.product_id == product.id + ).order_by(PriceRecord.timestamp.desc()).first() + if last_record: + products.append((product, last_record, last_record.price)) + + elif filter_type == 'active': + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + elif filter_type == 'category' and filter_category: + for record, product in query.filter(Product.category == filter_category).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + else: + # 預設:所有變動商品 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append((product, record, old_price)) + + session.close() + + if not products: + return "無符合條件的商品資料", 404 + + # 建立 Excel + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "價格變動明細" + + # 標題列 + headers = ['商品ID', '商品名稱', '分類', '原價格', '現價格', '變動金額', '變動百分比', '更新時間', '商品網址'] + ws.append(headers) + + # 設定標題列樣式 + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + header_font = Font(bold=True, color='FFFFFF') + for cell in ws[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal='center', vertical='center') + + # 填充資料 + for product, record, old_price in products: + change = record.price - old_price + change_pct = (change / old_price * 100) if old_price > 0 else 0 + ws.append([ + product.i_code, + product.name, + product.category or '未分類', + old_price, + record.price, + change, + f"{change_pct:.2f}%", + record.timestamp.strftime('%Y-%m-%d %H:%M'), + product.url + ]) + + # 調整欄寬 + ws.column_dimensions['A'].width = 12 + ws.column_dimensions['B'].width = 40 + ws.column_dimensions['C'].width = 15 + ws.column_dimensions['D'].width = 12 + ws.column_dimensions['E'].width = 12 + ws.column_dimensions['F'].width = 12 + ws.column_dimensions['G'].width = 12 + ws.column_dimensions['H'].width = 18 + ws.column_dimensions['I'].width = 50 + + # 儲存檔案 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"價格變動明細_{filter_type or 'all'}_{timestamp}.xlsx" + filepath = os.path.join(EXCEL_EXPORT_DIR, filename) + + os.makedirs(EXCEL_EXPORT_DIR, exist_ok=True) + wb.save(filepath) + + return send_file(filepath, as_attachment=True, download_name=filename) + + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 異動報表匯出失敗 | Type: {filter_type} | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/low_prices') +def export_low_prices(): + """🚩 新增:匯出歷史低價商品""" + try: + exporter = Exporter() + file_path = exporter.generate_low_price_report() + + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "目前無歷史低價商品", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 低價報表匯出失敗 | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/changes') +def export_changes(): + """🚩 需求:匯出篩選後的資料 (漲/跌/下架)""" + filter_type = request.args.get('type') + exporter = Exporter() + file_path = None + + try: + unique_items, today_start = get_consolidated_data() + + if filter_type == 'increase': + target_items = [i for i in unique_items if i['yesterday_diff'] > 0] + file_path = exporter.generate_custom_report(target_items, "今日漲價商品") + elif filter_type == 'decrease': + target_items = [i for i in unique_items if i['yesterday_diff'] < 0] + file_path = exporter.generate_custom_report(target_items, "今日跌價商品") + elif filter_type == 'delisted': + db = DatabaseManager() + session = db.get_session() + try: + today_start_naive = today_start.replace(tzinfo=None) + today_delisted_query = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start_naive + ) + raw_delisted_items = today_delisted_query.all() + + delisted_items_with_price = [] + + # 定義模擬物件 (移至迴圈外以提升效率) + class MockRecord: + def __init__(self, p, price): self.product = p; self.price = price; self.timestamp = p.updated_at + + for p in raw_delisted_items: + last_rec = session.query(PriceRecord).filter_by(product_id=p.id).order_by(desc(PriceRecord.timestamp)).first() + price = last_rec.price if last_rec else 0 + delisted_items_with_price.append({'product': p, 'last_price': price}) + + file_path = exporter.generate_delisted_report(delisted_items_with_price, "今日下架商品") + finally: + session.close() + + if file_path and os.path.exists(file_path): + return send_file(file_path, as_attachment=True) + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"[Web] [Export] ❌ 篩選匯出失敗 | Type: {filter_type} | Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/abc') +def export_abc_analysis(): + """API: 匯出 ABC 分析報表 (Excel)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 嘗試從快取讀取資料 (與 sales_analysis 共用快取) + df = None + cols_map = {} + + if table_name in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[table_name] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + return "請先瀏覽「業績分析」頁面以載入資料與快取。", 400 + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') # V-New: 取得商品ID欄位 + + # 2. 篩選資料 (複製 sales_analysis 的篩選邏輯以確保結果一致) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + target_df = df.copy() # 複製一份以免修改到快取 + + # 重新計算 Top N 分類 (用於 '其他' 篩選) + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + # 3. 執行 ABC 分析與匯出 + if col_amount and not target_df.empty: + # V-Fix: 同步 abc_analysis_detail 的聚合邏輯,確保匯出數據與網頁一致 + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_category: agg_rules[col_category] = 'first' + if col_vendor: agg_rules[col_vendor] = 'first' + if col_brand: agg_rules[col_brand] = 'first' + if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID + + # 執行聚合 + df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index() + + # 重新計算聚合後的毛利率 + if col_profit: + df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['calculated_margin_rate'] = 0.0 + df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # 排序與 ABC 分類 + target_df = df_agg.sort_values(by=col_amount, ascending=False) + target_df['cumulative_revenue'] = target_df[col_amount].cumsum() + total_revenue = target_df[col_amount].sum() + target_df['cumulative_pct'] = (target_df['cumulative_revenue'] / total_revenue) * 100 + + conditions = [(target_df['cumulative_pct'] <= 80), (target_df['cumulative_pct'] <= 95)] + choices = ['A', 'B'] + target_df['ABC_Class'] = np.select(conditions, choices, default='C') + + # V-New: 支援依類別篩選匯出 (例如只匯出 A 類) + filter_class = request.args.get('class') + if filter_class: + target_df = target_df[target_df['ABC_Class'] == filter_class] + + # V-New: 計算平均單價 (Avg Unit Price) + if col_qty: + target_df['avg_unit_price'] = (target_df[col_amount] / target_df[col_qty]).fillna(0) + + # V-New: 計算建議補貨量 (支援自訂係數) + if col_qty: + custom_factor = request.args.get('factor') + if custom_factor: + try: + factor = float(custom_factor) + # 若有指定係數,則全體套用 (通常用於單一類別匯出) + target_df['suggested_restock'] = (target_df[col_qty] * factor).astype(int) + except: + # 格式錯誤則回退至預設邏輯 + conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')] + choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2] + target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int) + else: + # 預設邏輯 (A=1.5, B=1.2, C=0) + conditions_restock = [(target_df['ABC_Class'] == 'A'), (target_df['ABC_Class'] == 'B')] + choices_restock = [target_df[col_qty] * 1.5, target_df[col_qty] * 1.2] + target_df['suggested_restock'] = np.select(conditions_restock, choices_restock, default=0).astype(int) + + # 整理匯出欄位 + export_cols = [] + header_map = {} + if col_pid: export_cols.append(col_pid); header_map[col_pid] = '商品ID' # V-New: 匯出商品ID + if col_name: export_cols.append(col_name); header_map[col_name] = '商品名稱' + if col_category: export_cols.append(col_category); header_map[col_category] = '分類' + if col_brand: export_cols.append(col_brand); header_map[col_brand] = '品牌' + if col_vendor: export_cols.append(col_vendor); header_map[col_vendor] = '廠商' + export_cols.append('ABC_Class'); header_map['ABC_Class'] = 'ABC分類' + if col_amount: export_cols.append(col_amount); header_map[col_amount] = '銷售金額' + if col_qty: export_cols.append(col_qty); header_map[col_qty] = '銷售數量' + # V-Fix: 移除 col_price 匯出,因為聚合後的資料表不包含原始單價欄位 (已由 avg_unit_price 取代) + if 'avg_unit_price' in target_df.columns: + export_cols.append('avg_unit_price'); header_map['avg_unit_price'] = '平均單價' + if col_cost: export_cols.append(col_cost); header_map[col_cost] = '成本' + if col_profit: export_cols.append(col_profit); header_map[col_profit] = '毛利' + if 'calculated_margin_rate' in target_df.columns: + export_cols.append('calculated_margin_rate'); header_map['calculated_margin_rate'] = '毛利率(%)' + if 'suggested_restock' in target_df.columns: + export_cols.append('suggested_restock') + header_map['suggested_restock'] = '建議補貨量' + + export_df = target_df[export_cols].rename(columns=header_map) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='ABC分析') + output.seek(0) + + filename_prefix = f"ABC_Analysis_{filter_class}_" if filter_class else "ABC_Analysis_" + return send_file(output, as_attachment=True, download_name=f"{filename_prefix}{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"ABC Export Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/api/export/excel/vendor') +def export_vendor_analysis(): + """API: 匯出廠商獲利能力排行 (Excel)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 嘗試從快取讀取資料 + df = None + cols_map = {} + + if table_name in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[table_name] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + # V-Fix: 快取失效時,重定向到 sales_analysis 以重新載入資料 + params = {k: v for k, v in request.args.items()} + flash('資料快取已失效,請稍候重新載入資料後再匯出。', 'warning') + return redirect(url_for('sales_analysis', **params)) + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + + if not col_vendor: + return "無法識別廠商欄位,無法匯出。", 400 + + # 2. 篩選資料 (複製 sales_analysis 的篩選邏輯) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + target_df = df.copy() + + # Top N 分類處理 + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + cat_group_all = df.groupby(col_category)[col_amount].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + if col_price and min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if col_price and max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + # 3. 執行廠商聚合 + if col_amount and not target_df.empty: + agg_dict = {col_amount: 'sum', col_name: 'nunique'} + if col_qty: agg_dict[col_qty] = 'sum' # V-Fix: 加入銷量聚合,否則無法計算 ASP + if col_profit: + agg_dict[col_profit] = 'sum' + elif col_cost: + agg_dict[col_cost] = 'sum' + + vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index() + + if col_profit: + vendor_group['total_profit'] = vendor_group[col_profit] + elif col_cost: + vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost] + else: + vendor_group['total_profit'] = 0 + + # V-Fix: 計算營收佔比 (Share %) + total_vendor_revenue = vendor_group[col_amount].sum() + vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100) + + vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0) + + # V-Fix: 計算平均客單價 (ASP) + if col_qty: + vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0) + + vendor_group['avg_sku_revenue'] = np.where(vendor_group[col_name] > 0, vendor_group[col_amount] / vendor_group[col_name], 0) + vendor_group = vendor_group.sort_values(by=col_amount, ascending=False) + + # V-Fix: 更新匯出欄位以匹配儀表板 + export_cols = [col_vendor, col_amount, 'revenue_share'] + header_map = {col_vendor: '廠商名稱', col_amount: '總業績', 'revenue_share': '佔比(%)'} + + if col_qty: + export_cols.extend([col_qty, 'asp']) + header_map.update({col_qty: '總銷量', 'asp': '平均客單(ASP)'}) + + export_cols.extend(['total_profit', 'margin_rate', col_name, 'avg_sku_revenue']) + header_map.update({'total_profit': '毛利額', 'margin_rate': '毛利率(%)', col_name: '商品數(SKU)', 'avg_sku_revenue': '平均單品產值'}) + + export_df = vendor_group[export_cols].rename(columns=header_map) + + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='廠商排行') + output.seek(0) + return send_file(output, as_attachment=True, download_name=f"Vendor_Ranking_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx", mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + return "無資料可匯出", 404 + except Exception as e: + sys_log.error(f"Vendor Export Error: {e}") + return f"匯出失敗: {e}", 500 + +@app.route('/abc_analysis/detail') +def abc_analysis_detail(): + """ABC 分析詳細報表頁面""" + try: + target_class = request.args.get('class', 'A') # 預設 A 類 + table_name = 'realtime_sales_monthly' + + # 1. 生成與主頁面一致的 cache_key + data_range_months = int(request.args.get('data_range', '0') or '0') + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 2. 使用共用篩選函式取得資料 + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + # V-Fix: 如果 cache_key 不存在,嘗試後補使用 table_name 固定鍵值 + if err and table_name in _SALES_PROCESSED_CACHE: + target_df, cols_map, err = _get_filtered_sales_data(table_name) + + if err: + # V-Fix: 如果自動重載也失敗,則顯示稍後再試,並引導回主頁面 + return f''' + + + + + 數據加載中 - WOOO TECH + + + +
+
+

數據準備中

+

正在自動重新加載數據,請稍後...

+ +
+ + + ''', 200 + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') + + + # 3. 執行 ABC 分類 + items = [] + total_revenue = 0 + if col_amount and not target_df.empty: + # V-Fix: 先針對商品進行聚合,確保 ABC 分析是基於「商品總銷量」而非「單筆訂單」 + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_category: agg_rules[col_category] = 'first' + if col_vendor: agg_rules[col_vendor] = 'first' + if col_brand: agg_rules[col_brand] = 'first' # V-New: 加入品牌 + if col_pid: agg_rules[col_pid] = 'first' # V-New: 聚合商品ID + if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) + + df_agg = target_df.groupby(col_name).agg(agg_rules).reset_index() + + # 重新計算聚合後的毛利率 + if col_profit: + df_agg['calculated_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['calculated_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['calculated_margin_rate'] = 0.0 + df_agg['calculated_margin_rate'] = df_agg['calculated_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # 執行 ABC 排序與計算 + df_agg = df_agg.sort_values(by=col_amount, ascending=False) + df_agg['cumulative_revenue'] = df_agg[col_amount].cumsum() + total_revenue = df_agg[col_amount].sum() + df_agg['cumulative_pct'] = (df_agg['cumulative_revenue'] / total_revenue) * 100 + + conditions = [(df_agg['cumulative_pct'] <= 80), (df_agg['cumulative_pct'] <= 95)] + choices = ['A', 'B'] + df_agg['ABC_Class'] = np.select(conditions, choices, default='C') + + # 4. 篩選特定類別 + class_df = df_agg[df_agg['ABC_Class'] == target_class].copy() + + # V-New: 計算平均單價與庫存建議 + if col_qty: + class_df['avg_unit_price'] = (class_df[col_amount] / class_df[col_qty]).fillna(0) + + # V-New: 處理動態補貨係數 + custom_factor = request.args.get('factor') + current_factor = 0.0 + + if custom_factor: + try: + current_factor = float(custom_factor) + except: + current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) + else: + current_factor = 1.5 if target_class == 'A' else (1.2 if target_class == 'B' else 0.0) + + class_df['suggested_restock'] = (class_df[col_qty] * current_factor).astype(int) + + items = class_df.to_dict('records') + + # 準備標題與描述 + class_info = { + 'A': {'title': 'A 類 - 核心商品', 'desc': '營收佔比前 80% 的主力商品,建議重點備貨與監控。', 'color': 'danger'}, + 'B': {'title': 'B 類 - 次要商品', 'desc': '營收佔比 80%~95% 的輔助商品,維持正常庫存。', 'color': 'warning'}, + 'C': {'title': 'C 類 - 長尾商品', 'desc': '營收佔比最後 5% 的長尾商品,建議評估清倉或縮減 SKU。', 'color': 'success'} + } + info = class_info.get(target_class, {'title': f'{target_class} 類', 'desc': '', 'color': 'secondary'}) + + # 計算 DataTables 預設排序欄位 (銷售金額) 的索引 + # 欄位順序: Rank(0), [PID], Name, [Brand], [Vendor], [Cat], [Margin], [AvgPrice, Qty, Restock], Amount + sort_col_index = 1 # Rank + if col_pid: sort_col_index += 1 + sort_col_index += 1 # Name + if col_brand: sort_col_index += 1 + if col_vendor: sort_col_index += 1 + if col_category: sort_col_index += 1 + if col_cost or col_profit: sort_col_index += 1 + if col_qty: sort_col_index += 3 + # 此時 sort_col_index 即為 Amount 欄位的索引 + + return render_template('abc_analysis_detail.html', + items=items, + info=info, + target_class=target_class, + current_factor=current_factor, # V-New: 傳遞當前係數 + total_revenue=total_revenue, + sort_col_index=sort_col_index, # V-New: 傳遞排序欄位索引 + cols={'name': col_name, 'amount': col_amount, 'qty': col_qty, 'cat': col_category, + 'vendor': col_vendor, 'brand': col_brand, 'cost': col_cost, 'profit': col_profit, 'date': col_date, 'pid': col_pid}, + # 傳遞當前查詢參數以供匯出連結使用 + query_string=request.query_string.decode()) + + except Exception as e: + sys_log.error(f"ABC Detail Error: {e}") + return f"系統錯誤: {e}" + +@app.route('/logs') +def show_logs(): + now_taipei = datetime.now(TAIPEI_TZ) + return render_template('logs.html', datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S')) + +@app.route('/api/run_task', methods=['POST']) +def trigger_task(): + try: + client_ip = request.remote_addr + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動執行請求 | IP: {client_ip}") + scheduled_job_wrapper() + return jsonify({"status": "success", "message": "爬蟲任務已在背景啟動"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/run_edm_task', methods=['POST']) +def trigger_edm_task(): + """🚩 新增:手動觸發 EDM 爬蟲任務""" + try: + target_lpn = "O1K5FBOqsvN" # 預設活動代碼 + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 EDM 執行請求 | LPN: {target_lpn}") + + # V-Fix: 強制重載 scheduler 模組,確保讀取到最新的截圖與通知邏輯 + import importlib + import scheduler + importlib.reload(scheduler) + + # 使用執行緒啟動,避免卡住 Web Server + task_thread = threading.Thread(target=scheduler.run_edm_task, args=(target_lpn,)) + task_thread.daemon = True + task_thread.start() + + return jsonify({"status": "success", "message": f"EDM 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發 EDM 任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/run_festival_task', methods=['POST']) +def trigger_festival_task(): + """🚩 新增:手動觸發 1.1 狂歡購物節爬蟲任務""" + try: + target_lpn = "O7ylWfihYUM" + sys_log.info(f"[Web] [Task] 🖱️ 接收到手動 Festival 執行請求 | LPN: {target_lpn}") + + # 使用執行緒啟動,避免卡住 Web Server + task_thread = threading.Thread(target=run_festival_task, args=(target_lpn,)) + task_thread.daemon = True + task_thread.start() + + return jsonify({"status": "success", "message": f"Festival 爬蟲任務 (LPN: {target_lpn}) 已在背景啟動,請稍後刷新頁面查看結果"}) + except Exception as e: + sys_log.error(f"[Web] [Task] ❌ 手動觸發 Festival 任務失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/trigger_momo_notification', methods=['POST']) +def trigger_momo_notification(): + """🚩 新增:手動觸發商品看板通知""" + try: + # 強制重載通知模組 + import importlib + import scheduler + import services.notification_manager + importlib.reload(scheduler) + importlib.reload(services.notification_manager) + from services.notification_manager import NotificationManager + + # 1. 取得統計數據 + stats = get_dashboard_stats() + + # 2. 截取儀表板畫面 + dashboard_url = "http://127.0.0.1/" + screenshot_path = scheduler.capture_page_screenshot(dashboard_url, "momo_dashboard") + + # 3. 發送通知 + notifier = NotificationManager() + sys_log.info(f"[Web] [Notification] 📢 手動觸發 MOMO 通知") + notifier.send_momo_report(stats, screenshot_path) + + return jsonify({"status": "success", "message": "已發送商品看板通知"}) + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/trigger_edm_notification', methods=['POST']) +def trigger_edm_notification(): + """🚩 新增:手動觸發 EDM 比價通知 (不重爬,僅重發)""" + try: + # V-Fix: 強制重新載入設定與通知模組,確保讀取到最新的 LINE ID (避免快取舊資料) + import importlib + import config + import services.notification_manager + import services.edm_notifier # V-New: 導入新的通知模組 + importlib.reload(config) + importlib.reload(services.notification_manager) + importlib.reload(services.edm_notifier) + + db = DatabaseManager() + session = db.get_session() + try: + # V-Fix: 改為只抓取最新一批次的異動資料,避免訊息過長 + # 1. 找出最新的 batch_id + latest_batch_tuple = session.query(PromoProduct.batch_id).filter(PromoProduct.page_type == 'edm').order_by(desc(PromoProduct.crawled_at)).first() + + if not latest_batch_tuple: + return jsonify({"status": "warning", "message": "目前無 EDM 商品資料,請先執行爬蟲"}), 400 + + latest_batch_id = latest_batch_tuple[0] + + # 2. 取得最新批次的所有異動商品 + products = session.query(PromoProduct).filter(PromoProduct.batch_id == latest_batch_id).all() + + if not products: + return jsonify({"status": "info", "message": "最新一輪掃描中無任何商品異動"}), 200 + + # V-Fix: 手動觸發時,嘗試尋找對應的截圖檔案 + screenshot_path = None + try: + filename = f"edm_{latest_batch_id}.png" + potential_path = os.path.join(BASE_DIR, 'web/static/screenshots', filename) + if os.path.exists(potential_path): + screenshot_path = potential_path + except Exception: pass + + from services.edm_notifier import EdmNotifier + notifier = EdmNotifier() + sys_log.info(f"[Web] [Notification] 📢 手動觸發 EDM 通知 | Count: {len(products)} | BatchID: {latest_batch_id}") + notifier.send_edm_report(products, screenshot_path) + + return jsonify({"status": "success", "message": f"已針對最新批次的 {len(products)} 筆商品異動發送通知"}) + finally: + session.close() + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 手動通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/test_notification', methods=['POST']) +def test_notification(): + """🚩 新增:測試訊息通知功能""" + try: + from services.notification_manager import NotificationManager + import config + import requests + notifier = NotificationManager() + + # --- 🕵️‍♂️ V9.13 更新:Messaging API 診斷邏輯 --- + sys_log.info("[Web] [Notification] 🕵️‍♂️ 執行手動通知發送測試 (Line/Telegram/Email)...") + + token = getattr(config, 'LINE_CHANNEL_ACCESS_TOKEN', None) + target_id = getattr(config, 'LINE_GROUP_ID', None) + + if token and target_id: + sys_log.info(f"[Web] [Notification] 🔑 偵測到 Channel Token: {token[:4]}...{token[-4:]}") + sys_log.info(f"[Web] [Notification] 🎯 目標 ID: {target_id}") + + # 2. 嘗試直接發送請求 + try: + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + payload = { + "to": target_id, + "messages": [ + { + "type": "text", + "text": "🧪 這是系統診斷測試訊息 (Messaging API)\n\n✅ 連線測試成功!" + } + ] + } + + sys_log.info("[Web] [Notification] 📡 正在嘗試連線至 Line Messaging API (push)...") + resp = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload, timeout=10) + + sys_log.info(f"[Web] [Notification] 📩 Line API 回應 | Code: {resp.status_code}") + sys_log.info(f"[Web] [Notification] 📄 Line API 內容 | Body: {resp.text}") + + if resp.status_code != 200: + return jsonify({"status": "error", "message": f"❌ Line API 拒絕連線: {resp.status_code} - {resp.text}"}), 400 + except Exception as req_err: + sys_log.error(f"[Web] [Notification] ❌ 直接連線測試發生異常 | Error: {req_err}") + return jsonify({"status": "error", "message": f"連線異常: {req_err}"}), 500 + else: + sys_log.warning("[Web] [Notification] ⚠️ 無法偵測到 Messaging API 設定 (Token 或 Group ID 缺失)") + return jsonify({"status": "error", "message": "設定檔缺少 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_GROUP_ID"}), 400 + + # 🚩 V9.14 修改:呼叫真實的日報發送邏輯 + notifier.send_daily_report() + + return jsonify({"status": "success", "message": "✅ 當日異動通知已發送 (Line/Telegram/Email)"}) + except ImportError: + return jsonify({"status": "error", "message": "❌ 找不到 NotificationManager 模組"}), 500 + except Exception as e: + sys_log.error(f"[Web] [Notification] ❌ 測試通知失敗 | Error: {e}") + return jsonify({"status": "error", "message": f"發送失敗: {str(e)}"}), 500 + +@app.route('/api/logs') +def get_logs_api(): + if os.path.exists(LOG_FILE_PATH): + try: + with open(LOG_FILE_PATH, 'r', encoding='utf-8') as f: + return jsonify({"logs": "".join(f.readlines()[-60:])}) + except Exception as e: + sys_log.error(f"[Web] [Logs] ❌ 日誌 API 讀取異常 | Error: {e}") + return jsonify({"logs": "讀取日誌異常"}) + return jsonify({"logs": "等待系統啟動中..."}) + +# 🚩 V9.82: 新增歷史價格 API +@app.route('/api/history/') +def get_price_history(product_id): + """API: 取得商品過去 180 天的價格歷史""" + db = DatabaseManager() + session = db.get_session() + try: + # 計算 180 天前的日期 + start_date = datetime.now(TAIPEI_TZ).replace(tzinfo=None) - timedelta(days=180) + + records = session.query(PriceRecord).filter( + PriceRecord.product_id == product_id, + PriceRecord.timestamp >= start_date + ).order_by(PriceRecord.timestamp).all() + + data = [{ + 't': r.timestamp.strftime('%Y-%m-%d %H:%M'), + 'p': r.price + } for r in records] + + return jsonify(data) + except Exception as e: + sys_log.error(f"[Web] [History] ❌ 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}") + return jsonify([]), 500 + finally: + session.close() + +@app.route('/api/price_change_details') +def get_price_change_details(): + """API: V9.4 取得價格變動商品明細 (供彈窗使用) - 修正:改用與儀表板相同的邏輯""" + filter_type = request.args.get('type', '') + filter_category = request.args.get('category', '') + filter_product_id = request.args.get('product_id', '') + + db = DatabaseManager() + session = db.get_session() + try: + # 取得今日起始時間 + now_taipei = datetime.now(TAIPEI_TZ) + today_start = now_taipei.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + + # 基礎查詢:取得所有商品的最新記錄 (與儀表板相同邏輯) + latest_records_subq = session.query( + func.max(PriceRecord.id).label('max_id') + ).group_by(PriceRecord.product_id).subquery() + + query = session.query(PriceRecord, Product).join( + latest_records_subq, + PriceRecord.id == latest_records_subq.c.max_id + ).join(Product, PriceRecord.product_id == Product.id) + + # 一次性查詢所有商品的「今日之前最後價格」(yesterday_prices_map) + product_ids = [r[0] for r in session.query(PriceRecord.product_id).join( + latest_records_subq, PriceRecord.id == latest_records_subq.c.max_id + ).all()] + + 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() + + yesterday_prices_q = session.query( + PriceRecord.product_id, PriceRecord.price + ).join( + yesterday_prices_subq, + PriceRecord.id == yesterday_prices_subq.c.max_id + ) + yesterday_prices_map = {pid: price for pid, price in yesterday_prices_q} + + # 根據 filter_type 進行篩選 + products = [] + + if filter_type == 'increase': + # 漲價商品 - 比對今日最新價格與今日之前的最後價格 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price > old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'decrease': + # 降價商品 + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price < old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'delisted': + # 下架商品 (今日狀態為 INACTIVE 且今天更新的) + today_delisted = session.query(Product).filter( + Product.status == 'INACTIVE', + Product.updated_at >= today_start + ).all() + + for product in today_delisted: + last_record = session.query(PriceRecord).filter( + PriceRecord.product_id == product.id + ).order_by(PriceRecord.timestamp.desc()).first() + + if last_record: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': last_record.price, + 'current_price': 0, + 'change': 0, + 'update_time': last_record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'active': + # 活躍商品 (今日有價格變動的) + for record, product in query.all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'category' and filter_category: + # 特定分類的變動商品 + for record, product in query.filter(Product.category == filter_category).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + + elif filter_type == 'max_change' and filter_product_id: + # 最大變動商品 - 只顯示指定的單一商品 + for record, product in query.filter(Product.i_code == filter_product_id).all(): + old_price = yesterday_prices_map.get(product.id) + if old_price is not None and record.price != old_price: + products.append({ + 'product_id': product.i_code, + 'name': product.name, + 'category': product.category, + 'url': product.url, + 'image_url': product.image_url or '/static/placeholder.png', + 'old_price': old_price, + 'current_price': record.price, + 'change': record.price - old_price, + 'update_time': record.timestamp.strftime('%Y-%m-%d %H:%M') + }) + break # 只需要一件商品 + + return jsonify({'products': products}) + + except Exception as e: + sys_log.error(f"[Web] [PriceChangeDetails] ❌ 獲取價格變動明細失敗 | Type: {filter_type} | Error: {e}") + return jsonify({'products': []}), 500 + finally: + session.close() + +@app.route('/api/backup', methods=['POST']) +def trigger_backup(): + """API: 觸發系統完整備份""" + # Note: [功能] 尚未實作「系統還原」功能 (Restore),需評估安全性後加入 + try: + sys_log.info("[System] [Backup] 💾 開始執行系統完整備份...") + backup_dir = os.path.join(BASE_DIR, 'backups') + if not os.path.exists(backup_dir): + os.makedirs(backup_dir) + + timestamp = datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M') + zip_filename = f"momo_system_backup_{SYSTEM_VERSION}_{timestamp}.zip" + zip_filepath = os.path.join(backup_dir, zip_filename) + + with zipfile.ZipFile(zip_filepath, 'w', zipfile.ZIP_DEFLATED) as zipf: + for root, dirs, files in os.walk(BASE_DIR): + # 排除不必要的目錄 + dirs[:] = [d for d in dirs if d not in ['backups', '__pycache__', 'venv', '.git', '.idea', '.vscode', 'node_modules']] + + for file in files: + if file == zip_filename: continue # 跳過正在寫入的檔案 + if file.endswith('.pyc') or file.endswith('.DS_Store'): continue + + file_path = os.path.join(root, file) + arcname = os.path.relpath(file_path, BASE_DIR) + zipf.write(file_path, arcname) + + sys_log.info(f"[System] [Backup] ✅ 系統備份完成 | File: {zip_filename}") + + # V-New: 回傳下載連結 + download_url = url_for('download_backup', filename=zip_filename) + + return jsonify({ + "status": "success", + "message": f"備份成功!\n檔案已儲存為: {zip_filename}\n即將開始下載...", + "download_url": download_url + }) + except Exception as e: + sys_log.error(f"[System] [Backup] ❌ 備份失敗 | Error: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + +@app.route('/api/backup/download/') +def download_backup(filename): + """ + API: 下載備份檔案(已加入路徑遍歷防護) + """ + try: + backup_dir = os.path.join(BASE_DIR, 'backups') + # 使用 safe_join 驗證路徑,防止路徑遍歷攻擊 + safe_path = safe_join(backup_dir, filename) + + # 確保檔案存在 + if not safe_path.exists(): + sys_log.warning(f"[Security] 備份檔案不存在 | File: {filename}") + return jsonify({'error': '檔案不存在'}), 404 + + # 確保是檔案而非目錄 + if not safe_path.is_file(): + sys_log.warning(f"[Security] 嘗試下載非檔案路徑 | Path: {filename}") + return jsonify({'error': '非法路徑'}), 400 + + return send_from_directory(backup_dir, safe_path.name, as_attachment=True) + + except ValueError as e: + # safe_join 偵測到路徑遍歷嘗試 + sys_log.error(f"[Security] 路徑遍歷攻擊嘗試被阻擋 | Filename: {filename} | Error: {e}") + return jsonify({'error': '非法路徑'}), 400 + except Exception as e: + sys_log.error(f"[System] 下載備份失敗 | Error: {e}") + return jsonify({'error': '下載失敗'}), 500 + +@app.route('/api/import_excel', methods=['POST']) +def import_excel(): + """ + API: 匯入 Excel/CSV 並自動建表 + 已加入檔案上傳安全驗證 (副檔名白名單、檔案名稱清理) + """ + try: + # 1. 檢查是否有上傳檔案 + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400 + + file = request.files['file'] + + # 2. 使用安全驗證函數 + is_valid, error_msg, safe_name = validate_upload_file(file) + if not is_valid: + sys_log.warning(f"[Security] 檔案上傳驗證失敗 | Filename: {file.filename} | Error: {error_msg}") + return jsonify({'status': 'error', 'message': error_msg}), 400 + + sys_log.info(f"[Web] [Import] 檔案上傳驗證通過 | Original: {file.filename} | Safe: {safe_name}") + + # 3. 根據副檔名讀取檔案 + df = None + filename_lower = safe_name.lower() + + if filename_lower.endswith(('.xlsx', '.xls')): + try: + df = pd.read_excel(file, engine='openpyxl', dtype=str) + except Exception as e: + return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + elif filename_lower.endswith('.csv'): + try: + # V-New: 嘗試用多種編碼讀取 CSV + try: + df = pd.read_csv(file, dtype=str) + except UnicodeDecodeError: + file.seek(0) # 重置文件指針 + df = pd.read_csv(file, encoding='big5', dtype=str) + except Exception as e: + return jsonify({'status': 'error', 'message': f'CSV 讀取失敗: {str(e)}'}), 500 + else: + # 理論上不會到這裡,因為 validate_upload_file 已經檢查過 + return jsonify({'status': 'error', 'message': '不支援的檔案格式'}), 400 + + if df is None: + return jsonify({'status': 'error', 'message': '無法讀取檔案內容'}), 500 + + # V-New: 增加日誌以確認目前為原始匯入模式 (提醒使用者已略過清理) + sys_log.info("[Web] [Import] ⚠️ 偵測到原始匯入模式 (Raw Import Mode) - 已略過智慧清理") + + # V-Fix: 1. 先標準化欄位名稱,確保後續關鍵字比對準確 + # df.columns = [str(c).strip().replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '').replace('/', '_') for c in df.columns] + + # V-Fix: 2. 執行智慧資料清理 (v3 保守模式 - 解決 'F' 被強制轉 0 的問題) + # sys_log.info("[Web] [Import] 執行智慧資料清理程序 (v3 保守模式)...") + + # 定義必須是數值的欄位關鍵字 (這些欄位必須是數字,髒資料轉 0 以免影響計算) + # numeric_keywords = ['序號', '數量', '單價', '金額', '成本', '毛利', '售價', '應收', '營收', + # 'Quantity', 'Qty', 'Price', 'Amount', 'Cost', 'Profit', 'Sales', 'Revenue'] + + # for col in df.columns: + # # 判斷是否為強制數值欄位 + # is_force_numeric = any(k in col for k in numeric_keywords) + + # if df[col].dtype == 'object': + # if is_force_numeric: + # # 策略 A: 強制數值欄位 -> 激進清理 (保留數字,其餘轉 0) + # # 先移除千分位逗號等非數值字符 + # cleaned_series = df[col].astype(str).str.replace(r'[^\d.-]', '', regex=True) + # converted_series = pd.to_numeric(cleaned_series, errors='coerce') + # df[col] = converted_series.fillna(0) + # sys_log.info(f"[Web] [Import] 強制清理數值欄位 '{col}' (髒資料已轉為 0)") + # else: + # # 策略 B: 一般欄位 -> 保守檢查 (保留 'F' 等文字) + # # 直接嘗試轉換,不移除文字 + # converted_series = pd.to_numeric(df[col], errors='coerce') + + # # 檢查有多少值變成了 NaN (原本不是 NaN/空字串,但轉換後變成 NaN 的) + # original_valid_mask = df[col].notna() & (df[col].astype(str).str.strip() != '') + # converted_valid_mask = converted_series.notna() + # loss_count = (original_valid_mask & ~converted_valid_mask).sum() + + # if loss_count == 0: + # # 如果沒有資料損失 (代表全是數字或空值),才轉換 + # df[col] = converted_series + # else: + # # 有資料損失 (例如包含 'F'),保留為文字 + # sys_log.info(f"[Web] [Import] 欄位 '{col}' 保留為文字 (含 {loss_count} 筆非數值資料,如 'F')") + + # 識別檔案類型 + is_daily_sales = '即時業績' in file.filename and '當日' in file.filename + is_sales_report = '即時業績' in file.filename and '全月' in file.filename + + if is_daily_sales: + table_name = 'daily_sales_snapshot' + + # V-New: 智慧匯入 - 根據 Excel 內的日期欄位自動拆分 snapshot_date + date_col = None + for possible_col in ['日期', '訂單日期', '交易日期', 'Date']: + if possible_col in df.columns: + date_col = possible_col + break + + if date_col: + # 使用 Excel 內的日期欄位作為 snapshot_date + sys_log.info(f"[Web] [Import] 使用 Excel 內的「{date_col}」欄位作為快照日期") + + # 將日期欄位轉換為標準格式 YYYY-MM-DD + df['snapshot_date'] = pd.to_datetime(df[date_col], errors='coerce').dt.strftime('%Y-%m-%d') + + # 移除無效日期的資料 + invalid_count = df['snapshot_date'].isna().sum() + if invalid_count > 0: + sys_log.warning(f"[Web] [Import] 發現 {invalid_count} 筆無效日期資料,已移除") + df = df.dropna(subset=['snapshot_date']) + + unique_dates = df['snapshot_date'].nunique() + sys_log.info(f"[Web] [Import] 識別為當日業績報表,包含 {unique_dates} 個不同日期") + else: + # 備用方案:從檔名提取日期 + snapshot_date = extract_snapshot_date_from_filename(file.filename) + if not snapshot_date: + return jsonify({'status': 'error', 'message': '無法從檔名提取日期,且 Excel 中無日期欄位'}), 400 + df['snapshot_date'] = snapshot_date + sys_log.info(f"[Web] [Import] Excel 無日期欄位,使用檔名日期: {snapshot_date}") + elif is_sales_report: + table_name = 'realtime_sales_monthly' + else: + filename_no_ext = os.path.splitext(file.filename)[0] + table_name = re.sub(r'[^\w\u4e00-\u9fff]+', '_', filename_no_ext).strip('_') + + if not table_name: table_name = f"import_{int(time.time())}" + + db = DatabaseManager() + engine = db.engine + + # V-Debug: 顯示實際寫入的資料庫路徑 + sys_log.info(f"[Web] [Import] 正在寫入資料庫: {engine.url}") + + if table_name in ['realtime_sales_monthly', 'daily_sales_snapshot']: + try: + # V-Fix: 實作自動去重邏輯 (Deduplication) + # 1. 檢查資料表是否存在 + inspector = inspect(engine) + if not inspector.has_table(table_name): + sys_log.info(f"[Web] [Import] 資料表不存在,建立新表: {table_name}") + df.to_sql(table_name, con=engine, if_exists='replace', index=False) + rows_imported = len(df) + message = f'匯入成功!已建立新資料表並寫入 {rows_imported} 筆資料。' + else: + sys_log.info(f"[Web] [Import] 資料表已存在,執行自動去重 (Deduplication)...") + + # 2. 讀取現有資料(優化:僅讀取相關日期的資料以進行去重) + try: + # 嘗試根據 incoming df 的日期範圍來過濾現有資料 + filter_clause = "" + if '日期' in df.columns: + # V-Fix: 確保日期格式與資料庫一致 (YYYY/MM/DD) 以便 SQL IN 查詢能正確比對 + # 有時 Pandas 會將其轉換為 datetime 或 2024-01-01 格式 + temp_dates = pd.to_datetime(df['日期'], errors='coerce') + unique_dates = temp_dates.dropna().dt.strftime('%Y/%m/%d').unique() + + if len(unique_dates) > 0: + date_list = "', '".join([str(d) for d in unique_dates]) + filter_clause = f" WHERE 日期 IN ('{date_list}')" + sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個日期相關的現有資料 (範例: {unique_dates[0]})") + elif 'snapshot_date' in df.columns: + unique_dates = df['snapshot_date'].dropna().unique() + if len(unique_dates) > 0: + date_list = "', '".join([str(d) for d in unique_dates]) + filter_clause = f" WHERE snapshot_date IN ('{date_list}')" + sys_log.info(f"[Web] [Import] 🔍 優化去重:僅讀取 {len(unique_dates)} 個快照日期相關的現有資料") + + if filter_clause: + # V-Security: 使用參數化查詢,防止 SQL Injection + # 注意:SQLAlchemy 的 text() 配合綁定參數 + # 針對 'IN' 查詢,SQLite 支援綁定多個參數 + try: + from sqlalchemy import text + + # 準備參數字典,例如 {'d0': '2024/01/01', 'd1': '2024/01/02', ...} + params = {f"d{i}": str(d) for i, d in enumerate(unique_dates)} + param_names = ", ".join([f":d{i}" for i in range(len(unique_dates))]) + + date_col = "日期" if '日期' in df.columns else "snapshot_date" + sql = text(f"SELECT * FROM {table_name} WHERE {date_col} IN ({param_names})") + + # V-Debug + # sys_log.debug(f"[Web] [Import] SQL Filter: {sql} with {len(params)} params") + + # Pandas read_sql 支援與參數一起執行 + df_existing = pd.read_sql(sql, con=engine, params=params) + except Exception as sql_err: + sys_log.error(f"[Web] [Import] 建立參數化 SQL 失敗: {sql_err}") + # 備用方案 (Sanitized) + df_existing = safe_read_sql(table_name, engine=engine) + else: + # 備用方案:若無日期欄位,仍讀取全表 + sys_log.warning(f"[Web] [Import] ⚠️ 無法根據日期過濾,讀取全表進行去重 (可能效能較差)") + df_existing = safe_read_sql(table_name, engine=engine) + + except Exception as e: + sys_log.warning(f"[Web] [Import] ⚠️ 讀取舊資料失敗 ({e}),略過去重直接累加。") + df_existing = pd.DataFrame() + + rows_to_write = df + + if not df_existing.empty: + # 3. 執行比對 (找出共有欄位) + common_cols = list(set(df.columns) & set(df_existing.columns)) + + # 針對 daily_sales_snapshot 使用特定去重鍵 + if table_name == 'daily_sales_snapshot': + # 優先使用 snapshot_date + 訂單編號 + if 'snapshot_date' in common_cols and '訂單編號' in common_cols: + common_cols = ['snapshot_date', '訂單編號'] + sys_log.info(f"[Web] [Import] 使用去重鍵: snapshot_date + 訂單編號") + elif 'snapshot_date' in common_cols: + # 備用方案:使用所有共有欄位 + sys_log.info(f"[Web] [Import] 使用全欄位去重 (共 {len(common_cols)} 個欄位)") + + if common_cols: + # 轉換為字串以確保比對準確 (處理 NaN 與型別差異) + # V-Fix: 加強去重邏輯,處理 '100.0' vs '100' 的問題 + def normalize_series(s): + return s.astype(str).str.strip().str.replace(r'\.0$', '', regex=True) + + df_str = df[common_cols].apply(normalize_series).fillna('') + existing_str = df_existing[common_cols].apply(normalize_series).fillna('') + + # 移除 df_existing 中的重複項 (優化 merge 效能) + existing_str = existing_str.drop_duplicates() + + # 使用 merge 找出 df 中已存在的資料 + merged = df_str.merge(existing_str, on=common_cols, how='left', indicator=True) + + # 只保留 'left_only' 的資料 (即新資料) + rows_to_write = df[merged['_merge'] == 'left_only'] + + duplicates_count = len(df) - len(rows_to_write) + sys_log.info(f"[Web] [Import] 🔍 自動去重: 發現 {duplicates_count} 筆重複資料,已忽略。") + + # 4. 寫入新資料 + if not rows_to_write.empty: + rows_to_write.to_sql(table_name, con=engine, if_exists='append', index=False) + rows_imported = len(rows_to_write) + message = f'匯入成功!已去重並新增 {rows_imported} 筆資料。' + else: + rows_imported = 0 + message = '匯入完成,但所有資料皆已存在 (重複),無新增數據。' + + # V-Fix: 無條件清除快取,確保行事曆能夠顯示最新資料 + # 原問題:只有 rows_imported > 0 時才清除快取,導致匯入後行事曆不更新 + if table_name in _SALES_DF_CACHE: + del _SALES_DF_CACHE[table_name] + sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}") + + # V-Opt: 清除所有相關的處理後快取(包含不同 data_range 的快取) + cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)] + for cache_key in cache_keys_to_delete: + del _SALES_PROCESSED_CACHE[cache_key] + sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}") + + return jsonify({'status': 'success', 'message': message, 'rows': rows_imported, 'table': table_name}) + + except Exception as de: + sys_log.error(f"[Web] [Import] 業績報表匯入去重或寫入時發生錯誤: {de}") + return jsonify({'status': 'error', 'message': f'業績報表匯入失敗: {de}'}), 500 + else: + # 對於非業績報表,維持覆蓋邏輯 + sys_log.info(f"[Web] [Import] 使用覆蓋模式 (replace)寫入資料表: {table_name}") + df.to_sql(table_name, con=engine, if_exists='replace', index=False) + + if table_name in _SALES_DF_CACHE: + del _SALES_DF_CACHE[table_name] + sys_log.info(f"[Web] [Cache] 🧹 已清除資料表快取: {table_name}") + + # V-Opt: 清除所有相關的處理後快取 + cache_keys_to_delete = [key for key in _SALES_PROCESSED_CACHE.keys() if key.startswith(table_name)] + for cache_key in cache_keys_to_delete: + del _SALES_PROCESSED_CACHE[cache_key] + sys_log.info(f"[Web] [Cache] 🧹 已清除處理後快取: {cache_key}") + + return jsonify({'status': 'success', 'message': f'通用匯入成功!資料已覆蓋至 {table_name}。', 'rows': len(df), 'table': table_name}) + + except Exception as e: + sys_log.error(f"[Web] [Import] ❌ 檔案匯入發生嚴重錯誤 | Error: {str(e)}") + return jsonify({'status': 'error', 'message': f'檔案匯入失敗: {str(e)}'}), 500 + +@app.route('/api/import/monthly_summary', methods=['POST']) +def import_monthly_summary(): + """API: 匯入月份總表數據分析""" + try: + if 'file' not in request.files: + return jsonify({'status': 'error', 'message': '未上傳檔案'}), 400 + + file = request.files['file'] + is_valid, error_msg, safe_name = validate_upload_file(file) + if not is_valid: + sys_log.warning(f"[Security] 月份總表上傳驗證失敗: {error_msg}") + return jsonify({'status': 'error', 'message': error_msg}), 400 + + # 讀取 Excel + try: + df = pd.read_excel(file, engine='openpyxl') + except Exception as e: + return jsonify({'status': 'error', 'message': f'Excel 讀取失敗: {str(e)}'}), 500 + + if df.empty: + return jsonify({'status': 'error', 'message': '檔案內容為空'}), 400 + + # 欄位對照表 (對應 Excel 繁體中文標題與資料庫英文欄位) + mapping = { + '年': 'year', '月': 'month', '商品部': 'department', '3C百貨': 'category_3c', + '處別': 'division', '科別': 'section', '區ID': 'area_id', '區名稱': 'area_name', + '商品_PM': 'pm_name', '品牌名稱_合併': 'brand_name', '廠商編號': 'vendor_id', + '廠商名稱': 'vendor_name', '借採轉': 'trade_type', '件單價': 'unit_price', + '銷售額_本月': 'sales_amt_curr', '銷售額_上月': 'sales_amt_prev', '銷售額_去年同期': 'sales_amt_yoa', + '毛1額_本月': 'profit_amt_curr', '毛1額_上月': 'profit_amt_prev', '毛1額_去年同期': 'profit_amt_yoa', + '折扣金額_本月': 'discount_amt_curr', '折扣金額_上月': 'discount_amt_prev', '折扣金額_去年同期': 'discount_amt_yoa', + '折價券_本月': 'coupon_amt_curr', '折價券_上月': 'coupon_amt_prev', '折價券_去年同期': 'coupon_amt_yoa', + '其他行銷活動_本月': 'other_mkt_curr', '其他行銷活動_上月': 'other_mkt_prev', '其他行銷活動_去年同期': 'other_mkt_yoa', + '點我折_本月': 'spot_disc_curr', '點我折_上月': 'spot_disc_prev', '點我折_去年同期': 'spot_disc_yoa', + '點數折抵_本月': 'point_disc_curr', '點數折抵_上月': 'point_disc_prev', '點數折抵_去年同期': 'point_disc_yoa', + '銷售量_本月': 'sales_vol_curr', '銷售量_上月': 'sales_vol_prev', '銷售量_去年同期': 'sales_vol_yoa', + '轉換率': 'conv_rate', '瀏覽數_本月': 'views_curr', '瀏覽數_上月': 'views_prev', '瀏覽數_去年同期': 'views_yoa' + } + + # 檢查必備欄位 (寬鬆檢查:只要有 mapping 中的欄位就匯入) + current_cols = df.columns.tolist() + import_mapping = {k: v for k, v in mapping.items() if k in current_cols} + + if len(import_mapping) < 5: # 至少要有幾個維度 + return jsonify({'status': 'error', 'message': '檔案欄位不符,請確認是否為正確的月份業績總表'}), 400 + + # 重新命名與清理資料 + target_df = df[list(import_mapping.keys())].rename(columns=import_mapping) + + # 轉換數值欄位,填補 NaN + numeric_cols = [v for k, v in import_mapping.items() if v not in [ + 'department', 'category_3c', 'division', 'section', 'area_id', 'area_name', + 'pm_name', 'brand_name', 'vendor_name', 'trade_type' + ]] + for col in numeric_cols: + target_df[col] = pd.to_numeric(target_df[col], errors='coerce').fillna(0) + + # 寫入資料庫 - 優化效能版本 (Phase 9 Optimization) + db = DatabaseManager() + engine = db.engine + + try: + # 取得要匯入的年月份,用於先行刪除重複資料 + years_months = target_df[['year', 'month']].drop_duplicates() + + with engine.begin() as conn: + # 1. 刪除該月份舊資料 (Transaction 開始) + for _, row in years_months.iterrows(): + conn.execute(text("DELETE FROM monthly_summary_analysis WHERE year = :y AND month = :m"), + {'y': int(row['year']), 'm': int(row['month'])}) + + # 2. 批量寫入 (使用 multi 方法加速,SQLite chunksize 建議 2000 避免參數過多) + # 比照 realtime_sales_monthly 的優化方式 + target_df.to_sql('monthly_summary_analysis', + con=conn, + if_exists='append', + index=False, + chunksize=2000, + method='multi') + + except Exception as e: + sys_log.error(f"[Web] [Import] 匯入資料庫失敗: {e}") + raise e + + + sys_log.info(f"[Web] [Import] 🚀 月份總表資料匯入成功 | 筆數: {len(target_df)}") + return jsonify({ + 'status': 'success', + 'message': f'成功匯入 {len(target_df)} 筆分析數據。', + 'rows': len(target_df) + }) + + except Exception as e: + sys_log.error(f"[Web] [Import] ❌ 月份總表匯入嚴重失敗: {str(e)}") + return jsonify({'status': 'error', 'message': f'匯入失敗: {str(e)}'}), 500 + +@app.route('/monthly_summary_analysis') +def monthly_summary_analysis_page(): + """月份總表數據分析展示頁 (Phase 9)""" + return render_template('monthly_summary_analysis.html', + datetime_now=datetime.now(TAIPEI_TZ).strftime('%Y-%m-%d %H:%M:%S'), + system_version=SYSTEM_VERSION) + +@app.route('/api/monthly_summary_data') +def get_monthly_summary_data(): + """API: 取得月份總表數據與分析指標 (Phase 9)""" + year = request.args.get('year', type=int) + month = request.args.get('month', type=int) + division = request.args.get('division') + pm_name = request.args.get('pm_name') + brand_name = request.args.get('brand_name') + vendor_name = request.args.get('vendor') + area_name = request.args.get('area_name') + trade_type = request.args.get('trade_type') + limit = request.args.get('limit', default=1000, type=int) + + # DEBUG LOGGING + import logging + debug_logger = logging.getLogger('app') + debug_logger.info(f"🔍 [API Debug] Request Args: {request.args}") + + db = DatabaseManager() + session = db.get_session() + try: + # 基礎查詢 + query = session.query(MonthlySummaryAnalysis) + + # 套用過濾 + if year: query = query.filter(MonthlySummaryAnalysis.year == year) + if month: query = query.filter(MonthlySummaryAnalysis.month == month) + if division: query = query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: query = query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: query = query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: query = query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + query = query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + query = query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: query = query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + # 取得統計數據 (KPIs) + kpi_query = session.query( + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('total_sales'), + func.sum(MonthlySummaryAnalysis.sales_amt_prev).label('total_sales_prev'), + func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('total_sales_yoa'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('total_profit'), + func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('total_vol'), + func.sum(MonthlySummaryAnalysis.views_curr).label('total_views') + ) + + # 同樣套用過濾到 KPI + if year: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.year == year) + if month: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.month == month) + if division: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + kpi_query = kpi_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: kpi_query = kpi_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + kpi_res = kpi_query.one() + + # 取得總筆數與月數 + total_rows = session.query(func.count(MonthlySummaryAnalysis.id)) + total_months_query = session.query(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).distinct() + + if year: + total_rows = total_rows.filter(MonthlySummaryAnalysis.year == year) + total_months_query = total_months_query.filter(MonthlySummaryAnalysis.year == year) + if month: + total_rows = total_rows.filter(MonthlySummaryAnalysis.month == month) + + total_rows = total_rows.scalar() + total_months = total_months_query.count() + + # 取得趨勢數據 (按月加總) + trend_query = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + + if division: trend_query = trend_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: trend_query = trend_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + trend_query = trend_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: trend_query = trend_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + # 取得排行榜 (Top 10 Brands) + rank_query = session.query( + MonthlySummaryAnalysis.brand_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).group_by(MonthlySummaryAnalysis.brand_name) + + if year: rank_query = rank_query.filter(MonthlySummaryAnalysis.year == year) + if month: rank_query = rank_query.filter(MonthlySummaryAnalysis.month == month) + if division: rank_query = rank_query.filter(MonthlySummaryAnalysis.division == division) + if pm_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: rank_query = rank_query.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + rank_query = rank_query.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: rank_query = rank_query.filter(MonthlySummaryAnalysis.trade_type == trade_type) + + rank_query = rank_query.order_by(desc('sales')).limit(10) + + # 取得明細資料 + rows_query = query.order_by( + MonthlySummaryAnalysis.year.desc(), + MonthlySummaryAnalysis.month.desc(), + MonthlySummaryAnalysis.sales_amt_curr.desc() + ).limit(limit) + + # --- 📊 V-New: 進階分析子查詢 (Phase 17) --- + def apply_filters(q, ignore_year=False): + if year and not ignore_year: q = q.filter(MonthlySummaryAnalysis.year == year) + if month: q = q.filter(MonthlySummaryAnalysis.month == month) + if division: q = q.filter(MonthlySummaryAnalysis.division == division) + if pm_name: q = q.filter(MonthlySummaryAnalysis.pm_name == pm_name) + if brand_name: q = q.filter(MonthlySummaryAnalysis.brand_name == brand_name) + if vendor_name: q = q.filter(MonthlySummaryAnalysis.vendor_name == vendor_name) + if area_name: + if ',' in area_name: + q = q.filter(MonthlySummaryAnalysis.area_name.in_(area_name.split(','))) + else: + q = q.filter(MonthlySummaryAnalysis.area_name == area_name) + if trade_type: q = q.filter(MonthlySummaryAnalysis.trade_type == trade_type) + return q + + # 廠商排行 + # 廠商排行 (Top 20, 分年度) + # 廠商排行 (Top 20, 分年度) + vendor_rank_q = session.query( + MonthlySummaryAnalysis.vendor_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.profit_amt_curr), else_=0)).label('profit_2025'), + ).group_by(MonthlySummaryAnalysis.vendor_name) + + vendor_rank_q = apply_filters(vendor_rank_q, ignore_year=True) + vendor_rank_q = vendor_rank_q.order_by(desc('sales')).limit(20) + + # 區域分佈 (按 area_name, Top 12, 分年度) + div_dist_q = session.query( + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by(MonthlySummaryAnalysis.area_name) + + div_dist_q = apply_filters(div_dist_q, ignore_year=True) + div_dist_q = div_dist_q.order_by(desc('sales')).limit(12) + + # 價格帶貢獻 (分年度) + price_cont_q = session.query( + case( + (MonthlySummaryAnalysis.unit_price < 500, '0-499'), + (MonthlySummaryAnalysis.unit_price < 1000, '500-999'), + (MonthlySummaryAnalysis.unit_price < 2000, '1,000-1,999'), + (MonthlySummaryAnalysis.unit_price < 5000, '2,000-4,999'), + (MonthlySummaryAnalysis.unit_price < 10000, '5,000-9,999'), + else_='10,000+' + ).label('price_range'), + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by('price_range') + price_cont_q = apply_filters(price_cont_q, ignore_year=True) + + # BCG 矩陣 (品牌 x 區域) + bcg_q = session.query( + MonthlySummaryAnalysis.brand_name, + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_vol_curr).label('vol'), + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(MonthlySummaryAnalysis.profit_amt_curr).label('profit') + ).group_by(MonthlySummaryAnalysis.brand_name, MonthlySummaryAnalysis.area_name)\ + .having(func.sum(MonthlySummaryAnalysis.sales_amt_curr) > 0) + + bcg_q = apply_filters(bcg_q) + bcg_q = bcg_q.order_by(desc('sales')).limit(100) + + # 熱力圖 (月份 x 分類) + # V-Opt: 預先執行一次 div_dist_q,避免重複查詢 + div_dist_results = div_dist_q.all() + top_12_areas = [r.area_name for r in div_dist_results] + + heatmap_q = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales') + ).filter(MonthlySummaryAnalysis.area_name.in_(top_12_areas))\ + .group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month, MonthlySummaryAnalysis.area_name)\ + .order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + heatmap_q = apply_filters(heatmap_q, ignore_year=True) + + # Highlights (Top 3) + def get_highlights_q(metric_col): + q = session.query(MonthlySummaryAnalysis.brand_name, func.sum(metric_col).label('val')) + q = apply_filters(q) + q = q.group_by(MonthlySummaryAnalysis.brand_name).order_by(desc('val')).limit(3) + return q + + rev_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_amt_curr) + profit_top_q = get_highlights_q(MonthlySummaryAnalysis.profit_amt_curr) + vol_top_q = get_highlights_q(MonthlySummaryAnalysis.sales_vol_curr) + + # 區域排行 + area_rank_q = session.query( + MonthlySummaryAnalysis.area_name, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales'), + func.sum(case((MonthlySummaryAnalysis.year == 2024, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2024'), + func.sum(case((MonthlySummaryAnalysis.year == 2025, MonthlySummaryAnalysis.sales_amt_curr), else_=0)).label('sales_2025') + ).group_by(MonthlySummaryAnalysis.area_name) + + area_rank_q = apply_filters(area_rank_q, ignore_year=True) + area_rank_q = area_rank_q.order_by(desc('sales')) + + # 年度對比趨勢 (需要包含本期與去年同期) + # 年度對比趨勢 (需要包含本期與去年同期) + yoy_trend_q = session.query( + MonthlySummaryAnalysis.year, + MonthlySummaryAnalysis.month, + func.sum(MonthlySummaryAnalysis.sales_amt_curr).label('sales_curr'), + func.sum(MonthlySummaryAnalysis.sales_amt_yoa).label('sales_yoa') + ) + yoy_trend_q = apply_filters(yoy_trend_q) + yoy_trend_q = yoy_trend_q.group_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month).order_by(MonthlySummaryAnalysis.year, MonthlySummaryAnalysis.month) + + rows = [] + for r in rows_query.all(): + rows.append({ + 'year': r.year, + 'month': r.month, + 'division': r.division, + 'pm_name': r.pm_name, + 'area_name': r.area_name, + 'brand_name': r.brand_name, + 'vendor_name': r.vendor_name, + 'trade_type': r.trade_type, + 'sales_amt_curr': r.sales_amt_curr, + 'sales_amt_yoa': r.sales_amt_yoa, + 'sales_vol_curr': r.sales_vol_curr, + 'profit_amt_curr': r.profit_amt_curr, + 'views_curr': r.views_curr + }) + + # V-Opt: 使用單一 SQL 查詢取得所有不重複的維度列表 + with db.engine.connect() as conn: + filters_result = conn.execute(text(""" + SELECT + GROUP_CONCAT(DISTINCT year) as years, + GROUP_CONCAT(DISTINCT month) as months, + GROUP_CONCAT(DISTINCT division) as divisions, + GROUP_CONCAT(DISTINCT pm_name) as pms, + GROUP_CONCAT(DISTINCT area_name) as areas, + GROUP_CONCAT(DISTINCT vendor_name) as vendors, + GROUP_CONCAT(DISTINCT trade_type) as trades + FROM monthly_summary_analysis + """)).fetchone() + + years_list = [int(x) for x in (filters_result[0] or '').split(',') if x] + months_list = [int(x) for x in (filters_result[1] or '').split(',') if x] + divisions_list = [x for x in (filters_result[2] or '').split(',') if x] + pms_list = [x for x in (filters_result[3] or '').split(',') if x] + areas_list = [x for x in (filters_result[4] or '').split(',') if x] + vendors_list = [x for x in (filters_result[5] or '').split(',') if x] + trades_list = [x for x in (filters_result[6] or '').split(',') if x] + # V-Opt: 預先執行所有查詢,避免在 jsonify 中重複執行 + area_rank_results = area_rank_q.all() + vendor_rank_results = vendor_rank_q.all() + price_cont_results = price_cont_q.all() + bcg_results = bcg_q.all() + heatmap_results = heatmap_q.all() + trend_results = trend_query.all() + yoy_trend_results = yoy_trend_q.all() + rank_results = rank_query.all() + rev_top_results = rev_top_q.all() + profit_top_results = profit_top_q.all() + vol_top_results = vol_top_q.all() + + return jsonify({ + 'status': 'success', + 'total_rows': total_rows, + 'total_months': total_months, + 'kpis': { + 'sales': int(kpi_res.total_sales or 0), + 'sales_prev': int(kpi_res.total_sales_prev or 0), + 'sales_yoa': int(kpi_res.total_sales_yoa or 0), + 'profit': int(kpi_res.total_profit or 0), + 'vol': int(kpi_res.total_vol or 0), + 'views': int(kpi_res.total_views or 0), + 'margin': round((kpi_res.total_profit / kpi_res.total_sales * 100), 2) if kpi_res.total_sales and kpi_res.total_profit else 0 + }, + 'trend': [{'date': f"{r.year}/{r.month}", 'sales': int(r.sales or 0)} for r in trend_results], + 'yoy_trend': [{'date': f"{r.year}/{r.month}", 'curr': int(r.sales_curr or 0), 'yoa': int(r.sales_yoa or 0)} for r in yoy_trend_results], + 'rankings': [{'brand': r.brand_name, 'sales': int(r.sales or 0)} for r in rank_results], + 'area_ranking': [ + { + 'name': r.area_name, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in area_rank_results + ], + 'vendor_ranking': [ + { + 'name': r.vendor_name, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0), + 'profit': int(r.profit or 0), + 'profit_2024': int(r.profit_2024 or 0), + 'profit_2025': int(r.profit_2025 or 0), + 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0 + } + for r in vendor_rank_results + ], + 'division_dist': [ + { + 'name': r.area_name, + 'value': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in div_dist_results + ], + 'price_contribution': [ + { + 'range': r.price_range, + 'sales': int(r.sales or 0), + 'sales_2024': int(r.sales_2024 or 0), + 'sales_2025': int(r.sales_2025 or 0) + } + for r in price_cont_results + ], + 'bcg_data': [ + {'name': f"{r.brand_name}-{r.area_name}", 'qty': int(r.vol or 0), 'margin': round((r.profit/r.sales*100), 2) if r.sales and r.profit else 0, 'sales': int(r.sales or 0)} + for r in bcg_results + ], + 'heatmap_data': [ + {'year': r.year, 'month': r.month, 'category': r.area_name, 'sales': int(r.sales or 0)} + for r in heatmap_results + ], + 'highlights': { + 'rev_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in rev_top_results], + 'profit_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in profit_top_results], + 'vol_top': [{'name': r.brand_name, 'value': int(r.val or 0)} for r in vol_top_results] + }, + 'filters': { + 'years': sorted(years_list, reverse=True), + 'months': sorted(months_list), + 'divisions': sorted(divisions_list), + 'pms': sorted(pms_list), + 'areas': sorted(areas_list), + 'vendors': sorted(vendors_list), + 'trades': sorted(trades_list) + }, + 'rows': rows + }) + + + + except Exception as e: + sys_log.error(f"取得月份總表數據失敗: {e}") + return jsonify({'status': 'error', 'message': str(e)}), 500 + finally: + session.close() + + + +# ================= 📊 V-New: 業績分析報表 ================= + + +def _get_filtered_sales_data(cache_key): + """ + 🚩 共用函式:從快取讀取資料並根據 request.args 進行篩選 + 回傳: (target_df, cols_map, error_message) + 參數: cache_key - 快取鍵值 (例如: "realtime_sales_monthly_3m") + """ + db = DatabaseManager() + + # 1. 檢查資料表與快取 + df = None + cols_map = {} + + if cache_key in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[cache_key] + df = cache_data['df'] + cols_map = cache_data['cols'] + else: + # V-Fix: 增加自動重載邏輯,如果快取不存在,試圖從資料庫載入 + sys_log.warning(f"[Sales Analysis] ⚠️ 快取不存在 ({cache_key}),試圖重新從資料庫載入...") + try: + # 判斷是自訂區間還是標配區間 + if "_custom_" in cache_key: + # 格式: realtime_sales_monthly_custom_2025-01-01_2025-01-31 + parts = cache_key.split('_custom_') + dates = parts[1].split('_') + start_d, end_d = dates[0], dates[1] + # 呼叫資料庫讀取 (不傳入 view, 會自動處理欄位映射) + result_df, result_cols = db.get_sales_data(table_name=table_name, start_date=start_d, end_date=end_d) + else: + # 格式: realtime_sales_monthly_1m + months = int(cache_key.split('_')[-1].replace('m', '') or '1') + result_df, result_cols = db.get_sales_data(table_name=table_name, months=months) + + if result_df is not None and not result_df.empty: + # 補回月份標籤供後續篩選 + if '日期' in result_df.columns: + result_df['_month_str'] = pd.to_datetime(result_df['日期']).dt.strftime('%Y-%m') + + # 自動存入快取 + _SALES_PROCESSED_CACHE[cache_key] = {'df': result_df, 'cols': result_cols, 'time': time.time()} + df = result_df + cols_map = result_cols + sys_log.info(f"[Sales Analysis] ✅ 快取成功自動重載 | 筆數: {len(df)}") + else: + return None, None, "資料庫無可用資料,請確認匯入狀態" + except Exception as ex: + sys_log.error(f"[Sales Analysis] 🚨 自動重載失敗: {ex}") + return None, None, f"快取失效且無法重載: {ex}" + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_date = cols_map.get('date') + col_return_qty = cols_map.get('return_qty') # V-New: 取得退貨欄位 + + # 2. 取得篩選參數 + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + # 3. 執行篩選 + target_df = df + + # Top N 分類處理 (用於 '其他' 篩選) + TOP_N_CATS = 12 + top_cats_names = [] + if col_category: + # 注意:這裡為了效能,簡單重算一次 Top N,或可考慮也快取起來 + cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + + if selected_category != 'all' and col_category: + # Top N 分類處理 (用於 '其他' 篩選) + TOP_N_CATS = 12 + # V-Opt: 從快取中讀取 Top N 分類,避免重算 + cache_data = _SALES_PROCESSED_CACHE.get(cache_key, {}) + top_cats_names = cache_data.get('top_cats') + + if top_cats_names is None: + cat_group_all = df.groupby(col_category)[cols_map.get('amount')].sum().sort_values(ascending=False) + if len(cat_group_all) > TOP_N_CATS: + top_cats_names = cat_group_all.head(TOP_N_CATS).index.tolist() + else: + top_cats_names = [] + # 回填快取以供下次使用 + if cache_key in _SALES_PROCESSED_CACHE: + _SALES_PROCESSED_CACHE[cache_key]['top_cats'] = top_cats_names + + if selected_category == '其他' and top_cats_names: + target_df = target_df[~target_df[col_category].isin(top_cats_names)] + else: + target_df = target_df[target_df[col_category] == selected_category] + + if selected_brand != 'all' and col_brand: target_df = target_df[target_df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: target_df = target_df[target_df[col_vendor] == selected_vendor] + if selected_activity != 'all' and col_activity: target_df = target_df[target_df[col_activity] == selected_activity] + if selected_payment != 'all' and col_payment: target_df = target_df[target_df[col_payment] == selected_payment] + + if selected_dow != 'all' and col_date: target_df = target_df[target_df['_dow'] == int(selected_dow)] + if selected_hour != 'all' and col_date: target_df = target_df[target_df['_hour'] == int(selected_hour)] + if selected_month != 'all' and col_date: target_df = target_df[target_df['_month_str'] == selected_month] + + if keyword: target_df = target_df[target_df[col_name].astype(str).str.contains(keyword, case=False, na=False)] + + if col_price: + if min_price: target_df = target_df[target_df[col_price] >= float(min_price)] + if max_price: target_df = target_df[target_df[col_price] <= float(max_price)] + + if min_margin: target_df = target_df[target_df['calculated_margin_rate'] >= float(min_margin)] + if max_margin: target_df = target_df[target_df['calculated_margin_rate'] <= float(max_margin)] + + return target_df, cols_map, None + +@app.route('/sales_analysis') +def sales_analysis(): + """V9.98: 業績分析儀表板 (效能優化版 + 穩定性增強)""" + now_taipei = datetime.now(TAIPEI_TZ) + datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S') + try: + # V-Fix: 定義全域默認變數以防止 Undefined 錯誤 + DEFAULT_COLS = { + 'name': None, 'date': None, 'amount': None, 'qty': None, + 'cat': None, 'category': None, 'brand': None, 'vendor': None, + 'activity': None, 'payment': None, 'price': None, 'cost': None, + 'profit': None, 'return_qty': None, 'pid': None + } + DEFAULT_KPI = {'revenue': 0, 'qty': 0, 'count': 0, 'sku_count': 0, 'cost': 0, 'gross_margin': 0, 'gross_margin_rate': 0, 'avg_price': 0} + DEFAULT_BAR_DATA = {'labels': [], 'chart_values': [], 'metric_label': ''} + DEFAULT_CHART_DATA = {'labels': [], 'chart_values': []} + + db = DatabaseManager() + + # 1. 處理參數 + table_name = request.args.get('table', 'realtime_sales_monthly') + + # 1. 檢查資料表是否存在 + inspector = inspect(db.engine) + if table_name not in inspector.get_table_names(): + return render_template('sales_analysis.html', + error="尚未匯入「即時業績(全月)」資料,請先至設定頁面匯入 Excel。", + table_name=table_name, + selected_metric='amount', + no_filter=False, + data_range_months=0, + start_date='', + end_date='', + total_records=0, + db_data_range='', + cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(), + items=[], bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + datetime_now=datetime_now_str) + + # V-New: 查詢資料庫的資料期間範圍 + db_data_range = '' + try: + # 取得日期欄位的最小值和最大值 + from sqlalchemy import text + date_query = text(f"SELECT MIN(日期) as min_date, MAX(日期) as max_date FROM {table_name}") + # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 + with db.engine.connect() as conn: + result = conn.execute(date_query).fetchone() + if result and result[0] and result[1]: + min_date = result[0] + max_date = result[1] + # 格式化為 YYYY年MM月 格式 + if isinstance(min_date, str): + try: + min_date_obj = datetime.strptime(min_date.split()[0], '%Y-%m-%d') + max_date_obj = datetime.strptime(max_date.split()[0], '%Y-%m-%d') + db_data_range = f"{min_date_obj.year}年{min_date_obj.month}月 ~ {max_date_obj.year}年{max_date_obj.month}月" + except: + db_data_range = f"{min_date} ~ {max_date}" + else: + db_data_range = f"{min_date.year}年{min_date.month}月 ~ {max_date.year}年{max_date.month}月" + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法取得資料期間範圍: {e}") + + # V-New: 取得篩選參數 + data_range_param = request.args.get('data_range', '') # 不再設預設值 + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + # V-New: 按需載入 - 如果沒有任何篩選條件,顯示引導頁面 + if not data_range_param and not start_date and not end_date: + sys_log.info("[Sales Analysis] 👋 首次進入頁面,等待用戶選擇篩選條件") + + # V-Fix: 即使在引導頁面,也要提供下拉選單選項 + # V-Opt: 讀取較多筆數(1000筆)以獲得更完整的月份資訊 + # V-Security: 使用安全的 SQL 讀取函式 + preview_df = safe_read_sql(table_name, engine=db.engine, limit=1000) + preview_categories = [] + preview_brands = [] + preview_vendors = [] + preview_activities = [] + preview_payments = [] + preview_months = [] # V-New: 新增月份列表 + + if not preview_df.empty: + cols = preview_df.columns.tolist() + def find_col(keywords): + for k in keywords: + for col in cols: + if k in str(col): return col + return None + + col_category = find_col(['館別', '商品館', '分類', 'Category']) + col_brand = find_col(['品牌', 'Brand']) + col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) + # V-Fix: 優先匹配具體的活動欄位名稱 + col_activity = find_col(['折扣活動名稱', '折價券活動名稱', '滿額再折扣活動名稱', '活動', 'Activity', 'Campaign']) + col_payment = find_col(['付款', 'Payment', 'Pay']) + # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) + col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) + col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) + + # V-Fix: 篩選掉空字串,只保留有效數據 + if col_category: + preview_categories = sorted([x for x in preview_df[col_category].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_brand: + preview_brands = sorted([x for x in preview_df[col_brand].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_vendor: + preview_vendors = sorted([x for x in preview_df[col_vendor].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_activity: + preview_activities = sorted([x for x in preview_df[col_activity].dropna().astype(str).unique().tolist() if x and x.strip()]) + if col_payment: + preview_payments = sorted([x for x in preview_df[col_payment].dropna().astype(str).unique().tolist() if x and x.strip()]) + + # V-Fix: 從數據庫直接查詢所有月份(而不是從預覽數據提取,避免只獲得部分月份) + if col_date_part: + try: + from sqlalchemy import text + with db.engine.connect() as conn: + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr("{col_date_part}", 1, 7), '/', '-') as month + FROM {table_name} + WHERE "{col_date_part}" IS NOT NULL AND "{col_date_part}" != '' + ORDER BY month + """)).fetchall() + preview_months = [row[0] for row in result if row[0] and '-' in str(row[0])] + sys_log.info(f"[Sales Analysis] 預覽模式從「{col_date_part}」欄位提取到 {len(preview_months)} 個月份") + except Exception as e: + sys_log.warning(f"[Sales Analysis] 無法從「{col_date_part}」欄位提取月份: {e}") + pass + + # 傳遞必要的變數以避免模板錯誤 + selected_metric = request.args.get('metric', 'amount') + # 建立空的數據結構 + empty_data = {'labels': [], 'chart_values': [], 'values': [], 'metric_label': ''} + return render_template('sales_analysis.html', + no_filter=True, + table_name=table_name, + selected_metric=selected_metric, + total_records=0, + cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(), + items=[], bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + scatter_data=None, + bcg_data=None, + seasonality_data=None, + dow_data=DEFAULT_CHART_DATA.copy(), + monthly_data=DEFAULT_CHART_DATA.copy(), + weekly_data=DEFAULT_CHART_DATA.copy(), + hourly_data=DEFAULT_CHART_DATA.copy(), + heatmap_data=None, + treemap_data=None, + all_categories=preview_categories, all_brands=preview_brands, all_vendors=preview_vendors, + all_activities=preview_activities, all_payments=preview_payments, all_months=preview_months, + selected_category='all', selected_brand='all', selected_vendor='all', + selected_activity='all', selected_payment='all', selected_dow='all', + selected_hour='all', selected_month='all', + keyword='', min_price='', max_price='', min_margin='', max_margin='', + data_range_months=0, start_date='', end_date='', + db_data_range=db_data_range, + marketing_data=None, + datetime_now=datetime_now_str) + + # 解析 data_range_months(有篩選時才處理) + data_range_months = int(data_range_param or '0') + + # V-New: 如果有自訂日期區間,則優先使用 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 2. 讀取與處理資料 (V-Opt: 使用二級快取機制 Raw -> Processed) + df = None + cols_map = {} + + # A. 優先檢查是否已有處理好的快取 (最快) + TTL 驗證 + cache_hit = False + if cache_key in _SALES_PROCESSED_CACHE: + cache_data = _SALES_PROCESSED_CACHE[cache_key] + cache_time = cache_data.get('time', 0) + # V-Opt: 加入 TTL 檢查(10 分鐘有效期) + if time.time() - cache_time < _SALES_CACHE_TTL: + df = cache_data['df'] + cols_map = cache_data['cols'] + cache_hit = True + sys_log.debug(f"[Sales Analysis] ✅ 快取命中: {cache_key} (age: {time.time() - cache_time:.0f}s)") + else: + sys_log.debug(f"[Sales Analysis] ⏰ 快取過期: {cache_key} (age: {time.time() - cache_time:.0f}s > {_SALES_CACHE_TTL}s)") + del _SALES_PROCESSED_CACHE[cache_key] + + if cache_hit: + + # 恢復欄位變數 + col_name = cols_map.get('name') + col_date = cols_map.get('date') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_pid = cols_map.get('pid') # V-New: 取得 PID 欄位 + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_return_qty = cols_map.get('return_qty') + + cached_pie_data = cache_data.get('pie_data', {'labels': [], 'chart_values': []}) # V-Opt: 讀取圓餅圖快取 + else: + # B. 若無處理後快取,則從 Raw Cache 或 DB 讀取並處理 + # V-Opt: 加入日期範圍篩選以減少記憶體使用 + # (data_range_months 已在上方定義) + + # 先讀取小樣本以識別日期欄位 (Security: Sanitized) + sample_df = safe_read_sql(table_name, engine=db.engine, limit=100) + if sample_df.empty: + return render_template('sales_analysis.html', + error="資料表為空,請重新匯入。", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None, + cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(), + items=[], bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + datetime_now=datetime_now_str) + + # 自動識別日期欄位(V-Fix: 優先匹配「日期」,因為「訂單日期」可能是固定文字) + sample_cols = sample_df.columns.tolist() + date_col_name = None + for col in sample_cols: + if any(keyword in str(col) for keyword in ['日期', '交易日期', 'Date', '訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']): + date_col_name = col + break + + # 根據是否有日期欄位決定查詢方式 + where_clause = None + params = {} + + if date_col_name: + + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + # 有自訂日期區間 - 使用 BETWEEN 或單邊範圍 + if start_date and end_date: + where_clause = f'"{date_col_name}" BETWEEN :start AND :end' + params = {'start': start_date_slash, 'end': end_date_slash} + sys_log.info(f"[Sales Analysis] 📅 使用自訂日期範圍: {start_date} ~ {end_date}") + elif start_date: + where_clause = f'"{date_col_name}" >= :start' + params = {'start': start_date_slash} + sys_log.info(f"[Sales Analysis] 📅 使用自訂開始日期: >= {start_date}") + else: # only end_date + where_clause = f'"{date_col_name}" <= :end' + params = {'end': end_date_slash} + sys_log.info(f"[Sales Analysis] 📅 使用自訂結束日期: <= {end_date}") + elif data_range_months > 0: + # 使用相對日期範圍(最近N個月) + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + where_clause = f'"{date_col_name}" >= :cutoff' + params = {'cutoff': cutoff_date} + sys_log.info(f"[Sales Analysis] 📊 使用日期範圍篩選: 最近 {data_range_months} 個月") + else: + sys_log.info(f"[Sales Analysis] 📊 載入全部資料(用戶選擇)") + else: + sys_log.info(f"[Sales Analysis] ⚠️ 未找到日期欄位,載入全部資料") + + if table_name in _SALES_DF_CACHE: + df = _SALES_DF_CACHE[table_name].copy() + else: + # V-Security: 使用安全的 SQL 讀取函式 (參數化查詢) + df = safe_read_sql(table_name, engine=db.engine, where_clause=where_clause, params=params) + sys_log.info(f"[Sales Analysis] 📊 載入資料: {len(df):,} 筆記錄 (Security: Parameterized)") + if not df.empty: + _SALES_DF_CACHE[table_name] = df.copy() + + if df.empty: + return render_template('sales_analysis.html', + error="資料表為空,請重新匯入。", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None, + cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(), + items=[], bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + datetime_now=datetime_now_str) + + # 3. 自動識別關鍵欄位 (模糊比對) + cols = df.columns.tolist() + def find_col(keywords): + # V-Opt: 改為優先遍歷關鍵字,確保優先匹配更精確的名稱 (例如 '廠商名稱' 優於 '廠商') + for k in keywords: + for col in cols: + if k in str(col): return col + return None + + col_name = find_col(['商品名稱', '品名', 'Name', 'Product']) + col_pid = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) # V-New: 偵測商品ID欄位 + + # V-Fix: 優先匹配「日期」欄位(「訂單日期」是固定文字,不是實際日期) + col_date_part = find_col(['日期', '交易日期', 'Date', 'Day']) + col_time_part = find_col(['訂單時間', '成立時間', '下單時間', '購買時間', '時間', 'Time', 'Created']) + + col_brand = find_col(['品牌', 'Brand']) # V-New: 品牌欄位 + col_vendor = find_col(['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) # V-Opt: 優先抓取名稱 + col_activity = find_col(['活動', '折扣', 'Activity', 'Campaign', 'Promotion', '專案']) # V-New: 活動欄位 + col_payment = find_col(['付款方式', 'Payment', 'Pay']) # V-New: 付款方式欄位 + col_price = find_col(['單價', 'Price', '價格', 'Avg Price']) # V-New: 嘗試尋找單價欄位 + col_cost = find_col(['成本', 'Cost', '進價', 'Cost Price', 'Wholesale']) # V-New: 成本欄位 + col_profit = find_col(['毛利', 'Profit', '利潤']) # V-New: 直接尋找毛利欄位 (若有) + col_return_qty = find_col(['退貨數量', 'Return Qty', '退貨']) # V-New: 退貨欄位 + col_amount = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total']) + col_qty = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity']) + col_category = find_col(['館別', '分類', 'Category']) + + if not col_name or not col_amount: + return render_template('sales_analysis.html', + error=f"無法自動識別關鍵欄位 (需包含 '名稱' 與 '金額')。偵測到的欄位: {cols}", + table_name=table_name, + selected_metric=request.args.get('metric', 'amount'), + no_filter=False, + data_range_months=data_range_months, + start_date=start_date, + end_date=end_date, + total_records=0, + db_data_range=db_data_range, + marketing_data=None, + cols=DEFAULT_COLS.copy(), kpi=DEFAULT_KPI.copy(), + items=[], bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + datetime_now=datetime_now_str) + + # 4. 資料處理 (Heavy Lifting - 只在快取建立時執行一次) + # 確保金額與數量是數字 + df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) + if col_qty: + df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) + + if col_cost: + df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) + + if col_profit: + df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) + + if col_return_qty: + df[col_return_qty] = pd.to_numeric(df[col_return_qty], errors='coerce').fillna(0) + + # V-Fix: 智慧日期時間合併邏輯 + col_date = None + if col_date_part and col_time_part: + # 兩者都有,嘗試合併 + try: + df['combined_dt'] = pd.to_datetime(df[col_date_part].astype(str) + ' ' + df[col_time_part].astype(str), errors='coerce') + col_date = 'combined_dt' + except: + # 合併失敗,退回使用時間欄位 (假設包含日期) 或日期欄位 + col_date = col_time_part or col_date_part + elif col_time_part: + # 只有時間欄位 (可能包含日期) + df[col_time_part] = pd.to_datetime(df[col_time_part], errors='coerce') + col_date = col_time_part + elif col_date_part: + # 只有日期欄位 + df[col_date_part] = pd.to_datetime(df[col_date_part], errors='coerce') + col_date = col_date_part + + # V-New: 若無明確單價欄位,則自動計算 (金額 / 數量) + if not col_price and col_amount and col_qty: + col_price = 'calculated_price' + # V-Opt: 使用 numpy 向量化運算加速 (取代 apply) + df[col_price] = np.where(df[col_qty] > 0, df[col_amount] / df[col_qty], 0) + + if col_price: + df[col_price] = pd.to_numeric(df[col_price], errors='coerce').fillna(0) + + # V-New: 預先計算毛利率 (Margin Rate) 用於篩選 + # 邏輯: (毛利 / 金額) * 100 + col_margin_rate = 'calculated_margin_rate' + with np.errstate(divide='ignore', invalid='ignore'): + if col_profit: + df[col_margin_rate] = (df[col_profit] / df[col_amount]) * 100 + elif col_cost: + df[col_margin_rate] = ((df[col_amount] - df[col_cost]) / df[col_amount]) * 100 + else: + df[col_margin_rate] = 0.0 + # 處理無限大與 NaN (轉為 0) + df[col_margin_rate] = df[col_margin_rate].replace([np.inf, -np.inf, np.nan], 0) + + # === V-Opt: 效能優化預計算 (V9.98) === + # 1. 日期維度 (加速篩選與聚合,避免重複呼叫 .dt 存取器) + if col_date: + df['_dow'] = df[col_date].dt.dayofweek + df['_hour'] = df[col_date].dt.hour + df['_week'] = df[col_date].dt.strftime('%G-W%V') + df['_month_str'] = df[col_date].dt.strftime('%Y-%m') # V-New: 月份維度 (YYYY-MM) + + # 2. 毛利額 (加速 Top 3 分析,避免 runtime 計算) + if col_profit: + df['calculated_profit'] = df[col_profit] + elif col_cost: + df['calculated_profit'] = df[col_amount] - df[col_cost] + else: + df['calculated_profit'] = 0.0 + + # 3. 全站分類圓餅圖 (已移至下方使用 target_df 計算) + + + # 建立/更新處理後快取 + cache_entry = { + 'df': df, + 'cols': { + 'name': col_name, 'date': col_date, 'amount': col_amount, + 'qty': col_qty, 'category': col_category, 'cat': col_category, # V-Fix: 加上 cat 對映 category + 'brand': col_brand, + 'vendor': col_vendor, 'activity': col_activity, 'payment': col_payment, + 'price': col_price, 'cost': col_cost, 'profit': col_profit, + 'return_qty': col_return_qty, + 'pid': col_pid # V-New: 儲存商品ID欄位 + }, + 'pid': col_pid, # V-New: 儲存商品ID欄位 + 'time': time.time() # V-Opt: 加入時間戳記用於 TTL 檢查 + } + _SALES_PROCESSED_CACHE[cache_key] = cache_entry + # V-Fix: 同時使用固定的 table_name 作為 key 儲存副本,供其他路由使用 + _SALES_PROCESSED_CACHE[table_name] = cache_entry + + # 🚩 V-Opt: 使用共用篩選函式 + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + if err: + # V-Fix: 若快取失效,重新導向自己以觸發重新讀取(保留所有查詢參數) + params = {k: v for k, v in request.args.items()} + return redirect(url_for('sales_analysis', **params)) + + # 重新取得變數 (因為 _get_filtered_sales_data 內部使用了 cols_map) + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_category = cols_map.get('category') + col_brand = cols_map.get('brand') + col_vendor = cols_map.get('vendor') + col_activity = cols_map.get('activity') + col_payment = cols_map.get('payment') + col_price = cols_map.get('price') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_return_qty = cols_map.get('return_qty') + col_date = cols_map.get('date') + col_pid = cols_map.get('pid') + + # V-Fix: 準備前端需要的下拉選單資料 + # V-Opt: 從數據庫直接查詢所有可用選項,而不是只從篩選後的快取中讀取 + # 這樣可以確保下拉選單顯示完整的可選項,即使當前篩選範圍很小 + # V-Fix: 預先初始化所有下拉選單變數,避免 referenced before assignment 錯誤 + all_categories = [] + all_brands = [] + all_vendors = [] + all_activities = [] + all_payments = [] + all_months = [] + + # V-Opt: 優先從快取中讀取下拉選單選項 + global _SALES_OPTIONS_CACHE + if table_name in _SALES_OPTIONS_CACHE and (time.time() - _SALES_OPTIONS_CACHE[table_name]['time'] < _SALES_OPTIONS_TTL): + opts = _SALES_OPTIONS_CACHE[table_name]['options'] + all_categories = opts.get('categories', []) + all_brands = opts.get('brands', []) + all_vendors = opts.get('vendors', []) + all_activities = opts.get('activities', []) + all_payments = opts.get('payments', []) + all_months = opts.get('months', []) + sys_log.debug(f"[Sales Analysis] [Cache] ✅ 使用下拉選單快取: {table_name}") + else: + try: + from sqlalchemy import text + # V-Fix: SQLAlchemy 2.0 需要使用 connection 對象 + with db.engine.connect() as conn: + # 讀取完整資料表的所有可用選項(使用 DISTINCT 以提升效能) + if col_category: + result = conn.execute(text(f'SELECT DISTINCT "{col_category}" FROM {table_name} WHERE "{col_category}" IS NOT NULL AND "{col_category}" != ""')).fetchall() + all_categories = sorted([str(row[0]) for row in result if row[0]]) + + if col_brand: + result = conn.execute(text(f'SELECT DISTINCT "{col_brand}" FROM {table_name} WHERE "{col_brand}" IS NOT NULL AND "{col_brand}" != ""')).fetchall() + all_brands = sorted([str(row[0]) for row in result if row[0]]) + + if col_vendor: + result = conn.execute(text(f'SELECT DISTINCT "{col_vendor}" FROM {table_name} WHERE "{col_vendor}" IS NOT NULL AND "{col_vendor}" != ""')).fetchall() + all_vendors = sorted([str(row[0]) for row in result if row[0]]) + + if col_activity: + result = conn.execute(text(f'SELECT DISTINCT "{col_activity}" FROM {table_name} WHERE "{col_activity}" IS NOT NULL AND "{col_activity}" != ""')).fetchall() + all_activities = sorted([str(row[0]) for row in result if row[0]]) + + if col_payment: + result = conn.execute(text(f'SELECT DISTINCT "{col_payment}" FROM {table_name} WHERE "{col_payment}" IS NOT NULL AND "{col_payment}" != ""')).fetchall() + all_payments = sorted([str(row[0]) for row in result if row[0]]) + + # V-Fix: 從數據庫提取所有月份(格式:YYYY-MM) + if col_date: + date_fields = ['日期', '交易日期', '訂單日期', '時間'] + for field in date_fields: + try: + result = conn.execute(text(f""" + SELECT DISTINCT replace(substr(\"{field}\", 1, 7), '/', '-') as month + FROM {table_name} + WHERE \"{field}\" IS NOT NULL AND \"{field}\" != '' + ORDER BY month + """)).fetchall() + if result: + all_months = [row[0] for row in result if row[0] and '-' in str(row[0])] + if all_months: break + except: continue + + # 存入快取 + _SALES_OPTIONS_CACHE[table_name] = { + 'options': { + 'categories': all_categories, 'brands': all_brands, 'vendors': all_vendors, + 'activities': all_activities, 'payments': all_payments, 'months': all_months + }, + 'time': time.time() + } + sys_log.info(f"[Sales Analysis] [Cache] 💾 已更新下拉選單快取: {table_name}") + except Exception as e: + sys_log.warning(f"[Sales Analysis] 從數據庫查詢下拉選項失敗: {e}") + # 如果查詢失敗,回退到從快取讀取 + if cache_key in _SALES_PROCESSED_CACHE: + original_df = _SALES_PROCESSED_CACHE[cache_key]['df'] + elif table_name in _SALES_PROCESSED_CACHE: + original_df = _SALES_PROCESSED_CACHE[table_name]['df'] + else: + original_df = pd.DataFrame() + + if not original_df.empty: + all_categories = sorted(original_df[col_category].dropna().astype(str).unique().tolist()) if col_category else [] + all_brands = sorted(original_df[col_brand].dropna().astype(str).unique().tolist()) if col_brand else [] + all_vendors = sorted(original_df[col_vendor].dropna().astype(str).unique().tolist()) if col_vendor else [] + all_activities = sorted(original_df[col_activity].dropna().astype(str).unique().tolist()) if col_activity else [] + all_payments = sorted(original_df[col_payment].dropna().astype(str).unique().tolist()) if col_payment else [] + all_months = sorted(original_df['_month_str'].dropna().unique().tolist()) if col_date and '_month_str' in original_df.columns else [] + + # 取得前端參數供模板回填 + selected_category = request.args.get('category', 'all') + selected_metric = request.args.get('metric', 'amount') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + selected_activity = request.args.get('activity', 'all') + selected_payment = request.args.get('payment', 'all') + selected_dow = request.args.get('dow', 'all') + selected_hour = request.args.get('hour', 'all') + selected_month = request.args.get('month', 'all') + keyword = request.args.get('keyword', '').strip() + min_price = request.args.get('min_price', '') + max_price = request.args.get('max_price', '') + min_margin = request.args.get('min_margin', '') + max_margin = request.args.get('max_margin', '') + + # 🚩 核心優化:檢查分析結果快取 (Result Cache) + # 快取鍵值應包含原始資料快取鍵 + 所有目前套用的篩選參數 + import hashlib + filter_str = f"{request.args.get('category')}_{request.args.get('brand')}_{request.args.get('vendor')}_{request.args.get('activity')}_{request.args.get('payment')}_{request.args.get('dow')}_{request.args.get('hour')}_{request.args.get('month')}_{request.args.get('keyword')}_{request.args.get('metric')}_{request.args.get('min_price')}_{request.args.get('max_price')}_{request.args.get('min_margin')}_{request.args.get('max_margin')}" + result_cache_key = hashlib.md5(f"{cache_key}_{filter_str}".encode()).hexdigest() + + if result_cache_key in _SALES_ANALYSIS_RESULT_CACHE and (time.time() - _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['time'] < _SALES_RESULT_TTL): + sys_log.info(f"[Sales Analysis] [Result Cache] 🚀 命中分析結果快取 | Key: {result_cache_key}") + cached_res = _SALES_ANALYSIS_RESULT_CACHE[result_cache_key]['data'] + return render_template('sales_analysis.html', **{**cached_res, 'public_url': public_url, 'datetime_now': datetime_now_str}) + + # 若未命中快取,執行下方昂貴的運算 + sort_col = col_amount + if selected_metric == 'qty' and col_qty: + sort_col = col_qty + + target_df = target_df.sort_values(by=sort_col, ascending=False) + + # 🚩 全面初始化分析變數,避免無數據時報錯 + treemap_data = [] + bcg_data = {'datasets': [], 'thresholds': {'x': 0, 'y': 0}} + heatmap_data = [] + scatter_data = [] + dow_data = {'labels': ['週一', '週二', '週三', '週四', '週五', '週六', '週日'], 'chart_values': [0]*7} + hourly_data = {'labels': [f"{i:02d}:00" for i in range(24)], 'chart_values': [0]*24} + weekly_data = {'labels': [], 'chart_values': []} + monthly_data = {'labels': [], 'chart_values': []} + vendor_stats = [] + seasonality_data = None + abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}} + insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []} + cat_data = {'labels': [], 'chart_values': []} + bar_data = {'labels': [], 'chart_values': [], 'metric_label': ''} + price_dist_data = {'labels': [], 'chart_values': []} + table_items = [] + total_revenue = total_qty = total_count = sku_count = total_cost = gross_margin = gross_margin_rate = avg_price = 0 + + + # 🚩 運算優化:合併 GroupBy 減少對大規模目標 DataFrame 的重複掃描 + # 建立一次性聚合字典 + agg_map = {col_amount: 'sum'} + if col_qty: agg_map[col_qty] = 'sum' + if 'calculated_profit' in target_df.columns: agg_map['calculated_profit'] = 'sum' + + # 1. 類別聚合 (包含 Top 3, 圓餅圖, 其他) + cat_agg = target_df.groupby(col_category).agg(agg_map) if col_category else pd.DataFrame() + + # 2. 商品聚合 (PID + Name) + product_groupby = [col_pid, col_name] if col_pid else col_name + prod_agg = target_df.groupby(product_groupby).agg(agg_map) if col_name else pd.DataFrame() + + # 📊 KPI 計算 (直接從 sum 取得,速度極快) + total_revenue = float(target_df[col_amount].sum()) + total_qty = float(target_df[col_qty].sum()) if col_qty else 0 + total_count = len(target_df) + sku_count = int(target_df[col_name].nunique()) if col_name else total_count + total_cost = float(target_df[col_cost].sum()) if col_cost else 0 + gross_margin = float(target_df['calculated_profit'].sum()) if 'calculated_profit' in target_df.columns else (total_revenue - total_cost) + gross_margin_rate = (gross_margin / total_revenue * 100) if total_revenue > 0 else 0 + avg_price = total_revenue / total_qty if total_qty > 0 else 0 + + # 📊 洞察分析 (從已聚合的 cat_agg/prod_agg 提取,不再掃描原表) + insights = {'rev_cats': [], 'rev_prods': [], 'margin_cats': [], 'margin_prods': [], 'qty_cats': [], 'qty_prods': []} + + def format_top3(agg_df, metric, is_prod=False): + if agg_df.empty: return [] + top_df = agg_df.sort_values(metric, ascending=False).head(3) + return [{'name': (str(k[-1]) if isinstance(k, tuple) else str(k)), 'value': float(v)} for k, v in top_df[metric].items() if v > 0] + + insights['rev_cats'] = format_top3(cat_agg, col_amount) + insights['rev_prods'] = format_top3(prod_agg, col_amount, True) + insights['qty_cats'] = format_top3(cat_agg, col_qty) if col_qty else [] + insights['qty_prods'] = format_top3(prod_agg, col_qty, True) if col_qty else [] + if 'calculated_profit' in target_df.columns: + insights['margin_cats'] = format_top3(cat_agg, 'calculated_profit') + insights['margin_prods'] = format_top3(prod_agg, 'calculated_profit', True) + + # 📊 1. 核心長條圖數據 (Top 20 商品) - 從 prod_agg 提取 + bar_data = {'labels': [], 'chart_values': [], 'metric_label': ('銷售金額' if selected_metric == 'amount' else '銷售數量')} + if not prod_agg.empty: + top20_df = prod_agg.sort_values(sort_col, ascending=False).head(20) + bar_data['labels'] = [(str(k[-1]) if isinstance(k, tuple) else str(k)) for k in top20_df.index] + bar_data['chart_values'] = [float(x) for x in top20_df[sort_col].tolist()] + + # 📊 2. 圓餅圖數據 (從 cat_agg 提取) + cat_data = {'labels': [], 'chart_values': []} + if not cat_agg.empty: + sorted_cat = cat_agg[col_amount].sort_values(ascending=False) + if len(sorted_cat) > 12: + top_cats = sorted_cat.head(12) + other_val = sorted_cat.iloc[12:].sum() + cat_data['labels'] = [str(x) for x in top_cats.index.tolist()] + ['其他'] + cat_data['chart_values'] = [float(x) for x in top_cats.tolist()] + [float(other_val)] + else: + cat_data['labels'] = [str(x) for x in sorted_cat.index.tolist()] + cat_data['chart_values'] = [float(x) for x in sorted_cat.tolist()] + + # 📊 價格帶分析 (維持 PD.CUT) + price_dist_data = {'labels': [], 'chart_values': []} + if col_price and not target_df.empty: + bins = [0, 500, 1000, 2000, 5000, 10000, float('inf')] + lbls = ['0-499', '500-999', '1,000-1,999', '2,000-4,999', '5,000-9,999', '10,000+'] + price_bins = pd.cut(target_df[col_price], bins=bins, labels=lbls, right=False) + range_group = target_df.groupby(price_bins, observed=False)[col_amount].sum() + price_dist_data['labels'] = lbls + price_dist_data['chart_values'] = [float(range_group.get(l, 0)) for l in lbls] + + # 📊 散佈圖數據 (取樣 300 點以保證前端流暢) + scatter_data = [] + if col_price and col_qty and not target_df.empty: + scatter_source = target_df.head(300) + for _, row in scatter_source.iterrows(): + scatter_data.append({ + 'x': float(row[col_price]), 'y': float(row[col_qty]), + 'name': str(row[col_name]), 'amt': float(row[col_amount]) + }) + + # 📊 BCG 矩陣分析 (BCG Matrix) + if col_qty and 'calculated_margin_rate' in target_df.columns and not target_df.empty: + med_qty = target_df[col_qty].median() if not target_df.empty else 1 + med_margin = target_df['calculated_margin_rate'].median() if not target_df.empty else 0 + bcg_data['thresholds'] = {'x': float(med_qty or 1), 'y': float(med_margin)} + # 分類邏輯 (Stars, Cows, Questions, Dogs) + stars = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50) + cows = target_df[(target_df[col_qty] >= med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50) + questions = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] >= med_margin)].head(50) + dogs = target_df[(target_df[col_qty] < med_qty) & (target_df['calculated_margin_rate'] < med_margin)].head(50) + + def format_p(df_seg): + return [{'x': float(row[col_qty]), 'y': float(row['calculated_margin_rate']), 'name': str(row[col_name]), 'amt': float(row[col_amount])} for _, row in df_seg.iterrows()] + + bcg_data['datasets'] = [ + {'label': '明星商品 (Stars)', 'data': format_p(stars), 'backgroundColor': 'rgba(255, 206, 86, 0.8)', 'borderColor': 'rgba(255, 206, 86, 1)'}, + {'label': '金牛商品 (Cows)', 'data': format_p(cows), 'backgroundColor': 'rgba(75, 192, 192, 0.8)', 'borderColor': 'rgba(75, 192, 192, 1)'}, + {'label': '問題商品 (Questions)', 'data': format_p(questions), 'backgroundColor': 'rgba(54, 162, 235, 0.8)', 'borderColor': 'rgba(54, 162, 235, 1)'}, + {'label': '瘦狗商品 (Dogs)', 'data': format_p(dogs), 'backgroundColor': 'rgba(201, 203, 207, 0.8)', 'borderColor': 'rgba(201, 203, 207, 1)'} + ] + + # 📊 多維度熱力圖 (Day x Hour) + if col_date and not target_df.empty: + dh_group = target_df.groupby(['_dow', '_hour'])[col_amount].sum() + max_val = dh_group.max() if not dh_group.empty else 1 + for (day, hour), val in dh_group.items(): + radius = 3 + (math.sqrt(val) / math.sqrt(max_val)) * 22 if val > 0 else 0 + heatmap_data.append({ + 'x': int(hour), 'y': int(day), 'r': radius, 'v': float(val) + }) + + # 📊 V-Fix: 計算星期業績趨勢 (dow_data) + dow_group = target_df.groupby('_dow')[col_amount].sum() + for i in range(7): + if i in dow_group.index: + dow_data['chart_values'][i] = float(dow_group[i]) + + # 📊 V-Fix: 計算小時業績趨勢 (hourly_data) + hour_group = target_df.groupby('_hour')[col_amount].sum() + for i in range(24): + if i in hour_group.index: + hourly_data['chart_values'][i] = float(hour_group[i]) + + # 📊 V-Fix: 計算週業績趨勢 (weekly_data) + if '_week' in target_df.columns: + week_group = target_df.groupby('_week')[col_amount].sum().sort_index() + weekly_data['labels'] = week_group.index.tolist() + weekly_data['chart_values'] = [float(v) for v in week_group.values] + + # 📊 V-Fix: 計算月業績趨勢 (monthly_data) + if '_month_str' in target_df.columns: + month_group = target_df.groupby('_month_str')[col_amount].sum().sort_index() + monthly_data['labels'] = month_group.index.tolist() + monthly_data['chart_values'] = [float(v) for v in month_group.values] + + # 📊 V-New: 板塊圖 (Treemap) 數據準備 + # 結構: Root -> Category -> Product (Top 5 per cat) + if col_category and col_name and col_amount and not target_df.empty: + # V-Opt: 優化聚合邏輯,先聚合再篩選,避免在迴圈中重複過濾大表 + # 1. 先聚合 Category + Product (大幅減少資料量) + cat_prod_group = target_df.groupby([col_category, col_name])[col_amount].sum().reset_index() + + # 2. 找出前 10 大分類 + top_cats = cat_prod_group.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() + + # 3. 針對前 10 大分類,各取前 5 大商品 + for cat in top_cats: + if not cat: continue + # 在縮減後的資料中篩選,速度極快 + cat_subset = cat_prod_group[cat_prod_group[col_category] == cat] + top_prods = cat_subset.nlargest(5, col_amount) + + for _, row in top_prods.iterrows(): + treemap_data.append({ + 'category': str(cat), + 'product': str(row[col_name]), + 'value': float(row[col_amount]), + 'color': get_color_for_string(str(cat)) # V-Fix: 增加顏色參數,確保與分類顏色一致且清晰 + }) + + # 📊 V-New: ABC 分析 (Pareto Analysis) - TODO #8 + # A類: 累積營收 0-80% (核心商品) + # B類: 累積營收 80-95% (次要商品) + # C類: 累積營收 95-100% (長尾商品) + abc_stats = {'A': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'B': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}, + 'C': {'count': 0, 'revenue': 0, 'pct_rev': 0, 'pct_sku': 0}} + + if not target_df.empty and col_amount: + # 使用 numpy 加速累積計算 + sorted_rev = target_df[col_amount].values # 已在上方排序過 + cumsum_rev = np.cumsum(sorted_rev) + total_rev_abc = cumsum_rev[-1] if len(cumsum_rev) > 0 else 0 + + if total_rev_abc > 0: + pct_cumsum = cumsum_rev / total_rev_abc * 100 + + # 找出分界點索引 + idx_a = np.searchsorted(pct_cumsum, 80) + idx_b = np.searchsorted(pct_cumsum, 95) + + # A類: 0 ~ idx_a + count_a = idx_a + 1 + rev_a = cumsum_rev[idx_a] if idx_a < len(cumsum_rev) else total_rev_abc + + # B類: idx_a+1 ~ idx_b + count_b = max(0, idx_b - idx_a) + rev_b = (cumsum_rev[idx_b] - cumsum_rev[idx_a]) if idx_b < len(cumsum_rev) else (total_rev_abc - cumsum_rev[idx_a]) + + # C類: idx_b+1 ~ end + count_c = max(0, len(cumsum_rev) - 1 - idx_b) + rev_c = total_rev_abc - cumsum_rev[idx_b] if idx_b < len(cumsum_rev) else 0 + + abc_stats['A'] = {'count': int(count_a), 'revenue': float(rev_a), 'pct_rev': float(rev_a/total_rev_abc*100), 'pct_sku': float(count_a/total_count*100)} + abc_stats['B'] = {'count': int(count_b), 'revenue': float(rev_b), 'pct_rev': float(rev_b/total_rev_abc*100), 'pct_sku': float(count_b/total_count*100)} + abc_stats['C'] = {'count': int(count_c), 'revenue': float(rev_c), 'pct_rev': float(rev_c/total_rev_abc*100), 'pct_sku': float(count_c/total_count*100)} + + # 📊 V-New: 廠商獲利能力排行 (Vendor Profitability) - TODO #9 + vendor_stats = [] + if col_vendor and col_amount and not target_df.empty: + # Group by vendor + agg_dict = {col_amount: 'sum', col_name: 'nunique'} # nunique 計算不重複商品數 (SKU) + if col_qty: agg_dict[col_qty] = 'sum' # V-New: 累加銷量 + if col_profit: + agg_dict[col_profit] = 'sum' + elif col_cost: + agg_dict[col_cost] = 'sum' + + # 使用 groupby 聚合 + vendor_group = target_df.groupby(col_vendor).agg(agg_dict).reset_index() + + # 計算毛利與毛利率 + if col_profit: + vendor_group['total_profit'] = vendor_group[col_profit] + elif col_cost: + vendor_group['total_profit'] = vendor_group[col_amount] - vendor_group[col_cost] + else: + vendor_group['total_profit'] = 0 + + # 計算營收佔比 (Share %) + total_vendor_revenue = vendor_group[col_amount].sum() + if total_vendor_revenue > 0: + vendor_group['revenue_share'] = (vendor_group[col_amount] / total_vendor_revenue * 100) + else: + vendor_group['revenue_share'] = 0.0 + + # 避免除以零 + vendor_group['margin_rate'] = np.where(vendor_group[col_amount] > 0, (vendor_group['total_profit'] / vendor_group[col_amount] * 100), 0) + + # 計算平均客單價 (ASP) + if col_qty: + vendor_group['asp'] = np.where(vendor_group[col_qty] > 0, vendor_group[col_amount] / vendor_group[col_qty], 0) + + # 排序:預設按總業績降序 + vendor_group = vendor_group.sort_values(by=col_amount, ascending=False) + + # 格式化輸出 (Top 100) + for _, row in vendor_group.head(100).iterrows(): + vendor_stats.append({ + 'name': str(row[col_vendor]), + 'revenue': float(row[col_amount]), + 'share': float(row['revenue_share']), # V-New + 'qty': float(row[col_qty]) if col_qty else 0, # V-New + 'asp': float(row.get('asp', 0)), # V-New + 'profit': float(row['total_profit']), + 'margin_rate': float(row['margin_rate']), + 'sku_count': int(row[col_name]) + }) + + # 📊 格式化 Top 1000 列表商品 (DataTables 使用) + table_items = [] + if not target_df.empty: + # 取得 Top 1000 + display_df = target_df.head(1000) + for i, (_, row) in enumerate(display_df.iterrows()): + item = { + 'rank': i + 1, + 'pid': str(row[col_pid]) if col_pid else 'N/A', + 'name': str(row[col_name]) if col_name else 'N/A', + 'amount': float(row[col_amount]), + } + if col_brand: item['brand'] = str(row[col_brand]) + if col_vendor: item['vendor'] = str(row[col_vendor]) + if col_category: item['cat'] = str(row[col_category]) + if col_qty: + item['qty'] = float(row[col_qty]) + item['avg_price'] = float(row[col_amount] / row[col_qty]) if row[col_qty] > 0 else 0 + if 'calculated_margin_rate' in df.columns: # 使用原 df 的 columns 判斷是否有此欄位 + item['margin_rate'] = float(row['calculated_margin_rate']) + if col_return_qty: + item['return_rate'] = float(row[col_return_qty] / row[col_qty] * 100) if row.get(col_qty, 0) > 0 else 0 + if col_date and '_month_str' in row: + item['date'] = str(row['_month_str']) + table_items.append(item) + + # 📊 V-New: 淡旺季熱力圖 (Seasonality Analysis) - TODO #10 + seasonality_data = None + if col_date and col_category and col_amount and not target_df.empty: + # 1. 取得前 10 大分類 (避免圖表過大) + # 使用 target_df (受篩選影響),這樣可以看特定品牌下的分類季節性 + top_cats_season = target_df.groupby(col_category)[col_amount].sum().nlargest(10).index.tolist() + + # 2. 聚合數據 (Month x Category) + season_group = target_df[target_df[col_category].isin(top_cats_season)].groupby(['_month_str', col_category])[col_amount].sum().reset_index() + + # 3. 轉換為 Bubble Chart 格式 + # X軸: 月份 (需解析 _month_str 取得順序) + # Y軸: 分類 (使用 top_cats_season 的索引) + + # 取得所有月份並排序 + all_months_sorted = sorted(target_df['_month_str'].unique()) + month_map = {m: i for i, m in enumerate(all_months_sorted)} + cat_map = {c: i for i, c in enumerate(top_cats_season)} + + points = [] + max_val_season = season_group[col_amount].max() if not season_group.empty else 1 + + for _, row in season_group.iterrows(): + m_str = row['_month_str'] + cat = row[col_category] + val = row[col_amount] + + if m_str in month_map and cat in cat_map: + # 正規化大小 (3~25px) + radius = 3 + (math.sqrt(val) / math.sqrt(max_val_season)) * 25 if val > 0 else 0 + points.append({ + 'x': month_map[m_str], + 'y': cat_map[cat], + 'r': radius, + 'v': float(val), + 'm': m_str, + 'c': cat + }) + + seasonality_data = { + 'datasets': [{ + 'label': '淡旺季熱點', + 'data': points, + # 顏色將在前端動態生成 + }], + 'yLabels': top_cats_season, + 'xLabels': all_months_sorted + } + + # 📊 V-New 2026-01-15: 行銷活動業績貢獻 (Marketing Campaign Contribution) + marketing_data = None + if not target_df.empty: + marketing_data = prepare_marketing_summary(target_df, sort_by=selected_metric) + + # 📊 封裝回傳內容並存入快取 + res_data = { + 'marketing_data': marketing_data, + 'items': table_items, + 'kpi': { + 'revenue': total_revenue, 'qty': total_qty, 'count': total_count, + 'sku_count': sku_count, 'cost': total_cost, 'gross_margin': gross_margin, + 'gross_margin_rate': gross_margin_rate, 'avg_price': avg_price + }, + 'insights': insights, 'abc_stats': abc_stats, 'vendor_stats': vendor_stats, + 'seasonality_data': seasonality_data, 'bar_data': bar_data, 'cat_data': cat_data, + 'price_dist_data': price_dist_data, 'scatter_data': scatter_data, 'bcg_data': bcg_data, + 'dow_data': dow_data, 'hourly_data': hourly_data, 'monthly_data': monthly_data, + 'weekly_data': weekly_data, 'heatmap_data': heatmap_data, 'treemap_data': treemap_data, + 'selected_category': selected_category, 'selected_brand': selected_brand, 'selected_vendor': selected_vendor, + 'selected_activity': selected_activity, 'selected_payment': selected_payment, 'selected_dow': selected_dow, + 'selected_hour': selected_hour, 'selected_month': selected_month, 'selected_metric': selected_metric, + 'keyword': keyword, 'min_price': min_price, 'max_price': max_price, 'min_margin': min_margin, 'max_margin': max_margin, + 'all_categories': all_categories, 'all_brands': all_brands, 'all_vendors': all_vendors, + 'all_activities': all_activities, 'all_payments': all_payments, 'all_months': all_months, + 'table_name': table_name, 'total_records': total_count, 'data_range_months': data_range_months, + 'start_date': start_date, 'end_date': end_date, 'db_data_range': db_data_range, + 'cols': cols_map, 'no_filter': False + } + + _SALES_ANALYSIS_RESULT_CACHE[result_cache_key] = {'data': res_data, 'time': time.time()} + + return render_template('sales_analysis.html', **{**res_data, 'public_url': public_url, 'datetime_now': datetime_now_str}) + + except Exception as e: + sys_log.error(f"Sales Analysis Error: {e}") + import traceback + traceback.print_exc() + return render_template('sales_analysis.html', + error=f"系統發生錯誤: {str(e)}", + total_records=0, + public_url=public_url if 'public_url' in locals() else '', + db_data_range=db_data_range if 'db_data_range' in locals() else '', + start_date=request.args.get('start_date', ''), + end_date=request.args.get('end_date', ''), + data_range_months=int(request.args.get('data_range', '1') or '1'), + marketing_data=None, + insights=None, + abc_stats=None, + vendor_stats=None, + seasonality_data=None, + scatter_data=None, + bcg_data=None, + dow_data=None, + hourly_data=None, + monthly_data=None, + weekly_data=None, + heatmap_data=None, + treemap_data=None, + all_categories=[], + all_brands=[], all_vendors=[], all_activities=[], all_payments=[], + all_months=[], + selected_category='all', + selected_brand='all', selected_vendor='all', + selected_activity='all', selected_payment='all', + selected_dow='all', selected_hour='all', + selected_month='all', + selected_metric=request.args.get('metric', 'amount'), + keyword=request.args.get('keyword', ''), + min_price='', max_price='', min_margin='', max_margin='', + items=[], + kpi=DEFAULT_KPI.copy(), + cols=DEFAULT_COLS.copy(), + no_filter=False, + bar_data=DEFAULT_BAR_DATA.copy(), + cat_data=DEFAULT_CHART_DATA.copy(), + price_dist_data=DEFAULT_CHART_DATA.copy(), + datetime_now=datetime_now_str) + +@app.route('/api/sales_analysis/table_data') +def get_sales_table_data(): + """API: 取得業績分析的詳細列表資料 (Server-side AJAX) - 使用 SQL 聚合優化""" + try: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + + # V-Fix: 取得所有篩選參數 + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') # V-Fix: 品牌篩選 + vendor_filter = request.args.get('vendor', 'all') # V-Fix: 廠商篩選 + activity_filter = request.args.get('activity', 'all') # V-Fix: 活動篩選 + payment_filter = request.args.get('payment', 'all') # V-Fix: 付款方式篩選 + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') # 星期篩選 + hour_filter = request.args.get('hour', 'all') # 小時篩選 + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + col_name = cols_map.get('name', '商品名稱') + col_pid = cols_map.get('pid', '商品ID') + col_brand = cols_map.get('brand', '品牌') + col_vendor = cols_map.get('vendor', '廠商名稱') + col_category = cols_map.get('category', '商品館') + col_amount = cols_map.get('amount', '總業績') + col_qty = cols_map.get('qty', '數量') + col_cost = cols_map.get('cost', '總成本') + col_return_qty = cols_map.get('return_qty', '退貨數量') + + # V-Opt: 使用純 SQL 聚合查詢,避免載入完整資料集 + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + # V-Fix: 只使用「日期」欄位(「訂單日期」欄位是固定文字「訂單日期」,不是實際日期) + if start_date and end_date: + date_filter = f"""AND ("日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}')""" + elif start_date: + date_filter = f"""AND ("日期" >= '{start_date_slash}')""" + else: # only end_date + date_filter = f"""AND ("日期" <= '{end_date_slash}')""" + elif data_range_months > 0: + # V-Fix: 使用斜線格式以匹配資料庫格式 + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + # V-Fix: 只使用「日期」欄位進行篩選(「訂單日期」是固定文字,不是實際日期) + date_filter = f"""AND ("日期" >= '{cutoff_date}')""" + + # V-Fix: 建立其他篩選條件 + additional_filters = [] + query_params = {} # V-Fix: 初始化查詢參數字典 + + # 分類篩選 + if category_filter and category_filter != 'all' and col_category: + additional_filters.append(f"\"{col_category}\" = :cat") + query_params['cat'] = category_filter + + if brand_filter and brand_filter != 'all' and col_brand: + additional_filters.append(f"\"{col_brand}\" = :brand") + query_params['brand'] = brand_filter + + if vendor_filter and vendor_filter != 'all' and col_vendor: + additional_filters.append(f"\"{col_vendor}\" = :vendor") + query_params['vendor'] = vendor_filter + + col_activity = cols_map.get('activity') + if activity_filter and activity_filter != 'all' and col_activity: + additional_filters.append(f"\"{col_activity}\" = :act") + query_params['act'] = activity_filter + + col_payment = cols_map.get('payment') + if payment_filter and payment_filter != 'all' and col_payment: + additional_filters.append(f"\"{col_payment}\" = :pay") + query_params['pay'] = payment_filter + + # 月份篩選 + if month_filter and month_filter != 'all': + month_filter_slash = month_filter.replace('-', '/') + additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)') + query_params['m1'] = f"{month_filter}%" + query_params['m2'] = f"{month_filter_slash}%" + + # 星期篩選 (需要從日期計算) + if dow_filter and dow_filter != 'all': + sqlite_dow = str((int(dow_filter) + 1) % 7) + additional_filters.append("strftime('%w', \"日期\") = :dow") + query_params['dow'] = sqlite_dow + + # 小時篩選 (需要從時間欄位提取) + if hour_filter and hour_filter != 'all': + hour_conditions = [] + for field in ["時間"]: # 通常只有「時間」欄位包含小時資訊 + hour_conditions.append(f"""CAST(strftime('%H', "{field}") AS INTEGER) = {hour_filter}""") + if hour_conditions: + additional_filters.append(f"({' OR '.join(hour_conditions)})") + + # 關鍵字篩選 + if keyword: + keyword_conditions = [] + if col_name: keyword_conditions.append(f"\"{col_name}\" LIKE :kw") + if col_pid: keyword_conditions.append(f"\"{col_pid}\" LIKE :kw") + if col_brand: keyword_conditions.append(f"\"{col_brand}\" LIKE :kw") + if col_vendor: keyword_conditions.append(f"\"{col_vendor}\" LIKE :kw") + if keyword_conditions: + additional_filters.append(f"({' OR '.join(keyword_conditions)})") + query_params['kw'] = f"%{keyword}%" + + # V-New: 價格區間篩選 (Price Range) + if (min_price_str or max_price_str) and col_qty and col_amount: + # 假設單價 = 總業績 / 數量 (防止除以零) + price_cal_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: + additional_filters.append(f"{price_cal_sql} >= {float(min_price_str)}") + if max_price_str: + additional_filters.append(f"{price_cal_sql} <= {float(max_price_str)}") + + # V-New: 毛利率區間篩選 (Margin Range) + if (min_margin_str or max_margin_str) and col_amount: + # 計算毛利額 SQL + if col_profit: + profit_cal_sql = f'"{col_profit}"' + elif col_cost: + profit_cal_sql = f'("{col_amount}" - "{col_cost}")' + else: + profit_cal_sql = "0" + + # 計算毛利率 SQL: (毛利 / 業績) * 100 + margin_cal_sql = f'({profit_cal_sql} * 100.0 / NULLIF("{col_amount}", 0))' + + if min_margin_str: + additional_filters.append(f"{margin_cal_sql} >= {float(min_margin_str)}") + if max_margin_str: + additional_filters.append(f"{margin_cal_sql} <= {float(max_margin_str)}") + + # 組合所有篩選條件 + all_filters = date_filter + if additional_filters: + all_filters += " AND " + " AND ".join(additional_filters) + + # SQL 聚合查詢 - 直接在資料庫層級完成聚合 + # V-Fix: 使用動態欄位名稱 + group_by_cols = [] + if col_pid: group_by_cols.append(f'"{col_pid}"') + if col_name: group_by_cols.append(f'"{col_name}"') + if col_brand: group_by_cols.append(f'"{col_brand}"') + if col_vendor: group_by_cols.append(f'"{col_vendor}"') + if col_category: group_by_cols.append(f'"{col_category}"') + group_by_clause = ', '.join(group_by_cols) if group_by_cols else '"商品ID"' + + sql_query = f""" + SELECT + {f'"{col_pid}" as product_id' if col_pid else "'未知' as product_id"}, + {f'"{col_name}" as name' if col_name else "'未知' as name"}, + {f'"{col_brand}" as brand' if col_brand else "'' as brand"}, + {f'"{col_vendor}" as vendor' if col_vendor else "'' as vendor"}, + {f'"{col_category}" as category' if col_category else "'' as category"}, + {f'SUM(CAST("{col_amount}" AS REAL)) as amount' if col_amount else '0 as amount'}, + {f'SUM(CAST("{col_qty}" AS REAL)) as qty' if col_qty else '0 as qty'}, + {f'SUM(CAST("{col_cost}" AS REAL)) as cost' if col_cost else '0 as cost'}, + {f'SUM(CAST("{col_return_qty}" AS REAL)) as return_qty' if col_return_qty else '0 as return_qty'}, + COUNT(*) as order_count + FROM {table_name} + WHERE 1=1 {all_filters} + GROUP BY {group_by_clause} + ORDER BY amount DESC + LIMIT 1000 + """ + + df_agg = pd.read_sql(text(sql_query), db.engine, params=query_params) + sys_log.info(f"[API] Table Data: SQL聚合查詢返回 {len(df_agg)} 筆商品 (篩選: category={category_filter}, month={month_filter}, dow={dow_filter}, hour={hour_filter}, keyword={keyword})") + + if df_agg.empty: + return jsonify({'data': []}) + + # 計算衍生欄位 + df_agg['margin_rate'] = ((df_agg['amount'] - df_agg['cost']) / df_agg['amount'] * 100).fillna(0) + df_agg['margin_rate'] = df_agg['margin_rate'].replace([np.inf, -np.inf], 0) + df_agg['avg_price'] = (df_agg['amount'] / df_agg['qty']).fillna(0) + df_agg['return_rate'] = (df_agg['return_qty'] / df_agg['qty'] * 100).fillna(0) + + # V-Fix: 應用價格區間篩選 (在計算欄位後才能篩選) + if min_price_str: + try: + min_price = float(min_price_str) + df_agg = df_agg[df_agg['avg_price'] >= min_price] + except ValueError: + pass + + if max_price_str: + try: + max_price = float(max_price_str) + df_agg = df_agg[df_agg['avg_price'] <= max_price] + except ValueError: + pass + + # V-Fix: 應用毛利區間篩選 (在計算欄位後才能篩選) + if min_margin_str: + try: + min_margin = float(min_margin_str) + df_agg = df_agg[df_agg['margin_rate'] >= min_margin] + except ValueError: + pass + + if max_margin_str: + try: + max_margin = float(max_margin_str) + df_agg = df_agg[df_agg['margin_rate'] <= max_margin] + except ValueError: + pass + + # 重新排序並限制到 1000 筆 + df_agg = df_agg.sort_values('amount', ascending=False).head(1000) + + # 轉換為 DataTables 格式 + data = [] + for i, row in enumerate(df_agg.to_dict('records')): + data.append({ + 'rank': i + 1, + 'product_id': row.get('product_id', ''), + 'name': row.get('name', ''), + 'brand': row.get('brand', ''), + 'vendor': row.get('vendor', ''), + 'category': row.get('category', ''), + 'margin_rate': row.get('margin_rate', 0), + 'month_str': '', # SQL聚合模式不需要月份字串 + 'avg_price': row.get('avg_price', 0), + 'return_rate': row.get('return_rate', 0), + 'qty': row.get('qty', 0), + 'amount': row.get('amount', 0) + }) + + return jsonify({'data': data}) + + except Exception as e: + sys_log.error(f"[API] Table Data Error: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + + +# V-Old: 保留舊版本以防需要回滾 +@app.route('/api/sales_analysis/table_data_pandas') +def get_sales_table_data_pandas(): + """API: 取得業績分析的詳細列表資料 (使用 pandas 聚合 - 舊版本)""" + try: + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1')) + cache_key = f"{table_name}_{data_range_months}m" + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + if err or target_df is None: + sys_log.warning(f"[API] Table Data: 快取不存在 ({cache_key}),返回空資料") + return jsonify({'data': []}) + + if target_df.empty: + return jsonify({'data': []}) + + col_name = cols_map.get('name') + col_amount = cols_map.get('amount') + col_qty = cols_map.get('qty') + col_cost = cols_map.get('cost') + col_profit = cols_map.get('profit') + col_category = cols_map.get('category') + col_vendor = cols_map.get('vendor') + col_date = cols_map.get('date') + col_brand = cols_map.get('brand') + col_return_qty = cols_map.get('return_qty') + + selected_metric = request.args.get('metric', 'amount') + + # 執行聚合 (V-Opt: 多維度聚合,增加精確度) + agg_rules = {col_amount: 'sum'} + if col_qty: agg_rules[col_qty] = 'sum' + if col_cost: agg_rules[col_cost] = 'sum' + if col_profit: agg_rules[col_profit] = 'sum' + if col_return_qty: agg_rules[col_return_qty] = 'sum' + if col_date: agg_rules['_month_str'] = lambda x: ', '.join(sorted(x.dropna().unique())) + + # Group By 鍵值:商品名稱 + 品牌 + 廠商 + 分類 (確保唯一性) + group_cols = [col_name] + if col_brand: group_cols.append(col_brand) + if col_vendor: group_cols.append(col_vendor) + if col_category: group_cols.append(col_category) + + df_agg = target_df.groupby(group_cols).agg(agg_rules).reset_index() + + # 計算毛利率 + if col_profit: + df_agg['agg_margin_rate'] = (df_agg[col_profit] / df_agg[col_amount]) * 100 + elif col_cost: + df_agg['agg_margin_rate'] = ((df_agg[col_amount] - df_agg[col_cost]) / df_agg[col_amount]) * 100 + else: + df_agg['agg_margin_rate'] = 0.0 + df_agg['agg_margin_rate'] = df_agg['agg_margin_rate'].replace([np.inf, -np.inf, np.nan], 0) + + # V-New: 計算平均單價與退貨率 + if col_qty: + df_agg['avg_price'] = (df_agg[col_amount] / df_agg[col_qty]).fillna(0) + if col_return_qty: + df_agg['return_rate'] = (df_agg[col_return_qty] / df_agg[col_qty] * 100).fillna(0) + + # 排序 + sort_col_agg = col_amount + if selected_metric == 'qty' and col_qty: + sort_col_agg = col_qty + + df_agg = df_agg.sort_values(by=sort_col_agg, ascending=False).head(1000) # 限制前 1000 筆 + + # 轉換為 DataTables 需要的格式 + data = [] + for i, row in enumerate(df_agg.to_dict('records')): + data.append({ + 'rank': i + 1, + 'name': row.get(col_name, ''), + 'brand': row.get(col_brand, ''), + 'vendor': row.get(col_vendor, ''), + 'category': row.get(col_category, ''), + 'margin_rate': row.get('agg_margin_rate', 0), + 'month_str': row.get('_month_str', ''), + 'avg_price': row.get('avg_price', 0), + 'return_rate': row.get('return_rate', 0), + 'qty': row.get(col_qty, 0), + 'amount': row.get(col_amount, 0) + }) + + return jsonify({'data': data}) + + except Exception as e: + sys_log.error(f"Table Data API Error: {e}") + return jsonify({'error': str(e)}), 500 + +@app.route('/api/export/excel/seasonality_detail') +def export_seasonality_detail(): + """API: 匯出淡旺季熱力圖的詳細資料 (點擊氣泡觸發)""" + try: + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + target_df, cols_map, err = _get_filtered_sales_data(cache_key) + + # V-Fix: 如果 cache_key 不存在,嘗試使用固定的 table_name + if err and table_name in _SALES_PROCESSED_CACHE: + target_df, cols_map, err = _get_filtered_sales_data(table_name) + + if err: return f"匯出失敗: {err}", 400 + + # 取得額外參數 + target_month = request.args.get('target_month') + target_category = request.args.get('target_category') + + if not target_month or not target_category: + return "缺少必要參數 (month, category)", 400 + + col_category = cols_map.get('category') + + # 進一步篩選 + export_df = target_df[ + (target_df['_month_str'] == target_month) & + (target_df[col_category] == target_category) + ] + + if export_df.empty: + return "該月份與分類無資料", 404 + + # 使用 BytesIO 直接在記憶體中產生 Excel (避免 Exporter 的類型不相容) + import io + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='明細') + output.seek(0) + + filename = f"Seasonality_{target_category}_{target_month}.xlsx" + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + except Exception as e: + sys_log.error(f"Seasonality Export Error: {e}") + return f"匯出失敗: {e}", 500 + +# ================= 💎 V-New: Top 3 Highlights 詳細列表 API ================= +@app.route('/api/sales_analysis/top_detail') +def get_top_detail(): + """API: 取得 Top N 詳細列表(業績貢獻王/獲利金雞母/人氣引流款)""" + try: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + top_type = request.args.get('type', 'revenue') # revenue/margin/quantity + metric = request.args.get('metric', 'amount') # amount/profit/qty + view_type = request.args.get('view', 'product') # product/category + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + col_name = cols_map.get('name', '商品名稱') + col_brand = cols_map.get('brand', '品牌') + col_vendor = cols_map.get('vendor', '廠商名稱') + col_category = cols_map.get('category', '商品館') + col_amount = cols_map.get('amount', '總業績') + col_qty = cols_map.get('qty', '數量') + col_cost = cols_map.get('cost', '總成本') + col_profit = cols_map.get('profit') # V-New: 取得利潤欄位 + col_activity = cols_map.get('activity', '活動名稱') + col_payment = cols_map.get('payment', '付款方式') + + + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + if start_date and end_date: + date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" + elif start_date: + date_filter = f"""AND "日期" >= '{start_date_slash}'""" + else: # only end_date + date_filter = f"""AND "日期" <= '{end_date_slash}'""" + elif data_range_months > 0: + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + date_filter = f"""AND "日期" >= '{cutoff_date}'""" + + # V-Fix: 補上其他所有篩選條件 (與 get_sales_table_data 一致) + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') + vendor_filter = request.args.get('vendor', 'all') + activity_filter = request.args.get('activity', 'all') + payment_filter = request.args.get('payment', 'all') + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') + hour_filter = request.args.get('hour', 'all') + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + additional_filters = [] + + if category_filter and category_filter != 'all': + additional_filters.append(f"\"{col_category}\" = :cat") + query_params['cat'] = category_filter + if brand_filter and brand_filter != 'all': + additional_filters.append(f"\"{col_brand}\" = :brand") + query_params['brand'] = brand_filter + if vendor_filter and vendor_filter != 'all': + additional_filters.append(f"\"{col_vendor}\" = :vendor") + query_params['vendor'] = vendor_filter + if activity_filter and activity_filter != 'all': + additional_filters.append(f"\"{col_activity}\" = :act") + query_params['act'] = activity_filter + if payment_filter and payment_filter != 'all': + additional_filters.append(f"\"{col_payment}\" = :pay") + query_params['pay'] = payment_filter + + # 時間維度 + if month_filter and month_filter != 'all': + month_filter_slash = month_filter.replace('-', '/') + additional_filters.append('("日期" LIKE :m1 OR "日期" LIKE :m2)') + query_params['m1'] = f"{month_filter}%" + query_params['m2'] = f"{month_filter_slash}%" + + if dow_filter and dow_filter != 'all': + sqlite_dow = str((int(dow_filter) + 1) % 7) + additional_filters.append("strftime('%w', \"日期\") = :dow") + query_params['dow'] = sqlite_dow + + if hour_filter and hour_filter != 'all': + additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""") + + # 關鍵字 + if keyword: + keyword_escaped = keyword.replace("'", "''") + k_conds = [] + for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: + k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") + additional_filters.append(f"({' OR '.join(k_conds)})") + + if (min_price_str or max_price_str): + price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") + if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") + + if (min_margin_str or max_margin_str): + if col_profit: + profit_sql = f'"{col_profit}"' + else: + profit_sql = f'("{col_amount}" - "{col_cost}")' + + margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' + if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") + if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") + + if additional_filters: + date_filter += " AND " + " AND ".join(additional_filters) + + # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) + if col_profit: + profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' + else: + profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' + + # 根據檢視類型和指標建立 SQL 查詢 + if view_type == 'category': + # 分類排行 + if metric == 'qty': + sql_query = f""" + SELECT + "{col_category}" as name, + SUM(CAST("{col_qty}" AS REAL)) as value + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{col_category}" as name, + {profit_select_sql} as value, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as margin_rate + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + else: # amount + sql_query = f""" + SELECT + "{col_category}" as name, + SUM(CAST("{col_amount}" AS REAL)) as value + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY value DESC + LIMIT 50 + """ + else: + # 商品排行(包含商品ID) + pid_col_sql = f'"{cols_map.get("pid", "商品ID")}"' # 商品ID 欄位 + if metric == 'qty': + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + SUM(CAST("{col_qty}" AS REAL)) as value + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + {profit_select_sql} as value, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as margin_rate + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + else: # amount + sql_query = f""" + SELECT + {pid_col_sql} as product_id, + "{col_name}" as name, + "{col_brand}" as brand, + "{col_vendor}" as vendor, + "{col_category}" as category, + SUM(CAST("{col_amount}" AS REAL)) as value + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY {pid_col_sql}, "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY value DESC + LIMIT 100 + """ + + # 執行查詢 + df = pd.read_sql(text(sql_query), db.engine, params=query_params) + sys_log.info(f"[API] Top Detail: {top_type}/{view_type} 返回 {len(df)} 筆資料") + + if df.empty: + return jsonify({'items': []}) + + # 轉換為 JSON + items = df.to_dict('records') + return jsonify({'items': items}) + + except Exception as e: + sys_log.error(f"[API] Top Detail Error: {e}") + import traceback + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +@app.route('/api/sales_analysis/export_top_detail') +def export_top_detail(): + """API: 匯出 Top N 詳細列表為 Excel""" + try: + from datetime import datetime, timedelta, timezone + import io + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + data_range_months = int(request.args.get('data_range', '1') or '1') + start_date = request.args.get('start_date', '') # V-New: 自訂開始日期 + end_date = request.args.get('end_date', '') # V-New: 自訂結束日期 + top_type = request.args.get('type', 'revenue') + metric = request.args.get('metric', 'amount') + view_type = request.args.get('view', 'product') + + db = DatabaseManager() + + # V-Fix: 從快取讀取欄位名稱對應,以支援不同的資料庫欄位名稱 + if start_date or end_date: + cache_key = f"{table_name}_custom_{start_date}_{end_date}" + else: + cache_key = f"{table_name}_{data_range_months}m" + + # 嘗試從快取讀取欄位名稱 + cols_map = {} + if cache_key in _SALES_PROCESSED_CACHE: + cols_map = _SALES_PROCESSED_CACHE[cache_key].get('cols', {}) + elif table_name in _SALES_PROCESSED_CACHE: # V-Fix: 也嘗試使用固定 key + cols_map = _SALES_PROCESSED_CACHE[table_name].get('cols', {}) + + # 取得實際欄位名稱(如果快取中沒有,使用預設名稱) + col_name = cols_map.get('name', '商品名稱') + col_brand = cols_map.get('brand', '品牌') + col_vendor = cols_map.get('vendor', '廠商名稱') + col_category = cols_map.get('category', '商品館') + col_amount = cols_map.get('amount', '總業績') + col_qty = cols_map.get('qty', '數量') + col_cost = cols_map.get('cost', '總成本') + col_profit = cols_map.get('profit') # V-New: 取得利潤欄位 + col_activity = cols_map.get('activity', '活動名稱') + col_payment = cols_map.get('payment', '付款方式') + + + # 建立日期篩選條件 + date_filter = "" + # V-New: 優先處理自訂日期區間 + if start_date or end_date: + # V-Fix: 處理日期格式轉換 (2025-01-01 -> 2025/01/01) + start_date_slash = start_date.replace('-', '/') if start_date else '' + end_date_slash = end_date.replace('-', '/') if end_date else '' + + if start_date and end_date: + date_filter = f"""AND "日期" BETWEEN '{start_date_slash}' AND '{end_date_slash}'""" + elif start_date: + date_filter = f"""AND "日期" >= '{start_date_slash}'""" + else: # only end_date + date_filter = f"""AND "日期" <= '{end_date_slash}'""" + elif data_range_months > 0: + cutoff_date = (datetime.now(TAIPEI_TZ) - timedelta(days=data_range_months * 30)).strftime('%Y/%m/%d') + date_filter = f"""AND "日期" >= '{cutoff_date}'""" + + # V-Fix: 補上其他所有篩選條件 (與 get_top_detail 一致) + category_filter = request.args.get('category', 'all') + brand_filter = request.args.get('brand', 'all') + vendor_filter = request.args.get('vendor', 'all') + activity_filter = request.args.get('activity', 'all') + payment_filter = request.args.get('payment', 'all') + month_filter = request.args.get('month', 'all') + dow_filter = request.args.get('dow', 'all') + hour_filter = request.args.get('hour', 'all') + min_price_str = request.args.get('min_price', '') + max_price_str = request.args.get('max_price', '') + min_margin_str = request.args.get('min_margin', '') + max_margin_str = request.args.get('max_margin', '') + keyword = request.args.get('keyword', '').strip() + + additional_filters = [] + + if category_filter and category_filter != 'all': + additional_filters.append(f""""{col_category}" = '{category_filter}'""") + if brand_filter and brand_filter != 'all': + additional_filters.append(f""""{col_brand}" = '{brand_filter}'""") + if vendor_filter and vendor_filter != 'all': + additional_filters.append(f""""{col_vendor}" = '{vendor_filter}'""") + if activity_filter and activity_filter != 'all': + additional_filters.append(f""""{col_activity}" = '{activity_filter}'""") + if payment_filter and payment_filter != 'all': + additional_filters.append(f""""{col_payment}" = '{payment_filter}'""") + + if month_filter and month_filter != 'all': + month_filter_slash = month_filter.replace('-', '/') + additional_filters.append(f"""("日期" LIKE '{month_filter}%' OR "日期" LIKE '{month_filter_slash}%')""") + + if dow_filter and dow_filter != 'all': + pandas_dow = int(dow_filter) + sqlite_dow = str((pandas_dow + 1) % 7) + additional_filters.append(f"""strftime('%w', "日期") = '{sqlite_dow}'""") + + if hour_filter and hour_filter != 'all': + additional_filters.append(f"""CAST(strftime('%H', "時間") AS INTEGER) = {hour_filter}""") + + if keyword: + keyword_escaped = keyword.replace("'", "''") + k_conds = [] + for col in [col_name, cols_map.get("pid", "商品ID"), col_brand, col_vendor]: + k_conds.append(f""""{col}" LIKE '%{keyword_escaped}%'""") + additional_filters.append(f"({' OR '.join(k_conds)})") + + if (min_price_str or max_price_str): + price_sql = f'CAST("{col_amount}" AS FLOAT) / NULLIF("{col_qty}", 0)' + if min_price_str: additional_filters.append(f"{price_sql} >= {float(min_price_str)}") + if max_price_str: additional_filters.append(f"{price_sql} <= {float(max_price_str)}") + + if (min_margin_str or max_margin_str): + if col_profit: + profit_sql = f'"{col_profit}"' + else: + profit_sql = f'("{col_amount}" - "{col_cost}")' + + margin_sql = f'({profit_sql} * 100.0 / NULLIF("{col_amount}", 0))' + if min_margin_str: additional_filters.append(f"{margin_sql} >= {float(min_margin_str)}") + if max_margin_str: additional_filters.append(f"{margin_sql} <= {float(max_margin_str)}") + + if additional_filters: + date_filter += " AND " + " AND ".join(additional_filters) + + # V-New: 準備利潤計算 SQL 片段 (SELECT 子句使用 SUM 聚合) + if col_profit: + profit_select_sql = f'SUM(CAST("{col_profit}" AS REAL))' + else: + profit_select_sql = f'SUM(CAST("{col_amount}" AS REAL)) - SUM(CAST("{col_cost}" AS REAL))' + + # 根據檢視類型和指標建立 SQL 查詢(與上面相同) + if view_type == 'category': + if metric == 'qty': + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 銷售數量 DESC + LIMIT 50 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + {profit_select_sql} as 毛利金額, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as 毛利率 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 毛利金額 DESC + LIMIT 50 + """ + else: # amount + sql_query = f""" + SELECT + "{col_category}" as 分類名稱, + SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 + FROM {table_name} + WHERE "{col_category}" IS NOT NULL {date_filter} + GROUP BY "{col_category}" + ORDER BY 銷售金額 DESC + LIMIT 50 + """ + else: + # 商品排行(包含商品ID) + if metric == 'qty': + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + SUM(CAST("{col_qty}" AS REAL)) as 銷售數量 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 銷售數量 DESC + LIMIT 100 + """ + elif metric == 'profit': + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + {profit_select_sql} as 毛利金額, + CASE + WHEN SUM(CAST("{col_amount}" AS REAL)) > 0 + THEN (({profit_select_sql}) / SUM(CAST("{col_amount}" AS REAL))) * 100 + ELSE 0 + END as 毛利率 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 毛利金額 DESC + LIMIT 100 + """ + else: # amount + sql_query = f""" + SELECT + "{cols_map.get("pid", "商品ID")}" as 商品ID, + "{col_name}" as 商品名稱, + "{col_brand}" as 品牌, + "{col_vendor}" as 廠商名稱, + "{col_category}" as 分類名稱, + SUM(CAST("{col_amount}" AS REAL)) as 銷售金額 + FROM {table_name} + WHERE "{col_name}" IS NOT NULL {date_filter} + GROUP BY "{cols_map.get("pid", "商品ID")}", "{col_name}", "{col_brand}", "{col_vendor}", "{col_category}" + ORDER BY 銷售金額 DESC + LIMIT 100 + """ + + # 執行查詢並匯出 + df = pd.read_sql(text(sql_query), db.engine, params=query_params) + + if df.empty: + return "無資料可匯出", 400 + + # 生成 Excel + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Top排行') + output.seek(0) + + # 生成檔案名稱 + type_names = {'revenue': '業績貢獻王', 'margin': '獲利金雞母', 'quantity': '人氣引流款'} + view_names = {'product': '商品排行', 'category': '分類排行'} + filename = f"{type_names.get(top_type, '排行')}_{view_names.get(view_type, '')}_{datetime.now(TAIPEI_TZ).strftime('%Y%m%d_%H%M')}.xlsx" + + sys_log.info(f"[Export] Top Detail: {filename} ({len(df)} 筆)") + + return send_file( + output, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + + except Exception as e: + sys_log.error(f"[Export] Top Detail Error: {e}") + return f"匯出失敗: {e}", 500 + +# ================= 📈 V-New: 年度對比 (Year-over-Year Comparison) ================= +# YoY 快取 +_YOY_CACHE = {} +_YOY_CACHE_TTL = 1800 # 30 分鐘 + +@app.route('/api/sales_analysis/yoy_comparison') +def yoy_comparison(): + """ + API: 年度對比分析 (YoY Comparison) - 已優化效能 + + 參數: + year1: 基準年 (例如 2025) + year2: 對比年 (例如 2026) + month: 月份 (可選,1-12,不帶則為全年) + metric: 指標 (revenue/qty/profit) + + 回傳: + JSON with year1 total, year2 total, growth rate, and monthly breakdown + + 優化: + - 使用單一 SQL 查詢取代 24 次查詢 + - 加入 30 分鐘快取機制 + """ + import time + start_time = time.time() + + try: + from datetime import datetime, timedelta, timezone + TAIPEI_TZ = timezone(timedelta(hours=8)) + + table_name = 'realtime_sales_monthly' + year1 = request.args.get('year1', '2025') + year2 = request.args.get('year2', '2026') + month = request.args.get('month', '') # 可選,1-12 + metric = request.args.get('metric', 'revenue') # revenue/qty/profit + + # 快取檢查 + cache_key = f"{year1}_{year2}_{month}_{metric}" + now = datetime.now(TAIPEI_TZ) + if cache_key in _YOY_CACHE: + cached = _YOY_CACHE[cache_key] + cache_age = (now - cached['timestamp']).total_seconds() + if cache_age < _YOY_CACHE_TTL: + sys_log.debug(f"[YoY] [Cache] 使用快取 | 快取年齡: {int(cache_age)}秒") + return jsonify(cached['data']) + + db = DatabaseManager() + + # 欄位名稱 + col_amount = '總業績' + col_qty = '數量' + col_cost = '總成本' + col_date = '日期' + + # 根據指標決定標籤 + if metric == 'qty': + metric_label = '銷售數量' + elif metric == 'profit': + metric_label = '毛利金額' + else: # revenue + metric_label = '銷售金額' + + # 優化:使用 Pandas 一次讀取兩年資料,再分組聚合 + # 比起 24 次 SQL 查詢,效能大幅提升 + years_filter = f'("{col_date}" LIKE :y1a OR "{col_date}" LIKE :y1b OR "{col_date}" LIKE :y2a OR "{col_date}" LIKE :y2b)' + params = { + 'y1a': f'{year1}/%', 'y1b': f'{year1}-%', + 'y2a': f'{year2}/%', 'y2b': f'{year2}-%' + } + + cols_to_fetch = [col_date, col_amount] + if metric == 'qty': + cols_to_fetch.append(col_qty) + elif metric == 'profit': + cols_to_fetch.extend([col_amount, col_cost]) + + cols_sql = ', '.join([f'"{c}"' for c in set(cols_to_fetch)]) + sql = f'SELECT {cols_sql} FROM "{table_name}" WHERE {years_filter}' + + df = pd.read_sql(text(sql), db.engine, params=params) + + if df.empty: + response = { + 'year1': {'label': f'{year1}年', 'total': 0}, + 'year2': {'label': f'{year2}年', 'total': 0}, + 'growth_rate': 0, + 'metric': metric, + 'metric_label': metric_label, + 'monthly_breakdown': [] + } + return jsonify(response) + + # 解析日期,提取年份和月份 + df['date_parsed'] = pd.to_datetime(df[col_date], errors='coerce') + df = df.dropna(subset=['date_parsed']) + df['year'] = df['date_parsed'].dt.year.astype(str) + df['month'] = df['date_parsed'].dt.month + + # 計算指標值 + if metric == 'qty': + df['value'] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) + elif metric == 'profit': + df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) - pd.to_numeric(df[col_cost], errors='coerce').fillna(0) + else: + df['value'] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) + + # 按年份+月份聚合 + grouped = df.groupby(['year', 'month'])['value'].sum().reset_index() + + # 計算年度總計 + total_year1 = float(grouped[grouped['year'] == year1]['value'].sum()) + total_year2 = float(grouped[grouped['year'] == year2]['value'].sum()) + + # 計算成長率 + if total_year1 > 0: + growth_rate = ((total_year2 - total_year1) / total_year1) * 100 + else: + growth_rate = 0 if total_year2 == 0 else 100 + + # 月度明細 + monthly_breakdown = [] + if not month: + # 建立年度-月份的 pivot 表 + pivot = grouped.pivot(index='month', columns='year', values='value').fillna(0) + + for m in range(1, 13): + v1 = float(pivot.loc[m, year1]) if m in pivot.index and year1 in pivot.columns else 0 + v2 = float(pivot.loc[m, year2]) if m in pivot.index and year2 in pivot.columns else 0 + m_growth = ((v2 - v1) / v1 * 100) if v1 > 0 else (0 if v2 == 0 else 100) + + monthly_breakdown.append({ + 'month': m, + 'month_label': f'{m}月', + 'year1_value': v1, + 'year2_value': v2, + 'growth_rate': round(m_growth, 2) + }) + + response = { + 'year1': { + 'label': f'{year1}年' + (f'{month}月' if month else ''), + 'total': total_year1 + }, + 'year2': { + 'label': f'{year2}年' + (f'{month}月' if month else ''), + 'total': total_year2 + }, + 'growth_rate': round(growth_rate, 2), + 'metric': metric, + 'metric_label': metric_label, + 'monthly_breakdown': monthly_breakdown + } + + # 儲存快取 + _YOY_CACHE[cache_key] = { + 'data': response, + 'timestamp': now + } + + elapsed = time.time() - start_time + sys_log.info(f"[YoY] {year1} vs {year2}: {total_year1:,.0f} -> {total_year2:,.0f} ({growth_rate:+.1f}%) | 耗時: {elapsed:.3f}s") + + return jsonify(response) + + except Exception as e: + sys_log.error(f"[YoY] Error: {e}") + traceback.print_exc() + return jsonify({'error': str(e)}), 500 + +# ================= 📈 V-New: 營運成長報表 (Growth Strategy) ================= +@app.route('/growth_analysis') +def growth_analysis(): + """營運成長策略報表 (MoM, YoY, AOV, YTD)""" + try: + db = DatabaseManager() + table_name = 'realtime_sales_monthly' + + # 1. 檢查資料表 + inspector = inspect(db.engine) + if table_name not in inspector.get_table_names(): + # V-Fix: 使用正確的模板或回傳錯誤訊息 + return f"尚未匯入業績資料 ({table_name})", 404 + + # 2. 讀取資料 (只讀取必要欄位以提升效能,使用安全函數防止 SQL Injection) + # 根據 inspect_columns.py 結果,使用正確的中文欄位名稱 + req_cols = ['日期', '總業績', '訂單編號', '總成本'] + df = safe_read_sql(table_name, columns=req_cols, engine=db.engine) + + if df.empty: + # V-Fix: 使用正確的模板或回傳錯誤訊息 + return f"資料表 {table_name} 為空", 404 + + # 3. 資料前處理 + df['dt'] = pd.to_datetime(df['日期'], errors='coerce') + df = df.dropna(subset=['dt']) # 移除日期無效的資料 + df['amount'] = pd.to_numeric(df['總業績'], errors='coerce').fillna(0) + df['cost'] = pd.to_numeric(df['總成本'], errors='coerce').fillna(0) + df['profit'] = df['amount'] - df['cost'] + + # 4. 按月聚合統計 + # resample('MS') 會將日期對齊到月初 (Month Start) + monthly_stats = df.set_index('dt').resample('MS').agg({ + 'amount': 'sum', + 'profit': 'sum', + '訂單編號': 'nunique' # 計算不重複訂單數 + }).rename(columns={'訂單編號': 'orders'}) + + # 5. 計算衍生指標 (AOV, MoM, YoY) + monthly_stats['aov'] = monthly_stats['amount'] / monthly_stats['orders'] + monthly_stats['margin_rate'] = (monthly_stats['profit'] / monthly_stats['amount']) * 100 + + # MoM (月增率) + monthly_stats['mom'] = monthly_stats['amount'].pct_change() * 100 + + # YoY (年增率) - shift(12) + monthly_stats['yoy'] = monthly_stats['amount'].pct_change(periods=12) * 100 + + # 填補 NaN (第一個月或無上期資料) + monthly_stats = monthly_stats.fillna(0) + + # 6. 準備圖表數據 + # 轉換索引為字串 'YYYY-MM' + labels = monthly_stats.index.strftime('%Y-%m').tolist() + + chart_data = { + 'labels': labels, + 'revenue': monthly_stats['amount'].tolist(), + 'profit': monthly_stats['profit'].tolist(), + 'orders': monthly_stats['orders'].tolist(), + 'aov': monthly_stats['aov'].round(0).tolist(), + 'mom': monthly_stats['mom'].round(2).tolist(), + 'yoy': monthly_stats['yoy'].round(2).tolist(), + 'margin_rate': monthly_stats['margin_rate'].round(1).tolist() + } + + # 7. 計算 KPI (YTD - Year to Date) + current_year = df['dt'].max().year + last_year = current_year - 1 + + ytd_mask = df['dt'].dt.year == current_year + last_ytd_mask = (df['dt'].dt.year == last_year) & (df['dt'].dt.dayofyear <= df['dt'].max().dayofyear) + + ytd_revenue = df.loc[ytd_mask, 'amount'].sum() + last_ytd_revenue = df.loc[last_ytd_mask, 'amount'].sum() + + ytd_growth = 0 + if last_ytd_revenue > 0: + ytd_growth = ((ytd_revenue - last_ytd_revenue) / last_ytd_revenue) * 100 + + # 近30天客單價 + last_month_mask = df['dt'] >= (df['dt'].max() - pd.Timedelta(days=30)) + recent_revenue = df.loc[last_month_mask, 'amount'].sum() + recent_orders = df.loc[last_month_mask, '訂單編號'].nunique() + recent_aov = recent_revenue / recent_orders if recent_orders > 0 else 0 + + kpi = { + 'ytd_revenue': ytd_revenue, + 'ytd_growth': ytd_growth, + 'current_year': current_year, + 'recent_aov': recent_aov, + 'total_orders': monthly_stats['orders'].sum() + } + + # V-Fix: 將模板移至根目錄,與 sales_analysis.html 一致,解決 TemplateNotFound 問題 + now_taipei = datetime.now(TAIPEI_TZ) + return render_template('growth_analysis.html', chart_data=chart_data, kpi=kpi, datetime_now=now_taipei.strftime('%Y-%m-%d %H:%M:%S')) + + except Exception as e: + sys_log.error(f"Growth Analysis Error: {e}") + return f"系統錯誤: {e}" + +# ================= 📅 V-New: 當日業績看板 ================= + +@app.route('/daily_sales') +def daily_sales(): + """當日業績看板 (Day-over-Day 與 Week-over-Week 分析)""" + now_taipei = datetime.now(TAIPEI_TZ) + datetime_now_str = now_taipei.strftime('%Y-%m-%d %H:%M:%S') + try: + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 1. 檢查資料表是否存在 + inspector = inspect(engine) + if table_name not in inspector.get_table_names(): + return render_template('daily_sales.html', + error="尚未匯入當日業績資料,請先至系統設定頁面匯入 Excel。", + selected_date=None, available_dates=[], current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None, + datetime_now=datetime_now_str) + + # 2. 讀取資料(使用快取) + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + # V-Security: 使用安全的 SQL 讀取函式 + df = safe_read_sql(table_name, engine=engine) + if df.empty: + return render_template('daily_sales.html', + error="資料表為空,請先匯入當日業績資料。", + selected_date=None, available_dates=[], current=None, dod=None, wow=None, + chart_data=None, categories=None, calendar_data=None, selected_month=None, + datetime_now=datetime_now_str) + + # 3. 資料前處理(欄位識別、型別轉換) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 4. 取得可用日期列表 + available_dates = sorted(df['snapshot_date'].unique(), reverse=True) + available_dates_str = [d.strftime('%Y-%m-%d') if isinstance(d, pd.Timestamp) else str(d) for d in available_dates] + + # 5. 取得選擇的日期(從 URL 參數或使用最新日期) + selected_date_param = request.args.get('date') + if selected_date_param: + selected_date = pd.to_datetime(selected_date_param) + else: + selected_date = df['snapshot_date'].max() + + # 6. 取得選擇的月份(用於行事曆顯示) + selected_month_param = request.args.get('month') + if selected_month_param: + selected_month = pd.to_datetime(selected_month_param) + else: + selected_month = selected_date + + # V-New 2026-01-15: 判斷是否為月概覽模式(沒有選擇特定日期) + is_month_view = not selected_date_param and not request.args.get('month') + # 如果只有 month 參數沒有 date 參數,也是月概覽模式 + if selected_month_param and not selected_date_param: + is_month_view = True + + # 7. 計算 KPI + current_kpi = calculate_daily_kpis(df, selected_date) + dod_kpi = calculate_dod(df, selected_date) + wow_kpi = calculate_wow(df, selected_date) + + # V-New 2026-01-15: 計算月度總計 KPI + month_start = selected_month.replace(day=1) + month_end = (month_start + pd.DateOffset(months=1)) - pd.Timedelta(days=1) + month_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + + # V-Fix 2026-01-15: 使用 find_col 動態獲取正確欄位名稱 + cols = month_df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名', 'Name']) + + month_kpi = { + 'total_revenue': float(month_df[col_amount].sum()) if col_amount else 0, + 'total_cost': float(month_df[col_cost].sum()) if col_cost else 0, + 'gross_margin': float(month_df[col_profit].sum()) if col_profit else 0, + 'total_qty': float(month_df[col_qty].sum()) if col_qty else 0, + 'sku_count': int(month_df[col_name].nunique()) if col_name else 0, + 'days_with_data': int(month_df['snapshot_date'].nunique()) + } + # 若無毛利欄位,用業績減成本計算 + if not col_profit and col_amount and col_cost: + month_kpi['gross_margin'] = month_kpi['total_revenue'] - month_kpi['total_cost'] + # 計算月度毛利率 + if month_kpi['total_revenue'] > 0: + month_kpi['margin_rate'] = month_kpi['gross_margin'] / month_kpi['total_revenue'] * 100 + else: + month_kpi['margin_rate'] = 0 + # 計算月度客單價 + if month_kpi['total_qty'] > 0: + month_kpi['avg_price'] = month_kpi['total_revenue'] / month_kpi['total_qty'] + else: + month_kpi['avg_price'] = 0 + + # 8. 準備圖表數據(根據選擇的日期) + chart_data = prepare_daily_charts(df, selected_date, days=30) + + # 9. 準備分類聚合列表 + # V-Fix 2026-01-15: 根據檢視模式(單日/月度)決定聚合範圍 + category_list = prepare_category_summary( + df, + date_str=selected_date, + is_month_view=is_month_view, + month_start=month_start if is_month_view else None, + month_end=month_end if is_month_view else None + ) + + # 10. 準備行事曆數據 + calendar_data = prepare_calendar_data(df, selected_month) + + # 11. V-New: 準備行銷活動業績數據 + marketing_data = prepare_marketing_summary( + df, + selected_date=selected_date if not is_month_view else None, + is_month_view=is_month_view, + month_start=month_start if is_month_view else None, + month_end=month_end if is_month_view else None + ) + + # 12. 回傳模板 + return render_template('daily_sales.html', + selected_date=selected_date.strftime('%Y-%m-%d') if isinstance(selected_date, pd.Timestamp) else selected_date, + available_dates=available_dates_str, + current=current_kpi, + dod=dod_kpi, + wow=wow_kpi, + month_kpi=month_kpi, # V-New: 月度總計 + is_month_view=is_month_view, # V-New: 月概覽模式標誌 + chart_data=chart_data, + categories=category_list, + calendar_data=calendar_data, + marketing_data=marketing_data, # V-New: 行銷活動數據 + selected_month=selected_month.strftime('%Y-%m') if isinstance(selected_month, pd.Timestamp) else selected_month, + datetime_now=datetime_now_str) + + except Exception as e: + sys_log.error(f"[Web] [DailySales] Error: {e}") + import traceback + traceback.print_exc() + return render_template('daily_sales.html', + error=f"系統錯誤: {str(e)}", + selected_date=None, + available_dates=[], + current=None, + dod=None, + wow=None, + month_kpi=None, + is_month_view=False, + chart_data=None, + categories=None, + calendar_data=None, + marketing_data=None, + selected_month=None, + datetime_now=datetime_now_str) + +@app.route('/daily_sales/export') +def export_daily_sales_category(): + """匯出當日業績分類明細為 Excel""" + try: + from datetime import datetime + import io + from flask import send_file + + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 檢查資料表是否存在 + inspector = inspect(engine) + if table_name not in inspector.get_table_names(): + return "資料表不存在", 404 + + # 讀取資料 + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + # V-Security: 使用安全的 SQL 讀取函式 + df = safe_read_sql(table_name, engine=engine) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 取得選擇的日期 + selected_date = request.args.get('date') + if not selected_date: + available_dates = sorted(df['snapshot_date'].unique(), reverse=True) + if available_dates: + selected_date = str(available_dates[0]) + else: + return "無可用日期", 404 + + # 準備分類資料 + categories = prepare_category_summary(df, selected_date) + + if not categories: + return "無資料可匯出", 404 + + # 轉為 DataFrame + export_df = pd.DataFrame(categories) + + # 重新排列欄位順序並重新命名為中文 + column_mapping = { + 'category': '分類', + 'vendor': '廠商', + 'revenue': '總業績', + 'cost': '總成本', + 'profit': '毛利', + 'margin_rate': '毛利率(%)', + 'qty': '總銷量', + 'sku_count': 'SKU數', + 'avg_price': '平均單價' + } + + # 只保留存在的欄位 + export_columns = [col for col in column_mapping.keys() if col in export_df.columns] + export_df = export_df[export_columns] + export_df = export_df.rename(columns=column_mapping) + + # 格式化數值欄位 + for col in export_df.columns: + if col in ['總業績', '總成本', '毛利', '總銷量', 'SKU數', '平均單價']: + export_df[col] = export_df[col].apply(lambda x: f"{x:,.0f}" if pd.notna(x) else "0") + elif col == '毛利率(%)': + export_df[col] = export_df[col].apply(lambda x: f"{x:.1f}" if pd.notna(x) else "0.0") + + # 產生檔案名稱 + filename = f"當日業績_分類明細_{selected_date}.xlsx" + + # 寫入 Excel + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + export_df.to_excel(writer, index=False, sheet_name='分類業績明細') + + # 調整欄寬 + worksheet = writer.sheets['分類業績明細'] + for idx, col in enumerate(export_df.columns, 1): + max_length = max( + export_df[col].astype(str).apply(len).max(), + len(col) + ) + 2 + worksheet.column_dimensions[chr(64 + idx)].width = min(max_length, 50) + + output.seek(0) + + sys_log.info(f"[Web] [DailySales] Excel 匯出成功: {filename}") + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + + except Exception as e: + sys_log.error(f"[Web] [DailySales] Excel 匯出失敗: {e}") + import traceback + traceback.print_exc() + return f"匯出失敗: {str(e)}", 500 + +# V-New 2026-01-15: 行銷活動業績匯出 API +@app.route('/daily_sales/export_marketing') +def export_marketing_summary_excel(): + """匯出行銷活動業績明細為 Excel""" + try: + import io + from flask import send_file + + db = DatabaseManager() + engine = db.engine + table_name = 'daily_sales_snapshot' + + # 讀取資料 + cache_key = f'{table_name}_daily' + if cache_key in _SALES_PROCESSED_CACHE: + df = _SALES_PROCESSED_CACHE[cache_key]['df'] + else: + # V-Security: 使用安全的 SQL 讀取函式 + df = safe_read_sql(table_name, engine=engine) + df = preprocess_daily_sales_data(df) + _SALES_PROCESSED_CACHE[cache_key] = {'df': df} + + # 取得參數 + activity_type = request.args.get('type', 'all') # coupon, discount, bonus, click, all + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + selected_date = request.args.get('date') + + # 額外篩選參數 (與 sales_analysis 同步) + selected_category = request.args.get('category', 'all') + selected_brand = request.args.get('brand', 'all') + selected_vendor = request.args.get('vendor', 'all') + keyword = request.args.get('keyword', '') + + # 決定日期範圍 + if start_date and end_date: + df = df[(df['snapshot_date'] >= pd.to_datetime(start_date)) & + (df['snapshot_date'] <= pd.to_datetime(end_date))] + date_label = f"{start_date}_{end_date}" + elif selected_date: + df = df[df['snapshot_date'] == pd.to_datetime(selected_date)] + date_label = selected_date + else: + date_label = "全部" + + # 應用額外篩選 + cols = df.columns.tolist() + col_category = find_col(cols, ['館別', '商品館', '分類', 'Category']) + col_brand = find_col(cols, ['品牌', 'Brand']) + col_vendor = find_col(cols, ['廠商名稱', 'Vendor Name', '廠商', '供應商', 'Vendor', 'Supplier']) + col_name = find_col(cols, ['商品名稱', '品名']) + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + + if selected_category != 'all' and col_category: + df = df[df[col_category] == selected_category] + if selected_brand != 'all' and col_brand: + df = df[df[col_brand] == selected_brand] + if selected_vendor != 'all' and col_vendor: + df = df[df[col_vendor] == selected_vendor] + if keyword and col_name: + df = df[df[col_name].str.contains(keyword, case=False, na=False)] + + # 定義行銷活動欄位 + marketing_cols = { + 'coupon': ('折價券活動名稱', '折價券活動'), + 'discount': ('折扣活動名稱', '折扣活動'), + 'bonus': ('滿額再折扣活動名稱', '滿額再折扣'), + 'click': ('點我再折扣', '點我再折扣') + } + + # 準備 Excel 輸出 + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + # 如果是 all,循環所有類型 + types_to_export = [activity_type] if activity_type != 'all' else ['coupon', 'discount', 'bonus', 'click'] + + summary_rows = [] + + for t in types_to_export: + if t not in marketing_cols: continue + col_internal, sheet_label = marketing_cols[t] + if col_internal not in df.columns: + continue + + # 聚合數據 + # V-Fix: 排除空值和 0 + m_df = df[df[col_internal].notna() & (df[col_internal] != '') & (df[col_internal] != '0') & (df[col_internal] != 0)] + + if m_df.empty: + continue + + grouped = m_df.groupby(col_internal).agg({ + col_amount: 'sum', + col_qty: 'sum', + col_name: 'count' # 訂單筆數/商品筆數 + }).reset_index() + + # 重命名 + grouped.columns = ['活動名稱', '總業績', '總銷量', '項目筆數'] + grouped = grouped.sort_values(by='總業績', ascending=False) + + # 寫入工作表 + grouped.to_excel(writer, sheet_name=sheet_label[:31], index=False) + + # 加入到總表數據 + grouped['活動類型'] = sheet_label + summary_rows.append(grouped) + + # 建立總表工作表 (如果有多個類型) + if len(summary_rows) > 1: + all_m_df = pd.concat(summary_rows).sort_values(by='總業績', ascending=False) + all_m_df = all_m_df[['活動類型', '活動名稱', '總業績', '總銷量', '項目筆數']] + all_m_df.to_excel(writer, sheet_name='合併總表', index=False) + + output.seek(0) + output.seek(0) + + filename = f"行銷活動分析_{date_label}.xlsx" + # 處理中文檔名編碼 + from urllib.parse import quote + encoded_filename = quote(filename) + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename, + conditional=True + ) + + except Exception as e: + sys_log.error(f"[Web] [Marketing] Excel 匯出失敗: {e}") + import traceback + traceback.print_exc() + return f"匯出失敗: {str(e)}", 500 + +def preprocess_daily_sales_data(df): + """前處理當日業績資料:欄位識別、型別轉換""" + cols = df.columns.tolist() + + # 欄位自動識別(使用現有的 find_col 函式) + col_amount = find_col(cols, ['銷售金額', '業績', '金額', 'Amount', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) + + # 型別轉換 + if col_amount: + df[col_amount] = pd.to_numeric(df[col_amount], errors='coerce').fillna(0) + if col_cost: + df[col_cost] = pd.to_numeric(df[col_cost], errors='coerce').fillna(0) + if col_profit: + df[col_profit] = pd.to_numeric(df[col_profit], errors='coerce').fillna(0) + if col_qty: + df[col_qty] = pd.to_numeric(df[col_qty], errors='coerce').fillna(0) + + # 日期轉換 + df['snapshot_date'] = pd.to_datetime(df['snapshot_date'], errors='coerce') + + return df + +def calculate_daily_kpis(df, date_str): + """計算單日 6 個 KPI""" + day_df = df[df['snapshot_date'] == date_str] + cols = day_df.columns.tolist() + + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名', 'Name']) + + total_revenue = float(day_df[col_amount].sum()) if col_amount else 0 + total_cost = float(day_df[col_cost].sum()) if col_cost else 0 + gross_margin = float(day_df[col_profit].sum()) if col_profit else (total_revenue - total_cost) + total_qty = float(day_df[col_qty].sum()) if col_qty else 0 + sku_count = int(day_df[col_name].nunique()) if col_name else 0 + avg_price = total_revenue / total_qty if total_qty > 0 else 0 + + return { + 'total_revenue': total_revenue, + 'total_cost': total_cost, + 'gross_margin': gross_margin, + 'total_qty': total_qty, + 'sku_count': sku_count, + 'avg_price': avg_price + } + +def calculate_dod(df, current_date): + """計算 Day-over-Day 變化率""" + current = calculate_daily_kpis(df, current_date) + prev_date = current_date - timedelta(days=1) + + if prev_date not in df['snapshot_date'].values: + return {k: 0.0 for k in current.keys()} + + previous = calculate_daily_kpis(df, prev_date) + + dod = {} + for key in current: + if previous[key] > 0: + dod[key] = ((current[key] - previous[key]) / previous[key]) * 100 + else: + dod[key] = 0.0 + return dod + +def calculate_wow(df, current_date): + """計算 Week-over-Week 變化率""" + current = calculate_daily_kpis(df, current_date) + prev_week_date = current_date - timedelta(days=7) + + if prev_week_date not in df['snapshot_date'].values: + return {k: 0.0 for k in current.keys()} + + previous = calculate_daily_kpis(df, prev_week_date) + + wow = {} + for key in current: + if previous[key] > 0: + wow[key] = ((current[key] - previous[key]) / previous[key]) * 100 + else: + wow[key] = 0.0 + return wow + +def prepare_daily_charts(df, selected_date, days=30): + """準備 4 個圖表的數據(根據選擇的日期)""" + # 取選擇日期前 N 天的數據 + start_date = selected_date - timedelta(days=days) + df_range = df[(df['snapshot_date'] >= start_date) & (df['snapshot_date'] <= selected_date)] + + # 按日期聚合 + cols = df_range.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', '總成本']) + col_profit = find_col(cols, ['毛利']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + # 日期聚合 + agg_dict = {} + if col_amount: + agg_dict[col_amount] = 'sum' + if col_cost: + agg_dict[col_cost] = 'sum' + if col_profit: + agg_dict[col_profit] = 'sum' + if col_qty: + agg_dict[col_qty] = 'sum' + + daily_agg = df_range.groupby('snapshot_date').agg(agg_dict).reset_index() + + # 計算或取得毛利(如果沒有毛利欄位,用業績-成本計算) + if col_profit and col_profit in daily_agg.columns: + daily_agg['profit'] = daily_agg[col_profit] + elif col_amount and col_cost and col_amount in daily_agg.columns and col_cost in daily_agg.columns: + daily_agg['profit'] = daily_agg[col_amount] - daily_agg[col_cost] + else: + daily_agg['profit'] = 0 + + # 計算客單價 + if col_amount and col_qty and col_amount in daily_agg.columns and col_qty in daily_agg.columns: + daily_agg['avg_price'] = (daily_agg[col_amount] / daily_agg[col_qty]).fillna(0) + else: + daily_agg['avg_price'] = 0 + + # 計算 DoD (Day-over-Day) 變化率 - 多個維度 + if col_amount and col_amount in daily_agg.columns: + daily_agg['dod_revenue'] = daily_agg[col_amount].pct_change() * 100 + if 'profit' in daily_agg.columns: + daily_agg['dod_profit'] = daily_agg['profit'].pct_change() * 100 + if 'avg_price' in daily_agg.columns: + daily_agg['dod_avg_price'] = daily_agg['avg_price'].pct_change() * 100 + if col_qty and col_qty in daily_agg.columns: + daily_agg['dod_qty'] = daily_agg[col_qty].pct_change() * 100 + + # 計算 WoW (Week-over-Week) 變化率 - 多個維度 + if col_amount and col_amount in daily_agg.columns: + daily_agg['wow_revenue'] = daily_agg[col_amount].pct_change(periods=7) * 100 + if 'profit' in daily_agg.columns: + daily_agg['wow_profit'] = daily_agg['profit'].pct_change(periods=7) * 100 + if 'avg_price' in daily_agg.columns: + daily_agg['wow_avg_price'] = daily_agg['avg_price'].pct_change(periods=7) * 100 + if col_qty and col_qty in daily_agg.columns: + daily_agg['wow_qty'] = daily_agg[col_qty].pct_change(periods=7) * 100 + + # Top 10 商品(選擇的日期,包含廠商) + selected_df = df[df['snapshot_date'] == selected_date] + top10_labels = [] + top10_values = [] + + if col_name and col_amount: + col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) + + if col_vendor: + # 如果有廠商欄位,按商品+廠商聚合 + top10_df = selected_df.groupby([col_name, col_vendor])[col_amount].sum().nlargest(10).reset_index() + top10_labels = [f"{row[col_name]} ({row[col_vendor]})" for _, row in top10_df.iterrows()] + top10_values = top10_df[col_amount].tolist() + else: + # 沒有廠商欄位,只按商品聚合 + top10 = selected_df.groupby(col_name)[col_amount].sum().nlargest(10) + top10_labels = top10.index.tolist() + top10_values = top10.values.tolist() + + return { + 'labels': daily_agg['snapshot_date'].dt.strftime('%m/%d').tolist() if not daily_agg.empty else [], + 'revenue': daily_agg[col_amount].tolist() if col_amount and col_amount in daily_agg.columns and not daily_agg.empty else [], + 'cost': daily_agg[col_cost].tolist() if col_cost and col_cost in daily_agg.columns and not daily_agg.empty else [], + 'profit': daily_agg['profit'].tolist() if 'profit' in daily_agg.columns and not daily_agg.empty else [], + 'qty': daily_agg[col_qty].tolist() if col_qty and col_qty in daily_agg.columns and not daily_agg.empty else [], + 'avg_price': daily_agg['avg_price'].tolist() if 'avg_price' in daily_agg.columns and not daily_agg.empty else [], + + # DoD 多維度 + 'dod_revenue': daily_agg['dod_revenue'].fillna(0).tolist() if 'dod_revenue' in daily_agg.columns and not daily_agg.empty else [], + 'dod_profit': daily_agg['dod_profit'].fillna(0).tolist() if 'dod_profit' in daily_agg.columns and not daily_agg.empty else [], + 'dod_avg_price': daily_agg['dod_avg_price'].fillna(0).tolist() if 'dod_avg_price' in daily_agg.columns and not daily_agg.empty else [], + 'dod_qty': daily_agg['dod_qty'].fillna(0).tolist() if 'dod_qty' in daily_agg.columns and not daily_agg.empty else [], + + # WoW 多維度 + 'wow_revenue': daily_agg['wow_revenue'].fillna(0).tolist() if 'wow_revenue' in daily_agg.columns and not daily_agg.empty else [], + 'wow_profit': daily_agg['wow_profit'].fillna(0).tolist() if 'wow_profit' in daily_agg.columns and not daily_agg.empty else [], + 'wow_avg_price': daily_agg['wow_avg_price'].fillna(0).tolist() if 'wow_avg_price' in daily_agg.columns and not daily_agg.empty else [], + 'wow_qty': daily_agg['wow_qty'].fillna(0).tolist() if 'wow_qty' in daily_agg.columns and not daily_agg.empty else [], + + 'top10_labels': top10_labels, + 'top10_values': top10_values + } + +def prepare_category_summary(df, date_str=None, is_month_view=False, month_start=None, month_end=None): + """準備分類聚合列表 (支援單日或月度範圍)""" + if is_month_view and month_start is not None and month_end is not None: + day_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + else: + day_df = df[df['snapshot_date'] == date_str] + cols = day_df.columns.tolist() + + col_category = find_col(cols, ['館別', '分類', 'Category']) + col_vendor = find_col(cols, ['廠商名稱', '廠商', 'Vendor', 'Supplier']) + col_amount = find_col(cols, ['銷售金額', '業績', '總業績']) + col_cost = find_col(cols, ['成本', '總成本']) + col_profit = find_col(cols, ['毛利']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + if not col_category or not col_amount: + return [] + + # 分類 + 廠商聚合 + agg_dict = {col_amount: 'sum'} + if col_cost: + agg_dict[col_cost] = 'sum' + if col_profit: + agg_dict[col_profit] = 'sum' + if col_qty: + agg_dict[col_qty] = 'sum' + if col_name: + agg_dict[col_name] = 'nunique' + + # 如果有廠商欄位,按分類+廠商聚合;否則只按分類聚合 + if col_vendor: + category_df = day_df.groupby([col_category, col_vendor]).agg(agg_dict).reset_index() + else: + category_df = day_df.groupby(col_category).agg(agg_dict).reset_index() + + # 計算毛利(如果資料中沒有毛利欄位,自動計算) + if col_profit and col_profit in category_df.columns: + # 資料中有毛利欄位,直接使用 + pass + elif col_amount and col_cost and col_amount in category_df.columns and col_cost in category_df.columns: + # 資料中沒有毛利欄位,用 業績 - 成本 計算 + category_df['profit_calculated'] = category_df[col_amount] - category_df[col_cost] + col_profit = 'profit_calculated' + else: + col_profit = None + + # 計算毛利率 + if col_profit and col_profit in category_df.columns and col_amount and col_amount in category_df.columns: + category_df['margin_rate'] = (category_df[col_profit] / category_df[col_amount] * 100).fillna(0) + else: + category_df['margin_rate'] = 0 + + # 計算均價 + if col_qty and col_amount: + category_df['avg_price'] = (category_df[col_amount] / category_df[col_qty]).fillna(0) + else: + category_df['avg_price'] = 0 + + # 重新命名欄位以便模板使用 + rename_dict = {col_category: 'category', col_amount: 'revenue'} + if col_vendor: + rename_dict[col_vendor] = 'vendor' + if col_cost: + rename_dict[col_cost] = 'cost' + if col_profit and col_profit in category_df.columns: + rename_dict[col_profit] = 'profit' + if col_qty: + rename_dict[col_qty] = 'qty' + if col_name: + rename_dict[col_name] = 'sku_count' + + category_df = category_df.rename(columns=rename_dict) + + # 確保 profit 欄位存在,如果不存在則設為 0 + if 'profit' not in category_df.columns: + category_df['profit'] = 0 + + # 轉為字典列表 + return category_df.to_dict('records') + +# V-New 2026-01-15: 行銷活動業績聚合函數 +def prepare_marketing_summary(df, selected_date=None, is_month_view=False, month_start=None, month_end=None, sort_by='revenue'): + """ + 準備行銷活動業績貢獻數據 + 支援單日模式和月度模式,並可指定排序維度 (revenue, qty, profit) + """ + # 決定使用的數據範圍 + if is_month_view and month_start is not None and month_end is not None: + target_df = df[(df['snapshot_date'] >= month_start) & (df['snapshot_date'] <= month_end)] + elif selected_date is not None: + target_df = df[df['snapshot_date'] == selected_date] + else: + target_df = df + + if target_df.empty: + return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} + + cols = target_df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_qty = find_col(cols, ['銷售數量', '銷量', '數量', 'Qty']) + col_profit = find_col(cols, ['毛利', 'Profit', '利潤']) + col_cost = find_col(cols, ['成本', 'Cost', '總成本']) + + if not col_amount: + return {'coupon': [], 'discount': [], 'bonus': [], 'click': []} + + # 定義四種行銷活動欄位 + marketing_cols = { + 'coupon': '折價券活動名稱', # 折價券活動 + 'discount': '折扣活動名稱', # 折扣活動 + 'bonus': '滿額再折扣活動名稱', # 滿額再折扣 + 'click': '點我再折扣' # 點我再折扣 + } + + result = {} + + # 確保 sort_by 欄位存在,否則退回 revenue + actual_sort_key = sort_by if sort_by in ['revenue', 'qty', 'profit'] else 'revenue' + + for key, col_name in marketing_cols.items(): + if col_name not in cols: + result[key] = [] + continue + + # 篩選有該行銷活動的記錄 + activity_df = target_df[ + (target_df[col_name].notna()) & + (target_df[col_name] != '') & + (target_df[col_name] != '0') & + (target_df[col_name] != 0) + ] + + if activity_df.empty: + result[key] = [] + continue + + # 聚合計算 + agg_args = { + 'revenue': (col_amount, 'sum'), + 'order_count': (col_amount, 'count') + } + if col_qty: agg_args['qty'] = (col_qty, 'sum') + if col_profit: agg_args['profit'] = (col_profit, 'sum') + + grouped = activity_df.groupby(col_name).agg(**agg_args).reset_index() + + # 若需要手動計算毛利 (金額 - 成本) + if 'profit' not in agg_args and col_cost: + cost_agg = activity_df.groupby(col_name)[col_cost].sum().reset_index() + grouped = grouped.merge(cost_agg, on=col_name) + grouped['profit'] = grouped['revenue'] - grouped[col_cost] + + grouped = grouped.rename(columns={col_name: 'name'}) + + # 動態排序 + sort_col = actual_sort_key if actual_sort_key in grouped.columns else 'revenue' + grouped = grouped.sort_values(sort_col, ascending=False).head(15) + + # 轉為字典列表 + records = [] + for _, row in grouped.iterrows(): + record = { + 'name': str(row['name'])[:50], + 'revenue': float(row['revenue']), + 'order_count': int(row['order_count']) + } + if 'qty' in row: record['qty'] = float(row['qty']) + if 'profit' in row: record['profit'] = float(row['profit']) + records.append(record) + + result[key] = records + + return result + + +def get_taiwan_holiday(date): + """判斷是否為台灣國定假日,回傳 (is_holiday, holiday_name)""" + year = date.year + month = date.month + day = date.day + + # 2026年台灣國定假日(根據人事行政總處公佈) + holidays_2026 = { + (1, 1): '元旦', + # 春節連假 (2/14-2/22,共9天) + (2, 14): '春節連假', + (2, 15): '小年夜', + (2, 16): '除夕', + (2, 17): '春節 (初一)', + (2, 18): '春節 (初二)', + (2, 19): '春節 (初三)', + (2, 20): '春節連假', + (2, 21): '春節連假', + (2, 22): '春節連假', + # 和平紀念日 (2/28-3/2,共3天) + (2, 28): '和平紀念日', + (3, 2): '和平紀念日補假', + # 兒童節+清明節 (4/3-4/6,共4天) + (4, 3): '兒童節補假', + (4, 4): '清明節', + (4, 5): '清明節連假', + (4, 6): '清明節補假', + # 勞動節 (5/1-5/3,共3天) + (5, 1): '勞動節', + # 端午節 (6/19-6/21,共3天) + (6, 19): '端午節', + # 中秋節+教師節 (9/25-9/28,共4天) + (9, 25): '中秋節', + (9, 28): '教師節', + # 國慶日 (10/9-10/11,共3天) + (10, 9): '國慶日補假', + (10, 10): '國慶日', + # 光復節 (10/25-10/26,共2天) + (10, 25): '臺灣光復節', + (10, 26): '光復節補假', + # 行憲紀念日 (12/25-12/27,共3天) + (12, 25): '行憲紀念日', + } + + # 2027年台灣國定假日(預先計算部分) + holidays_2027 = { + (1, 1): '元旦', + (2, 11): '春節 (除夕)', + (2, 12): '春節 (初一)', + (2, 13): '春節 (初二)', + (2, 14): '春節 (初三)', + (2, 15): '春節 (初四)', + (2, 16): '春節 (初五)', + (2, 17): '春節 (初六)', + (2, 28): '和平紀念日', + (4, 4): '清明節', + (4, 5): '清明節連假', + (6, 14): '端午節', + (9, 21): '中秋節', + (10, 10): '國慶日', + (10, 11): '國慶日連假', + } + + holidays = holidays_2026 if year == 2026 else (holidays_2027 if year == 2027 else {}) + + holiday_name = holidays.get((month, day)) + return (True, holiday_name) if holiday_name else (False, None) + +def prepare_calendar_data(df, selected_month): + """準備行事曆數據(豐富版:顯示總業績、毛利、SKU數 + DoD%)""" + import calendar + + # 取得該月份的年月 + year = selected_month.year + month = selected_month.month + + # 計算該月第一天和最後一天 + first_day = pd.Timestamp(year=year, month=month, day=1) + last_day = pd.Timestamp(year=year, month=month, day=calendar.monthrange(year, month)[1]) + + # 計算行事曆顯示範圍(包含前後月份的日期以填滿週) + # 取得該月第一天是星期幾 (0=Monday, 6=Sunday) + first_weekday = first_day.weekday() + + # 計算行事曆起始日(從週一開始) + calendar_start = first_day - timedelta(days=first_weekday) + + # 計算該月最後一天是星期幾 + last_weekday = last_day.weekday() + + # 計算行事曆結束日(到週日結束) + calendar_end = last_day + timedelta(days=(6 - last_weekday)) + + # 取得該月份及前後各一天的所有資料(用於計算 DoD) + data_start = first_day - timedelta(days=1) + data_end = last_day + month_df = df[(df['snapshot_date'] >= data_start) & (df['snapshot_date'] <= data_end)] + + # 取得欄位 + cols = df.columns.tolist() + col_amount = find_col(cols, ['銷售金額', '業績', '金額', '總業績']) + col_cost = find_col(cols, ['成本', 'Cost']) + col_profit = find_col(cols, ['毛利', 'Profit']) + col_qty = find_col(cols, ['銷售數量', '銷量', 'Qty', '數量']) + col_name = find_col(cols, ['商品名稱', '品名']) + + # 為每一天計算 KPI + calendar_days = [] + current_date = calendar_start + + while current_date <= calendar_end: + # 取得星期(0=週一, 6=週日) + weekday = current_date.weekday() + weekday_names = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'] + + # 判斷是否為國定假日 + is_holiday, holiday_name = get_taiwan_holiday(current_date) + + day_data = { + 'date': current_date.strftime('%Y-%m-%d'), + 'day': current_date.day, + 'weekday': weekday_names[weekday], + 'is_weekend': weekday >= 5, # 週六或週日 + 'is_holiday': is_holiday, + 'holiday_name': holiday_name, + 'is_current_month': current_date.month == month, + 'has_data': False, + 'revenue': 0, + 'profit': 0, + 'margin_rate': 0, + 'sku_count': 0, + 'qty': 0, + 'avg_price': 0, + 'dod_percent': 0, + 'dod_direction': 'neutral' # 'up', 'down', 'neutral' + } + + # 如果該日期在當前月份範圍內,計算 KPI + if first_day <= current_date <= last_day: + day_df = month_df[month_df['snapshot_date'] == current_date] + + if not day_df.empty: + day_data['has_data'] = True + + # 計算總業績 + if col_amount: + day_data['revenue'] = float(day_df[col_amount].sum()) + + # 計算毛利(優先使用毛利欄位,否則用業績-成本計算) + if col_profit: + day_data['profit'] = float(day_df[col_profit].sum()) + elif col_cost and col_amount: + total_cost = float(day_df[col_cost].sum()) + day_data['profit'] = day_data['revenue'] - total_cost + + # 計算毛利率 + if day_data['revenue'] > 0: + day_data['margin_rate'] = (day_data['profit'] / day_data['revenue']) * 100 + + # 計算銷量 + if col_qty: + day_data['qty'] = float(day_df[col_qty].sum()) + + # 計算客單價(總業績 / 總銷量) + if day_data['qty'] > 0: + day_data['avg_price'] = day_data['revenue'] / day_data['qty'] + + # 計算 SKU 數 + if col_name: + day_data['sku_count'] = int(day_df[col_name].nunique()) + + # 計算 DoD% + prev_date = current_date - timedelta(days=1) + prev_df = month_df[month_df['snapshot_date'] == prev_date] + + if not prev_df.empty and col_amount: + prev_revenue = float(prev_df[col_amount].sum()) + if prev_revenue > 0: + dod = ((day_data['revenue'] - prev_revenue) / prev_revenue) * 100 + day_data['dod_percent'] = round(dod, 1) + day_data['dod_direction'] = 'up' if dod >= 0 else 'down' + + calendar_days.append(day_data) + current_date += timedelta(days=1) + + # 組織成週結構(每週 7 天) + weeks = [] + for i in range(0, len(calendar_days), 7): + weeks.append(calendar_days[i:i+7]) + + # 計算上個月和下個月的年月 + prev_month = selected_month - pd.DateOffset(months=1) + next_month = selected_month + pd.DateOffset(months=1) + + return { + 'year': year, + 'month': month, + 'month_name': selected_month.strftime('%Y年%m月'), + 'weeks': weeks, + 'prev_month': prev_month.strftime('%Y-%m'), + 'next_month': next_month.strftime('%Y-%m') + } + +# ================= ⚙️ 5. 服務啟動邏輯 ================= + +def run_schedule(): + """在背景執行緒中運行排程""" + sys_log.info("🚀 排程服務已啟動,等待任務...") + while True: + schedule.run_pending() + time.sleep(1) + +def init_scheduler(): + """初始化排程任務(Gunicorn 模式下也會執行)""" + schedule.every(1).hours.do(run_momo_task) + schedule.every(1).hours.do(run_edm_task) + schedule.every(1).hours.do(run_festival_task) + sys_log.info(f"📅 已設定每小時執行主站、EDM與購物節爬蟲任務") + + schedule.every(30).minutes.do(run_auto_import_task) + sys_log.info(f"📅 已設定每 30 分鐘執行 Google Drive 自動匯入任務") + + schedule.every(30).minutes.do(run_whitepage_check) + sys_log.info(f"📅 已設定每 30 分鐘執行網頁白頁監控任務") + + # 啟動排程執行緒 + scheduler_thread = threading.Thread(target=run_schedule, daemon=True) + scheduler_thread.start() + sys_log.info("✅ 排程器已在背景執行緒中啟動") + +# V-New: 在模組載入時自動初始化排程(Gunicorn 模式下也會執行) +# 🚩 V-Fix 2026-01-14: 停用自動排程器以避免多個 gunicorn workers 重複執行任務 +# 原因:每個 worker 都會啟動排程器,導致 4x 資源消耗(4 workers × 3 爬蟲任務 = 12 Chrome 實例同時運行) +# 解決方案:改用獨立的 run_scheduler.py 或透過 Web UI 手動觸發任務 +# try: +# init_scheduler() +# except Exception as e: +# sys_log.error(f"❌ 排程器初始化失敗: {e}") +sys_log.info("ℹ️ 自動排程器已停用(避免重複執行),請使用 run_scheduler.py 或 Web UI 手動觸發") + +def start_flask(): + sys_log.info("🚀 Web 服務正在啟動於 port 80...") + app.run(host='0.0.0.0', port=80, use_reloader=False) + +def scheduled_job_wrapper(): + """執行 MOMO 爬蟲任務並發送通知""" + timestamp = datetime.now(TAIPEI_TZ).strftime('%H:%M:%S') + sys_log.info(f"⏰ [{timestamp}] 啟動背景抓取執行緒...") + + def job(): + # 1. 執行爬蟲 + run_momo_task() + + # 2. 發送通知 (僅發送今日異動) + try: + # 重新載入通知模組 + import importlib + import scheduler + import services.notification_manager + importlib.reload(scheduler) + importlib.reload(services.notification_manager) + from services.notification_manager import NotificationManager + + stats = get_dashboard_stats() + + # 只要有任何異動數據就發送通知 + if any(stats.values()): + screenshot_path = scheduler.capture_page_screenshot("http://127.0.0.1/", "momo_dashboard") + NotificationManager().send_momo_report(stats, screenshot_path) + except Exception as e: + sys_log.error(f"[Scheduler] ❌ 發送通知失敗: {e}") + + threading.Thread(target=job, daemon=True).start() + + +# ========================================== +# 🔧 路由清理:移除被模組化路由覆蓋的重複端點 +# ========================================== +def cleanup_duplicate_routes(): + """ + 當模組化路由啟用時,移除 app.py 中定義的重複路由 + 這確保 Blueprint 中的路由優先被使用 + """ + from routes import MODULAR_ENDPOINTS + + if not MODULAR_ENDPOINTS: + return # 沒有啟用任何模組化路由 + + # 找出需要移除的端點(app.py 中定義的,沒有藍圖前綴) + endpoints_to_remove = set() + rules_to_remove = [] + + for rule in list(app.url_map.iter_rules()): + # 只移除 app.py 中定義的路由(沒有藍圖前綴) + if '.' not in rule.endpoint and rule.endpoint in MODULAR_ENDPOINTS: + endpoints_to_remove.add(rule.endpoint) + rules_to_remove.append(rule) + + if not endpoints_to_remove: + return + + # 從 view_functions 中移除重複的端點 + for endpoint in endpoints_to_remove: + if endpoint in app.view_functions: + del app.view_functions[endpoint] + + # 從 url_map._rules 中移除對應的規則 + # 注意:這是直接操作內部結構,需要同時更新索引 + for rule in rules_to_remove: + try: + app.url_map._rules.remove(rule) + # 同時從 _rules_by_endpoint 中移除 + if rule.endpoint in app.url_map._rules_by_endpoint: + endpoint_rules = app.url_map._rules_by_endpoint[rule.endpoint] + if rule in endpoint_rules: + endpoint_rules.remove(rule) + if not endpoint_rules: + del app.url_map._rules_by_endpoint[rule.endpoint] + except (ValueError, KeyError): + pass # 規則可能已被移除 + + sys_log.info(f"[Routes] 🧹 已清理 {len(endpoints_to_remove)} 條重複路由 (模組化路由優先)") + + +# 執行路由清理 +cleanup_duplicate_routes() + + +# ========================================== +# 🔧 端點別名:確保模組化路由啟用後 url_for 仍可使用舊名稱 +# ========================================== +def register_endpoint_aliases(): + """ + 當模組化路由啟用時,為藍圖端點建立別名 + 這樣模板中的 url_for('index') 仍可正常運作 + """ + from routes import MODULAR_ENDPOINTS + from werkzeug.routing import Rule + + if not MODULAR_ENDPOINTS: + return + + # 端點別名對應表:舊名稱 -> (藍圖端點, URL路徑, 方法) + aliases = { + # dashboard_routes + 'index': ('dashboard.index', '/', ['GET']), + 'brand_assets': ('dashboard.brand_assets', '/brand_assets', ['GET']), + # system_routes + 'health_check': ('system.health_check', '/health', ['GET']), + 'prometheus_metrics': ('system.prometheus_metrics', '/metrics', ['GET']), + 'settings': ('system.settings', '/settings', ['GET']), + 'system_settings_page': ('system.system_settings_page', '/system_settings', ['GET']), + 'show_logs': ('system.show_logs', '/logs', ['GET']), + 'get_logs_api': ('system.get_logs_api', '/api/logs', ['GET']), + 'add_category': ('system.add_category', '/api/categories', ['POST']), + 'update_category': ('system.update_category', '/api/categories/', ['PUT']), + 'delete_category': ('system.delete_category', '/api/categories/', ['DELETE']), + 'test_url': ('system.test_url', '/api/test_url', ['POST']), + 'trigger_backup': ('system.trigger_backup', '/api/backup', ['POST']), + 'download_backup': ('system.download_backup', '/api/backup/download/', ['GET']), + # edm_routes + 'edm_dashboard': ('edm.edm_dashboard', '/edm', ['GET']), + 'festival_dashboard': ('edm.festival_dashboard', '/festival', ['GET']), + # monthly_routes + 'monthly_summary_analysis_page': ('monthly.monthly_summary_analysis_page', '/monthly_summary_analysis', ['GET']), + 'get_monthly_summary_data': ('monthly.get_monthly_summary_data', '/api/monthly_summary_data', ['GET']), + # daily_sales_routes + 'daily_sales': ('daily_sales.daily_sales', '/daily_sales', ['GET']), + 'export_daily_sales_category': ('daily_sales.export_daily_sales_category', '/daily_sales/export', ['GET']), + 'export_marketing_summary_excel': ('daily_sales.export_marketing_summary_excel', '/daily_sales/export_marketing', ['GET']), + } + + registered_count = 0 + for old_name, (new_name, url_path, methods) in aliases.items(): + # 只有當舊端點不存在且新端點存在時才建立別名 + if old_name not in app.view_functions and new_name in app.view_functions: + # 1. 複製 view function + app.view_functions[old_name] = app.view_functions[new_name] + # 2. 建立 URL 規則(讓 url_for 可以找到) + rule = Rule(url_path, endpoint=old_name, methods=methods) + app.url_map.add(rule) + registered_count += 1 + + if registered_count > 0: + sys_log.info(f"[Routes] 🔗 已建立 {registered_count} 個端點別名 (相容舊版 url_for)") + + +# 執行端點別名註冊 +register_endpoint_aliases() + + +if __name__ == "__main__": + banner = f" MOMO 專業數據管理系統 {SYSTEM_VERSION} " + sys_log.info(f"{ '='*20} {banner} {'='*20}") + + # 啟動前先檢查資料庫結構 + repair_database_schema() + + # 使用生產環境域名 + public_url = "https://momo.wooo.work" + sys_log.info(f"✅ 使用固定網址: {public_url}") + + # 🚩 V9.7 將公開 URL 寫入設定檔,供其他模組使用 + try: + url_config_path = os.path.join(BASE_DIR, 'data', 'url_config.json') + with open(url_config_path, 'w') as f: + json.dump({"public_url": public_url}, f) + except Exception as file_err: + sys_log.error(f"⚠️ URL 設定檔寫入失敗 (不影響服務運行,可能磁碟已滿): {file_err}") + + web_server = threading.Thread(target=start_flask) + web_server.daemon = True + web_server.start() + + # 排程器已在模組載入時自動初始化(見 init_scheduler() 函式) + sys_log.info("ℹ️ 排程器已在全域範圍初始化完成") + + try: + while True: + time.sleep(3600) + except KeyboardInterrupt: + sys_log.info("🔌 Web 服務已關閉") + try: + ngrok.disconnect(public_url) + except Exception as e: + sys_log.info(f"ℹ️ Ngrok 關閉時無需額外操作: {e}") diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..9f1eaae --- /dev/null +++ b/auth.py @@ -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 模組已載入(增強安全版本)") \ No newline at end of file diff --git a/auto_import_index.html b/auto_import_index.html new file mode 100644 index 0000000..df69986 --- /dev/null +++ b/auto_import_index.html @@ -0,0 +1,730 @@ + + + + + + + + 當日業績報表匯入 - WOOO TECH + + + + + + + + {% include 'components/_navbar.html' %} + +
+ +
+
+

當日業績報表匯入

+

+ + 支援兩種匯入方式:Google Drive 自動匯入(每 30 分鐘檢查)或 手動上傳 +

+
+
+ + +
+
+
Google Drive 自動匯入配置
+
+
+
+ +
+

+ + 系統每 30 分鐘自動檢查 Google Drive → 下載檔案 → 匯入資料庫 → 刪除雲端原檔 +

+
+ +
+
+ + + 設定要監控的 Google Drive 資料夾路徑 +
+
+ + + 用於過濾特定名稱的檔案 +
+
+ +
+ + + + +
+
+
+ + +
+
+
手動上傳匯入
+
+
+
+ +
+
每日業績快照
+

匯入格式:即時業績_當日_YYYYMMDD.xlsx(例如:即時業績_當日_20260113.xlsx)

+

資料將會累加寫入daily_sales_snapshot 資料表,並自動去重。

+
+ +
+
+ + +
+
+ +
+
+
+
+ + +
+
+
匯入任務歷史
+ +
+
+ + + + + +
+
+
+ + + + + + \ No newline at end of file diff --git a/bin/Activate.ps1 b/bin/Activate.ps1 new file mode 100644 index 0000000..16ba529 --- /dev/null +++ b/bin/Activate.ps1 @@ -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" diff --git a/bin/activate b/bin/activate new file mode 100644 index 0000000..2b0c49d --- /dev/null +++ b/bin/activate @@ -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 diff --git a/bin/activate.csh b/bin/activate.csh new file mode 100644 index 0000000..16e5a45 --- /dev/null +++ b/bin/activate.csh @@ -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 . +# Ported to Python 3.3 venv by Andrew Svetlov + +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 diff --git a/bin/activate.fish b/bin/activate.fish new file mode 100644 index 0000000..9ffffb4 --- /dev/null +++ b/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /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 diff --git a/bin/flask b/bin/flask new file mode 100755 index 0000000..86c2444 --- /dev/null +++ b/bin/flask @@ -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()) diff --git a/bin/ngrok b/bin/ngrok new file mode 100755 index 0000000..cb864dc --- /dev/null +++ b/bin/ngrok @@ -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()) diff --git a/bin/normalizer b/bin/normalizer new file mode 100755 index 0000000..3b3b62b --- /dev/null +++ b/bin/normalizer @@ -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()) diff --git a/bin/pip b/bin/pip new file mode 100755 index 0000000..bc189a2 --- /dev/null +++ b/bin/pip @@ -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()) diff --git a/bin/pip3 b/bin/pip3 new file mode 100755 index 0000000..bc189a2 --- /dev/null +++ b/bin/pip3 @@ -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()) diff --git a/bin/pip3.13 b/bin/pip3.13 new file mode 100755 index 0000000..bc189a2 --- /dev/null +++ b/bin/pip3.13 @@ -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()) diff --git a/bin/pyngrok b/bin/pyngrok new file mode 100755 index 0000000..cb864dc --- /dev/null +++ b/bin/pyngrok @@ -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()) diff --git a/bin/python b/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/bin/python3 b/bin/python3 new file mode 120000 index 0000000..f894c65 --- /dev/null +++ b/bin/python3 @@ -0,0 +1 @@ +/opt/anaconda3/bin/python3 \ No newline at end of file diff --git a/bin/python3.13 b/bin/python3.13 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/bin/python3.13 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/bin/wsdump b/bin/wsdump new file mode 100755 index 0000000..4bacfe6 --- /dev/null +++ b/bin/wsdump @@ -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()) diff --git a/brand_assets.html b/brand_assets.html new file mode 100644 index 0000000..f7043d2 --- /dev/null +++ b/brand_assets.html @@ -0,0 +1,197 @@ + + + + + + + WOOO TECH 品牌資產庫 + + + + + +
+

WOOO TECH 品牌資產庫

+ +
+

1. 主品牌標誌 (Main Logo)

+
+
+
+ Main Logo +
+
+ SVG (向量)
+ +
+
+
+
+ Main Logo JPG +
+
+ JPG (白底)
+ +
+
+ +
+
+ 其他格式
+ +
+
+
+
+ +
+

2. 玻璃質感版 (Glass Version)

+
+
+
+ Glass Logo +
+
+ SVG (向量)
+ +
+
+
+
+ Glass Logo JPG +
+
+ JPG (白底)
+ +
+
+
+
+ +
+

3. 能量流動版 (Gradient Version)

+
+
+
+ Gradient Logo +
+
+ SVG (向量)
+ +
+
+
+
+ Gradient Logo JPG +
+
+ JPG (白底)
+ +
+
+
+
+
+ + + \ No newline at end of file diff --git a/components b/components new file mode 120000 index 0000000..e35c694 --- /dev/null +++ b/components @@ -0,0 +1 @@ +templates/components \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..610654e --- /dev/null +++ b/config.py @@ -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, # 業績分析 +} \ No newline at end of file diff --git a/crawler/__init__.py b/crawler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crawler_management.html b/crawler_management.html new file mode 100644 index 0000000..9db3177 --- /dev/null +++ b/crawler_management.html @@ -0,0 +1,426 @@ + + + + + + 爬蟲管理 - MOMO Pro System + + + + +
+
+

🔧 爬蟲管理

+

管理系統爬蟲的啟用狀態和執行頻率

+
+ +
+ +
+
+
0
+
啟用中
+
+
+
0
+
已暫停
+
+
+
0
+
爬蟲總數
+
+
+ +
+ +
+ +
+

💡 使用說明

+
    +
  • 切換開關可以啟用或停用爬蟲
  • +
  • 停用的爬蟲程式碼和資料會保留,隨時可以重新啟用
  • +
  • 變更執行頻率後,需要重啟排程器服務才會生效
  • +
  • 重啟排程器:sudo systemctl restart momo-scheduler
  • +
+
+
+ + + + diff --git a/daily_sales.html b/daily_sales.html new file mode 100644 index 0000000..2a97ec5 --- /dev/null +++ b/daily_sales.html @@ -0,0 +1,1906 @@ + + + + + + + 當日業績看板 - WOOO TECH + + + + + + + + + {% include 'components/_navbar.html' %} + +
+ {% if error %} +
+ +

{{ error }}

+

請前往 系統設定頁面 匯入當日業績 Excel 檔案。

+
+ {% else %} + + + + + {% if calendar_data %} +
+
+
{{ + calendar_data.month_name }} 業績行事曆
+
+ + +
+
+ + +
+
週一
+
週二
+
週三
+
週四
+
週五
+
週六
+
週日
+ + + {% for week in calendar_data.weeks %} + {% for day in week %} +
+ +
+
{{ day.day }}
+ {% if day.is_holiday %} +
🎊 {{ day.holiday_name }}
+ {% endif %} +
+ + {% if day.has_data %} +
+ {% if day.dod_direction == 'up' %} + {% elif day.dod_direction == 'down' %} + {% endif %} + {{ day.dod_percent }}% +
+
+
業績 ${{ '{:,.0f}'.format(day.revenue) }}
+
毛利 ${{ '{:,.0f}'.format(day.profit) }} ({{ '{:.1f}%'.format(day.margin_rate) }})
+
SKU {{ '{:,.0f}'.format(day.sku_count) }}
+
客單 ${{ '{:,.0f}'.format(day.avg_price) }}
+
銷量 {{ '{:,.0f}'.format(day.qty) }}
+
+ {% endif %} +
+ {% endfor %} + {% endfor %} +
+
+ {% endif %} + + + +
+
+ {% if is_month_view %} + 月度總計模式 + 顯示 {{ calendar_data.month_name }} 所有天數的加總 + {% else %} + 單日模式 + 顯示 {{ selected_date }} 的業績 + + 查看月度總計 + + {% endif %} +
+ {% if is_month_view and month_kpi %} + 累計 {{ month_kpi.days_with_data }} 天 + {% endif %} +
+ +
+
+
+
+
總業績
+ {% if is_month_view and month_kpi %} +
${{ "{:,.0f}".format(month_kpi.total_revenue) }}
+
+ 月度累計 +
+ {% else %} +
${{ "{:,.0f}".format(current.total_revenue) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.total_revenue) }} + + WoW + + + {{ "{:+.1f}%".format(wow.total_revenue) }} + +
+ {% endif %} + +
+
+
+
+
+
+
總成本
+ {% if is_month_view and month_kpi %} +
${{ "{:,.0f}".format(month_kpi.total_cost) }}
+
+ 月度累計 +
+ {% else %} +
${{ "{:,.0f}".format(current.total_cost) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.total_cost) }} + + WoW + + + {{ "{:+.1f}%".format(wow.total_cost) }} + +
+ {% endif %} + +
+
+
+
+
+
+
毛利
+ {% if is_month_view and month_kpi %} +
${{ "{:,.0f}".format(month_kpi.gross_margin) }}
+
+ 月度累計 + 毛利率 {{ "{:.1f}%".format(month_kpi.margin_rate) + }} +
+ {% else %} +
${{ "{:,.0f}".format(current.gross_margin) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.gross_margin) }} + + WoW + + + {{ "{:+.1f}%".format(wow.gross_margin) }} + +
+ {% endif %} + +
+
+
+
+ +
+
+
+
+
SKU 數
+ {% if is_month_view and month_kpi %} +
{{ "{:,.0f}".format(month_kpi.sku_count) }}
+
+ 月度不重複商品 +
+ {% else %} +
{{ "{:,.0f}".format(current.sku_count) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.sku_count) }} + + WoW + + + {{ "{:+.1f}%".format(wow.sku_count) }} + +
+ {% endif %} + +
+
+
+
+
+
+
客單價
+ {% if is_month_view and month_kpi %} +
${{ "{:,.0f}".format(month_kpi.avg_price) }}
+
+ 月度平均 +
+ {% else %} +
${{ "{:,.0f}".format(current.avg_price) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.avg_price) }} + + WoW + + + {{ "{:+.1f}%".format(wow.avg_price) }} + +
+ {% endif %} + +
+
+
+
+
+
+
總銷量
+ {% if is_month_view and month_kpi %} +
{{ "{:,.0f}".format(month_kpi.total_qty) }}
+
+ 月度累計 +
+ {% else %} +
{{ "{:,.0f}".format(current.total_qty) }}
+
+ DoD + + + {{ "{:+.1f}%".format(dod.total_qty) }} + + WoW + + + {{ "{:+.1f}%".format(wow.total_qty) }} + +
+ {% endif %} + +
+
+
+
+ + +
+
+
+
+ 每日業績趨勢(近 30 天) +
+
+
+ +
+
+
+
+
+
+
+ 日成長率 (DoD %) +
+
+
+ +
+
+
+
+
+ +
+
+
+
+ 週成長對比 (WoW) +
+
+
+ +
+
+
+
+
+
+
+ 商品 Top 10 +
+
+ +
+
+ +
+
+
+
+
+
+ + + {% if marketing_data %} +
+
+
+
+ 行銷活動業績貢獻 + +
+
+
+ +
+
折扣活動 Top 10
+ {% if marketing_data.discount %} +
+ +
+ {% else %} +
+ +

暫無折扣活動數據

+
+ {% endif %} +
+ +
+
折價券活動 Top 10
+ {% if marketing_data.coupon %} +
+ +
+ {% else %} +
+ +

暫無折價券活動數據

+
+ {% endif %} +
+
+
+
+
+
+ {% endif %} + + +
+
+
+
+ 分類業績明細 + +
+
+ +
+ + + + + + + + + + + + + + + + {% for cat in categories %} + + + + + + + + + + + + {% endfor %} + +
分類廠商總業績總成本毛利毛利率總銷量SKU 數平均單價
{{ cat.category }}{{ cat.vendor if cat.vendor else '-' }}${{ "{:,.0f}".format(cat.revenue) }}${{ "{:,.0f}".format(cat.cost if cat.cost else 0) }}${{ "{:,.0f}".format(cat.profit if cat.profit else 0) }}{{ "{:.1f}%".format(cat.margin_rate) }}{{ "{:,.0f}".format(cat.qty if cat.qty else 0) }}{{ cat.sku_count if cat.sku_count else 0 }}${{ "{:,.0f}".format(cat.avg_price) }}
+
+
+
+
+
+ {% endif %} +
+ + + + + + + + + \ No newline at end of file diff --git a/dashboard.html b/dashboard.html new file mode 100644 index 0000000..ad8be5d --- /dev/null +++ b/dashboard.html @@ -0,0 +1,1388 @@ + + + + + + + + MOMO 價格監控系統 + + + + + + + + + + {% include 'components/_navbar.html' %} + +
+ +
+ +
+
+
+
+ 商品監控概況 +
+
+
+
+
監控總數
+

{{ total_products | number_format }}

+
+
+
+
+
+
今日新增
+

{{ today_new_products }}

+
+
+
+
+
+
週增長
+

{{ week_new_products }}

+
+
+
+
+
+
穩定商品
+

{{ stable_count }}

+
7天未變價
+
+
+
+ +
+
+
+ + +
+
+
+
+ 今日價格動態 +
+ + +
+
+
漲價
+
{{ cnt_increase }}
+
件商品
+
+
+
降價
+
{{ cnt_decrease }}
+
件商品
+
+
+
下架
+
{{ today_delisted_count }}
+
件商品
+
+ + +
+
平均漲幅
+
+${{ avg_increase | abs | int | number_format }}
+
{{ cnt_increase }} 件平均
+
+
+
平均跌幅
+
-${{ avg_decrease | abs | int | number_format }}
+
{{ cnt_decrease }} 件平均
+
+
+
活躍度
+
{{ activity_rate | round(1) }}%
+
{{ active_count }} 件有變動
+
+ + + {% if most_active_category %} +
+
最活躍分類
+
{{ most_active_category }}
+
{{ most_active_count }} 件商品變動
+
+ {% endif %} + + {% if max_change_item %} +
+
最大變動
+
+ {% if max_change_value > 0 %}+{% endif %}${{ max_change_value | abs | int | + number_format }} +
+
+ {{ max_change_item.record.product.name[:20] }}{% if + max_change_item.record.product.name|length > 20 %}...{% endif %} +
+
+ {% endif %} +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+ +
+
+
+ + + +
+
+
+
+
+ + +
+
+
+
+
商品列表 ({{ total_items }}筆)
+ {% set momo_stats_list = scheduler_stats.get('momo_task', []) %} + {% if momo_stats_list %} + {% set latest_run = momo_stats_list[0] %} + {% set prev_run = momo_stats_list[1] if momo_stats_list|length > 1 else None %} +
+ + 上次排程 ({{ latest_run.last_run.split(' ')[1] }}): + {% if latest_run.status == 'Success' %} + 掃描 {{ latest_run.scraped_count | default(0) }} 筆 + 新增 {{ latest_run.new_products | default(0) }} 項 + 成功 + {% else %} + 失敗 + {% endif %} + {% endif %} +
+
+ +
+
+ +
+ + + + + + + + + + + + + + {% for item in items %} + + {% set badge_attr = 'style="background-color: ' ~ item.category_color ~ '; color: #333;"' %} + + + {% set tooltip_content %} +
+ {% if item.today_changes %} + 📅 今日變價歷程:
+ {% for change in item.today_changes|reverse %} + {% if change.diff > 0 %} + {% set color_class = 'text-danger' %} + {% set arrow = '▲' %} + {% else %} + {% set color_class = 'text-success' %} + {% set arrow = '▼' %} + {% endif %} + {% set diff_val = change.diff|abs %} + {{ change.time }}   ${{ change.price | number_format }} ({{ arrow }}{{ diff_val }})
+ {% endfor %} + {% else %} + 今日價格無波動 + {% endif %} +
+ {% endset %} + + + + + + + {% else %} + + + + {% endfor %} + +
+ 分類 {% if + current_sort == 'category' %}{% else + %}{% endif %} + + 商品名稱 {% if + current_sort == 'name' %}{% else + %}{% endif %} + + 當天價格 {% if + current_sort == 'price' %}{% else + %}{% endif %} + + 昨日漲跌 + {% if current_sort == 'yesterday_change' %}{% else + %}{% endif %} + + 週漲跌 {% if + current_sort == 'week_change' %}{% else + %}{% endif %} + + 更新時間 {% if + current_sort == 'timestamp' %}{% else + %}{% endif %} + + 上架時間 +
{{ item.record.product.category }} + +
+
+ {% if item.record.product.image_url %} + 商品圖 + {% else %} +
+ 無圖
+ {% endif %} +
+
+ + {{ item.record.product.name }} + +
+ + ID: {{ item.record.product.i_code }} + + {% if item.status == 'PRICE_UP' %} + 漲價 + {% elif item.status == 'PRICE_DOWN' %} + 降價 + {% elif item.status == 'DELISTED' %} + 下架 + {% endif %} +
+
+
+
+ ${{ item.record.price | number_format }} + + {% if item.yesterday_diff != 0 %} + {% set old_price = item.record.price - item.yesterday_diff %} + {% set percent_change = (item.yesterday_diff / old_price * 100) | round(1) if old_price + > 0 else 0 %} +
+ {{ '▲' if item.yesterday_diff > 0 else '▼' }} {{ + item.yesterday_diff | abs | number_format }} + ({{ percent_change }}%) +
+ {% else %} + - + {% endif %} +
+ {% if item.stats['7d_diff'] > 0 %} + +{{ item.stats['7d_diff'] | number_format }} + {% elif item.stats['7d_diff'] < 0 %} {{ item.stats['7d_diff'] | + number_format }} + {% else %} + - + {% endif %} + {{ item.record.timestamp.strftime('%m-%d %H:%M') }} + + {% if item.safe_created_at %} + {{ item.safe_created_at.strftime('%m-%d %H:%M') }} + {% else %} + - + {% endif %} +
+ {% if search_query %} + 找不到與「{{ search_query }}」相關的商品 + {% else %} + 沒有符合條件的商品 + {% endif %} +
+
+ + + {% if total_pages > 1 %} + + {% endif %} +
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/data/.migrated_sales_append_flag b/data/.migrated_sales_append_flag new file mode 100644 index 0000000..f9e1cf7 --- /dev/null +++ b/data/.migrated_sales_append_flag @@ -0,0 +1 @@ +2026-01-09T11:20:11.640213+08:00 \ No newline at end of file diff --git a/data/__init__.py b/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/data/categories.json b/data/categories.json new file mode 100644 index 0000000..b1e59dd --- /dev/null +++ b/data/categories.json @@ -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" + } +] \ No newline at end of file diff --git a/data/crawler_config.json b/data/crawler_config.json new file mode 100644 index 0000000..8341ffb --- /dev/null +++ b/data/crawler_config.json @@ -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" + } +} diff --git a/data/scheduler_stats.json b/data/scheduler_stats.json new file mode 100644 index 0000000..e2fc206 --- /dev/null +++ b/data/scheduler_stats.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/data/system_status.json b/data/system_status.json new file mode 100644 index 0000000..9da0ca9 --- /dev/null +++ b/data/system_status.json @@ -0,0 +1 @@ +{"status": "OK", "message": "任務執行完成", "timestamp": "2026-01-05 00:26:28"} \ No newline at end of file diff --git a/data/url_config.json b/data/url_config.json new file mode 100644 index 0000000..fafa56e --- /dev/null +++ b/data/url_config.json @@ -0,0 +1 @@ +{"public_url": "https://mo.wooo.work"} \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/database/ai_models.py b/database/ai_models.py new file mode 100644 index 0000000..ce15f24 --- /dev/null +++ b/database/ai_models.py @@ -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}" diff --git a/database/edm_dashboard.html b/database/edm_dashboard.html new file mode 100644 index 0000000..fc662bf --- /dev/null +++ b/database/edm_dashboard.html @@ -0,0 +1,145 @@ + +{% macro slugify(text) -%} +{{ text|string|replace(' ', '_')|replace(':', '')|replace('!', '')|replace('?', '')|replace('/', '')|replace('&', '')|replace('(', '')|replace(')', '') }} +{%- endmacro %} + + + + + MOMO 限時搶購監控 + + + + + + +
+
+
+

🔥 限時搶購監控儀表板

+

+ 活動時間: {{ activity_time }} | + 最後更新: {{ last_update }} | + 商品總數: {{ total_edm_products }} +

+
+
+ + +
+
+ + + + +
+ {% for slot, stats in slot_stats.items() %} + {% set items = grouped_items.get(slot, []) %} + {% set slot_id = slugify(slot) %} +
+ + +
+ 📊 時段統計: + 新品: {{ stats['new'] }} + 漲價: {{ stats['up'] }} + 降價: {{ stats['down'] }} + 下架: {{ stats.get('delisted_last_run', 0) }} +
+ +
+ {% for item in items %} +
+
+
+
+ + {{ item.name }} + +
+
+ ${{ item.price }} +
+ {% if item.status_change == 'NEW' %} + NEW + {% elif item.status_change == 'PRICE_DOWN' %} + ↘ 降價 + {% elif item.status_change == 'PRICE_UP' %} + ↗ 漲價 + {% elif item.status_change == 'DELISTED' %} + 下架 + {% endif %} +
+
+
+ 分類: {{ item.main_category or '未分類' }} +
+ 頻次: {{ item.frequency }} 次 +
+
+
+
+ {% endfor %} +
+
+ {% endfor %} +
+
+ + + + + \ No newline at end of file diff --git a/database/edm_models.py b/database/edm_models.py new file mode 100644 index 0000000..cc2c500 --- /dev/null +++ b/database/edm_models.py @@ -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) \ No newline at end of file diff --git a/database/import_models.py b/database/import_models.py new file mode 100644 index 0000000..3d4a973 --- /dev/null +++ b/database/import_models.py @@ -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"" + + 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"" + + 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, + } diff --git a/database/manager.py b/database/manager.py new file mode 100644 index 0000000..61ff3ca --- /dev/null +++ b/database/manager.py @@ -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() \ No newline at end of file diff --git a/database/models.py b/database/models.py new file mode 100644 index 0000000..93bf973 --- /dev/null +++ b/database/models.py @@ -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'), + ) \ No newline at end of file diff --git a/database/notification_models.py b/database/notification_models.py new file mode 100644 index 0000000..58da3a4 --- /dev/null +++ b/database/notification_models.py @@ -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''' + } +] diff --git a/database/permission_models.py b/database/permission_models.py new file mode 100644 index 0000000..e945364 --- /dev/null +++ b/database/permission_models.py @@ -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)}" diff --git a/database/trend_models.py b/database/trend_models.py new file mode 100644 index 0000000..b14e770 --- /dev/null +++ b/database/trend_models.py @@ -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, '其他') diff --git a/database/user_models.py b/database/user_models.py new file mode 100644 index 0000000..017a530 --- /dev/null +++ b/database/user_models.py @@ -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 已載入") diff --git a/database/vendor_manager.py b/database/vendor_manager.py new file mode 100644 index 0000000..05c9b95 --- /dev/null +++ b/database/vendor_manager.py @@ -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() diff --git a/database/vendor_models.py b/database/vendor_models.py new file mode 100644 index 0000000..261b339 --- /dev/null +++ b/database/vendor_models.py @@ -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") diff --git a/deploy/QUICK_START.md b/deploy/QUICK_START.md new file mode 100644 index 0000000..a4594fa --- /dev/null +++ b/deploy/QUICK_START.md @@ -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 部署用戶(預設: wooo) + --domain 域名 + --no-docker 不安裝 Docker + --no-k3s 不安裝 K3s + --no-nginx 不安裝 Nginx + --no-firewall 不設定防火牆 +``` + +### 2. 完整部署腳本 (`full-deploy.sh`) + +環境安裝 + 應用部署: + +```bash +sudo ./deploy/scripts/full-deploy.sh [選項] + +選項: + --user 部署用戶(預設: wooo) + --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 diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..ee2396c --- /dev/null +++ b/deploy/README.md @@ -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 -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 自動化 diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..dfe10dc --- /dev/null +++ b/deploy/deploy.sh @@ -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 "$@" diff --git a/deploy_docker_guide.md b/deploy_docker_guide.md new file mode 100644 index 0000000..4715206 --- /dev/null +++ b/deploy_docker_guide.md @@ -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 </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 diff --git a/deploy_scripts/backup.sh b/deploy_scripts/backup.sh new file mode 100644 index 0000000..280c9fb --- /dev/null +++ b/deploy_scripts/backup.sh @@ -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" diff --git a/deploy_scripts/deploy_cloudrun.sh b/deploy_scripts/deploy_cloudrun.sh new file mode 100755 index 0000000..9f4acd6 --- /dev/null +++ b/deploy_scripts/deploy_cloudrun.sh @@ -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 "" diff --git a/deploy_scripts/setup_autostart.sh b/deploy_scripts/setup_autostart.sh new file mode 100755 index 0000000..a872210 --- /dev/null +++ b/deploy_scripts/setup_autostart.sh @@ -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 "" diff --git a/deploy_scripts/setup_nginx.sh b/deploy_scripts/setup_nginx.sh new file mode 100644 index 0000000..58833fd --- /dev/null +++ b/deploy_scripts/setup_nginx.sh @@ -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 < /dev/null < /dev/null < /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 </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 "" diff --git a/deploy_to_uat.sh b/deploy_to_uat.sh new file mode 100755 index 0000000..b3302d8 --- /dev/null +++ b/deploy_to_uat.sh @@ -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 "" diff --git a/design_assets_recommendation.md b/design_assets_recommendation.md new file mode 100644 index 0000000..6b5ef15 --- /dev/null +++ b/design_assets_recommendation.md @@ -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. 為您生成上述建議清單中的其他圖片 (如登入背景或無數據插圖)。 diff --git a/disable_maintenance.sh b/disable_maintenance.sh new file mode 100644 index 0000000..1cb81e2 --- /dev/null +++ b/disable_maintenance.sh @@ -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 "==========================================" diff --git a/docker-compose.devops.yml b/docker-compose.devops.yml new file mode 100644 index 0000000..3f03a50 --- /dev/null +++ b/docker-compose.devops.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a98419 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/alertmanager/alertmanager.yml b/docker/alertmanager/alertmanager.yml new file mode 100644 index 0000000..1f46c56 --- /dev/null +++ b/docker/alertmanager/alertmanager.yml @@ -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'] diff --git a/docker/blackbox/blackbox.yml b/docker/blackbox/blackbox.yml new file mode 100644 index 0000000..4fa8397 --- /dev/null +++ b/docker/blackbox/blackbox.yml @@ -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 diff --git a/docker/grafana/provisioning/dashboards/dashboards.yaml b/docker/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000..82577cd --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboards.yaml @@ -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 diff --git a/docker/grafana/provisioning/dashboards/json/container-monitoring.json b/docker/grafana/provisioning/dashboards/json/container-monitoring.json new file mode 100644 index 0000000..11226bd --- /dev/null +++ b/docker/grafana/provisioning/dashboards/json/container-monitoring.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": "" +} diff --git a/docker/grafana/provisioning/dashboards/json/database-monitoring.json b/docker/grafana/provisioning/dashboards/json/database-monitoring.json new file mode 100644 index 0000000..c0fc368 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/json/database-monitoring.json @@ -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": "" +} diff --git a/docker/grafana/provisioning/dashboards/json/logs-dashboard.json b/docker/grafana/provisioning/dashboards/json/logs-dashboard.json new file mode 100644 index 0000000..41c0748 --- /dev/null +++ b/docker/grafana/provisioning/dashboards/json/logs-dashboard.json @@ -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 +} diff --git a/docker/grafana/provisioning/dashboards/json/system-overview.json b/docker/grafana/provisioning/dashboards/json/system-overview.json new file mode 100644 index 0000000..16ac7bb --- /dev/null +++ b/docker/grafana/provisioning/dashboards/json/system-overview.json @@ -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": "" +} diff --git a/docker/grafana/provisioning/datasources/datasources.yaml b/docker/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000..995295f --- /dev/null +++ b/docker/grafana/provisioning/datasources/datasources.yaml @@ -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}" diff --git a/docker/loki/loki-config.yaml b/docker/loki/loki-config.yaml new file mode 100644 index 0000000..000d1f0 --- /dev/null +++ b/docker/loki/loki-config.yaml @@ -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 diff --git a/docker/nginx-monitor/conf.d/monitor.conf b/docker/nginx-monitor/conf.d/monitor.conf new file mode 100644 index 0000000..1633d0e --- /dev/null +++ b/docker/nginx-monitor/conf.d/monitor.conf @@ -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 ' + + + + Service Unavailable + + + +
+

Service Unavailable

+

The requested monitoring service is not running.

+

Please start monitoring services with:

+

docker-compose --profile monitoring up -d

+

Back to Service List

+
+ +'; + } +} diff --git a/docker/nginx-monitor/html/index.html b/docker/nginx-monitor/html/index.html new file mode 100644 index 0000000..51be4ff --- /dev/null +++ b/docker/nginx-monitor/html/index.html @@ -0,0 +1,362 @@ + + + + + + WOOO Monitoring Services + + + + + + +
+
+
+
+
+
+
+
+
+
+
+ + + + diff --git a/docker/nginx-monitor/html/static/images/WOOO_Logo_trimmed.jpg b/docker/nginx-monitor/html/static/images/WOOO_Logo_trimmed.jpg new file mode 100644 index 0000000..2607e3f Binary files /dev/null and b/docker/nginx-monitor/html/static/images/WOOO_Logo_trimmed.jpg differ diff --git a/docker/nginx-monitor/html/static/images/WOOO_Main_Logo.jpg b/docker/nginx-monitor/html/static/images/WOOO_Main_Logo.jpg new file mode 100644 index 0000000..ba2b22e Binary files /dev/null and b/docker/nginx-monitor/html/static/images/WOOO_Main_Logo.jpg differ diff --git a/docker/nginx-monitor/html/static/images/logo_v4_glass.png b/docker/nginx-monitor/html/static/images/logo_v4_glass.png new file mode 100644 index 0000000..be3c1be Binary files /dev/null and b/docker/nginx-monitor/html/static/images/logo_v4_glass.png differ diff --git a/docker/nginx-monitor/nginx.conf b/docker/nginx-monitor/nginx.conf new file mode 100644 index 0000000..a6abde7 --- /dev/null +++ b/docker/nginx-monitor/nginx.conf @@ -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; +} diff --git a/docker/nginx-monitor/static/images/logo_v4_glass.png b/docker/nginx-monitor/static/images/logo_v4_glass.png new file mode 100644 index 0000000..be3c1be Binary files /dev/null and b/docker/nginx-monitor/static/images/logo_v4_glass.png differ diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf new file mode 100644 index 0000000..1fcf7f3 --- /dev/null +++ b/docker/nginx/conf.d/default.conf @@ -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; + } +} diff --git a/docker/nginx/html/api/health-check.sh b/docker/nginx/html/api/health-check.sh new file mode 100644 index 0000000..b7638ab --- /dev/null +++ b/docker/nginx/html/api/health-check.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# 健康檢查 API - 由 Nginx fcgiwrap 執行 +# 用法: /api/health?service= + +# 設定 HTTP headers +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Access-Control-Allow-Methods: GET" +echo "Cache-Control: no-cache" +echo "" + +# 解析查詢參數 +SERVICE=$(echo "$QUERY_STRING" | sed -n 's/.*service=\([^&]*\).*/\1/p') + +# 定義服務健康檢查 URL +declare -A HEALTH_URLS=( + ["momo-uat"]="https://mo.wooo.work/health" + ["momo-gcp"]="https://momo.wooo.work/health" + ["gitlab"]="http://127.0.0.1:8929/" + ["registry"]="http://127.0.0.1:5002/v2/" + ["n8n"]="http://127.0.0.1:5678/" + ["grafana"]="http://127.0.0.1:30030/" + ["prometheus"]="http://10.43.25.78:9090/-/healthy" + ["alertmanager"]="http://10.43.79.187:9093/-/healthy" + ["superset"]="http://127.0.0.1:8088/health" + ["metabase"]="http://127.0.0.1:3030/api/health" +) + +# 檢查服務 +if [[ -z "$SERVICE" ]]; then + # 返回所有服務狀態 + echo '{"services": {' + first=true + for svc in "${!HEALTH_URLS[@]}"; do + url="${HEALTH_URLS[$svc]}" + start_time=$(date +%s%3N) + response=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$url" 2>/dev/null) + end_time=$(date +%s%3N) + response_time=$((end_time - start_time)) + + if [[ "$response" == "200" ]] || [[ "$response" == "302" ]] || [[ "$response" == "401" ]]; then + status="online" + else + status="offline" + fi + + if [ "$first" = true ]; then + first=false + else + echo "," + fi + echo -n "\"$svc\": {\"status\": \"$status\", \"code\": $response, \"responseTime\": $response_time}" + done + echo '}}' +else + # 返回單個服務狀態 + url="${HEALTH_URLS[$SERVICE]}" + if [[ -z "$url" ]]; then + echo '{"error": "Unknown service", "service": "'"$SERVICE"'"}' + exit 0 + fi + + start_time=$(date +%s%3N) + response=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 --max-time 10 "$url" 2>/dev/null) + end_time=$(date +%s%3N) + response_time=$((end_time - start_time)) + + if [[ "$response" == "200" ]] || [[ "$response" == "302" ]] || [[ "$response" == "401" ]]; then + status="online" + else + status="offline" + fi + + echo "{\"service\": \"$SERVICE\", \"status\": \"$status\", \"code\": $response, \"responseTime\": $response_time}" +fi diff --git a/docker/nginx/html/api/health.sh b/docker/nginx/html/api/health.sh new file mode 100644 index 0000000..9fa2206 --- /dev/null +++ b/docker/nginx/html/api/health.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 健康檢查 API +# 輸出 JSON 格式的服務狀態 + +echo "Content-Type: application/json" +echo "Access-Control-Allow-Origin: *" +echo "Cache-Control: no-cache, no-store, must-revalidate" +echo "" + +check_service() { + local name=$1 + local url=$2 + local start_time=$(date +%s%N) + + local response=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 5 "$url" 2>/dev/null) + + local end_time=$(date +%s%N) + local response_time=$(( (end_time - start_time) / 1000000 )) + + if [[ "$response" == "200" ]] || [[ "$response" == "302" ]] || [[ "$response" == "401" ]]; then + echo "\"$name\": {\"status\": \"online\", \"code\": $response, \"responseTime\": $response_time}" + else + echo "\"$name\": {\"status\": \"offline\", \"code\": $response, \"responseTime\": $response_time}" + fi +} + +echo '{"services": {' + +# 核心服務 +check_service "momo-uat" "https://mo.wooo.work/health" +echo "," +check_service "momo-gcp" "https://momo.wooo.work/health" +echo "," + +# 開發工具 +check_service "gitlab" "http://127.0.0.1:8929/" +echo "," +check_service "registry" "http://127.0.0.1:5002/v2/" +echo "," +check_service "n8n" "http://127.0.0.1:5678/" +echo "," + +# 監控服務 +check_service "grafana" "http://127.0.0.1:30030/" +echo "," +check_service "prometheus" "http://10.43.25.78:9090/-/healthy" +echo "," +check_service "alertmanager" "http://10.43.79.187:9093/-/healthy" +echo "," + +# BI 平台 +check_service "superset" "http://127.0.0.1:8088/health" +echo "," +check_service "metabase" "http://127.0.0.1:3030/api/health" + +echo '},' +echo "\"timestamp\": \"$(date -Iseconds)\"" +echo '}' diff --git a/docker/nginx/html/monitor-index-clean.html b/docker/nginx/html/monitor-index-clean.html new file mode 100644 index 0000000..baa5fd3 --- /dev/null +++ b/docker/nginx/html/monitor-index-clean.html @@ -0,0 +1,804 @@ + + + + + + MOMO Pro - 監控中心 + + + + + +
+
+
+
+

MOMO Pro 監控中心

+

統一監控 UAT + GCP 雙環境

+
+
+
+ + 上次更新: - +
+ +
+
+
+
+ +
+ +

+ + 應用服務 +

+
+
+
+
+ + MOMO Pro System + UAT +
+

測試環境 - 商品看板與業績分析系統

+ +
+
+
+
+
+ + MOMO Pro System + GCP +
+

正式環境 - 商品看板與業績分析系統

+ +
+
+
+
+
+ + Apache Superset +
+

BI 分析儀表板 - 資料視覺化平台

+ +
+
+
+ + +

+ + 開發工具 +

+
+
+
+
+ + GitLab +
+

Git 版本控制 + CI/CD 自動化部署

+ +
+
+
+
+
+ + Docker Registry +
+

私有容器映像倉庫

+ +
+
+
+
+
+ + n8n +
+

自動化工作流程引擎 (29 個工作流程)

+ +
+
+
+ + +

+ + 監控服務 (K8s) +

+
+
+
+
+ + Grafana +
+

監控儀表板 - K8s 叢集視覺化

+ + +
+
+
+
+
+ + Prometheus +
+

時序資料庫 - 指標收集與查詢

+ +
+
+
+
+
+ + Alertmanager +
+

告警管理 - 整合 Telegram 通知

+ +
+
+
+ + +

+ + 容器管理與日誌 +

+
+
+
+
+ + Portainer +
+

Docker 容器管理平台

+ +
+
+
+
+
+ + Loki +
+

日誌聚合系統 (Grafana 整合)

+ +
+
+
+
+
+ + cAdvisor +
+

容器資源監控

+ +
+
+
+ + +

+ + BI 分析平台 +

+
+
+
+
+ + Metabase +
+

資料分析與視覺化平台

+ +
+
+
+
+
+ + Docker Grafana +
+

Docker 版監控儀表板

+ +
+
+
+ + +

+ + 檔案與協作 +

+
+
+
+
+ + Nextcloud +
+

私有雲端檔案儲存

+ +
+
+
+ + +

+ + 系統狀態概覽 +

+
+
+
+
K8s Pods 狀態
+
+
+
momo namespace
+
    +
  • + Running + momo-app +
  • +
  • + Running + momo-postgres +
  • +
  • + Running + momo-scheduler +
  • +
  • + Running + postgres-exporter +
  • +
+
+
+
monitoring namespace
+
    +
  • + Running + prometheus-grafana +
  • +
  • + Running + alertmanager +
  • +
  • + Running + prometheus +
  • +
  • + Running + node-exporter +
  • +
+
+
+
+
+
+ + +

+ + 排程任務總覽 +

+
+ +
+
+
Cron 排程
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
頻率任務
*/5 * * * *域名健康監控
*/5 * * * *主自動修復 (UAT+GCP)
*/5 * * * *Docker 健康監控
*/5 * * * *K8s 健康監控
0 */2 * * *新聞抓取 (每2小時)
30 */3 * * *AI 處理 (每3小時)
+
+
+
+ +
+
+
n8n 工作流程 (29 個)
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
頻率工作流程
每 5 分鐘雙環境健康監控
每 10 分鐘K8s Pod 狀態監控
每 15 分鐘PostgreSQL 慢查詢監控
每 30 分鐘Google Drive 匯入監控
每小時磁碟空間監控
每日 09:00每日系統報告
每日 09:00SSL 證書監控
每週一 09:00每週業績摘要
+
+ +
+
+
+ + +
+
+
+
Python Scheduler (momo-scheduler Pod)
+
+
+
每 30 分鐘
+
    +
  • Google Drive 自動匯入
  • +
  • 網頁白頁監控
  • +
+
+
+
每 1 小時
+
    +
  • 主站商品爬蟲
  • +
  • EDM 限時搶購爬蟲
  • +
+
+
+
每 6 小時
+
    +
  • 購物節活動爬蟲
  • +
+
+
+
每日
+
    +
  • 每日業績 Telegram 通知
  • +
  • 每日業績 LINE 通知
  • +
+
+
+
+
+
+ + +

+ + 自動修復機制 +

+
+
+
+
UATUAT 環境修復
+
+ + + + + + + + + + + + + + + + + + +
OOM Handler每 15 分鐘
PostgreSQL Repair每 30 分鐘
Auto Rollback每 5 分鐘
+
+
+
+
+
+
GCPGCP 環境修復 (遠端)
+
+ + + + + + + + + + + + + + + + + + +
OOM Handler GCP每 15 分鐘
PostgreSQL Repair GCP每 30 分鐘
Auto Rollback GCP每 5 分鐘
+
+
+
+
+
+
+
+
修復能力總覽
+
+
+
記憶體問題
+

OOM 自動增加記憶體限制 +50%

+

自動重啟 Pod

+
+
+
資料庫問題
+

連線失敗自動重啟

+

死鎖自動終止查詢

+

表膨脹自動 VACUUM

+
+
+
應用問題
+

5 次健康失敗自動回滾

+

服務無回應自動重啟

+
+
+
+
+
+
+ + + + + + diff --git a/docker/nginx/html/monitor-index.html b/docker/nginx/html/monitor-index.html new file mode 100644 index 0000000..015c9f3 --- /dev/null +++ b/docker/nginx/html/monitor-index.html @@ -0,0 +1,905 @@ + + + + + + WOOO Monitoring Services + + + + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+

Monitoring Services

+

WOOO TECH 監控服務中心

+

最後更新: -

+
+ + +
+
+
+
+
Registry
+
檢測中...
+
+
+
+
Grafana
+
檢測中...
+
+
+
+
Prometheus
+
檢測中...
+
+
+
+
n8n
+
檢測中...
+
+
+
+
MOMO App
+
檢測中...
+
+
+
+
Database
+
檢測中...
+
+
+
+
Superset
+
檢測中...
+
+
+
+ + +
+
+ 即時告警 + 0 +
+
+
+ +

載入中...

+
+
+
+ + +
+
+ n8n 監控工作流程 +
+
+

載入中...

+
+
+ + + + + + + + + + + + + + +
+

系統管理

+
+ +
+
+

Portainer

+

Docker 容器管理介面

+
+
+ +
+
+

pgAdmin

+

PostgreSQL 管理介面

+
+
+
+
+
+

Watchtower

+

自動偵測映像更新並重啟容器

+
+ Auto +
+
+
+ + + + + + +
+
+ + + + diff --git a/docker/nginx/html/monitor-realtime.html b/docker/nginx/html/monitor-realtime.html new file mode 100644 index 0000000..ecd6c5b --- /dev/null +++ b/docker/nginx/html/monitor-realtime.html @@ -0,0 +1,382 @@ + + + + + + MOMO Pro - 監控中心 (即時狀態) + + + + + +
+
+
+
+

MOMO Pro 監控中心

+

即時服務狀態監控 - UAT + GCP 雙環境

+
+
+ 載入中... + +
+
+
+
+ +
+
+
+
-
+
運行中
+
+
+
-
+
離線
+
+
+
-
+
檢查中
+
+
+
-
+
總服務數
+
+
+ +

核心應用服務

+
+ +

開發工具

+
+ +

監控服務

+
+ +

BI 分析平台

+
+
+ + + + + + diff --git a/docker/nginx/html/static/images/WOOO_Logo_trimmed.jpg b/docker/nginx/html/static/images/WOOO_Logo_trimmed.jpg new file mode 100644 index 0000000..2607e3f Binary files /dev/null and b/docker/nginx/html/static/images/WOOO_Logo_trimmed.jpg differ diff --git a/docker/nginx/monitor-clean.conf b/docker/nginx/monitor-clean.conf new file mode 100644 index 0000000..070af08 --- /dev/null +++ b/docker/nginx/monitor-clean.conf @@ -0,0 +1,430 @@ +# ============================================================================= +# WOOO TECH - Monitor Dashboard +# Nginx 配置 - UAT Server (192.168.0.110) +# 所有監控工具統一入口 +# 2026-02-08 整理版本 - 移除 Harbor,其他服務保留 +# ============================================================================= + +# 上游服務定義 +upstream grafana_backend { + server 127.0.0.1:3000; +} + +upstream prometheus_backend { + # K8s Prometheus ClusterIP + server 10.43.25.78:9090; +} + +upstream alertmanager_backend { + # K8s Alertmanager ClusterIP + server 10.43.79.187:9093; +} + +upstream portainer_backend { + server 127.0.0.1:9000; +} + +upstream n8n_backend { + server 127.0.0.1:5678; +} + +upstream superset_backend { + server 127.0.0.1:8088; +} + +upstream gitlab_backend { + server 127.0.0.1:8929; +} + +upstream nextcloud_backend { + server 127.0.0.1:8081; +} + +upstream loki_backend { + server 127.0.0.1:3100; +} + +upstream metabase_backend { + server 127.0.0.1:3001; +} + +upstream grist_backend { + server 127.0.0.1:8484; +} + +upstream cadvisor_backend { + server 127.0.0.1:8080; +} + +upstream blackbox_backend { + server 127.0.0.1:9115; +} + +upstream node_exporter_backend { + server 127.0.0.1:9100; +} + +upstream postgres_exporter_backend { + server 127.0.0.1:9187; +} + +# K8s Grafana (NodePort) +upstream k8s_grafana_backend { + server 127.0.0.1:30030; +} + +# Docker Registry (HTTPS 通過 Nginx 代理) +upstream registry_backend { + server 127.0.0.1:5002; +} + +# ============================================================================= +# monitor.wooo.work - 監控入口 (HTTP -> HTTPS 重定向) +# ============================================================================= +server { + listen 80; + server_name monitor.wooo.work; + + # HSTS - 強制 HTTPS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + return 301 https://$server_name$request_uri; +} + +# ============================================================================= +# monitor.wooo.work - 監控入口 (HTTPS) +# ============================================================================= +server { + listen 443 ssl http2; + server_name monitor.wooo.work; + + # HSTS - 強制 HTTPS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # SSL 證書 + ssl_certificate /etc/letsencrypt/live/monitor.wooo.work/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/monitor.wooo.work/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 監控首頁 (靜態頁面) + root /var/www/monitor; + index index.html; + + # 首頁 + location = / { + try_files /index.html =404; + } + + # ========================================================================= + # Docker Grafana (Port 3000) + # ========================================================================= + location /grafana/ { + proxy_pass http://grafana_backend; + 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"; + } + + # ========================================================================= + # K8s Grafana (NodePort 30030) + # ========================================================================= + location /k8s-grafana/ { + proxy_pass http://k8s_grafana_backend/; + 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_redirect / /k8s-grafana/; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + sub_filter_once off; + sub_filter_types text/html application/javascript; + sub_filter 'src="/' 'src="/k8s-grafana/'; + sub_filter '"/api/' '"/k8s-grafana/api/'; + } + + # ========================================================================= + # Prometheus (Port 9090) + # ========================================================================= + location /prometheus/ { + proxy_pass http://prometheus_backend/; + 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_redirect / /prometheus/; + } + + # ========================================================================= + # Alertmanager (Port 9093) + # ========================================================================= + location /alertmanager/ { + proxy_pass http://alertmanager_backend/; + 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_redirect / /alertmanager/; + } + + # ========================================================================= + # Portainer (Port 9000) + # ========================================================================= + location /portainer/ { + proxy_pass http://portainer_backend/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /portainer/api/ { + proxy_pass http://portainer_backend/api/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # ========================================================================= + # n8n (Port 5678) + # ========================================================================= + location /n8n/ { + proxy_pass http://n8n_backend/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # ========================================================================= + # Apache Superset BI (Port 8088) + # ========================================================================= + # 認證相關路徑重定向 + location = /login/ { + return 302 /superset/login/; + } + location = /logout/ { + return 302 /superset/logout/; + } + location ^~ /lang/ { + return 302 /superset$request_uri; + } + location ^~ /users/ { + return 302 /superset$request_uri; + } + + location ^~ /static/ { + return 302 /superset$request_uri; + } + + location /superset/ { + proxy_pass http://superset_backend/; + + proxy_redirect ~^(/superset/.*)$ $1; + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + + gzip off; + proxy_set_header Accept-Encoding ""; + 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; + + sub_filter '"/static/' '"/superset/static/'; + sub_filter "'/static/" "'/superset/static/"; + sub_filter_once off; + sub_filter_types text/html application/javascript text/css; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + } + + # ========================================================================= + # Loki (Port 3100) + # ========================================================================= + location /loki/ { + proxy_pass http://loki_backend/; + 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; + } + + # ========================================================================= + # Metabase (Port 3001) + # ========================================================================= + location /metabase/ { + proxy_pass http://metabase_backend/; + 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_redirect / /metabase/; + } + + # ========================================================================= + # cAdvisor (Port 8080) + # ========================================================================= + location /cadvisor/ { + proxy_pass http://cadvisor_backend/; + 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_redirect / /cadvisor/; + } + + # ========================================================================= + # Blackbox Exporter (Port 9115) + # ========================================================================= + location /blackbox/ { + proxy_pass http://blackbox_backend/; + 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; + } + + # ========================================================================= + # Node Exporter (Port 9100) + # ========================================================================= + location /node-exporter/ { + proxy_pass http://node_exporter_backend/; + 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; + } + + # ========================================================================= + # PostgreSQL Exporter (Port 9187) + # ========================================================================= + location /postgres-exporter/ { + proxy_pass http://postgres_exporter_backend/; + 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; + } + + # ========================================================================= + # Docker Registry (Port 5002) + # ========================================================================= + location /registry/ { + proxy_pass http://registry_backend/; + 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; + + # Registry 需要大檔案上傳 + client_max_body_size 0; + proxy_read_timeout 900; + proxy_send_timeout 900; + } +} + +# ============================================================================= +# gitlab.wooo.work - GitLab (僅內網) +# ============================================================================= +server { + listen 80; + server_name gitlab.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://gitlab_backend; + 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_buffers 8 32k; + proxy_buffer_size 64k; + client_max_body_size 0; + proxy_read_timeout 600s; + } +} + +# ============================================================================= +# cloud.wooo.work - Nextcloud (僅內網) +# ============================================================================= +server { + listen 80; + server_name cloud.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://nextcloud_backend; + 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; + client_max_body_size 10G; + proxy_read_timeout 600s; + } +} + +# ============================================================================= +# grist.wooo.work - Grist (僅內網) +# ============================================================================= +server { + listen 80; + server_name grist.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://grist_backend; + 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; + } +} diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..8b6a3ec --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,56 @@ +# ============================================================================= +# WOOO TECH - Momo Pro System +# Nginx Configuration +# ============================================================================= + +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +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" ' + 'rt=$request_time uct="$upstream_connect_time" ' + 'uht="$upstream_header_time" urt="$upstream_response_time"'; + + 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 application/xml+rss text/javascript application/x-javascript; + + # 安全標頭 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 上傳限制 + client_max_body_size 50M; + + # 包含其他配置 + include /etc/nginx/conf.d/*.conf; +} diff --git a/docker/nginx/sites-available/monitor b/docker/nginx/sites-available/monitor new file mode 100644 index 0000000..7abd410 --- /dev/null +++ b/docker/nginx/sites-available/monitor @@ -0,0 +1,449 @@ +# ============================================================================= +# WOOO TECH - Monitor Dashboard +# Nginx 配置 - UAT Server (192.168.0.110) +# 所有監控工具統一入口 +# 2026-02-08 整理版本 - 移除 Harbor,其他服務保留 +# ============================================================================= + +# 上游服務定義 +upstream grafana_backend { + server 127.0.0.1:3000; +} + +upstream prometheus_backend { + # K8s Prometheus ClusterIP + server 10.43.25.78:9090; +} + +upstream alertmanager_backend { + # K8s Alertmanager ClusterIP + server 10.43.79.187:9093; +} + +upstream portainer_backend { + server 127.0.0.1:9000; +} + +upstream n8n_backend { + server 10.43.193.218:5678; +} + +upstream superset_backend { + server 127.0.0.1:8088; +} + +upstream gitlab_backend { + server 127.0.0.1:8929; +} + +upstream nextcloud_backend { + server 127.0.0.1:8081; +} + +upstream loki_backend { + server 127.0.0.1:3100; +} + +upstream metabase_backend { + server 127.0.0.1:3001; +} + +upstream grist_backend { + server 127.0.0.1:8484; +} + +upstream cadvisor_backend { + server 127.0.0.1:8080; +} + +upstream blackbox_backend { + server 127.0.0.1:9115; +} + +upstream node_exporter_backend { + server 127.0.0.1:9100; +} + +upstream postgres_exporter_backend { + server 127.0.0.1:9187; +} + +# K8s Grafana (NodePort) +upstream k8s_grafana_backend { + server 127.0.0.1:30030; +} + +# Docker Registry (HTTPS 通過 Nginx 代理) +upstream registry_backend { + server 127.0.0.1:5002; +} + +# ============================================================================= +# monitor.wooo.work - 監控入口 (HTTP -> HTTPS 重定向) +# ============================================================================= +server { + listen 80; + server_name monitor.wooo.work; + + # HSTS - 強制 HTTPS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + return 301 https://$server_name$request_uri; +} + +# ============================================================================= +# monitor.wooo.work - 監控入口 (HTTPS) +# ============================================================================= +server { + listen 443 ssl http2; + server_name monitor.wooo.work; + + # HSTS - 強制 HTTPS + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; + + # SSL 證書 + ssl_certificate /etc/letsencrypt/live/monitor.wooo.work/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/monitor.wooo.work/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # 監控首頁 (靜態頁面) + root /var/www/monitor; + index index.html; + + # 首頁 + # API 代理 - 轉發到 MOMO App + # API 代理 - 轉發到 MOMO App + location /api/ { + proxy_pass https://mo.wooo.work/api/; + proxy_set_header Host mo.wooo.work; + 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_ssl_verify off; + } + location = / { + try_files /index.html =404; + } + + # ========================================================================= + # Docker Grafana (Port 3000) + # ========================================================================= + location /grafana/ { + proxy_pass http://grafana_backend; + 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"; + } + + # ========================================================================= + # K8s Grafana (NodePort 30030) + # ========================================================================= + location /k8s-grafana/ { + proxy_pass http://k8s_grafana_backend/; + 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_redirect / /k8s-grafana/; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + sub_filter_once off; + sub_filter_types text/html application/javascript; + sub_filter 'src="/' 'src="/k8s-grafana/'; + sub_filter '"/api/' '"/k8s-grafana/api/'; + } + + # ========================================================================= + # Prometheus (Port 9090) + # ========================================================================= + location /prometheus/ { + proxy_pass http://prometheus_backend/; + 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_redirect / /prometheus/; + } + + # ========================================================================= + # Alertmanager (Port 9093) + # ========================================================================= + location /alertmanager/ { + proxy_pass http://alertmanager_backend/; + 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_redirect / /alertmanager/; + } + + # ========================================================================= + # Portainer (Port 9000) + # ========================================================================= + location /portainer/ { + proxy_pass http://portainer_backend/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /portainer/api/ { + proxy_pass http://portainer_backend/api/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # ========================================================================= + # n8n (Port 5678) + # ========================================================================= + location /n8n/ { + proxy_pass http://n8n_backend/; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + # ========================================================================= + # Apache Superset BI (Port 8088) + # ========================================================================= + # 認證相關路徑重定向 + location = /login/ { + return 302 /superset/login/; + } + location = /logout/ { + return 302 /superset/logout/; + } + location ^~ /lang/ { + return 302 /superset$request_uri; + } + location ^~ /users/ { + return 302 /superset$request_uri; + } + + location ^~ /static/ { + return 302 /superset$request_uri; + } + + # Superset 首頁特殊處理 + + + # Superset 登入頁面特殊處理 + location = /superset/login/ { + proxy_pass http://superset_backend/login/; + 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; + } + + location /superset/ { + # 根路徑重定向到 welcome + if ($request_uri = /superset/) { + return 302 /superset/welcome/; + } + proxy_pass http://superset_backend; + + proxy_redirect ~^(/superset/.*)$ $1; + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + + gzip off; + proxy_set_header Accept-Encoding ""; + 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; + + sub_filter '"/static/' '"/superset/static/'; + sub_filter "'/static/" "'/superset/static/"; + sub_filter_once off; + sub_filter_types text/html application/javascript text/css; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + } + + # ========================================================================= + # Loki (Port 3100) + # ========================================================================= + location /loki/ { + proxy_pass http://loki_backend/; + 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; + } + + # ========================================================================= + # Metabase (Port 3001) + # ========================================================================= + location /metabase/ { + proxy_pass http://metabase_backend/; + 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_redirect / /metabase/; + } + + # ========================================================================= + # cAdvisor (Port 8080) + # ========================================================================= + location /cadvisor/ { + proxy_pass http://cadvisor_backend/; + 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_redirect / /cadvisor/; + } + + # ========================================================================= + # Blackbox Exporter (Port 9115) + # ========================================================================= + location /blackbox/ { + proxy_pass http://blackbox_backend/; + 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; + } + + # ========================================================================= + # Node Exporter (Port 9100) + # ========================================================================= + location /node-exporter/ { + proxy_pass http://node_exporter_backend/; + 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; + } + + # ========================================================================= + # PostgreSQL Exporter (Port 9187) + # ========================================================================= + location /postgres-exporter/ { + proxy_pass http://postgres_exporter_backend/; + 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; + } + + # ========================================================================= + # Docker Registry (Port 5002) + # ========================================================================= + location /registry/ { + proxy_pass http://registry_backend/; + 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; + + # Registry 需要大檔案上傳 + client_max_body_size 0; + proxy_read_timeout 900; + proxy_send_timeout 900; + } +} + +# ============================================================================= +# gitlab.wooo.work - GitLab (僅內網) +# ============================================================================= +server { + listen 80; + server_name gitlab.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://gitlab_backend; + 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_buffers 8 32k; + proxy_buffer_size 64k; + client_max_body_size 0; + proxy_read_timeout 600s; + } +} + +# ============================================================================= +# cloud.wooo.work - Nextcloud (僅內網) +# ============================================================================= +server { + listen 80; + server_name cloud.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + + location / { + proxy_pass http://nextcloud_backend; + 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; + client_max_body_size 10G; + proxy_read_timeout 600s; + } +} + +# ============================================================================= +# grist.wooo.work - Grist (僅內網) +# ============================================================================= +server { + listen 80; + server_name grist.wooo.work; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-XSS-Protection "1; mode=block" always; + +} diff --git a/docker/postgres/init/01-init.sql b/docker/postgres/init/01-init.sql new file mode 100644 index 0000000..b6d5f42 --- /dev/null +++ b/docker/postgres/init/01-init.sql @@ -0,0 +1,67 @@ +-- ============================================================================= +-- PostgreSQL 初始化腳本 +-- WOOO TECH - Momo Pro System +-- ============================================================================= + +-- 建立 Metabase 專用資料庫 +CREATE DATABASE metabase; + +-- 建立分析用資料表 (從 SQLite 同步) +-- 這些表結構對應 SQLite 的主要資料表 + +-- 即時銷售月報表 +CREATE TABLE IF NOT EXISTS realtime_sales_monthly ( + id SERIAL PRIMARY KEY, + 日期 DATE, + 訂單編號 VARCHAR(50), + 商品名稱 TEXT, + 商品編號 VARCHAR(50), + 數量 INTEGER, + 總業績 DECIMAL(15, 2), + 總成本 DECIMAL(15, 2), + 廠商名稱 VARCHAR(200), + 分類名稱 VARCHAR(200), + 品牌名稱 VARCHAR(200), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 建立索引以加速查詢 +CREATE INDEX idx_sales_date ON realtime_sales_monthly(日期); +CREATE INDEX idx_sales_vendor ON realtime_sales_monthly(廠商名稱); +CREATE INDEX idx_sales_category ON realtime_sales_monthly(分類名稱); +CREATE INDEX idx_sales_brand ON realtime_sales_monthly(品牌名稱); + +-- EDM 資料表 +CREATE TABLE IF NOT EXISTS edm_data ( + id SERIAL PRIMARY KEY, + 活動名稱 VARCHAR(500), + 活動開始日期 DATE, + 活動結束日期 DATE, + 活動類型 VARCHAR(100), + 狀態 VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 商品資料表 +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + 商品編號 VARCHAR(50) UNIQUE, + 商品名稱 TEXT, + 廠商名稱 VARCHAR(200), + 分類名稱 VARCHAR(200), + 品牌名稱 VARCHAR(200), + 售價 DECIMAL(10, 2), + 成本 DECIMAL(10, 2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 授權給 momo 用戶 +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO momo; +GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO momo; + +-- 顯示初始化完成訊息 +DO $$ +BEGIN + RAISE NOTICE '✅ PostgreSQL 初始化完成 - WOOO Analytics'; +END $$; diff --git a/docker/postgres/postgresql.conf b/docker/postgres/postgresql.conf new file mode 100644 index 0000000..da69bce --- /dev/null +++ b/docker/postgres/postgresql.conf @@ -0,0 +1,81 @@ +# ============================================================================= +# PostgreSQL 效能優化配置 +# WOOO TECH - Momo Pro System +# 針對 8GB RAM 伺服器優化 +# ============================================================================= + +# ----------------------------------------------------------------------------- +# 連線設定 +# ----------------------------------------------------------------------------- +listen_addresses = '*' +max_connections = 100 + +# ----------------------------------------------------------------------------- +# 記憶體配置 (針對 8GB RAM 優化) +# ----------------------------------------------------------------------------- +# shared_buffers: 建議設為總 RAM 的 25% (8GB * 0.25 = 2GB) +shared_buffers = 2GB + +# work_mem: 每個排序/Hash 操作的記憶體 (大型查詢需要更多) +# 計算: (RAM - shared_buffers) / (max_connections * 2) +work_mem = 64MB + +# maintenance_work_mem: VACUUM, CREATE INDEX 等維護操作使用 +maintenance_work_mem = 512MB + +# effective_cache_size: 告訴 planner 系統總共有多少快取可用 +# 建議設為總 RAM 的 75% +effective_cache_size = 6GB + +# ----------------------------------------------------------------------------- +# 磁碟 I/O 配置 +# ----------------------------------------------------------------------------- +# 使用 SSD 時建議調高 +random_page_cost = 1.1 +effective_io_concurrency = 200 + +# 預讀設定 (對於大表 Seq Scan 很重要) +seq_page_cost = 1.0 + +# ----------------------------------------------------------------------------- +# WAL (Write-Ahead Log) 配置 +# ----------------------------------------------------------------------------- +wal_buffers = 64MB +checkpoint_completion_target = 0.9 +max_wal_size = 2GB +min_wal_size = 1GB + +# ----------------------------------------------------------------------------- +# 查詢計劃器配置 +# ----------------------------------------------------------------------------- +# 鼓勵使用索引 +enable_seqscan = on +enable_indexscan = on +enable_bitmapscan = on + +# 並行查詢 (利用多核心) +max_parallel_workers_per_gather = 2 +max_parallel_workers = 4 +max_worker_processes = 8 +parallel_tuple_cost = 0.01 +parallel_setup_cost = 1000 + +# ----------------------------------------------------------------------------- +# 自動 VACUUM 配置 +# ----------------------------------------------------------------------------- +autovacuum = on +autovacuum_vacuum_scale_factor = 0.1 +autovacuum_analyze_scale_factor = 0.05 + +# ----------------------------------------------------------------------------- +# 日誌配置 +# ----------------------------------------------------------------------------- +log_min_duration_statement = 1000 +log_checkpoints = on +log_lock_waits = on + +# ----------------------------------------------------------------------------- +# 統計收集 +# ----------------------------------------------------------------------------- +track_activities = on +track_counts = on diff --git a/docker/prometheus/alert_rules.yml b/docker/prometheus/alert_rules.yml new file mode 100644 index 0000000..68bacf9 --- /dev/null +++ b/docker/prometheus/alert_rules.yml @@ -0,0 +1,223 @@ +# ============================================================================= +# WOOO TECH - Momo Pro System +# Prometheus Alert Rules +# Version: 1.0 +# ============================================================================= +# +# 告警嚴重程度定義: +# - critical: 需要立即處理的嚴重問題 +# - warning: 需要關注但不緊急的問題 +# - info: 資訊性通知 +# +# ============================================================================= + +groups: + # =========================================================================== + # 主機資源監控告警 + # =========================================================================== + - name: host_alerts + rules: + # ----------------------------------------------------------------------- + # CPU 使用率告警 + # ----------------------------------------------------------------------- + - alert: HostHighCpuUsage + expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 50 + for: 5m + labels: + severity: warning + category: cpu + annotations: + summary: "主機 CPU 使用率過高" + description: "主機 {{ $labels.instance }} CPU 使用率超過 50% 持續 5 分鐘,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + - alert: HostCriticalCpuUsage + expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 + for: 5m + labels: + severity: critical + category: cpu + annotations: + summary: "主機 CPU 使用率嚴重過高" + description: "主機 {{ $labels.instance }} CPU 使用率超過 80%,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + # ----------------------------------------------------------------------- + # 記憶體使用率告警 + # ----------------------------------------------------------------------- + - alert: HostHighMemoryUsage + expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 50 + for: 5m + labels: + severity: warning + category: memory + annotations: + summary: "主機記憶體使用率過高" + description: "主機 {{ $labels.instance }} 記憶體使用率超過 50% 持續 5 分鐘,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + - alert: HostCriticalMemoryUsage + expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 85 + for: 5m + labels: + severity: critical + category: memory + annotations: + summary: "主機記憶體使用率嚴重過高" + description: "主機 {{ $labels.instance }} 記憶體使用率超過 85%,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + # ----------------------------------------------------------------------- + # 磁碟使用率告警 + # ----------------------------------------------------------------------- + - alert: HostHighDiskUsage + expr: (1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"})) * 100 > 80 + for: 5m + labels: + severity: warning + category: disk + annotations: + summary: "主機磁碟使用率過高" + description: "主機 {{ $labels.instance }} 磁碟 {{ $labels.mountpoint }} 使用率超過 80%,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + - alert: HostCriticalDiskUsage + expr: (1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"})) * 100 > 90 + for: 5m + labels: + severity: critical + category: disk + annotations: + summary: "主機磁碟空間嚴重不足" + description: "主機 {{ $labels.instance }} 磁碟 {{ $labels.mountpoint }} 使用率超過 90%,當前值: {{ $value | printf \"%.1f\" }}%" + value: "{{ $value | printf \"%.1f\" }}%" + + # ----------------------------------------------------------------------- + # 系統負載告警 + # ----------------------------------------------------------------------- + - alert: HostHighLoadAverage + expr: node_load5 / count without(cpu, mode) (node_cpu_seconds_total{mode="idle"}) > 0.8 + for: 5m + labels: + severity: warning + category: load + annotations: + summary: "主機系統負載過高" + description: "主機 {{ $labels.instance }} 5分鐘負載平均值過高,當前值: {{ $value | printf \"%.2f\" }}" + value: "{{ $value | printf \"%.2f\" }}" + + # =========================================================================== + # 容器監控告警 + # =========================================================================== + - name: container_alerts + rules: + # ----------------------------------------------------------------------- + # 容器 CPU 使用率 + # ----------------------------------------------------------------------- + - alert: ContainerHighCpuUsage + expr: (rate(container_cpu_usage_seconds_total{name!=""}[5m]) * 100) > 50 + for: 5m + labels: + severity: warning + category: container_cpu + annotations: + summary: "容器 CPU 使用率過高" + description: "容器 {{ $labels.name }} CPU 使用率超過 50%,當前值: {{ $value | printf \"%.1f\" }}%" + container: "{{ $labels.name }}" + value: "{{ $value | printf \"%.1f\" }}%" + + # ----------------------------------------------------------------------- + # 容器記憶體使用率 + # ----------------------------------------------------------------------- + - alert: ContainerHighMemoryUsage + expr: (container_memory_usage_bytes{name!=""} / container_spec_memory_limit_bytes{name!=""}) * 100 > 50 + for: 5m + labels: + severity: warning + category: container_memory + annotations: + summary: "容器記憶體使用率過高" + description: "容器 {{ $labels.name }} 記憶體使用率超過 50%,當前值: {{ $value | printf \"%.1f\" }}%" + container: "{{ $labels.name }}" + value: "{{ $value | printf \"%.1f\" }}%" + + # =========================================================================== + # 網站健康監控告警 + # =========================================================================== + - name: website_alerts + rules: + # ----------------------------------------------------------------------- + # 網站無法訪問 + # ----------------------------------------------------------------------- + - alert: WebsiteDown + expr: probe_success{job=~"blackbox-http.*"} == 0 + for: 1m + labels: + severity: critical + category: website + annotations: + summary: "網站無法訪問" + description: "網站 {{ $labels.instance }} 無法訪問,請立即檢查" + + # ----------------------------------------------------------------------- + # 網站響應時間過長 + # ----------------------------------------------------------------------- + - alert: WebsiteSlowResponse + expr: probe_http_duration_seconds{job=~"blackbox-http.*"} > 5 + for: 2m + labels: + severity: warning + category: website + annotations: + summary: "網站響應緩慢" + description: "網站 {{ $labels.instance }} 響應時間超過 5 秒,當前值: {{ $value | printf \"%.2f\" }} 秒" + value: "{{ $value | printf \"%.2f\" }}s" + + # =========================================================================== + # 網路連通性告警 + # =========================================================================== + - name: network_alerts + rules: + # ----------------------------------------------------------------------- + # 主機無法 Ping + # ----------------------------------------------------------------------- + - alert: HostUnreachable + expr: probe_success{job="blackbox-icmp"} == 0 + for: 1m + labels: + severity: critical + category: network + annotations: + summary: "主機無法連通" + description: "主機 {{ $labels.instance }} 無法 ping 通,可能已離線" + + # ----------------------------------------------------------------------- + # TCP 端口無法連接 + # ----------------------------------------------------------------------- + - alert: ServicePortDown + expr: probe_success{job=~"blackbox-tcp.*"} == 0 + for: 1m + labels: + severity: critical + category: network + annotations: + summary: "服務端口無法連接" + description: "服務 {{ $labels.instance }} 無法連接,請檢查服務狀態" + + # =========================================================================== + # PostgreSQL 資料庫監控告警 + # =========================================================================== + - name: postgres_alerts + rules: + # ----------------------------------------------------------------------- + # PostgreSQL 無法連接 + # ----------------------------------------------------------------------- + - alert: PostgresDown + expr: pg_up == 0 + for: 1m + labels: + severity: critical + category: database + annotations: + summary: "PostgreSQL 無法連接" + description: "PostgreSQL 資料庫無法連接,請立即檢查" diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml new file mode 100644 index 0000000..0efc042 --- /dev/null +++ b/docker/prometheus/prometheus.yml @@ -0,0 +1,326 @@ +# ============================================================================= +# WOOO TECH - Momo Pro System +# Prometheus Configuration +# Version: 3.0 - With Alerting +# ============================================================================= + +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + monitor: 'momo-pro-system' + +# ============================================================================= +# 告警規則 +# ============================================================================= +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# ============================================================================= +# Alertmanager 配置 +# ============================================================================= +alerting: + alertmanagers: + - static_configs: + - targets: + - momo-alertmanager:9093 + +# ============================================================================= +# Scrape Configurations +# ============================================================================= +scrape_configs: + # =========================================================================== + # 基礎設施監控 + # =========================================================================== + + # --------------------------------------------------------------------------- + # Prometheus 自身監控 + # --------------------------------------------------------------------------- + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + labels: + instance: 'prometheus' + service: 'monitoring' + + # --------------------------------------------------------------------------- + # Node Exporter - UAT 主機監控(CPU, Memory, Disk, Network) + # --------------------------------------------------------------------------- + - job_name: 'node-exporter' + static_configs: + - targets: ['node-exporter:9100'] + labels: + instance: 'uat-server' + env: 'uat' + host: '192.168.0.110' + service: 'infrastructure' + + # --------------------------------------------------------------------------- + # cAdvisor - Docker 容器指標監控 + # --------------------------------------------------------------------------- + - job_name: 'cadvisor' + static_configs: + - targets: ['cadvisor:8080'] + labels: + instance: 'docker-host' + env: 'uat' + service: 'container' + + # =========================================================================== + # 應用服務監控 + # =========================================================================== + + # --------------------------------------------------------------------------- + # Momo Flask 應用 - 資料庫與應用指標 + # --------------------------------------------------------------------------- + # Momo Flask 應用 - 健康檢查 (應用未提供 /metrics,改用 /health) + # --------------------------------------------------------------------------- + - job_name: 'momo-app' + static_configs: + - targets: ['192.168.0.110:5001'] + labels: + instance: 'momo-flask' + env: 'uat' + service: 'application' + metrics_path: /metrics + scrape_interval: 30s + scrape_timeout: 10s + + # =========================================================================== + # 網站健康監控 (HTTP/HTTPS) + # =========================================================================== + + # --------------------------------------------------------------------------- + # Blackbox HTTP - UAT 網站 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-http-uat' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - https://mo.wooo.work + - https://mo.wooo.work/health + - http://192.168.0.110:5001 + - http://192.168.0.110:5001/health + labels: + env: 'uat' + probe_type: 'http' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # --------------------------------------------------------------------------- + # Blackbox HTTP - PROD 網站 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-http-prod' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - https://momo.wooo.work + - https://momo.wooo.work/health + labels: + env: 'prod' + probe_type: 'http' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # --------------------------------------------------------------------------- + # Blackbox HTTP - 公司官網 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-http-corporate' + metrics_path: /probe + params: + module: [http_2xx] + static_configs: + - targets: + - https://wooo.work + labels: + env: 'prod' + probe_type: 'http' + service: 'corporate' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # =========================================================================== + # 端口連通性監控 (TCP) + # =========================================================================== + + # --------------------------------------------------------------------------- + # Blackbox TCP - UAT 服務端口 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-tcp-uat' + metrics_path: /probe + params: + module: [tcp_connect] + static_configs: + - targets: + - 192.168.0.110:5001 # Flask 應用 (正確 port) + - 192.168.0.110:22 # SSH + - 192.168.0.110:9090 # Prometheus + - 192.168.0.110:3000 # Grafana + - 192.168.0.110:3100 # Loki + - 192.168.0.110:9000 # Portainer HTTP + labels: + env: 'uat' + probe_type: 'tcp' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # --------------------------------------------------------------------------- + # Blackbox TCP - PROD 服務端口 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-tcp-prod' + metrics_path: /probe + params: + module: [tcp_connect] + static_configs: + - targets: + - 34.80.130.190:22 # GCP SSH + - 34.80.130.190:80 # GCP HTTP + - 34.80.130.190:443 # GCP HTTPS + labels: + env: 'prod' + probe_type: 'tcp' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # =========================================================================== + # 網路連通性監控 (ICMP Ping) + # =========================================================================== + + # --------------------------------------------------------------------------- + # Blackbox ICMP - 所有主機 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-icmp' + metrics_path: /probe + params: + module: [icmp] + static_configs: + - targets: + - 192.168.0.110 # UAT Server + labels: + env: 'uat' + probe_type: 'icmp' + - targets: + - 34.80.130.190 # GCP PROD Server + labels: + env: 'prod' + probe_type: 'icmp' + - targets: + - 8.8.8.8 # Google DNS + - 1.1.1.1 # Cloudflare DNS + labels: + env: 'external' + probe_type: 'icmp' + service: 'network-check' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # =========================================================================== + # DNS 解析監控 + # =========================================================================== + + # --------------------------------------------------------------------------- + # Blackbox DNS - 域名解析檢查 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-dns' + metrics_path: /probe + params: + module: [dns_check] + static_configs: + - targets: + - 8.8.8.8 # Google DNS - mo.wooo.work + labels: + domain: 'mo.wooo.work' + probe_type: 'dns' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + - job_name: 'blackbox-dns-momo' + metrics_path: /probe + params: + module: [dns_check_momo] + static_configs: + - targets: + - 8.8.8.8 # Google DNS - momo.wooo.work + labels: + domain: 'momo.wooo.work' + probe_type: 'dns' + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: blackbox-exporter:9115 + + # =========================================================================== + # 監控系統自身 + # =========================================================================== + + # --------------------------------------------------------------------------- + # Loki 日誌系統 + # --------------------------------------------------------------------------- + - job_name: 'loki' + static_configs: + - targets: ['loki:3100'] + labels: + instance: 'loki' + service: 'logging' + + # --------------------------------------------------------------------------- + # Grafana 視覺化 + # --------------------------------------------------------------------------- + - job_name: 'grafana' + static_configs: + - targets: ['grafana:3000'] + labels: + instance: 'grafana' + service: 'visualization' + + # --------------------------------------------------------------------------- + # Blackbox Exporter 自身 + # --------------------------------------------------------------------------- + - job_name: 'blackbox-exporter' + static_configs: + - targets: ['blackbox-exporter:9115'] + labels: + instance: 'blackbox' + service: 'monitoring' diff --git a/docker/promtail/promtail-config.yaml b/docker/promtail/promtail-config.yaml new file mode 100644 index 0000000..2c0dd30 --- /dev/null +++ b/docker/promtail/promtail-config.yaml @@ -0,0 +1,119 @@ +# ============================================================================= +# WOOO TECH - Momo Pro System +# Promtail Configuration +# ============================================================================= + +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # ========================================================================== + # Flask/Gunicorn Application Logs + # ========================================================================== + - job_name: momo-app + static_configs: + - targets: + - localhost + labels: + job: momo-app + env: production + __path__: /var/log/app/*.log + + pipeline_stages: + - multiline: + firstline: '^\d{4}-\d{2}-\d{2}' + max_wait_time: 3s + - regex: + expression: '^(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) - (?P\w+) - (?P.*)$' + - labels: + level: + - timestamp: + source: timestamp + format: '2006-01-02 15:04:05,000' + + # ========================================================================== + # Gunicorn Access Log + # ========================================================================== + - job_name: gunicorn-access + static_configs: + - targets: + - localhost + labels: + job: gunicorn-access + env: production + __path__: /var/log/app/gunicorn-access.log + + pipeline_stages: + - regex: + expression: '^(?P\S+) - - \[(?P[^\]]+)\] "(?P\S+) (?P\S+) (?P\S+)" (?P\d+) (?P\d+) "(?P[^"]*)" "(?P[^"]*)"' + - labels: + method: + status: + path: + + # ========================================================================== + # Gunicorn Error Log + # ========================================================================== + - job_name: gunicorn-error + static_configs: + - targets: + - localhost + labels: + job: gunicorn-error + env: production + __path__: /var/log/app/gunicorn-error.log + + pipeline_stages: + - multiline: + firstline: '^\[\d{4}-\d{2}-\d{2}' + max_wait_time: 3s + + # ========================================================================== + # Nginx Access Log + # ========================================================================== + - job_name: nginx-access + static_configs: + - targets: + - localhost + labels: + job: nginx-access + env: production + __path__: /var/log/nginx/*access*.log + + pipeline_stages: + - regex: + expression: '^(?P\S+) - (?P\S+) \[(?P[^\]]+)\] "(?P\S+) (?P\S+) (?P\S+)" (?P\d+) (?P\d+) "(?P[^"]*)" "(?P[^"]*)"' + - labels: + method: + status: + - metrics: + http_request_total: + type: Counter + description: "Total HTTP requests" + source: status + config: + action: inc + + # ========================================================================== + # Nginx Error Log + # ========================================================================== + - job_name: nginx-error + static_configs: + - targets: + - localhost + labels: + job: nginx-error + env: production + __path__: /var/log/nginx/*error*.log + + pipeline_stages: + - multiline: + firstline: '^\d{4}/\d{2}/\d{2}' + max_wait_time: 3s diff --git a/docker/registry/config.yml b/docker/registry/config.yml new file mode 100644 index 0000000..aff9955 --- /dev/null +++ b/docker/registry/config.yml @@ -0,0 +1,28 @@ +# ============================================================================= +# Docker Registry 配置 +# ============================================================================= + +version: 0.1 + +log: + level: info + formatter: text + +storage: + filesystem: + rootdirectory: /var/lib/registry + delete: + enabled: true + cache: + blobdescriptor: inmemory + +http: + addr: :5000 + headers: + X-Content-Type-Options: [nosniff] + +health: + storagedriver: + enabled: true + interval: 10s + threshold: 3 diff --git a/docker/registry/docker-compose.yml b/docker/registry/docker-compose.yml new file mode 100644 index 0000000..317a8d7 --- /dev/null +++ b/docker/registry/docker-compose.yml @@ -0,0 +1,74 @@ +# ============================================================================= +# WOOO TECH - Docker Registry +# 自建私有 Container Registry (取代 Harbor) +# ============================================================================= +# +# 部署方式: +# cd /home/wooo/registry +# docker compose up -d +# +# 測試: +# curl -u admin:password https://registry.wooo.work/v2/_catalog +# +# ============================================================================= + +version: '3.8' + +services: + registry: + image: registry:2 + container_name: docker-registry + restart: unless-stopped + ports: + - "127.0.0.1:5002:5000" # 僅本地連線,透過 Nginx 反向代理 (避免與 Harbor 衝突) + volumes: + - registry-data:/var/lib/registry + - ./config.yml:/etc/docker/registry/config.yml:ro + environment: + - REGISTRY_STORAGE_DELETE_ENABLED=true + - TZ=Asia/Taipei + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:5000/v2/"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - registry-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + # Registry UI (可選,提供 Web 介面) + registry-ui: + image: joxit/docker-registry-ui:latest + container_name: docker-registry-ui + restart: unless-stopped + profiles: + - ui # 使用 --profile ui 啟用 + ports: + - "127.0.0.1:5001:80" + environment: + - REGISTRY_TITLE=WOOO Registry + - REGISTRY_URL=http://registry:5000 + - SINGLE_REGISTRY=true + - DELETE_IMAGES=true + depends_on: + - registry + networks: + - registry-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + registry-network: + driver: bridge + name: registry-network + +volumes: + registry-data: + name: docker-registry-data diff --git a/docker/registry/setup.sh b/docker/registry/setup.sh new file mode 100644 index 0000000..ff3e059 --- /dev/null +++ b/docker/registry/setup.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# ============================================================================= +# Docker Registry 安裝腳本 +# ============================================================================= + +set -e + +# 顏色 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[INFO]${NC} $1"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; } + +# 配置 +REGISTRY_USER="${REGISTRY_USER:-admin}" +REGISTRY_PASSWORD="${REGISTRY_PASSWORD:-Wooo_Registry_2026}" +DOMAIN="registry.wooo.work" + +# ============================================================================= +# 1. 建立認證檔案 (htpasswd) +# ============================================================================= +setup_auth() { + log "建立認證檔案..." + + # 安裝 htpasswd 工具 + if ! command -v htpasswd &> /dev/null; then + apt-get update && apt-get install -y apache2-utils + fi + + # 建立 htpasswd 檔案 + mkdir -p /etc/nginx/conf.d + htpasswd -Bbn "$REGISTRY_USER" "$REGISTRY_PASSWORD" > /etc/nginx/conf.d/.htpasswd + + log "認證檔案已建立: /etc/nginx/conf.d/.htpasswd" + log "帳號: $REGISTRY_USER" +} + +# ============================================================================= +# 2. 設定 Nginx +# ============================================================================= +setup_nginx() { + log "設定 Nginx..." + + # 複製配置 + cp /home/wooo/momo_pro_system/config/nginx/sites-available/registry /etc/nginx/sites-available/ + + # 啟用網站 + ln -sf /etc/nginx/sites-available/registry /etc/nginx/sites-enabled/ + + # 測試並重載 + nginx -t && systemctl reload nginx + + log "Nginx 配置完成" +} + +# ============================================================================= +# 3. 申請 SSL 證書 +# ============================================================================= +setup_ssl() { + log "申請 SSL 證書..." + + if [[ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]]; then + log "SSL 證書已存在" + return + fi + + # 先用 HTTP 配置 + cat > /tmp/registry-http.conf << 'EOF' +server { + listen 80; + server_name registry.wooo.work; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} +EOF + + cp /tmp/registry-http.conf /etc/nginx/sites-available/registry + ln -sf /etc/nginx/sites-available/registry /etc/nginx/sites-enabled/ + nginx -t && systemctl reload nginx + + # 申請證書 + certbot certonly --webroot -w /var/www/certbot -d "$DOMAIN" --non-interactive --agree-tos --email admin@wooo.work + + # 恢復完整配置 + cp /home/wooo/momo_pro_system/config/nginx/sites-available/registry /etc/nginx/sites-available/ + nginx -t && systemctl reload nginx + + log "SSL 證書申請完成" +} + +# ============================================================================= +# 4. 啟動 Registry +# ============================================================================= +start_registry() { + log "啟動 Docker Registry..." + + cd /home/wooo/registry + docker compose up -d + + # 等待啟動 + sleep 5 + + # 健康檢查 + if curl -s http://127.0.0.1:5000/v2/ | grep -q "{}"; then + log "Registry 啟動成功" + else + error "Registry 啟動失敗" + fi +} + +# ============================================================================= +# 5. 測試 +# ============================================================================= +test_registry() { + log "測試 Registry..." + + # 登入測試 + echo "$REGISTRY_PASSWORD" | docker login "$DOMAIN" -u "$REGISTRY_USER" --password-stdin + + # 推送測試映像 + docker pull alpine:latest + docker tag alpine:latest "$DOMAIN/test/alpine:latest" + docker push "$DOMAIN/test/alpine:latest" + + # 拉取測試 + docker rmi "$DOMAIN/test/alpine:latest" + docker pull "$DOMAIN/test/alpine:latest" + + # 清理 + docker rmi "$DOMAIN/test/alpine:latest" + + log "Registry 測試通過!" +} + +# ============================================================================= +# 主程式 +# ============================================================================= +main() { + echo "" + echo "==========================================" + echo " Docker Registry 安裝" + echo "==========================================" + echo "" + + # 檢查 root + if [[ $EUID -ne 0 ]]; then + error "請使用 root 執行: sudo $0" + fi + + # 建立目錄 + mkdir -p /home/wooo/registry + cp -r /home/wooo/momo_pro_system/docker/registry/* /home/wooo/registry/ + + setup_auth + setup_ssl + setup_nginx + start_registry + test_registry + + echo "" + echo "==========================================" + echo " 安裝完成!" + echo "==========================================" + echo "" + echo "Registry URL: https://$DOMAIN" + echo "帳號: $REGISTRY_USER" + echo "密碼: $REGISTRY_PASSWORD" + echo "" + echo "使用方式:" + echo " docker login $DOMAIN" + echo " docker push $DOMAIN/wooo/momo-pro-system:latest" + echo "" +} + +# 執行 +main "$@" diff --git a/docker/superset/DASHBOARD_GUIDE.md b/docker/superset/DASHBOARD_GUIDE.md new file mode 100644 index 0000000..b89d6ff --- /dev/null +++ b/docker/superset/DASHBOARD_GUIDE.md @@ -0,0 +1,321 @@ +# Superset 儀表板建置指南 + +> MOMO Pro System - BI 分析平台 +> 建立日期: 2026-02-07 + +--- + +## 存取資訊 + +| 項目 | 值 | +|------|-----| +| URL | https://monitor.wooo.work/superset/ | +| 帳號 | admin | +| 密碼 | Wooo_Superset_2026 | + +--- + +## 已建立的資料集 + +| 資料集 | 說明 | 主要時間欄位 | +|--------|------|--------------| +| `daily_sales_snapshot` | 每日銷售快照 | snapshot_date | +| `realtime_sales_monthly` | 即時業績月度資料 | - | +| `monthly_summary_analysis` | 月度總結分析 | report_month | +| `products` | 商品資料 | updated_at | +| `price_records` | 價格記錄 | timestamp | + +--- + +## 需建立的儀表板 + +### 1. 銷售分析總覽 (Sales Analysis Dashboard) + +**對應頁面**: `/sales_analysis` + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| 每日銷售趨勢 | Line Chart | daily_sales_snapshot | X軸: snapshot_date, Y軸: SUM(金額) | +| 銷售額 TOP 10 商品 | Bar Chart | daily_sales_snapshot | 依商品名稱群組,取前 10 名 | +| 銷售通路分佈 | Pie Chart | daily_sales_snapshot | 依通路群組 | +| 星期銷售熱力圖 | Heatmap | daily_sales_snapshot | X軸: 星期, Y軸: 時段 | +| 銷售數據表格 | Table | daily_sales_snapshot | 詳細銷售記錄 | + +**建立步驟**: + +1. 前往 **Charts** > **+ Chart** +2. 選擇資料集 `daily_sales_snapshot` +3. 選擇圖表類型 (如 Line Chart) +4. 設定 X 軸、Y 軸、分組欄位 +5. 點擊 **Save** 儲存圖表 +6. 將圖表加入儀表板 + +--- + +### 2. 當日業績追蹤 (Daily Sales Dashboard) + +**對應頁面**: `/daily_sales` + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| 當日業績總覽 | Big Number | daily_sales_snapshot | 顯示今日總銷售額 | +| 業績達成率 | Gauge Chart | daily_sales_snapshot | 對比目標達成率 | +| 時段業績分佈 | Area Chart | daily_sales_snapshot | X軸: 時段, Y軸: 金額 | +| 商品銷售排行 | Bar Chart | daily_sales_snapshot | 今日銷售 TOP 20 | +| 業績明細表 | Table | daily_sales_snapshot | 可篩選日期的明細 | + +**篩選器設定**: +- 新增 **Time Filter** 設定為 `snapshot_date` +- 預設顯示今天的資料 + +--- + +### 3. 成長分析 (Growth Analysis Dashboard) + +**對應頁面**: `/growth_analysis` + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| 月度成長趨勢 | Line Chart | realtime_sales_monthly | 顯示月度成長率 | +| 年增率比較 | Bar Chart | realtime_sales_monthly | YoY 比較 | +| 成長率 KPI | Big Number with Trendline | realtime_sales_monthly | 月成長率指標 | +| 品類成長分析 | Treemap | realtime_sales_monthly | 各品類成長貢獻 | + +**計算欄位** (Metrics): +```sql +-- 月增長率 +(SUM(本月金額) - SUM(上月金額)) / SUM(上月金額) * 100 +``` + +--- + +### 4. 月度總結 (Monthly Summary Dashboard) + +**對應頁面**: `/monthly_summary_analysis` + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| 月度業績總覽 | Big Number | monthly_summary_analysis | 當月總業績 | +| 月度趨勢比較 | Line Chart | monthly_summary_analysis | 12 個月趨勢 | +| 月度業績表格 | Pivot Table | monthly_summary_analysis | 月份 x 指標 | +| 月環比分析 | Bar Chart | monthly_summary_analysis | MoM 比較 | + +--- + +### 5. ABC 分析 (ABC Analysis Dashboard) + +**對應頁面**: `/abc_analysis/detail` + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| ABC 分類圓餅圖 | Pie Chart | products | A/B/C 類商品佔比 | +| 帕累托曲線 | Dual Line Chart | products | 累計銷售貢獻 | +| ABC 商品列表 | Table | products | 可篩選分類的商品表 | +| 分類銷售佔比 | Sunburst Chart | products | 階層式銷售分佈 | + +**計算欄位** (需在 SQL Lab 建立虛擬資料集): +```sql +SELECT + i_code, + product_name, + total_sales, + SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_sales, + SUM(total_sales) OVER () as grand_total, + CASE + WHEN SUM(total_sales) OVER (ORDER BY total_sales DESC) / SUM(total_sales) OVER () <= 0.7 THEN 'A' + WHEN SUM(total_sales) OVER (ORDER BY total_sales DESC) / SUM(total_sales) OVER () <= 0.9 THEN 'B' + ELSE 'C' + END as abc_class +FROM products +WHERE total_sales > 0 +ORDER BY total_sales DESC +``` + +--- + +### 6. 商品價格趨勢 (Price Trends Dashboard) + +**對應頁面**: 商品看板的價格趨勢 + +**建議圖表**: + +| 圖表名稱 | 圖表類型 | 資料集 | 說明 | +|----------|----------|--------|------| +| 價格變動時間線 | Line Chart | price_records | 選定商品的價格歷史 | +| 今日價格變動 | Table | price_records | 今日有變動的商品 | +| 漲價/降價統計 | Bar Chart | price_records | 漲降價商品數量 | +| 價格變動熱力圖 | Heatmap | price_records | 時間 x 商品類別 | + +**篩選器**: +- 商品篩選器 (product_id) +- 時間範圍篩選器 (timestamp) + +--- + +## 建立儀表板步驟 + +### Step 1: 建立圖表 + +1. 登入 Superset +2. 點擊 **Charts** > **+ Chart** +3. 選擇資料集 (如 `daily_sales_snapshot`) +4. 選擇圖表類型 +5. 設定維度 (Dimensions) 和指標 (Metrics) +6. 設定篩選條件 +7. 點擊 **Run** 預覽 +8. 點擊 **Save** 儲存 + +### Step 2: 建立儀表板 + +1. 點擊 **Dashboards** > **+ Dashboard** +2. 輸入儀表板名稱 (如「銷售分析總覽」) +3. 點擊 **Edit dashboard** +4. 從右側 Charts 清單拖曳圖表到畫布 +5. 調整圖表大小和位置 +6. 新增篩選器 (Filter box) +7. 點擊 **Save** + +### Step 3: 設定篩選器 + +1. 在儀表板編輯模式 +2. 點擊 **+ Add filter** (左上角) +3. 選擇篩選類型: + - **Time Filter**: 時間範圍 + - **Select Filter**: 下拉選單 + - **Range Filter**: 數值範圍 +4. 選擇要影響的圖表 + +--- + +## SQL Lab 進階查詢 + +對於複雜的分析需求,可以使用 SQL Lab 建立虛擬資料集: + +### 範例: 銷售成長分析虛擬表 + +```sql +-- 建立虛擬資料集: sales_growth_analysis +WITH monthly_sales AS ( + SELECT + DATE_TRUNC('month', snapshot_date) as month, + SUM(金額) as total_amount, + COUNT(DISTINCT 商品代碼) as product_count, + COUNT(*) as order_count + FROM daily_sales_snapshot + GROUP BY DATE_TRUNC('month', snapshot_date) +) +SELECT + month, + total_amount, + product_count, + order_count, + LAG(total_amount) OVER (ORDER BY month) as prev_month_amount, + (total_amount - LAG(total_amount) OVER (ORDER BY month)) / + NULLIF(LAG(total_amount) OVER (ORDER BY month), 0) * 100 as growth_rate +FROM monthly_sales +ORDER BY month DESC +``` + +**使用步驟**: +1. 點擊 **SQL Lab** > **SQL Editor** +2. 貼上 SQL 查詢 +3. 執行查詢確認結果 +4. 點擊 **Save** > **Save Dataset** +5. 使用此虛擬資料集建立圖表 + +--- + +## 權限設定 + +### 建立唯讀角色 + +1. 前往 **Settings** > **List Roles** +2. 點擊 **+ Add** +3. 角色名稱: `MOMO_Viewer` +4. 權限設定: + - `can read on Chart` + - `can read on Dashboard` + - `datasource access on [MOMO_UAT].[daily_sales_snapshot]` + - (其他需要的資料集權限) + +### 建立用戶 + +1. 前往 **Settings** > **List Users** +2. 點擊 **+ Add** +3. 設定帳號密碼 +4. 指派角色 `MOMO_Viewer` + +--- + +## 嵌入儀表板到現有系統 + +### iframe 嵌入 + +```html + + +``` + +### Superset 嵌入設定 + +1. 前往 **Settings** > **Feature Flags** +2. 啟用 `ENABLE_DASHBOARD_EMBEDDING` +3. 在儀表板設定中允許嵌入 + +--- + +## 排程報告 (未來功能) + +Superset 支援排程發送報告: + +1. 前往儀表板 +2. 點擊 **...** > **Schedule Report** +3. 設定: + - 收件人 (Email) + - 排程頻率 (Daily/Weekly) + - 報告格式 (PDF/Image) + +> 注意: 需要額外設定 SMTP 和 Celery Beat + +--- + +## 常見問題 + +### Q1: 圖表顯示「No data」 +- 檢查時間篩選器範圍 +- 確認資料集有資料 +- 檢查 SQL 查詢條件 + +### Q2: 連線到 MOMO_UAT 失敗 +- 確認 PostgreSQL Pod IP 正確 +- 檢查 superset_readonly 用戶權限 +- 驗證網路連通性 + +### Q3: 儀表板載入緩慢 +- 減少單一儀表板的圖表數量 +- 使用時間範圍限制資料量 +- 考慮建立物化視圖 + +--- + +## 下一步 + +1. 依照本指南建立 6 個儀表板 +2. 測試所有圖表功能 +3. 設定用戶權限 +4. 評估是否嵌入或取代現有頁面 diff --git a/docker/superset/IMPLEMENTATION_GUIDE.md b/docker/superset/IMPLEMENTATION_GUIDE.md new file mode 100644 index 0000000..c6eaca7 --- /dev/null +++ b/docker/superset/IMPLEMENTATION_GUIDE.md @@ -0,0 +1,466 @@ +# Superset 功能實作指南 + +> 本指南詳細說明如何在 Superset 中複製現有頁面的分析功能 +> 建立日期: 2026-02-08 + +--- + +## 存取資訊 + +| 項目 | 值 | +|------|-----| +| URL | https://monitor.wooo.work/superset/ | +| 帳號 | admin | +| 密碼 | Wooo_Superset_2026 | +| 資料庫 | MOMO_UAT | + +--- + +## 已建立的資料集 + +| 資料集 | 資料表 | 用途 | +|--------|--------|------| +| daily_sales_snapshot | public.daily_sales_snapshot | 當日業績 | +| realtime_sales_monthly | public.realtime_sales_monthly | 銷售分析、成長分析 | +| monthly_summary_analysis | public.monthly_summary_analysis | 月度總結 | +| products | public.products | 商品資料、ABC 分析 | +| price_records | public.price_records | 價格趨勢 | + +--- + +## 第一部分:當日業績 (Daily Sales) + +**對應頁面**: `/daily_sales` + +### 1.1 SQL Lab 建立虛擬資料集 + +在 SQL Lab 執行以下查詢,然後儲存為資料集: + +```sql +-- 資料集名稱: daily_sales_kpi +-- 用途: 每日 KPI 彙總 + +SELECT + snapshot_date, + DATE_TRUNC('month', snapshot_date) as month, + EXTRACT(DOW FROM snapshot_date) as day_of_week, + COUNT(DISTINCT "商品代碼") as sku_count, + SUM("銷售金額") as total_revenue, + SUM("總成本") as total_cost, + SUM("銷售金額") - SUM("總成本") as gross_margin, + SUM("銷售數量") as total_qty, + CASE + WHEN SUM("銷售金額") > 0 + THEN (SUM("銷售金額") - SUM("總成本")) / SUM("銷售金額") * 100 + ELSE 0 + END as margin_rate, + CASE + WHEN SUM("銷售數量") > 0 + THEN SUM("銷售金額") / SUM("銷售數量") + ELSE 0 + END as avg_price +FROM daily_sales_snapshot +GROUP BY snapshot_date +ORDER BY snapshot_date DESC +``` + +### 1.2 建議圖表 + +| 圖表名稱 | 類型 | 說明 | +|----------|------|------| +| 當日業績 Big Number | Big Number with Trendline | 顯示最新日期的 total_revenue | +| 當日毛利 Big Number | Big Number with Trendline | 顯示最新日期的 gross_margin | +| 30 天業績趨勢 | Line Chart | X軸: snapshot_date, Y軸: total_revenue | +| DoD 比較 | Bar Chart | 比較今日與昨日 | +| WoW 比較 | Bar Chart | 比較今日與上週同日 | +| 分類業績圓餅圖 | Pie Chart | 依商品分類分組 | +| 日曆熱力圖 | Calendar Heatmap | 每日業績視覺化 | + +### 1.3 建立步驟 + +1. **SQL Lab** → 執行上述 SQL +2. 點擊 **Save** → **Save Dataset** +3. 命名為 `daily_sales_kpi` +4. 前往 **Charts** → **+ Chart** +5. 選擇 `daily_sales_kpi` 資料集 +6. 依序建立各圖表 + +--- + +## 第二部分:銷售分析 (Sales Analysis) + +**對應頁面**: `/sales_analysis` + +### 2.1 SQL Lab 建立虛擬資料集 + +```sql +-- 資料集名稱: sales_analysis_detail +-- 用途: 銷售明細分析 + +SELECT + "日期" as order_date, + "商品名稱" as product_name, + "商品代碼" as product_code, + "館別" as category, + "品牌" as brand, + "廠商名稱" as vendor, + "總業績" as amount, + "總成本" as cost, + "總業績" - "總成本" as profit, + "銷量" as qty, + CASE + WHEN "總業績" > 0 + THEN ("總業績" - "總成本") / "總業績" * 100 + ELSE 0 + END as margin_rate, + EXTRACT(DOW FROM "日期"::date) as day_of_week, + EXTRACT(HOUR FROM "訂單時間"::time) as order_hour, + DATE_TRUNC('month', "日期"::date) as month, + DATE_TRUNC('week', "日期"::date) as week +FROM realtime_sales_monthly +WHERE "日期" IS NOT NULL +``` + +### 2.2 建議圖表 + +| 圖表名稱 | 類型 | 設定 | +|----------|------|------| +| 總業績 KPI | Big Number | SUM(amount) | +| 總毛利 KPI | Big Number | SUM(profit) | +| 毛利率 KPI | Big Number | AVG(margin_rate) | +| 業績 TOP 20 商品 | Bar Chart (Horizontal) | GROUP BY product_name, ORDER BY SUM(amount) DESC LIMIT 20 | +| 分類業績分佈 | Pie Chart | GROUP BY category | +| 品牌業績排行 | Bar Chart | GROUP BY brand | +| 廠商業績排行 | Bar Chart | GROUP BY vendor | +| 星期銷售熱力圖 | Heatmap | X: day_of_week, Y: order_hour, Value: SUM(amount) | +| 月度趨勢 | Line Chart | X: month, Y: SUM(amount) | +| 週趨勢 | Line Chart | X: week, Y: SUM(amount) | +| 價格區間分佈 | Histogram | amount 分佈 | +| BCG 矩陣 | Scatter Plot | X: SUM(qty), Y: margin_rate, Size: SUM(amount) | +| 樹狀圖 | Treemap | 分類 → 品牌 階層 | + +### 2.3 篩選器設定 + +建立以下 Filter Box: +- 日期範圍 (order_date) +- 分類 (category) +- 品牌 (brand) +- 廠商 (vendor) +- 星期 (day_of_week) +- 時段 (order_hour) + +--- + +## 第三部分:成長分析 (Growth Analysis) + +**對應頁面**: `/growth_analysis` + +### 3.1 SQL Lab 建立虛擬資料集 + +```sql +-- 資料集名稱: growth_analysis_monthly +-- 用途: 月度成長分析 (MoM, YoY, AOV) + +WITH monthly_data AS ( + SELECT + DATE_TRUNC('month', "日期"::date) as month, + SUM("總業績") as revenue, + SUM("總成本") as cost, + SUM("總業績") - SUM("總成本") as profit, + COUNT(DISTINCT "訂單編號") as orders + FROM realtime_sales_monthly + WHERE "日期" IS NOT NULL + GROUP BY DATE_TRUNC('month', "日期"::date) +), +with_growth AS ( + SELECT + month, + revenue, + profit, + orders, + revenue / NULLIF(orders, 0) as aov, + profit / NULLIF(revenue, 0) * 100 as margin_rate, + -- MoM (月增率) + (revenue - LAG(revenue) OVER (ORDER BY month)) / + NULLIF(LAG(revenue) OVER (ORDER BY month), 0) * 100 as mom, + -- YoY (年增率) + (revenue - LAG(revenue, 12) OVER (ORDER BY month)) / + NULLIF(LAG(revenue, 12) OVER (ORDER BY month), 0) * 100 as yoy + FROM monthly_data +) +SELECT + month, + revenue, + profit, + orders, + ROUND(aov::numeric, 0) as aov, + ROUND(margin_rate::numeric, 1) as margin_rate, + COALESCE(ROUND(mom::numeric, 2), 0) as mom, + COALESCE(ROUND(yoy::numeric, 2), 0) as yoy +FROM with_growth +ORDER BY month DESC +``` + +### 3.2 建議圖表 + +| 圖表名稱 | 類型 | 說明 | +|----------|------|------| +| YTD 業績 | Big Number | 今年累計業績 | +| YTD 成長率 | Big Number | 與去年同期比較 | +| 近 30 天客單價 | Big Number | 最近客單價 | +| 月度業績趨勢 | Line Chart | X: month, Y: revenue | +| 月度毛利趨勢 | Line Chart | X: month, Y: profit | +| MoM 月增率 | Bar Chart | X: month, Y: mom (紅/綠顏色區分正負) | +| YoY 年增率 | Bar Chart | X: month, Y: yoy | +| 客單價趨勢 | Line Chart | X: month, Y: aov | +| 毛利率趨勢 | Line Chart | X: month, Y: margin_rate | +| 綜合指標雙軸圖 | Mixed Chart | 左軸: revenue, 右軸: margin_rate | + +--- + +## 第四部分:月度總結 (Monthly Summary) + +**對應頁面**: `/monthly_summary_analysis` + +### 4.1 使用現有資料集 + +直接使用 `monthly_summary_analysis` 資料集。 + +### 4.2 建議圖表 + +| 圖表名稱 | 類型 | 說明 | +|----------|------|------| +| 本月業績 | Big Number | 最新月份的業績 | +| 月度業績比較 | Bar Chart | 12 個月業績對比 | +| 月環比 | Line Chart | MoM 變化 | +| 月度彙總表 | Pivot Table | 月份 x 各項指標 | +| 季度彙總 | Bar Chart | 依季度分組 | + +--- + +## 第五部分:ABC 分析 + +**對應頁面**: `/abc_analysis/detail` + +### 5.1 SQL Lab 建立虛擬資料集 + +```sql +-- 資料集名稱: abc_analysis +-- 用途: 商品 ABC 分類 + +WITH product_sales AS ( + SELECT + p.i_code, + p.name as product_name, + p.category, + COALESCE(SUM(d."銷售金額"), 0) as total_sales, + COALESCE(SUM(d."銷售數量"), 0) as total_qty + FROM products p + LEFT JOIN daily_sales_snapshot d ON p.i_code = d."商品代碼" + GROUP BY p.i_code, p.name, p.category + HAVING COALESCE(SUM(d."銷售金額"), 0) > 0 +), +ranked AS ( + SELECT + *, + SUM(total_sales) OVER (ORDER BY total_sales DESC) as cumulative_sales, + SUM(total_sales) OVER () as grand_total, + ROW_NUMBER() OVER (ORDER BY total_sales DESC) as rank + FROM product_sales +) +SELECT + i_code, + product_name, + category, + total_sales, + total_qty, + cumulative_sales, + grand_total, + rank, + cumulative_sales / grand_total * 100 as cumulative_pct, + CASE + WHEN cumulative_sales / grand_total <= 0.7 THEN 'A' + WHEN cumulative_sales / grand_total <= 0.9 THEN 'B' + ELSE 'C' + END as abc_class +FROM ranked +ORDER BY rank +``` + +### 5.2 建議圖表 + +| 圖表名稱 | 類型 | 說明 | +|----------|------|------| +| ABC 分類圓餅圖 | Pie Chart | GROUP BY abc_class | +| ABC 分類商品數 | Bar Chart | COUNT BY abc_class | +| 帕累托曲線 | Dual Axis Line | 銷售額 + 累計百分比 | +| A 類商品列表 | Table | FILTER abc_class = 'A' | +| B 類商品列表 | Table | FILTER abc_class = 'B' | +| C 類商品列表 | Table | FILTER abc_class = 'C' | +| 分類 ABC 分佈 | Stacked Bar | X: category, Y: COUNT, Color: abc_class | + +--- + +## 第六部分:價格趨勢 + +**對應頁面**: 商品看板價格歷史 + +### 6.1 使用現有資料集 + +直接使用 `price_records` 資料集。 + +### 6.2 建議圖表 + +| 圖表名稱 | 類型 | 說明 | +|----------|------|------| +| 價格歷史折線圖 | Line Chart | X: timestamp, Y: current_price, Filter: product_id | +| 今日價格變動 | Table | WHERE DATE(timestamp) = CURRENT_DATE | +| 漲價商品數 | Big Number | COUNT WHERE price_change > 0 | +| 降價商品數 | Big Number | COUNT WHERE price_change < 0 | +| 價格變動分佈 | Histogram | price_change_pct 分佈 | + +--- + +## 儀表板建立順序 + +### 建議順序(由簡到繁) + +1. **成長分析儀表板** - 圖表較少,資料結構簡單 +2. **月度總結儀表板** - 使用現有資料集 +3. **當日業績儀表板** - 需要 DoD/WoW 計算 +4. **銷售分析儀表板** - 圖表最多,篩選器複雜 +5. **ABC 分析儀表板** - 需要進階 SQL +6. **價格趨勢儀表板** - 需要時間序列處理 + +--- + +## 驗證對照表 + +每個儀表板建立完成後,請與現有頁面比對以下項目: + +| 驗證項目 | 檢查點 | +|----------|--------| +| 數據一致性 | KPI 數值是否與現有頁面一致 | +| 圖表呈現 | 圖表類型是否適當呈現資料 | +| 篩選功能 | 篩選器是否正常運作 | +| 效能 | 載入時間是否可接受 | +| 互動性 | 點擊鑽取是否正常 | + +--- + +## 常用 Superset 操作 + +### 建立圖表快速步驟 + +1. **Charts** → **+ Chart** +2. 選擇資料集 +3. 選擇圖表類型 +4. 設定 Metrics (指標) 和 Dimensions (維度) +5. 設定篩選條件 +6. **Run Query** 預覽 +7. **Save** 儲存 + +### 建立儀表板 + +1. **Dashboards** → **+ Dashboard** +2. 輸入名稱 +3. **Edit Dashboard** +4. 從右側拖曳圖表 +5. 調整佈局 +6. 新增篩選器 +7. **Save** + +### 設定篩選器 + +1. 在儀表板編輯模式 +2. 點擊 **+ Add filter** +3. 選擇欄位和類型 +4. 設定影響的圖表 + +--- + +## 注意事項 + +1. **欄位名稱**: PostgreSQL 區分大小寫,中文欄位需用雙引號包起來 +2. **日期格式**: 確保日期欄位正確轉換為 DATE 類型 +3. **效能**: 大資料集建議加入時間篩選限制 +4. **快取**: Superset 有快取機制,測試時可能需要清除快取 + +--- + +## 故障排除 + +### 頁面無限載入 (Infinite Loading) + +**症狀**: 訪問 Superset 頁面時,畫面顯示無限載入中 + +**原因**: Superset Docker 映像 (3.1.0/3.1.1) 中的 `theme.5ab95322dc4a489d8e8f.entry.js` 檔案大小為 0 bytes (映像構建問題) + +**解決方案**: 已在 docker-compose.yml 的啟動命令中自動修復: + +```bash +echo '(function(){console.log("Theme loaded");})();' > /app/superset/static/assets/theme.5ab95322dc4a489d8e8f.entry.js +``` + +**手動修復** (如果需要): + +```bash +docker exec momo-superset sh -c 'echo "(function(){console.log(\"Theme loaded\");})();" > /app/superset/static/assets/theme.5ab95322dc4a489d8e8f.entry.js' +``` + +### 子路徑 404 錯誤 + +**症狀**: 訪問 `/superset/` 返回 404 + +**原因**: Nginx 子路徑配置需要特別處理 URL 重寫 + +**解決方案**: 參考 `nginx-superset.conf` 配置,關鍵設定: +- `proxy_redirect / /superset/;` - 重寫重定向 +- `sub_filter` - 重寫 HTML 中的靜態資源路徑 +- `gzip off;` - 禁用 gzip 讓 sub_filter 生效 + +### 雙重前綴問題 (/superset/superset/) (2026-02-08 修復) + +**症狀**: +- 訪問 `https://monitor.wooo.work/superset/` 被重定向到 `/superset/superset/welcome/` +- 頁面無限載入 + +**根本原因**: +Superset 內部 Flask blueprints 路由已經是 `/superset/...`(例如 `/superset/welcome/`)。 +如果 Nginx 使用 `proxy_redirect / /superset/;`,會把 `/superset/welcome/` 再次加前綴變成 `/superset/superset/welcome/`。 + +**解決方案**: + +1. **superset_config.py** - 禁用 x_prefix: +```python +ENABLE_PROXY_FIX = True +PROXY_FIX_CONFIG = { + "x_for": 1, + "x_proto": 1, + "x_host": 1, + "x_prefix": 0, # 必須為 0! +} +``` + +2. **Nginx 配置** - 智能 proxy_redirect: +```nginx +location /superset/ { + proxy_pass http://127.0.0.1:8088/; + + # 關鍵:已是 /superset/ 開頭的路徑保持不變 + proxy_redirect /superset/ /superset/; + # 其他路徑才添加 /superset/ 前綴 + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + + # 只重寫 static 路徑 + sub_filter '"/static/' '"/superset/static/'; + sub_filter "'/static/" "'/superset/static/"; + sub_filter_once off; +} +``` + +3. **驗證**: +```bash +# 應該返回 302 到 /superset/welcome/ (不是 /superset/superset/welcome/) +curl -sI https://monitor.wooo.work/superset/ | grep -i location +``` diff --git a/docker/superset/README.md b/docker/superset/README.md new file mode 100644 index 0000000..4a34447 --- /dev/null +++ b/docker/superset/README.md @@ -0,0 +1,202 @@ +# Apache Superset 部署指南 + +## 概述 + +Apache Superset 是 MOMO Pro System 的 BI 分析平台,用於建立進階分析儀表板。 + +## 架構 + +``` + ┌─────────────────────────────────────┐ + │ Apache Superset (UAT) │ + │ https://monitor.wooo.work/superset│ + └──────────────┬──────────────────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ + │ UAT 資料庫 │ │ GCP 資料庫 │ │ DEV 資料庫 │ + │ 192.168.0.110 │ │35.194.233.141 │ │ 127.0.0.1 │ + │ momo_analytics│ │ momo_analytics│ │ momo_database │ + └───────────────┘ └───────────────┘ └───────────────┘ +``` + +## 快速部署 + +```bash +# 1. SSH 到 UAT 主機 +ssh wooo@192.168.0.110 + +# 2. 進入 Superset 目錄 +cd /home/wooo/momo_pro_system/docker/superset + +# 3. 執行部署腳本 +chmod +x deploy.sh +./deploy.sh deploy + +# 4. 設定 Nginx 反向代理 (見下方) + +# 5. 設定資料庫唯讀用戶 (見下方) +``` + +## 服務管理 + +```bash +# 查看狀態 +./deploy.sh status + +# 查看日誌 +./deploy.sh logs + +# 重啟服務 +./deploy.sh restart + +# 停止服務 +./deploy.sh stop + +# 清除所有資料 (危險) +./deploy.sh clean +``` + +## 訪問資訊 + +| 項目 | 值 | +|------|-----| +| 內部 URL | `http://127.0.0.1:8088` | +| 外部 URL | `https://monitor.wooo.work/superset/` | +| 帳號 | `admin` | +| 密碼 | `Wooo_Superset_2026` | + +## Nginx 配置 + +將以下內容加入 `/etc/nginx/sites-available/monitor`: + +```nginx +# Superset BI 分析平台 +location /superset/ { + proxy_pass http://127.0.0.1:8088/; + 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 /superset; + + # WebSocket 支援 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超時設定 + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; +} +``` + +然後重啟 Nginx: +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +## 資料庫連線設定 + +### 1. 建立唯讀用戶 + +在 UAT PostgreSQL 執行: +```bash +kubectl exec -it momo-postgres-0 -n momo -- psql -U momo -d momo_analytics +``` + +執行 SQL: +```sql +CREATE ROLE superset_readonly WITH LOGIN PASSWORD 'Wooo_Superset_RO_2026'; +GRANT CONNECT ON DATABASE momo_analytics TO superset_readonly; +GRANT USAGE ON SCHEMA public TO superset_readonly; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO superset_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO superset_readonly; +``` + +### 2. 在 Superset 新增資料庫連線 + +登入 Superset 後: +1. 點擊右上角 `+` → `Database` +2. 選擇 `PostgreSQL` +3. 輸入連線資訊 + +**UAT 環境連線:** +``` +postgresql+psycopg2://superset_readonly:Wooo_Superset_RO_2026@host.docker.internal:5432/momo_analytics +``` + +> 注意: 使用 `host.docker.internal` 連接主機上的 K8s PostgreSQL + +**GCP 環境連線:** +``` +postgresql+psycopg2://superset_readonly:Wooo_Superset_RO_2026@35.194.233.141:5432/momo_analytics +``` + +> 注意: GCP 需要先設定防火牆規則允許 UAT IP (114.32.151.246) + +## 預計實作的儀表板 + +| 儀表板 | 對應功能 | 資料表 | +|--------|---------|--------| +| 銷售分析總覽 | `/sales_analysis` | daily_sales_snapshot, realtime_sales_monthly | +| 當日業績追蹤 | `/daily_sales` | daily_sales_snapshot | +| 成長分析 | `/growth_analysis` | realtime_sales_monthly | +| 月度總結 | `/monthly_summary_analysis` | monthly_summary_analysis | +| ABC 分析 | `/abc_analysis/detail` | products, price_records | +| 商品價格趨勢 | `/` (首頁看板) | products, price_records | + +## 資源需求 + +| 項目 | 最低需求 | 建議配置 | +|------|----------|----------| +| CPU | 2 核心 | 4 核心 | +| RAM | 4 GB | 8 GB | +| 硬碟 | 10 GB | 20 GB | + +## 故障排除 + +### 問題: 容器啟動失敗 + +```bash +# 查看日誌 +docker compose logs superset + +# 檢查資料庫連線 +docker compose logs superset-db +``` + +### 問題: 無法連線到外部資料庫 + +1. 確認防火牆規則 +2. 確認資料庫用戶權限 +3. 測試連線: +```bash +docker exec -it momo-superset bash +pip install psycopg2-binary +python -c "import psycopg2; conn = psycopg2.connect('postgresql://...')" +``` + +### 問題: SQL Lab 查詢超時 + +修改 `superset_config.py`: +```python +SQLLAB_TIMEOUT = 600 # 秒 +``` + +## 備份 + +```bash +# 備份 Superset 資料 +docker exec superset-postgres pg_dump -U superset superset > superset_backup.sql + +# 還原 +docker exec -i superset-postgres psql -U superset superset < superset_backup.sql +``` + +## 更新日誌 + +- **2026-02-08**: 初始部署 diff --git a/docker/superset/custom-assets/theme.entry.js b/docker/superset/custom-assets/theme.entry.js new file mode 100644 index 0000000..907692e --- /dev/null +++ b/docker/superset/custom-assets/theme.entry.js @@ -0,0 +1,12 @@ +/* + * Licensed to the Apache Software Foundation (ASF) + * Minimal theme entry point - fixes 0-byte bug in official images + * MOMO Pro System custom fix + */ +(function() { + 'use strict'; + console.log('[Superset] Theme module loaded'); + if (typeof module !== 'undefined' && module.exports) { + module.exports = {}; + } +})(); diff --git a/docker/superset/deploy.sh b/docker/superset/deploy.sh new file mode 100755 index 0000000..40c53ac --- /dev/null +++ b/docker/superset/deploy.sh @@ -0,0 +1,186 @@ +#!/bin/bash +# ============================================================================= +# Apache Superset 部署腳本 +# MOMO Pro System - UAT 環境 +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# 顏色定義 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# 顯示使用方式 +usage() { + echo "使用方式: $0 [命令]" + echo "" + echo "命令:" + echo " deploy 部署 Superset (預設)" + echo " stop 停止 Superset" + echo " restart 重啟 Superset" + echo " logs 查看日誌" + echo " status 查看狀態" + echo " clean 清除所有資料 (危險)" + echo "" +} + +# 部署 Superset +deploy() { + log_info "開始部署 Apache Superset..." + + # 檢查 Docker + if ! command -v docker &> /dev/null; then + log_error "Docker 未安裝" + exit 1 + fi + + # 拉取映像 + log_info "拉取 Docker 映像..." + docker compose pull + + # 啟動服務 + log_info "啟動 Superset 服務..." + docker compose up -d + + # 等待健康檢查 + log_info "等待服務啟動 (約 2 分鐘)..." + local max_wait=180 + local waited=0 + local interval=10 + + while [ $waited -lt $max_wait ]; do + if docker compose ps | grep -q "healthy"; then + log_success "Superset 啟動成功!" + break + fi + + # 檢查是否有容器失敗 + if docker compose ps | grep -q "Exit"; then + log_error "容器啟動失敗" + docker compose logs --tail=50 + exit 1 + fi + + sleep $interval + waited=$((waited + interval)) + log_info "等待中... ($waited/$max_wait 秒)" + done + + if [ $waited -ge $max_wait ]; then + log_warn "等待超時,請手動檢查服務狀態" + docker compose ps + fi + + # 顯示訪問資訊 + echo "" + log_success "==========================================" + log_success "Apache Superset 部署完成!" + log_success "==========================================" + echo "" + echo "內部訪問: http://127.0.0.1:8088" + echo "外部訪問: https://monitor.wooo.work/superset/" + echo "" + echo "登入帳號: admin" + echo "登入密碼: Wooo_Superset_2026" + echo "" + echo "下一步:" + echo " 1. 設定 Nginx 反向代理" + echo " 2. 新增資料庫連線 (UAT/GCP)" + echo " 3. 建立資料集和儀表板" + echo "" +} + +# 停止服務 +stop() { + log_info "停止 Superset 服務..." + docker compose down + log_success "服務已停止" +} + +# 重啟服務 +restart() { + log_info "重啟 Superset 服務..." + docker compose restart + log_success "服務已重啟" +} + +# 查看日誌 +logs() { + docker compose logs -f --tail=100 +} + +# 查看狀態 +status() { + echo "" + log_info "Superset 服務狀態:" + echo "" + docker compose ps + echo "" + + # 檢查健康狀態 + if docker compose ps | grep -q "healthy"; then + log_success "所有服務運行正常" + elif docker compose ps | grep -q "unhealthy"; then + log_warn "有服務不健康" + fi +} + +# 清除所有資料 +clean() { + log_warn "這將刪除所有 Superset 資料,包括:" + log_warn " - 儀表板" + log_warn " - 圖表" + log_warn " - 資料集" + log_warn " - 資料庫連線設定" + echo "" + read -p "確定要繼續嗎? (輸入 YES 確認): " confirm + + if [ "$confirm" = "YES" ]; then + log_info "停止並清除服務..." + docker compose down -v + log_success "已清除所有資料" + else + log_info "已取消" + fi +} + +# 主程式 +case "${1:-deploy}" in + deploy) + deploy + ;; + stop) + stop + ;; + restart) + restart + ;; + logs) + logs + ;; + status) + status + ;; + clean) + clean + ;; + -h|--help) + usage + ;; + *) + log_error "未知命令: $1" + usage + exit 1 + ;; +esac diff --git a/docker/superset/docker-compose.yml b/docker/superset/docker-compose.yml new file mode 100644 index 0000000..f5aed26 --- /dev/null +++ b/docker/superset/docker-compose.yml @@ -0,0 +1,113 @@ +# ============================================================================= +# Apache Superset - Docker Compose 配置 +# MOMO Pro System - UAT 環境 +# 用途:BI 分析平台,連接 UAT、GCP、DEV 環境資料庫 +# ============================================================================= +# +# 重要:Apache Superset 官方映像存在 theme.js 0 bytes bug +# 所有版本(1.5.3, 2.0.x, 2.1.x, 3.x, 4.x)都有這個問題 +# 此配置包含啟動時自動修復腳本 +# ============================================================================= + +services: + superset: + # 使用 2.1.3 版本 (3.x/4.x 的 theme.js 有 0 bytes bug) + image: apache/superset:2.1.3 + container_name: momo-superset + restart: unless-stopped + ports: + - "127.0.0.1:8088:8088" + environment: + # 基本設定 + - SUPERSET_SECRET_KEY=wooo_superset_secret_key_2026_momo_pro + - SUPERSET_LOAD_EXAMPLES=no + - TZ=Asia/Taipei + + # 資料庫連線 (Superset 內部 metadata) + - DATABASE_HOST=superset-db + - DATABASE_PORT=5432 + - DATABASE_USER=superset + - DATABASE_PASSWORD=Wooo_Superset_DB_2026 + - DATABASE_DB=superset + + # Redis 快取 + - REDIS_HOST=superset-redis + - REDIS_PORT=6379 + + volumes: + - superset_home:/app/superset_home + - ./superset_config.py:/app/pythonpath/superset_config.py:ro + # theme.js 修復文件 + - ./custom-assets/theme.entry.js:/tmp/theme-fix.js:ro + depends_on: + superset-db: + condition: service_healthy + superset-redis: + condition: service_healthy + networks: + - superset-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8088/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s + command: > + bash -c " + echo '等待資料庫初始化...' && + sleep 10 && + echo '修復 theme.js 0-byte bug...' && + for f in /app/superset/static/assets/theme.*.entry.js; do + if [ -f \"$$f\" ] && [ ! -s \"$$f\" ]; then + cp /tmp/theme-fix.js \"$$f\" && + echo \"已修復: $$f\" + fi + done && + superset db upgrade && + superset fab create-admin --username admin --firstname Admin --lastname User --email admin@wooo.work --password Wooo_Superset_2026 || true && + superset init && + echo 'Superset 啟動中...' && + gunicorn --bind 0.0.0.0:8088 --workers 4 --timeout 120 --access-logfile - 'superset.app:create_app()' + " + + superset-db: + image: postgres:15-alpine + container_name: superset-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=superset + - POSTGRES_PASSWORD=Wooo_Superset_DB_2026 + - POSTGRES_DB=superset + - TZ=Asia/Taipei + volumes: + - superset_db:/var/lib/postgresql/data + networks: + - superset-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U superset"] + interval: 10s + timeout: 5s + retries: 5 + + superset-redis: + image: redis:7-alpine + container_name: superset-redis + restart: unless-stopped + volumes: + - superset_redis:/data + networks: + - superset-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + superset_home: + superset_db: + superset_redis: + +networks: + superset-network: + driver: bridge diff --git a/docker/superset/nginx-superset.conf b/docker/superset/nginx-superset.conf new file mode 100644 index 0000000..bffbb28 --- /dev/null +++ b/docker/superset/nginx-superset.conf @@ -0,0 +1,118 @@ +# ============================================================================= +# Nginx 反向代理配置 - Apache Superset +# 複製此配置到 /etc/nginx/sites-available/superset +# ============================================================================= + +# 將此內容加入到 monitor.wooo.work 的 server 區塊中 +# 2026-02-08 更新:修復登入重定向問題 + +# ============================================================================= +# 關鍵設定 0:重定向 Superset 相關路徑 +# 解決 Superset 生成的 URL 沒有 /superset/ 前綴的問題 +# ============================================================================= + +# 認證相關路徑重定向 +location = /login/ { + return 302 /superset/login/; +} +location = /logout/ { + return 302 /superset/logout/; +} + +# 語言切換路徑重定向 +location ^~ /lang/ { + return 302 /superset$request_uri; +} + +# 用戶資訊路徑重定向 +location ^~ /users/ { + return 302 /superset$request_uri; +} + +# 靜態資源路徑重定向 (SPA 動態載入) +location ^~ /static/ { + return 302 /superset$request_uri; +} + +# Superset BI 分析平台 +# 重要:Superset 內部路由已是 /superset/...,不需要再添加前綴 +# 2026-02-08 更新:修復雙重前綴問題 (/superset/superset/) +location /superset/ { + proxy_pass http://127.0.0.1:8088/; + 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; + # 注意:不設置 X-Forwarded-Prefix 和 X-Script-Name + # 因為 superset_config.py 中 x_prefix=0,Superset 不讀取這些標頭 + + # 關鍵設定 1:Superset 內部路由已是 /superset/,保持不變 + # 只對非 /superset/ 開頭的路徑添加前綴 + proxy_redirect /superset/ /superset/; + proxy_redirect ~^/(?!superset)(.*)$ /superset/$1; + + # 關鍵設定 2:禁用 gzip 壓縮,讓 sub_filter 可以正常運作 + proxy_set_header Accept-Encoding ""; + gzip off; + + # 關鍵設定 3:重寫 HTML 中的 URL + # 只處理靜態資源路徑(不處理 href/action,避免重複添加) + sub_filter '"/static/' '"/superset/static/'; + sub_filter "'/static/" "'/superset/static/"; + sub_filter_once off; + sub_filter_types text/html application/javascript text/javascript text/css; + + # WebSocket 支援 (SQL Lab 需要) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超時設定 (SQL Lab 查詢可能較長) + proxy_connect_timeout 300; + proxy_send_timeout 300; + proxy_read_timeout 300; + + # 緩衝設定 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; +} + +# Superset 靜態資源 (可選,提升效能) +location /superset/static/ { + proxy_pass http://127.0.0.1:8088/static/; + proxy_cache_valid 200 1d; + proxy_cache_valid any 1m; + expires 1d; + add_header Cache-Control "public, immutable"; +} + +# ============================================================================= +# 或者使用獨立的 server 區塊 (superset.wooo.work) +# ============================================================================= + +# server { +# listen 443 ssl http2; +# server_name superset.wooo.work; +# +# ssl_certificate /etc/letsencrypt/live/superset.wooo.work/fullchain.pem; +# ssl_certificate_key /etc/letsencrypt/live/superset.wooo.work/privkey.pem; +# +# location / { +# proxy_pass http://127.0.0.1:8088; +# 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 300; +# proxy_send_timeout 300; +# proxy_read_timeout 300; +# } +# } diff --git a/docker/superset/setup_datasets.py b/docker/superset/setup_datasets.py new file mode 100644 index 0000000..4c67b29 --- /dev/null +++ b/docker/superset/setup_datasets.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Superset 資料集與儀表板自動設定腳本 +MOMO Pro System - UAT 環境 + +使用方式: + docker exec momo-superset python /app/setup_datasets.py +""" + +import sys +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# 要建立的資料集 +DATASETS = [ + { + "table_name": "daily_sales_snapshot", + "description": "每日銷售快照 - 用於當日業績分析", + "main_dttm_col": "snapshot_date", + }, + { + "table_name": "realtime_sales_monthly", + "description": "即時業績月度資料 - 用於銷售分析和成長分析", + "main_dttm_col": None, + }, + { + "table_name": "monthly_summary_analysis", + "description": "月度總結分析 - 用於月度報表", + "main_dttm_col": "report_month", + }, + { + "table_name": "products", + "description": "商品資料 - 用於商品看板和 ABC 分析", + "main_dttm_col": "updated_at", + }, + { + "table_name": "price_records", + "description": "價格記錄 - 用於價格趨勢分析", + "main_dttm_col": "timestamp", + }, +] + + +def setup_datasets(): + """建立所有資料集""" + # 在函數內部導入,確保 app context 正確 + from superset.app import create_app + app = create_app() + + with app.app_context(): + from superset.extensions import db + from superset.connectors.sqla.models import SqlaTable + from superset.models.core import Database + + # 找到 MOMO_UAT 資料庫 + database = db.session.query(Database).filter_by(database_name="MOMO_UAT").first() + + if not database: + logger.error("找不到 MOMO_UAT 資料庫,請先新增資料庫連線") + sys.exit(1) + + logger.info(f"找到資料庫: {database.database_name} (ID: {database.id})") + + created_count = 0 + for ds_config in DATASETS: + table_name = ds_config["table_name"] + + # 檢查是否已存在 + existing = db.session.query(SqlaTable).filter_by( + table_name=table_name, + database_id=database.id + ).first() + + if existing: + logger.info(f"資料集已存在: {table_name}") + continue + + # 建立新資料集 + dataset = SqlaTable( + table_name=table_name, + database_id=database.id, + schema="public", + description=ds_config.get("description", ""), + ) + + # 設定時間欄位 + if ds_config.get("main_dttm_col"): + dataset.main_dttm_col = ds_config["main_dttm_col"] + + db.session.add(dataset) + logger.info(f"建立資料集: {table_name}") + created_count += 1 + + db.session.commit() + logger.info(f"完成! 建立了 {created_count} 個資料集") + + # 同步資料集欄位 + logger.info("同步資料集欄位...") + for ds_config in DATASETS: + table_name = ds_config["table_name"] + dataset = db.session.query(SqlaTable).filter_by( + table_name=table_name, + database_id=database.id + ).first() + + if dataset: + try: + dataset.fetch_metadata() + logger.info(f"已同步欄位: {table_name}") + except Exception as e: + logger.warning(f"同步欄位失敗 {table_name}: {e}") + + db.session.commit() + logger.info("資料集設定完成!") + + +if __name__ == "__main__": + setup_datasets() diff --git a/docker/superset/setup_readonly_users.sql b/docker/superset/setup_readonly_users.sql new file mode 100644 index 0000000..5706c15 --- /dev/null +++ b/docker/superset/setup_readonly_users.sql @@ -0,0 +1,52 @@ +-- ============================================================================= +-- Superset 唯讀用戶設定 +-- 用於 UAT 和 GCP 環境的 PostgreSQL 資料庫 +-- ============================================================================= + +-- 建立唯讀用戶 (在 UAT 資料庫執行) +-- 連線到 momo_analytics 資料庫後執行 + +-- 1. 建立唯讀角色 +CREATE ROLE superset_readonly WITH LOGIN PASSWORD 'Wooo_Superset_RO_2026'; + +-- 2. 授予連線權限 +GRANT CONNECT ON DATABASE momo_analytics TO superset_readonly; + +-- 3. 授予 schema 使用權限 +GRANT USAGE ON SCHEMA public TO superset_readonly; + +-- 4. 授予所有現有資料表的 SELECT 權限 +GRANT SELECT ON ALL TABLES IN SCHEMA public TO superset_readonly; + +-- 5. 設定預設權限 (新建立的資料表也會自動授權) +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO superset_readonly; + +-- 6. 授予序列讀取權限 (某些查詢可能需要) +GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO superset_readonly; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON SEQUENCES TO superset_readonly; + +-- ============================================================================= +-- 驗證權限 +-- ============================================================================= +-- 使用 superset_readonly 用戶連線後測試: +-- SELECT * FROM products LIMIT 5; +-- SELECT * FROM daily_sales_snapshot LIMIT 5; +-- SELECT * FROM price_records LIMIT 5; + +-- ============================================================================= +-- 連線字串 (供 Superset 使用) +-- ============================================================================= +-- UAT 環境: +-- postgresql+psycopg2://superset_readonly:Wooo_Superset_RO_2026@momo-postgres:5432/momo_analytics +-- +-- GCP 環境 (需要從 UAT 連線): +-- postgresql+psycopg2://superset_readonly:Wooo_Superset_RO_2026@35.194.233.141:5432/momo_analytics + +-- ============================================================================= +-- 撤銷權限 (如需移除) +-- ============================================================================= +-- REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM superset_readonly; +-- REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM superset_readonly; +-- REVOKE USAGE ON SCHEMA public FROM superset_readonly; +-- REVOKE CONNECT ON DATABASE momo_analytics FROM superset_readonly; +-- DROP ROLE superset_readonly; diff --git a/docker/superset/superset_config.py b/docker/superset/superset_config.py new file mode 100644 index 0000000..e4fd883 --- /dev/null +++ b/docker/superset/superset_config.py @@ -0,0 +1,164 @@ +# ============================================================================= +# Apache Superset Configuration +# MOMO Pro System - UAT 環境 +# ============================================================================= +import os + +# --------------------------------------------------------- +# Superset 基本設定 +# --------------------------------------------------------- +ROW_LIMIT = 50000 +SUPERSET_WEBSERVER_PORT = 8088 +SUPERSET_WEBSERVER_TIMEOUT = 120 + +# --------------------------------------------------------- +# 反向代理設定 (Nginx 子路徑 /superset/) +# 2026-02-13 更新:使用 APPLICATION_ROOT 徹底修復 URL 問題 +# --------------------------------------------------------- +ENABLE_PROXY_FIX = True +PROXY_FIX_CONFIG = { + "x_for": 1, # 信任 X-Forwarded-For + "x_proto": 1, # 信任 X-Forwarded-Proto + "x_host": 1, # 信任 X-Forwarded-Host + "x_prefix": 0, # 禁用!讓 APPLICATION_ROOT 處理 +} + +# ============================================================================= +# 關鍵設定:Cookie 路徑 +# ============================================================================= +# 必須使用 "/" 因為 Superset 的登入頁面是 /login/(不在 /superset/ 下) +# 如果設為 /superset/,cookie 不會被發送到 /login/ 頁面 +SESSION_COOKIE_PATH = "/" + +# Secret key (必須設定) +SECRET_KEY = os.environ.get('SUPERSET_SECRET_KEY', 'wooo_superset_secret_key_2026_momo_pro') + +# --------------------------------------------------------- +# 資料庫設定 (Superset Metadata) +# --------------------------------------------------------- +SQLALCHEMY_DATABASE_URI = ( + f"postgresql+psycopg2://" + f"{os.environ.get('DATABASE_USER', 'superset')}:" + f"{os.environ.get('DATABASE_PASSWORD', 'Wooo_Superset_DB_2026')}@" + f"{os.environ.get('DATABASE_HOST', 'superset-db')}:" + f"{os.environ.get('DATABASE_PORT', '5432')}/" + f"{os.environ.get('DATABASE_DB', 'superset')}" +) + +# --------------------------------------------------------- +# Redis 快取設定 +# --------------------------------------------------------- +REDIS_HOST = os.environ.get('REDIS_HOST', 'superset-redis') +REDIS_PORT = os.environ.get('REDIS_PORT', 6379) + +CACHE_CONFIG = { + 'CACHE_TYPE': 'RedisCache', + 'CACHE_DEFAULT_TIMEOUT': 300, + 'CACHE_KEY_PREFIX': 'superset_', + 'CACHE_REDIS_HOST': REDIS_HOST, + 'CACHE_REDIS_PORT': REDIS_PORT, + 'CACHE_REDIS_DB': 0, +} + +DATA_CACHE_CONFIG = { + 'CACHE_TYPE': 'RedisCache', + 'CACHE_DEFAULT_TIMEOUT': 600, + 'CACHE_KEY_PREFIX': 'superset_data_', + 'CACHE_REDIS_HOST': REDIS_HOST, + 'CACHE_REDIS_PORT': REDIS_PORT, + 'CACHE_REDIS_DB': 1, +} + +# --------------------------------------------------------- +# 語言和時區設定 +# --------------------------------------------------------- +BABEL_DEFAULT_LOCALE = 'zh_Hant_TW' +BABEL_DEFAULT_FOLDER = 'superset/translations' +LANGUAGES = { + 'en': {'flag': 'us', 'name': 'English'}, + 'zh': {'flag': 'cn', 'name': '简体中文'}, + 'zh_Hant_TW': {'flag': 'tw', 'name': '繁體中文'}, +} + +# 時區設定 +DEFAULT_TIMEZONE = 'Asia/Taipei' + +# --------------------------------------------------------- +# 功能開關 +# --------------------------------------------------------- +FEATURE_FLAGS = { + 'ENABLE_TEMPLATE_PROCESSING': True, + 'DASHBOARD_NATIVE_FILTERS': True, + 'DASHBOARD_CROSS_FILTERS': True, + 'DASHBOARD_NATIVE_FILTERS_SET': True, + 'ALERT_REPORTS': True, + 'EMBEDDABLE_CHARTS': True, + 'EMBEDDED_SUPERSET': True, + # 關閉 Global Async Queries(避免 WebSocket 連接問題) + 'GLOBAL_ASYNC_QUERIES': False, +} + +# ============================================================================= +# 禁用 WebSocket(避免瀏覽器嘗試連接 ws://127.0.0.1:8080) +# ============================================================================= +# 這個 URL 會被嵌入到頁面的 JavaScript 中 +# 設為空字串來避免瀏覽器嘗試建立 WebSocket 連接 +GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "" + +# --------------------------------------------------------- +# 安全設定 +# --------------------------------------------------------- +# 允許嵌入 iframe +HTTP_HEADERS = { + 'X-Frame-Options': 'SAMEORIGIN', +} + +# ============================================================================= +# CSRF 設定 - 完全禁用 +# ============================================================================= +# Superset 6.0 SPA 架構下,CSRF 與 React SPA 有衝突 +# SPA 無法正確從 HttpOnly session cookie 中讀取 CSRF token +# Superset 內部 API 已有 JWT/Session 認證機制,禁用 CSRF 不會影響安全性 +WTF_CSRF_ENABLED = False + +# 保留以下設定以防未來需要啟用 +WTF_CSRF_EXEMPT_LIST = [] +WTF_CSRF_TIME_LIMIT = 60 * 60 * 24 * 7 # 7 天 +WTF_CSRF_SSL_STRICT = False + +# --------------------------------------------------------- +# 資料庫連線設定 +# --------------------------------------------------------- +# 預設允許的資料庫驅動 +PREFERRED_DATABASES = [ + 'PostgreSQL', +] + +# SQL Lab 設定 +SQL_MAX_ROW = 100000 +DISPLAY_MAX_ROW = 10000 + +# --------------------------------------------------------- +# 日誌設定 +# --------------------------------------------------------- +LOG_FORMAT = '%(asctime)s:%(levelname)s:%(name)s:%(message)s' +LOG_LEVEL = 'INFO' + +# --------------------------------------------------------- +# 郵件設定 (告警報表用) +# --------------------------------------------------------- +SMTP_HOST = 'smtp.gmail.com' +SMTP_STARTTLS = True +SMTP_SSL = False +SMTP_PORT = 587 +SMTP_MAIL_FROM = 'superset@wooo.work' + +# --------------------------------------------------------- +# 額外的 Jinja 模板函數 +# --------------------------------------------------------- +from flask import g + +JINJA_CONTEXT_ADDONS = { + 'current_user_id': lambda: g.user.id if hasattr(g, 'user') and g.user else None, + 'current_username': lambda: g.user.username if hasattr(g, 'user') and g.user else None, +} diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md new file mode 100644 index 0000000..89ce495 --- /dev/null +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -0,0 +1,490 @@ +# MOMO PRO — AI 競價情報模組 Single Source of Truth + +> **最後更新**: 2026-04-17 深夜 (台北時間) +> **狀態**: 🟢 188 生產容器實彈驗證完成 — Hermes 19s + NIM 1206 tokens + Telegram message_id=282 全通 +> **適用版本**: V10.2 AIOPS 架構 + +--- + +## 一、三模型路由架構 + +``` +SQL漏斗(~300筆) + ↓ +[Hermes 3 8B] — 分析師 (本地 Ollama, 零成本) + 模型: hermes3:latest @ 192.168.0.111:11434 + 任務: 競價威脅分類 → TOP 20 HIGH/MED/LOW + ↓ +[NemoTron NIM] — 派發器 (雲端, 免費配額) + 模型: meta/llama-3.1-8b-instruct @ NVIDIA NIM + 任務: Tool Calling → Telegram 告警 / DB 寫入 + ↓ +[Gemini] — 策略師 (每週, 費用審批制) + 任務: 週策略報告 (尚未實作) +``` + +| 角色 | 模型 | 主機 | 成本 | 每日限額 | +|------|------|------|------|---------| +| 分析師 | hermes3:latest | 192.168.0.111:11434 | 零 | 無限 | +| 派發器 | meta/llama-3.1-8b-instruct | NVIDIA NIM | 免費 80/天 | 80 (保留20給AWOOOI) | +| 策略師 | Gemini | 雲端 | 需審批 | — | + +--- + +## 二、真實資料庫 Schema(已校對確認) + +### 2.1 `products` 表(SQLAlchemy ORM,SQLite/PostgreSQL 通用) + +| 欄位 | 型別 | 說明 | +|------|------|------| +| `id` | Integer PK | 主鍵 | +| `i_code` | String(50) UNIQUE | **MOMO 商品代碼**(爬蟲來源,即商品 SKU) | +| `name` | String(255) | 商品名稱 | +| `url` | String(500) | MOMO 商品頁 URL | +| `image_url` | Text | 商品圖片 URL | +| `category` | String(100) | 分類名稱(直接欄位) | +| `status` | String(20) | 預設 `'ACTIVE'` | +| `created_at` | DateTime | 建立時間 | +| `updated_at` | DateTime | 更新時間 | +| `category_id` | Integer FK → categories.id | 分類關聯(可選) | + +> **重要**: `i_code` = MOMO 網站上的商品 ID(例如 `I132467614`) + +### 2.2 `price_records` 表 + +| 欄位 | 型別 | 說明 | +|------|------|------| +| `id` | Integer PK | 主鍵 | +| `product_id` | Integer FK → products.id | 商品關聯 | +| `price` | Float | **MOMO 自家售價**(爬蟲抓取) | +| `timestamp` | DateTime indexed | 抓取時間戳 | + +> ⚠️ **架構限制**: `price_records` **只存 MOMO 自家售價**,無 `source` 欄位,無競品(PChome)價格。 +> PChome 比價資料必須由外部爬蟲即時抓取,以 `pchome_prices: dict` 形式注入 `HermesAnalystService.run()`。 + +### 2.3 `daily_sales_snapshot` 表(動態表,從 Excel 匯入) + +> **重要**: 此表由 `import_service.py` 使用 `df.to_sql()` 動態建立。 +> 欄位名稱**完全繼承自匯入的 MOMO Excel 報表原始欄位**,加上程式碼追加的 `snapshot_date`。 + +#### 已確認的關鍵欄位(實際 MOMO 報表欄位名稱) + +| 欄位 | 型別 | 說明 | 備注 | +|------|------|------|------| +| `snapshot_date` | Date | 資料所屬日期(程式追加) | 由 `import_service.py` 從「日期」欄位解析 | +| `商品ID` | VARCHAR | **商品識別碼**(= `products.i_code`) | ⚠️ 非 `商品編號`!| +| `商品名稱` | TEXT | 商品名稱 | | +| `銷售金額` | NUMERIC | 銷售業績金額 | 系統以 find_col 模糊比對,優先 `銷售金額` | +| `數量` | NUMERIC | 銷售數量 | | +| `總成本` | NUMERIC | 成本 | | +| `廠商名稱` | VARCHAR | 廠商名稱 | | +| `商品館` / `館別` | VARCHAR | 分類 | | + +#### 欄位自動偵測邏輯(`find_col`) + +系統使用 keyword 模糊比對,**不要求欄位名完全固定**: + +```python +SKU/商品ID = find_col(['商品ID', 'Product ID', 'ID', 'i_code', 'Item Code']) +商品名稱 = find_col(['商品名稱', '品名', 'Name', 'Product']) +銷售金額 = find_col(['銷售金額', '業績', '金額', 'Amount', 'Sales', 'Total']) +成本 = find_col(['成本', 'Cost', '進價', '總成本']) +數量 = find_col(['銷售數量', '銷量', '數量', 'Qty', 'Quantity']) +日期 = find_col(['日期', '訂單日期', '交易日期', 'Date']) +分類 = find_col(['商品館', '館別', '分類', 'Category']) +``` + +### 2.4 `competitor_prices` 表(Migration 004 — 已建立) + +競品價格快取表,由 `competitor_price_feeder.py` Worker 寫入,AI Pipeline LEFT JOIN 消費。 + +| 欄位 | 型別 | 說明 | +|------|------|------| +| `id` | SERIAL PK | 主鍵 | +| `sku` | VARCHAR(50) | MOMO 商品代碼(= products.i_code) | +| `source` | VARCHAR(30) | 競品來源:`'pchome'`(預留 shopee 等) | +| `price` | NUMERIC(10,2) | 競品售價 | +| `original_price` | NUMERIC(10,2) | 競品原價 | +| `discount_pct` | INTEGER | 折扣 %(NULL=未折扣) | +| `competitor_product_id` | VARCHAR(100) | PChome 商品 ID | +| `competitor_product_name` | TEXT | PChome 商品名稱(核對用) | +| `match_score` | NUMERIC(4,3) | 模糊比對分數(0~1),< 0.45 不寫入 | +| `tags` | JSONB | 語意標籤,如 `["on_sale","discount_20pct"]` | +| `crawled_at` | TIMESTAMP | 爬取時間 | +| `expires_at` | TIMESTAMP | TTL = crawled_at + 6h,過期後 Hermes 忽略 | + +**UNIQUE**: `(sku, source)` — 同一 SKU+來源只有一筆,ON CONFLICT UPDATE + +**語意標籤字典**: + +| 標籤 | 觸發條件 | +|------|---------| +| `on_sale` | PChome `is_on_sale = True` | +| `discount_10pct` | 折扣 10-19% | +| `discount_20pct` | 折扣 20-29% | +| `discount_30pct` | 折扣 ≥ 30% | +| `low_stock` | 庫存 < 10 | +| `high_rating` | 評分 ≥ 4.5 | + +### 2.5 `ai_price_recommendations` 表(Migration 003 — 已建立) + +此表需執行 `migrations/003_ai_price_recommendations.sql` 才能完整寫入 DB: + +```sql +CREATE TABLE IF NOT EXISTS ai_price_recommendations ( + id SERIAL PRIMARY KEY, + sku VARCHAR(50) UNIQUE NOT NULL, + name TEXT NOT NULL, + reason TEXT, + status VARCHAR(20) DEFAULT 'pending', -- pending / approved / rejected + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +> **現狀**: `nemoton_dispatcher_service.py` 的 `_exec_add_to_recommendation()` 在 engine 注入且表存在時才寫入,否則只發 Telegram 通知,不會 crash。 + +--- + +## 三、SQL 漏斗設計(已修正欄位名稱) + +`hermes_analyst_service.py` → `fetch_candidates()` 的核心 SQL: + +```sql +WITH latest_momo_price AS ( + -- 從爬蟲商品庫取最新 MOMO 售價 + SELECT + p.i_code AS sku, -- MOMO 商品代碼 + p.name, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' +), +recent_sales AS ( + -- 從每日業績快照計算近7天 vs 前7天銷售額 + SELECT + "商品ID" AS sku, -- ⚠️ 實際欄位名為「商品ID」(非「商品編號」) + SUM(CASE WHEN snapshot_date >= CURRENT_DATE - 7 + THEN COALESCE("銷售金額"::numeric, 0) ELSE 0 END) AS sales_7d_curr, + SUM(CASE WHEN snapshot_date >= CURRENT_DATE - 14 + AND snapshot_date < CURRENT_DATE - 7 + THEN COALESCE("銷售金額"::numeric, 0) ELSE 0 END) AS sales_7d_prev + FROM daily_sales_snapshot + GROUP BY "商品ID" +) +SELECT lmp.sku, lmp.name, lmp.category, lmp.momo_price, + rs.sales_7d_curr, rs.sales_7d_prev +FROM latest_momo_price lmp +JOIN recent_sales rs ON rs.sku = lmp.sku +WHERE lmp.rn = 1 + AND rs.sales_7d_prev > 0 + AND (rs.sales_7d_curr - rs.sales_7d_prev) / rs.sales_7d_prev < -0.10 +ORDER BY (rs.sales_7d_curr - rs.sales_7d_prev) / rs.sales_7d_prev ASC +LIMIT 300 +``` + +**漏斗效果**: 226萬筆 price_records → ~300 筆(近7天銷量跌幅 > 10% 的活躍商品) + +**JOIN 邏輯**: +- `products.i_code` ↔ `daily_sales_snapshot."商品ID"` — 均為 MOMO 商品代碼,格式相同 + +--- + +## 四、競品價格補給線架構(已實裝) + +### 生產者-消費者解耦設計 + +``` +[competitor_price_feeder.py Worker] ←← 每 4 小時獨立運行 + ↓ 搜尋 PChome(search_products) + ↓ 模糊比對(price_comparison.py) + ↓ 提取語意標籤 + ↓ UPSERT competitor_prices(TTL 6h) + ↓ +[HermesAnalystService.fetch_candidates()] ←← AI Pipeline 消費端 + ↓ LEFT JOIN competitor_prices(零網路等待) + ↓ 有效期內(expires_at > NOW())+ match_score ≥ 0.45 才 JOIN + ↓ pchome_price + competitor_tags 一起傳給 Hermes +``` + +### 關鍵設計決策 + +| 決策 | 選擇 | 原因 | +|------|------|------| +| 解耦方式 | DB 表快取(非 Redis) | PostgreSQL 已是核心,無需額外依賴;支援 JOIN | +| TTL | 6 小時 | 與 AI Pipeline 排程週期對齊 | +| 比對算法 | 品牌(0.4) + 規格(0.3) + 關鍵字(0.3) | 依賴現有 `price_comparison.py` | +| 最低比對門檻 | 0.45 | 低於此分數不寫入,避免張冠李戴影響 AI 決策 | +| 語意標籤 | JSONB 陣列 | 傳給 Hermes 提升情境感知品質 | + +### 競品比對邏輯(`competitor_price_feeder.py`) + +``` +MOMO 商品名稱[:20字] + → PChomeCrawler.search_products(keyword, limit=10) + → _find_best_match(momo_name, results) + → ProductNameParser(品牌 + 規格 + 關鍵字) + → _structural_similarity() → score + → score ≥ 0.45 → _upsert_competitor_price() +``` + +### `fetch_candidates()` v2 漏斗(已更新) + +```sql +LEFT JOIN competitor_prices cp + ON cp.sku = lmp.sku + AND cp.source = 'pchome' + AND cp.expires_at > NOW() + AND cp.match_score >= 0.45 +``` +→ 無競品資料的商品仍回傳,`pchome_price=NULL`,`_batch_analyze` 自動跳過 + +### 執行方式 + +```bash +# 手動觸發一輪抓取 +python3 services/competitor_price_feeder.py + +# 未來整合為 K3s CronJob(每 4 小時) +# k8s/jobs/competitor-price-feeder-cronjob.yaml +``` + +--- + +## 五、Telegram 語意化訊息規範(ChatOps 標準) + +### 5.1 核心原則 + +1. **語意化排版 (Semantic Formatting)** — Emoji 作為視覺標籤,0.5 秒判斷嚴重性 +2. **倒金字塔結構** — 結論先行 → 核心數據 → AI 洞察 → 建議行動 → 運算足跡 +3. **收斂行動呼籲 (Call to Action)** — 每則訊息只有一個明確的 👉 建議行動 +4. **底部運算足跡** — FinOps + Observability,用分隔線隔開主訊息 + +### 5.2 語意化 Emoji 字典 + +| 類別 | Emoji | 語意 | +|------|-------|------| +| 身份識別 | `⚡ NemoTron 派發器` | Dispatcher 身份 | +| 身份識別 | `🔍 Hermes 3 8B` | Analyst 身份(僅出現在足跡) | +| 風險級別 | `🚨` | 高危險,立即行動 | +| 風險級別 | `⚠️` | 中風險,人工覆核 | +| 風險級別 | `💡` | 低風險,策略建議 | +| 例行報告 | `📊` | 核心數據區塊標頭 | +| 業務屬性 | `💰` | 價格/毛利 | +| 業務屬性 | `📦` | 庫存/銷量 | +| 業務屬性 | `🏆` | 競品情報 | +| AI 洞察 | `🧠` | AI 分析結果 | +| 運算足跡 | `⚙️` | FinOps 底部區塊 | + +### 5.3 三大類訊息模板(標準格式) + +#### 類別一:緊急告警(`trigger_price_alert` 觸發) + +``` +🚨 [⚡ NemoTron 派發器] 競價高危險預警 + +⚠️ 核心問題:[A003 舒特膚 AD 乳液] 價格大幅落後競品,訂單流失中! + +📊 關鍵數據: +• 我方價格:$1,200 +• 競品價格:$980 (價差 22.4%) +• 銷量變化:近七天銷量 -35.0% + +🧠 AI 洞察 (信心度 85%): +價差已突破 20% 警戒線,且伴隨實質銷量下滑,高度判定為競品大力促銷攔截。 + +👉 建議行動:建議立即降價至 $1,000 迎戰,或發放 $200 專屬折價券 + +───────────────────── +⚙️ 運算足跡: +• 🔍 分析: Hermes 3 8B (本地 111) | 耗時: 34.2s | Tokens: 512 | $0 成本 +• ⚡ 決策: NemoTron NIM | 185 Tokens | $0 (配額內 2/80) +``` + +#### 類別二:人工覆核(`flag_for_human_review` 觸發) + +``` +⚠️ [⚡ NemoTron 派發器] 異常波動需人工覆核 + +🔍 待查商品:[A001 玻尿酸面膜10片裝] + +📊 矛盾數據: +• 價格狀態:無明顯價差 (與競品齊平) +• 異常現象:過去 3 天銷量突然掛零 (平日日均 15 件) +• 庫存狀態:目前庫存充足 (500+ 件) + +🧠 AI 洞察 (信心度 45%): +數據出現矛盾訊號,AI 信心不足以自主決策,需人工走查確認。 + +👉 建議行動:請營運人員立即進行人工走查。 + +───────────────────── +⚙️ 運算足跡: +• 🔍 分析: Hermes 3 8B (本地 111) | 耗時: 34.2s | Tokens: 512 | $0 成本 +• ⚡ 決策: NemoTron NIM | 185 Tokens | $0 (配額內 2/80) +``` + +#### 類別三:策略執行通知(`add_to_recommendation` 觸發) + +``` +💡 [⚡ NemoTron 派發器] 潛力商品自動佈署 + +🏆 推薦品項:[A009 美白化妝水150ml] 已自動加入「首頁推薦區塊」 + +📊 決策依據: +我方價格低於市場 20%,近7天銷量回升,具備流量轉換潛力 + +🧠 AI 洞察 (信心度 82%): +具備價格競爭優勢,NemoTron 主動提升曝光量以最大化業績。 + +👉 執行狀態:✅ 系統已自動寫入 ai_price_recommendations 推薦表 + +───────────────────── +⚙️ 運算足跡: +• 🔍 分析: Hermes 3 8B (本地 111) | 耗時: 34.2s | Tokens: 512 | $0 成本 +• ⚡ 決策: NemoTron NIM | 185 Tokens | $0 (配額內 2/80) +``` + +#### 類別四(未來):Gemini 雲端推理週報 + +``` +... (前文省略) ... +───────────────────── +⚙️ 運算足跡: +• 🔍 彙整: Hermes 3 8B (本地 111) | 耗時: 12s | $0 成本 +• 🧠 推理: Gemini 1.5 Flash | 8,420 Tokens | 費用: 約 $0.003 USD +``` + +### 5.4 運算足跡資料來源 + +| 模型 | API Response 欄位 | 說明 | +|------|-----------------|------| +| Hermes (Ollama) | `eval_count`, `total_duration` | 生成 tokens 數 + 推理耗時 (ns→s) | +| NemoTron (NIM) | `usage.total_tokens` (OpenAI 格式) | prompt + completion tokens 合計 | +| Gemini | `usageMetadata.totalTokenCount` | 乘費率算 USD | + +> **程式碼位置**: `nemoton_dispatcher_service.py` → `_build_footprint_block(hermes_stats, nim_stats)` + +### 5.5 Inline Keyboard 按鈕(Level 2 互動,待實裝) + +當收到類別一緊急告警時,訊息底部附帶互動按鈕(Telegram Bot API inline_keyboard): + +``` +[ ✅ 批准降價 ] [ ❌ 拒絕並忽略 ] [ 🔗 查看報表 ] +``` + +- `✅ 批准降價` → 呼叫 MOMO PRO 後台 API 改價 + 決策寫入知識庫 +- `❌ 拒絕並忽略` → 決策寫入知識庫(訓練未來在此品類保守點) +- `🔗 查看報表` → 跳轉至 MOMO PRO 該商品數據分析頁 + +> **現狀**: 尚未實裝,Inline Keyboard 需搭配 Telegram Webhook + callback_query handler + +--- + +## 六、Telegram 告警架構 + +### 告警群組 +- 群組: **小龍蝦** (業務情報專用,非 SRE 維運) +- Chat ID: `-1003940688311` +- Bot: `8610496165:AAFOlcWV4oRUSC2TI-fYux7JV97fjNzsYR8` + +### 單 Bot 多身份策略(One Bot, Multiple Headers) +| 模組 | Telegram 標頭 | +|------|--------------| +| Hermes 分析師 | `[Hermes 分析師]` | +| NemoTron 派發器 | `[NemoTron 派發器]` | +| Gemini 策略師 | `[Gemini 策略師]` (未來) | + +### 三種告警類型 +| Tool | 觸發條件 | Telegram 格式 | +|------|---------|--------------| +| `trigger_price_alert` | HIGH 風險 (gap>15% + 銷量跌>20%) | 🔴/🟡 競價威脅告警 | +| `add_to_recommendation` | 我方價格低於競品且銷量正成長 | ⭐ 推薦商品候選 | +| `flag_for_human_review` | 信心 < 0.6 或情況複雜 | ⚠️ 需要人工審核 | + +--- + +## 六、已驗證的服務參數 + +### Hermes 分析師 +| 參數 | 值 | +|------|---| +| 模型 | `hermes3:latest` | +| Ollama URL | `http://192.168.0.111:11434` | +| Timeout | 120s | +| Temperature | 0.1 | +| 實測推理時間 | **19.3s(3筆,實彈 2026-04-17)** | +| TOP_N | 20(每次最多輸出威脅數) | + +### NemoTron 派發器 +| 參數 | 值 | +|------|---| +| 模型 | `meta/llama-3.1-8b-instruct` | +| NIM API URL | `https://integrate.api.nvidia.com/v1` | +| Timeout | 60s | +| 每日配額上限 | 80(留 20 給 AWOOOI) | +| 配額耗盡 fallback | 直接派發 HIGH 風險告警,跳過 NIM | +| 實測 token 消耗 | **1206 tokens/輪(實彈 2026-04-17)** | + +--- + +## 七、已知技術債與待辦 + +| 優先 | 項目 | 說明 | +|------|------|------| +| ✅ | Migration 003 + 004 | `competitor_prices` + `ai_price_recommendations` 已在 188 `momo_analytics` 執行 | +| ✅ | 188 生產容器實彈驗證 | Hermes + NIM + PostgreSQL + Telegram 全通 (2026-04-17) | +| ✅ | momo-app port 5001→5002 | docker-registry 佔用 5001,docker-compose.yml 改為 `127.0.0.1:5002:80`,已 Up healthy | +| ✅ | momo-app 雙網路連線 | 同時連 `momo-network` + `momo-pro_default`(後者含 `momo-db` alias `momo-postgres`)| +| P1 | PChome Feeder CronJob | `competitor_price_feeder.py` 每 4 小時排程 (Scheduler 整合) | +| P1 | 告警去重 TTL | 同一 SKU 短期內重複告警未防範 | +| P1 | `daily_sales_snapshot` 欄位防禦 | 若 Excel 欄位名變更,JOIN 條件會靜默失效 | +| P2 | Scheduler 整合 | 每6小時自動觸發 Hermes→NIM→Telegram 管線 | +| P2 | Gemini 策略師 | 週報生成(需費用審批後實作) | + +--- + +## 八、部署拓撲(2026-04-17 確認) + +### 實體機器對應 + +| 服務 | 主機 | 容器名 | 說明 | +|------|------|--------|------| +| PostgreSQL | 192.168.0.188 | `momo-db` | pgvector/pgvector:pg14,含所有 AI 相關表 | +| momo-app | 192.168.0.188 | `momo-pro-system` | **Up healthy,port 5002:80**(5001 被 docker-registry 佔用,已改 5002) | +| momo-scheduler | 192.168.0.188 | `momo-scheduler` | 常駐排程容器 | +| Hermes 3 8B | 192.168.0.111 | Ollama 原生 | `hermes3:latest`,E2E 可達 | +| E2E 驗證容器 | 192.168.0.188 | `momo-e2e-test` | 臨時容器,含新服務模組 | + +### 188 `/home/ollama/momo-pro/.env` 正確設定 + +```bash +TELEGRAM_BOT_TOKEN=8610496165:AAFOlcWV4oRUSC2TI-fYux7JV97fjNzsYR8 # ← 唯一正確 token +TELEGRAM_CHAT_IDS=["-1003940688311"] # 小龍蝦群組 +NVIDIA_API_KEY=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 +USE_POSTGRESQL=true +POSTGRES_HOST=momo-db +# POSTGRES_DB / USER / PASSWORD 使用 docker-compose.yml 預設值 +``` + +> ⚠️ **Split-brain 陷阱**:188 容器環境曾存在舊 bot token `8569720657`(不同 bot,不在小龍蝦群組), +> 已於 2026-04-17 深夜修正為正確 token `8610496165`。 + +--- + +## 九、校對歷程 + +| 日期 | 問題 | 修正 | +|------|------|------| +| 2026-04-17 | `fetch_candidates()` SQL 使用 `"商品編號"` | 修正為 `"商品ID"`(與 MOMO Excel 實際欄位名一致) | +| 2026-04-17 | Hermes gap_pct 由 LLM 計算 → 誤差大 | 改為 Python 預算 `(momo-pchome)/pchome*100` | +| 2026-04-17 | 推理時間 52s | 預算 gap_pct 後降至 19.3s (3筆) | +| 2026-04-17 | `model_footprint` DB 欄位寫入 `{}` | 分離 `footprint_text`(Telegram 顯示)與 `footprint_data`(DB JSON)| +| 2026-04-17 | SQLite 語法 `NOW()` / `datetime('now')` / `::jsonb` | 全部改為 `CURRENT_TIMESTAMP` / Python 計算(跨 DB 相容)| +| 2026-04-17 | 本機 SQLite 測試通過但 188 未同步任何檔案 | rsync 推送 6 核心檔案 + 全站 dry-run 對帳 + migrations 跑通 | +| 2026-04-17 | 188 容器無 volume mount,`docker cp` 臨時解 | 重建 image(`COPY . .` bake 進新代碼);port 5001 衝突記錄為技術債 | +| 2026-04-17 | 188 .env Telegram token 不正確(split-brain)| 修正為 `8610496165`,188→Telegram message_id=282 確認 | +| 2026-04-17 | NIM Tool Calling E2E | 真實 NVIDIA_API_KEY 驗證:dispatched=3, errors=[] | diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..7ca2a02 --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,502 @@ +# MOMO Pro System - API 文件 + +> 最後更新:2026-01-29 + +--- + +## 📌 概述 + +### Base URL +- **生產環境**: `https://mo.wooo.work` +- **開發環境**: `http://localhost:5000` + +### 認證方式 +- **Session Cookie**: 透過 `/login` 取得 +- **Bot API Token**: Header `Authorization: Bearer ` + +### 回應格式 +```json +{ + "success": true, + "data": { ... }, + "message": "操作成功" +} +``` + +### 錯誤回應 +```json +{ + "success": false, + "error": "錯誤訊息", + "code": "ERROR_CODE" +} +``` + +--- + +## 🔐 認證 API + +### 登入 +```http +POST /login +Content-Type: application/x-www-form-urlencoded +``` + +| 參數 | 類型 | 必填 | 說明 | +|------|------|------|------| +| username | string | ✓ | 帳號 | +| password | string | ✓ | 密碼 | + +**回應**: 302 Redirect 或 401 + +### 登出 +```http +GET /logout +``` + +### 修改密碼 +```http +POST /api/user/change_password +Content-Type: application/json +``` + +```json +{ + "current_password": "舊密碼", + "new_password": "新密碼" +} +``` + +--- + +## 📊 商品看板 API + +### 取得商品列表 +```http +GET /api/products +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| page | int | 頁碼 (default: 1) | +| per_page | int | 每頁筆數 (default: 50) | +| category | string | 分類篩選 | +| filter | string | 狀態篩選 (all/increase/decrease/delisted) | +| q | string | 搜尋關鍵字 | +| sort_by | string | 排序欄位 | +| order | string | 排序方向 (asc/desc) | + +**回應**: +```json +{ + "items": [...], + "total": 5576, + "page": 1, + "per_page": 50, + "total_pages": 112 +} +``` + +### 取得商品價格歷史 +```http +GET /api/history/{product_id} +``` + +**回應**: +```json +[ + {"t": "2026-01-29 14:00", "p": 3280}, + {"t": "2026-01-28 14:00", "p": 3180} +] +``` + +### 手動執行爬蟲 +```http +POST /api/run_task +``` + +### 發送價格通知 +```http +POST /api/trigger_momo_notification +``` + +--- + +## 📈 業績分析 API + +### 取得每日業績 +```http +GET /api/daily_sales +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| date | string | 日期 (YYYY-MM-DD) | +| pm | string | PM 篩選 | +| brand | string | 品牌篩選 | + +### 取得月份總表 +```http +GET /api/monthly_summary +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| year | int | 年份 | +| month | int | 月份 | + +### 取得銷售趨勢 +```http +GET /api/sales/trend +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| start_date | string | 開始日期 | +| end_date | string | 結束日期 | +| dimension | string | 維度 (brand/pm/category) | + +--- + +## 📦 廠商缺貨 API + +### 匯入缺貨資料 +```http +POST /api/vendor/stockout/import +Content-Type: multipart/form-data +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| file | file | Excel 檔案 | + +### 取得缺貨列表 +```http +GET /api/vendor/stockout +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| batch_id | string | 批次篩選 | +| vendor_code | string | 廠商篩選 | +| status | string | 狀態篩選 | + +### 發送通知郵件 +```http +POST /api/vendor/stockout/send +Content-Type: application/json +``` + +```json +{ + "stockout_ids": [1, 2, 3] +} +``` + +### 廠商清單 CRUD + +```http +GET /api/vendor/list # 取得廠商列表 +POST /api/vendor/list # 新增廠商 +PUT /api/vendor/list/{vendor_id} # 更新廠商 +DELETE /api/vendor/list/{vendor_id} # 刪除廠商 +``` + +### 廠商郵件 CRUD + +```http +GET /api/vendor/{vendor_id}/emails # 取得郵件列表 +POST /api/vendor/{vendor_id}/emails # 新增郵件 +DELETE /api/vendor/email/{email_id} # 刪除郵件 +``` + +--- + +## 🤖 AI API + +### 生成文案 +```http +POST /api/ai/generate +Content-Type: application/json +``` + +```json +{ + "product_name": "商品名稱", + "style": "吸睛", + "keywords": ["保濕", "抗老"], + "provider": "ollama" +} +``` + +**回應**: +```json +{ + "content": "生成的文案內容", + "model": "llama3:70b", + "duration": 5.2, + "input_tokens": 120, + "output_tokens": 85 +} +``` + +### 取得熱門推薦 +```http +GET /api/ai/trending +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| source | string | 來源 (mybest/cosme/youtube) | +| category | string | 分類 | + +### 網路搜尋 (Gemini) +```http +POST /api/ai/search +Content-Type: application/json +``` + +```json +{ + "query": "搜尋關鍵字" +} +``` + +### AI 歷史記錄 + +```http +GET /api/ai/history # 取得歷史列表 +GET /api/ai/history/{id} # 取得單筆記錄 +PUT /api/ai/history/{id}/favorite # 切換收藏 +DELETE /api/ai/history/{id} # 刪除記錄 +``` + +### AI 使用統計 +```http +GET /api/ai/usage/stats +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| start_date | string | 開始日期 | +| end_date | string | 結束日期 | + +--- + +## 👤 用戶管理 API + +### 取得用戶列表 (Admin) +```http +GET /api/users +``` + +### 新增用戶 (Admin) +```http +POST /api/users +Content-Type: application/json +``` + +```json +{ + "username": "帳號", + "email": "郵箱", + "password": "密碼", + "role": "user", + "display_name": "顯示名稱" +} +``` + +### 更新用戶 (Admin) +```http +PUT /api/users/{user_id} +``` + +### 刪除用戶 (Admin) +```http +DELETE /api/users/{user_id} +``` + +### 重設密碼 (Admin) +```http +POST /api/users/{user_id}/reset_password +``` + +### 取得登入歷史 +```http +GET /api/users/login_history +``` + +--- + +## ⚙️ 系統 API + +### 取得系統狀態 +```http +GET /api/system/status +``` + +**回應**: +```json +{ + "cpu_percent": 25.5, + "memory_percent": 68.2, + "disk_percent": 45.0, + "uptime": "5 days" +} +``` + +### 取得爬蟲狀態 +```http +GET /api/system/crawler/status +``` + +### 系統清理 +```http +POST /api/system/cleanup +``` + +### K8s Pod 重啟 +```http +POST /api/system/k8s/restart +Content-Type: application/json +``` + +```json +{ + "deployment": "momo-app" +} +``` + +### 健康檢查 +```http +GET /health +``` + +**回應**: `200 OK` + +### Prometheus 指標 +```http +GET /metrics +``` + +--- + +## 📥 匯入 API + +### 列出 Google Drive 檔案 +```http +GET /api/import/drive/files +``` + +### 匯入 Drive 檔案 +```http +POST /api/import/drive/import +Content-Type: application/json +``` + +```json +{ + "file_id": "Google Drive 檔案 ID", + "job_type": "daily_sales" +} +``` + +### 取得匯入進度 +```http +GET /api/import/jobs/{job_id} +``` + +--- + +## 📤 匯出 API + +### 匯出商品報表 +```http +GET /api/export/excel/all +GET /api/export/excel/changes +GET /api/export/excel/delisted +``` + +### 匯出業績報表 +```http +GET /api/export/daily_sales +``` + +| 參數 | 類型 | 說明 | +|------|------|------| +| date | string | 日期 | +| format | string | 格式 (excel/csv) | + +--- + +## 🔔 通知 API + +### 發送 Telegram 通知 +```http +POST /api/notification/telegram +Content-Type: application/json +``` + +```json +{ + "message": "通知內容" +} +``` + +### 渲染通知模板 (供 n8n 呼叫) +```http +POST /api/notification/render +Content-Type: application/json +``` + +```json +{ + "code": "disk_warning", + "variables": { + "usage_percent": 85 + } +} +``` + +### 取得通知模板 +```http +GET /api/notification/templates +GET /api/notification/templates/{code} +PUT /api/notification/templates/{code} +``` + +--- + +## 🤖 Bot API + +### Telegram Webhook +```http +POST /api/bot/telegram/webhook +``` + +### Bot 指令處理 +```http +POST /api/bot/command +Content-Type: application/json +``` + +```json +{ + "command": "/sales", + "args": ["today"] +} +``` + +--- + +## 📝 錯誤代碼 + +| 代碼 | HTTP | 說明 | +|------|------|------| +| AUTH_REQUIRED | 401 | 需要登入 | +| AUTH_FAILED | 401 | 登入失敗 | +| PERMISSION_DENIED | 403 | 權限不足 | +| NOT_FOUND | 404 | 資源不存在 | +| VALIDATION_ERROR | 400 | 參數驗證錯誤 | +| RATE_LIMITED | 429 | 請求過於頻繁 | +| SERVER_ERROR | 500 | 伺服器錯誤 | diff --git a/docs/APP_REFACTOR_PLAN.md b/docs/APP_REFACTOR_PLAN.md new file mode 100644 index 0000000..2678d33 --- /dev/null +++ b/docs/APP_REFACTOR_PLAN.md @@ -0,0 +1,346 @@ +# app.py 重構計畫 + +## 文件資訊 +- **建立日期**: 2026-01-18 +- **目標檔案**: app.py (7,253 行) +- **目標**: 模組化拆分,提高可維護性 + +--- + +## 一、現況分析 + +### 1.1 檔案規模 +- **總行數**: 7,253 行 +- **路由數量**: 49 個 +- **函數數量**: 約 70 個 + +### 1.2 最大函數 (需優先拆分) + +| 排名 | 函數名稱 | 行數 | 所屬模組建議 | +|------|----------|------|--------------| +| 1 | `sales_analysis()` | 1,020 行 | sales_routes.py | +| 2 | `get_monthly_summary_data()` | 388 行 | monthly_routes.py | +| 3 | `import_excel()` | 288 行 | import_routes.py | +| 4 | `get_sales_table_data()` | 267 行 | sales_routes.py | +| 5 | `export_top_detail()` | 256 行 | sales_routes.py | +| 6 | `get_top_detail()` | 252 行 | sales_routes.py | +| 7 | `edm_dashboard()` | 244 行 | edm_routes.py | +| 8 | `index()` | 190 行 | dashboard_routes.py | + +### 1.3 現有路由分類 + +``` +頁面路由 (12個): +├── / (首頁/商品看板) +├── /settings (設定) +├── /system_settings (系統設定) +├── /brand_assets (品牌資源) +├── /edm (EDM 儀表板) +├── /festival (節慶儀表板) +├── /abc_analysis/detail (ABC 分析詳情) +├── /logs (系統日誌) +├── /monthly_summary_analysis (月結分析) +├── /sales_analysis (業績分析) +├── /growth_analysis (成長分析) +└── /daily_sales (當日業績) + +API 路由 (37個): +├── 系統 API (3個): /health, /metrics, /api/logs +├── 分類管理 API (4個): /api/categories/* +├── 匯出 API (11個): /api/export/* +├── 任務觸發 API (6個): /api/run_*, /api/trigger_* +├── 匯入 API (2個): /api/import_excel, /api/import/monthly_summary +├── 備份 API (2個): /api/backup, /api/backup/download/* +├── 業績分析 API (5個): /api/sales_analysis/* +├── 月結分析 API (1個): /api/monthly_summary_data +├── 其他 API (3個): /api/test_url, /api/history/*, /api/price_change_details +└── 當日業績匯出 (2個): /daily_sales/export* +``` + +--- + +## 二、重構架構設計 + +### 2.1 目標目錄結構 + +``` +momo_pro_system/ +├── app.py # 主應用程式 (精簡後約 500 行) +├── config.py # 配置檔 (保持不變) +├── auth.py # 認證模組 (保持不變) +│ +├── routes/ # 🆕 路由模組目錄 +│ ├── __init__.py # Blueprint 註冊中心 +│ ├── dashboard_routes.py # 商品看板 (首頁) +│ ├── sales_routes.py # 業績分析相關 +│ ├── daily_sales_routes.py # 當日業績相關 +│ ├── monthly_routes.py # 月結分析相關 +│ ├── edm_routes.py # EDM 儀表板 +│ ├── export_routes.py # 匯出功能 +│ ├── import_routes.py # 匯入功能 +│ ├── system_routes.py # 系統管理 (設定、日誌、備份) +│ └── api_routes.py # 通用 API (健康檢查、任務觸發等) +│ +├── services/ # 服務層 (現有) +│ ├── __init__.py +│ ├── cache_service.py # 🆕 快取服務 (從 app.py 抽出) +│ ├── sales_service.py # 🆕 業績計算服務 +│ ├── dashboard_service.py # 🆕 看板數據服務 +│ └── ... (現有服務) +│ +├── utils/ # 工具函數 (現有) +│ ├── __init__.py +│ ├── validators.py # 🆕 驗證函數 (從 app.py 抽出) +│ ├── formatters.py # 🆕 格式化函數 +│ └── ... (現有工具) +│ +├── database/ # 資料庫層 (保持不變) +├── vendor_routes.py # 廠商缺貨路由 (已存在) +└── auto_import_routes.py # 自動匯入路由 (已存在) +``` + +### 2.2 模組劃分詳情 + +#### 模組 1: `routes/dashboard_routes.py` (~400 行) +```python +# 包含路由: +- GET / # index() - 商品看板首頁 +- GET /brand_assets # brand_assets() + +# 包含函數: +- get_consolidated_data() # 153 行 +- get_full_dashboard_data() # 148 行 +- get_dashboard_stats() # 16 行 +``` + +#### 模組 2: `routes/sales_routes.py` (~2,000 行) +```python +# 包含路由: +- GET /sales_analysis # sales_analysis() - 1,020 行 +- GET /api/sales_analysis/table_data # get_sales_table_data() - 267 行 +- GET /api/sales_analysis/table_data_pandas +- GET /api/sales_analysis/top_detail # get_top_detail() - 252 行 +- GET /api/sales_analysis/export_top_detail +- GET /api/sales_analysis/yoy_comparison + +# 包含函數: +- _get_filtered_sales_data() +``` + +#### 模組 3: `routes/daily_sales_routes.py` (~800 行) +```python +# 包含路由: +- GET /daily_sales # daily_sales() - 161 行 +- GET /daily_sales/export # export_daily_sales_category() +- GET /daily_sales/export_marketing +- GET /growth_analysis # growth_analysis() + +# 包含函數: +- preprocess_daily_sales_data() +- calculate_daily_kpis() +- calculate_dod() +- calculate_wow() +- prepare_daily_charts() +- prepare_category_summary() +- prepare_marketing_summary() +- get_taiwan_holiday() +- prepare_calendar_data() +``` + +#### 模組 4: `routes/monthly_routes.py` (~500 行) +```python +# 包含路由: +- GET /monthly_summary_analysis +- GET /api/monthly_summary_data # 388 行 +- POST /api/import/monthly_summary +``` + +#### 模組 5: `routes/edm_routes.py` (~400 行) +```python +# 包含路由: +- GET /edm # edm_dashboard() - 244 行 +- GET /festival # festival_dashboard() - 148 行 +``` + +#### 模組 6: `routes/export_routes.py` (~800 行) +```python +# 包含路由: +- GET /api/export/all_categories +- GET /api/export/excel/all +- GET /api/export/excel/changes +- GET /api/export/excel/delisted +- GET /api/export/price_changes # 156 行 +- GET /api/export/low_prices +- GET /api/export/changes +- GET /api/export/excel/abc # 175 行 +- GET /api/export/excel/vendor # 135 行 +- GET /api/export/excel/seasonality_detail +``` + +#### 模組 7: `routes/import_routes.py` (~350 行) +```python +# 包含路由: +- POST /api/import_excel # import_excel() - 288 行 +``` + +#### 模組 8: `routes/system_routes.py` (~400 行) +```python +# 包含路由: +- GET /settings +- GET /system_settings +- GET /logs +- GET /api/logs +- POST /api/backup +- GET /api/backup/download/ +- POST /api/categories (CRUD) +- POST /api/test_url + +# 包含函數: +- load_categories() +- save_categories() +- load_scheduler_stats() +``` + +#### 模組 9: `routes/api_routes.py` (~300 行) +```python +# 包含路由: +- GET /health +- GET /metrics +- POST /api/run_task +- POST /api/run_edm_task +- POST /api/run_festival_task +- POST /api/trigger_momo_notification +- POST /api/trigger_edm_notification +- POST /api/test_notification +- GET /api/history/ +- GET /api/price_change_details +- GET /abc_analysis/detail + +# 包含函數: +- track_query_time() +``` + +--- + +## 三、重構執行步驟 + +### 階段 1: 準備工作 (風險: 低) +1. ✅ 完整備份現有程式碼 +2. 建立 `routes/` 目錄結構 +3. 建立 `routes/__init__.py` Blueprint 註冊中心 + +### 階段 2: 抽取共用模組 (風險: 低) +1. 建立 `services/cache_service.py` - 抽取快取相關變數和函數 +2. 建立 `utils/validators.py` - 抽取驗證函數 +3. 建立 `utils/formatters.py` - 抽取格式化函數 + +### 階段 3: 拆分路由模組 (風險: 中) +**執行順序 (由簡單到複雜):** + +| 順序 | 模組 | 路由數 | 複雜度 | 依賴關係 | +|------|------|--------|--------|----------| +| 1 | system_routes.py | 8 | 低 | 無外部依賴 | +| 2 | api_routes.py | 10 | 低 | scheduler | +| 3 | export_routes.py | 10 | 中 | DatabaseManager, Exporter | +| 4 | import_routes.py | 1 | 中 | DatabaseManager | +| 5 | edm_routes.py | 2 | 中 | DatabaseManager | +| 6 | monthly_routes.py | 3 | 中 | DatabaseManager | +| 7 | dashboard_routes.py | 2 | 高 | cache_service, DatabaseManager | +| 8 | daily_sales_routes.py | 4 | 高 | 多個輔助函數 | +| 9 | sales_routes.py | 6 | 高 | cache_service, 複雜邏輯 | + +### 階段 4: 整合測試 (風險: 低) +1. 本機測試所有路由 +2. UAT 環境部署測試 +3. 回歸測試所有功能 + +--- + +## 四、安全措施 + +### 4.1 備份策略 +```bash +# 重構前完整備份 +cp -r /Users/ogt/momo_pro_system /Users/ogt/momo_pro_system_backup_$(date +%Y%m%d_%H%M%S) + +# 資料庫備份 +cp /Users/ogt/momo_pro_system/data/momo_database.db /Users/ogt/momo_pro_system/backups/ +``` + +### 4.2 漸進式部署 +1. 每拆分一個模組後立即測試 +2. 使用 feature flag 控制新舊路由切換 +3. 保留原始 app.py 直到所有模組穩定 + +### 4.3 回滾方案 +```bash +# 如發生嚴重問題,立即回滾 +cp /Users/ogt/momo_pro_system_backup_YYYYMMDD/app.py /Users/ogt/momo_pro_system/app.py +rm -rf /Users/ogt/momo_pro_system/routes/ +# 重新部署 +``` + +--- + +## 五、預期效益 + +### 5.1 程式碼品質 +| 指標 | 重構前 | 重構後 | +|------|--------|--------| +| app.py 行數 | 7,253 | ~500 | +| 最大函數行數 | 1,020 | ~200 | +| 模組數量 | 1 | 10 | +| 可測試性 | 低 | 高 | + +### 5.2 維護性改善 +- **問題定位**: 從搜尋 7000 行 → 搜尋 300-800 行 +- **功能擴展**: 只需修改相關模組 +- **團隊協作**: 可並行開發不同模組 +- **部署風險**: 可單獨部署/回滾特定模組 + +--- + +## 六、時程規劃 + +| 階段 | 工作項目 | 預估工作量 | +|------|----------|------------| +| 1 | 準備工作 + 共用模組 | 1-2 小時 | +| 2 | system_routes + api_routes | 2-3 小時 | +| 3 | export_routes + import_routes | 2-3 小時 | +| 4 | edm_routes + monthly_routes | 2-3 小時 | +| 5 | dashboard_routes | 2-3 小時 | +| 6 | daily_sales_routes | 3-4 小時 | +| 7 | sales_routes (最複雜) | 4-5 小時 | +| 8 | 整合測試 + 修復 | 2-3 小時 | +| **總計** | | **18-26 小時** | + +--- + +## 七、注意事項 + +### 7.1 已知問題需修復 +1. **datetime 重複導入**: 多處函數內有 `from datetime import datetime`,需統一移除 +2. **快取變數**: `_SALES_DF_CACHE` 等全域變數需移至 cache_service.py +3. **循環依賴**: 拆分時需注意模組間的 import 順序 + +### 7.2 不動的部分 +- `config.py` - 配置檔保持不變 +- `auth.py` - 認證模組保持不變 +- `vendor_routes.py` - 已是 Blueprint,保持不變 +- `auto_import_routes.py` - 已是 Blueprint,保持不變 +- `database/` 目錄 - 資料庫層保持不變 +- `services/` 目錄 - 現有服務保持不變 + +--- + +## 八、執行確認 + +重構開始前,請確認: +- [ ] 已完成完整備份 +- [ ] 已確認 UAT 環境可正常運作 +- [ ] 已預留足夠時間進行測試 +- [ ] 已準備好回滾方案 + +--- + +*文件結束* diff --git a/docs/CICD_DEPLOYMENT_GUIDE.md b/docs/CICD_DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..c08fd5a --- /dev/null +++ b/docs/CICD_DEPLOYMENT_GUIDE.md @@ -0,0 +1,419 @@ +# MOMO Pro System - CI/CD 與部署指南 + +> 完整的 CI/CD 流程與一鍵部署自動化指南 +> 最後更新: 2026-02-06 + +--- + +## 目錄 + +1. [架構總覽](#架構總覽) +2. [CI/CD 流程](#cicd-流程) +3. [一鍵部署到新主機](#一鍵部署到新主機) +4. [手動部署步驟](#手動部署步驟) +5. [自動啟動機制](#自動啟動機制) +6. [故障排除](#故障排除) + +--- + +## 架構總覽 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ MOMO Pro System 部署架構 │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ 開發環境 (Mac) UAT 環境 (192.168.0.110) │ +│ ┌──────────────┐ ┌────────────────────────────────────┐ │ +│ │ 本地開發 │ │ K3s Kubernetes 叢集 │ │ +│ │ git push │───────────────►│ │ │ +│ └──────────────┘ CI/CD │ ┌────────────────────────────┐ │ │ +│ │ │ momo-app (Flask) │ │ │ +│ ┌──────────────┐ │ │ momo-scheduler (爬蟲) │ │ │ +│ │ GitLab │◄───────────────►│ │ momo-postgres (PostgreSQL) │ │ │ +│ │ :8929 │ │ └────────────────────────────┘ │ │ +│ └──────────────┘ │ │ │ +│ │ ┌────────────────────────────┐ │ │ +│ │ │ 監控: Prometheus/Grafana │ │ │ +│ │ │ 告警: Alertmanager/Telegram│ │ │ +│ │ └────────────────────────────┘ │ │ +│ └────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## CI/CD 流程 + +### 流程圖 + +``` +git push + │ + ▼ +┌───────────────────┐ +│ GitLab CI 觸發 │ +│ .gitlab-ci.yml │ +└─────────┬─────────┘ + │ + ▼ +┌───────────────────┐ ┌───────────────────┐ +│ Stage: test │────►│ Stage: deploy │ +│ • pytest 測試 │ │ • rsync 同步 │ +│ • 允許失敗 │ │ • docker build │ +└───────────────────┘ │ • k3s import │ + │ • kubectl restart│ + │ • 健康檢查 │ + └─────────┬─────────┘ + │ + ▼ + ┌───────────────────┐ + │ Telegram 通知 │ + │ 成功 ✅ / 失敗 ❌ │ + └───────────────────┘ +``` + +### GitLab CI 配置 (.gitlab-ci-simple.yml) + +此配置**不依賴 Harbor**,直接在 K3s 主機建置映像: + +```yaml +stages: + - test + - deploy + +variables: + K3S_HOST: "192.168.0.110" + K3S_USER: "wooo" + IMAGE_NAME: "momo-pro-system" + +deploy: + stage: deploy + script: + # 1. 同步程式碼 + - rsync -avz --delete --exclude='.git' ./ ${K3S_USER}@${K3S_HOST}:/home/wooo/momo_pro_system/ + + # 2. 在 K3s 主機建置 + - ssh ${K3S_USER}@${K3S_HOST} "cd /home/wooo/momo_pro_system && docker build -t momo-pro-system:local ." + + # 3. 匯入到 K3s containerd + - ssh ${K3S_USER}@${K3S_HOST} "docker save momo-pro-system:local | sudo k3s ctr images import -" + + # 4. 重啟服務 + - ssh ${K3S_USER}@${K3S_HOST} "sudo kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo" +``` + +### 手動觸發部署 + +```bash +# 方法 1: 推送到 GitLab +git add . +git commit -m "feat: 新功能" +git push gitlab main + +# 方法 2: 使用部署腳本 +./scripts/deploy/build-and-deploy.sh +``` + +--- + +## 一鍵部署到新主機 + +### 前置需求 + +| 項目 | 需求 | +|------|------| +| 作業系統 | Ubuntu 22.04 / Debian 12 | +| CPU | 4 核心以上 | +| 記憶體 | 8GB 以上 | +| 硬碟 | 50GB SSD | +| 網路 | 開放 80, 443 端口 | + +### 方法 1: SSH 遠端部署 + +```bash +# 從本地部署到新主機 +./deploy/deploy.sh --ssh -h <新主機IP> -u root deploy + +# 例如: +./deploy/deploy.sh --ssh -h 192.168.0.200 -u wooo deploy +``` + +### 方法 2: 匯出部署包 + +```bash +# 1. 在開發機匯出 +./deploy/deploy.sh --export --with-data + +# 2. 複製到新主機 +scp momo-pro-system_*.tar.gz root@新主機:/opt/ + +# 3. 在新主機解壓並執行 +ssh root@新主機 +cd /opt +tar -xzf momo-pro-system_*.tar.gz +cd momo-pro-system +./deploy/deploy.sh deploy +``` + +### 方法 3: K8s 部署 + +```bash +# 1. 確保 K3s 已安裝 +curl -sfL https://get.k3s.io | sh - + +# 2. 套用 K8s 配置 +kubectl apply -f k8s/00-namespace.yaml +kubectl apply -f k8s/01-secrets.yaml +kubectl apply -f k8s/02-configmap.yaml +kubectl apply -f k8s/03-postgres.yaml +kubectl apply -f k8s/04-momo-app.yaml +kubectl apply -f k8s/05-scheduler.yaml + +# 3. 建置並匯入映像 +docker build -t momo-pro-system:local . +docker save momo-pro-system:local | sudo k3s ctr images import - + +# 4. 重啟服務 +kubectl rollout restart deployment/momo-app deployment/momo-scheduler -n momo +``` + +--- + +## 手動部署步驟 + +### Step 1: 準備環境 + +```bash +# 安裝 Docker +curl -fsSL https://get.docker.com | sh +sudo usermod -aG docker $USER + +# 安裝 K3s (可選) +curl -sfL https://get.k3s.io | sh - +``` + +### Step 2: 複製程式碼 + +```bash +git clone http://192.168.0.110:8929/root/momo-pro-system.git +cd momo-pro-system +``` + +### Step 3: 配置環境變數 + +```bash +cp deploy/configs/.env.template .env +# 編輯 .env 填入實際值 +``` + +### Step 4: 建置映像 + +```bash +docker build -t momo-pro-system:local . +``` + +### Step 5: 部署服務 + +**Docker Compose 方式:** +```bash +docker compose up -d +``` + +**K8s 方式:** +```bash +# 匯入映像 +docker save momo-pro-system:local | sudo k3s ctr images import - + +# 套用配置 +kubectl apply -f k8s/ + +# 重啟 +kubectl rollout restart deployment/momo-app -n momo +``` + +### Step 6: 設定 SSL + +```bash +# 使用 Certbot +sudo certbot --nginx -d mo.yourdomain.com +``` + +### Step 7: 驗證 + +```bash +# 健康檢查 +curl https://mo.yourdomain.com/health +``` + +--- + +## 自動啟動機制 + +### systemd 服務 + +系統重開機後自動啟動所有服務: + +```bash +# 安裝服務 +sudo cp scripts/tools/momo-startup-complete.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable momo-startup-complete.service + +# 查看狀態 +systemctl status momo-startup-complete.service +``` + +### 啟動順序 + +``` +系統啟動 + │ + ▼ (60秒延遲) +Docker 服務啟動 + │ + ▼ +Harbor Registry (可選) + │ + ▼ +K8s Services +├── PostgreSQL (先啟動) +├── momo-app (等待 PostgreSQL) +└── momo-scheduler (等待 momo-app) + │ + ▼ +健康檢查 + Telegram 通知 +``` + +### 啟動腳本 + +```bash +# 手動執行啟動腳本 +sudo /home/wooo/momo_pro_system/scripts/tools/system_startup_complete.sh + +# 查看日誌 +sudo journalctl -u momo-startup-complete.service +``` + +--- + +## 故障排除 + +### CI/CD Pipeline 失敗 + +| 錯誤 | 原因 | 解決方案 | +|------|------|---------| +| SSH 連線失敗 | SSH key 未設定 | 設定 GitLab CI/CD 變數中的 SSH_PRIVATE_KEY | +| Docker build 失敗 | 磁碟空間不足 | 執行 `docker system prune -af` | +| kubectl 權限錯誤 | kubeconfig 問題 | 使用 `sudo kubectl` | + +### K8s Pod 問題 + +```bash +# 查看 Pod 狀態 +kubectl get pods -n momo + +# 查看日誌 +kubectl logs -f deployment/momo-app -n momo + +# 進入 Pod 除錯 +kubectl exec -it deployment/momo-app -n momo -- /bin/bash + +# 強制重啟 +kubectl delete pod -n momo -l app=momo-app +``` + +### 資料庫連線問題 + +```bash +# 檢查 PostgreSQL +kubectl logs momo-postgres-0 -n momo + +# 測試連線 +kubectl exec -n momo momo-postgres-0 -- psql -U momo -d momo_analytics -c "SELECT 1" +``` + +### 健康檢查失敗 + +```bash +# 檢查服務狀態 +curl -v https://mo.wooo.work/health + +# 檢查 Nginx +sudo nginx -t +sudo systemctl status nginx + +# 檢查 SSL +openssl s_client -connect mo.wooo.work:443 -servername mo.wooo.work +``` + +--- + +## 常用命令速查 + +### 部署 + +```bash +# 完整部署 +./scripts/deploy/build-and-deploy.sh + +# 只重啟服務 +ssh wooo@192.168.0.110 "sudo kubectl rollout restart deployment/momo-app -n momo" +``` + +### 監控 + +```bash +# 查看 Pod 狀態 +ssh wooo@192.168.0.110 "sudo kubectl get pods -n momo" + +# 查看資源使用 +ssh wooo@192.168.0.110 "sudo kubectl top pods -n momo" + +# 查看日誌 +ssh wooo@192.168.0.110 "sudo kubectl logs -f deployment/momo-app -n momo --tail=100" +``` + +### 備份 + +```bash +# 備份資料庫 +./deploy/deploy.sh backup + +# 匯出部署包(含資料) +./deploy/deploy.sh --export --with-data +``` + +### 驗證 + +```bash +# 執行完整驗證 +./scripts/verify-cicd.sh + +# 健康檢查 +curl -s https://mo.wooo.work/health | jq +``` + +--- + +## 相關檔案 + +| 檔案 | 說明 | +|------|------| +| `.gitlab-ci-simple.yml` | 簡化版 CI/CD 配置 (不依賴 Harbor) | +| `scripts/deploy/build-and-deploy.sh` | 本地建置部署腳本 | +| `deploy/deploy.sh` | 一鍵部署主腳本 | +| `scripts/tools/system_startup_complete.sh` | 系統自動啟動腳本 | +| `scripts/verify-cicd.sh` | CI/CD 完整驗證腳本 | +| `k8s/` | K8s 配置檔案目錄 | +| `k8s/optimized/` | 優化版 K8s 配置 | + +--- + +## 聯絡資訊 + +- **Telegram 告警**: Bot `@wooowooowooobot` +- **GitLab**: http://192.168.0.110:8929 +- **Grafana**: http://192.168.0.110:30030 +- **正式網址**: https://mo.wooo.work diff --git a/docs/CI_CD_EVALUATION.md b/docs/CI_CD_EVALUATION.md new file mode 100644 index 0000000..8b16608 --- /dev/null +++ b/docs/CI_CD_EVALUATION.md @@ -0,0 +1,660 @@ +# MOMO Pro System - CI/CD 機制評估報告 + +## 執行摘要 + +本文件評估 MOMO Pro System 導入 CI/CD(持續整合/持續部署)機制的建議方案。 + +**現況分析**: +- 部署方式:手動執行 `deploy.sh` 腳本(使用 gcloud 命令) +- 版本控制:本地 Git 倉庫,尚未設定遠端倉庫 +- 測試覆蓋:無自動化測試套件 +- 部署環境:GCP VM (asia-east1-a), e2-standard-2 + +**建議方案**:GitHub Actions(推薦)或 GitLab CI + +--- + +## 1. 平台選擇評估 + +### 1.1 GitHub Actions(推薦)✅ + +**優點**: +- 與 GitHub 原生整合,無需額外設定 +- 每月 2,000 分鐘免費額度(私有倉庫) +- 公有倉庫完全免費 +- 豐富的 Marketplace Actions 可重用 +- 文件齊全,社群活躍 +- 支援 Self-hosted Runners(可在 GCP VM 上運行,節省成本) + +**缺點**: +- 需要將程式碼推送到 GitHub +- 超過免費額度後需付費($0.008/分鐘) +- 相對 GitLab CI,功能較精簡 + +**適用場景**: +- 開源或小型團隊專案 +- 需要快速上手的 CI/CD 解決方案 +- 預算有限,希望最大化利用免費額度 + +**成本估算**: +- 免費額度:2,000 分鐘/月(私有倉庫) +- 每次 CI/CD 執行約 5-10 分鐘 +- 預計每月約 100-200 次部署 → **完全免費**(使用 Self-hosted Runner) + +--- + +### 1.2 GitLab CI + +**優點**: +- 可以 Self-hosted(完全控制) +- 內建 Container Registry +- 更強大的 Pipeline 配置功能 +- 免費版功能豐富 +- 每月 400 分鐘免費額度(私有倉庫,GitLab.com) + +**缺點**: +- Self-hosted 需要額外維護 +- GitLab.com 免費額度較少(400 分鐘) +- 學習曲線較陡峭 +- 如果使用 GitLab.com,需要遷移程式碼 + +**適用場景**: +- 大型企業專案 +- 需要更複雜的 Pipeline 流程 +- 已有 GitLab 基礎設施 + +**成本估算**: +- GitLab.com 免費額度:400 分鐘/月 +- Self-hosted:需額外 VM 資源(約 $20-50/月) + +--- + +### 1.3 建議結論 + +**推薦 GitHub Actions**,理由: +1. 免費額度充足(2,000 分鐘 + Self-hosted Runner 無限制) +2. 與 GitHub 生態系統整合度高 +3. 學習成本低,快速上手 +4. 可在現有 GCP VM 上運行 Self-hosted Runner,完全免費 + +--- + +## 2. CI/CD Pipeline 設計 + +### 2.1 Pipeline 階段規劃 + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Trigger │ -> │ Build │ -> │ Test │ -> │ Deploy │ +│ (Git Push) │ │ (安裝依賴) │ │ (自動測試) │ │ (部署到GCP) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ +``` + +### 2.2 建議的 Workflow + +#### 階段 1:程式碼檢查(Linting & Security) +- **觸發條件**:Pull Request 或 Push 到 main/develop +- **執行內容**: + - Python 語法檢查(flake8 / pylint) + - 安全漏洞掃描(bandit) + - 程式碼格式檢查(black) +- **目標**:確保程式碼品質和安全性 +- **執行時間**:~1-2 分鐘 + +#### 階段 2:自動化測試(Testing) +- **觸發條件**:通過階段 1 +- **執行內容**: + - 單元測試(pytest) + - 整合測試(測試資料庫操作) + - API 端點測試(測試 Flask routes) +- **目標**:確保功能正確性 +- **執行時間**:~2-3 分鐘 + +#### 階段 3:建置與打包(Build) +- **觸發條件**:通過階段 2 +- **執行內容**: + - 安裝 Python 依賴(pip install -r requirements.txt) + - 檢查依賴版本衝突 + - 建置文件版本號 +- **目標**:確保可部署性 +- **執行時間**:~1-2 分鐘 + +#### 階段 4:部署(Deployment) +- **觸發條件**: + - **自動觸發**:Push 到 main 分支 + - **手動觸發**:透過 GitHub Actions UI 手動執行 +- **執行內容**: + - 備份遠端資料庫 + - 上傳程式碼到 GCP VM + - 重啟服務(systemctl restart momo) + - 健康檢查(確認服務正常運行) + - 失敗時自動回滾 +- **目標**:零停機部署 +- **執行時間**:~3-5 分鐘 + +--- + +## 3. 具體實作方案 + +### 3.1 GitHub Actions 實作 + +#### 目錄結構 +``` +.github/ +└── workflows/ + ├── ci.yml # 持續整合(測試、檢查) + ├── deploy.yml # 持續部署(部署到 GCP) + └── backup.yml # 定期備份任務(每日) +``` + +#### 範例:`.github/workflows/ci.yml` +```yaml +name: CI - Continuous Integration + +on: + pull_request: + branches: [main, develop] + push: + branches: [develop] + +jobs: + lint: + name: 程式碼檢查 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: 設定 Python 環境 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: 安裝檢查工具 + run: | + pip install flake8 bandit black + + - name: Python 語法檢查 + run: flake8 app.py scheduler.py --max-line-length=120 + + - name: 安全漏洞掃描 + run: bandit -r . -x ./venv,./lib + + - name: 程式碼格式檢查 + run: black --check app.py scheduler.py + + test: + name: 自動化測試 + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + + - name: 設定 Python 環境 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: 安裝依賴 + run: | + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: 執行測試 + run: | + pytest tests/ -v --cov=. --cov-report=html + + - name: 上傳測試覆蓋率報告 + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: htmlcov/ +``` + +#### 範例:`.github/workflows/deploy.yml` +```yaml +name: CD - Deploy to GCP + +on: + push: + branches: [main] + workflow_dispatch: # 允許手動觸發 + +jobs: + deploy: + name: 部署到 GCP Production + runs-on: ubuntu-latest + environment: + name: production + url: http://35.194.172.48 + + steps: + - uses: actions/checkout@v4 + + - name: 設定 GCP 認證 + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: 設定 gcloud CLI + uses: google-github-actions/setup-gcloud@v2 + + - name: 備份遠端資料庫 + run: | + gcloud compute ssh momo-server \ + --zone=asia-east1-a \ + --command="bash ~/momo_pro_system/backup_system.py" + + - name: 上傳程式碼 + run: | + gcloud compute scp --recurse \ + --exclude='.git' --exclude='venv' --exclude='logs' \ + ./*.py ./*.html \ + momo-server:~/momo_pro_system/ \ + --zone=asia-east1-a + + - name: 重啟服務 + run: | + gcloud compute ssh momo-server \ + --zone=asia-east1-a \ + --command="sudo systemctl restart momo" + + - name: 健康檢查 + run: | + sleep 10 + gcloud compute ssh momo-server \ + --zone=asia-east1-a \ + --command=" + if sudo systemctl is-active --quiet momo; then + echo '✓ 服務運行正常' + exit 0 + else + echo '✗ 服務啟動失敗' + sudo journalctl -u momo -n 50 --no-pager + exit 1 + fi + " + + - name: 部署失敗時回滾 + if: failure() + run: | + echo "部署失敗,執行回滾..." + gcloud compute ssh momo-server \ + --zone=asia-east1-a \ + --command="bash ~/momo_pro_system/rollback_latest.sh" +``` + +--- + +### 3.2 Self-hosted Runner 設定(節省成本) + +在 GCP VM 上安裝 GitHub Actions Runner,讓 CI/CD 在自己的伺服器上執行(完全免費)。 + +#### 安裝步驟(在 GCP VM 上執行) +```bash +# 1. 建立 Runner 目錄 +mkdir -p ~/actions-runner && cd ~/actions-runner + +# 2. 下載 Runner +curl -o actions-runner-linux-x64-2.313.0.tar.gz \ + -L https://github.com/actions/runner/releases/download/v2.313.0/actions-runner-linux-x64-2.313.0.tar.gz + +# 3. 解壓縮 +tar xzf ./actions-runner-linux-x64-2.313.0.tar.gz + +# 4. 配置 Runner(需要 GitHub Token) +./config.sh --url https://github.com/YOUR_USERNAME/momo_pro_system \ + --token YOUR_TOKEN + +# 5. 安裝為系統服務 +sudo ./svc.sh install +sudo ./svc.sh start +``` + +使用 Self-hosted Runner 後,所有 CI/CD 任務都在您的 GCP VM 上執行,**完全免費**。 + +--- + +## 4. 自動化測試策略 + +### 4.1 測試框架選擇:pytest + +建議使用 `pytest` 作為主要測試框架,理由: +- Python 最流行的測試框架 +- 語法簡潔,易於編寫和維護 +- 豐富的插件生態系統 +- 支援測試覆蓋率報告 + +### 4.2 測試架構規劃 + +``` +tests/ +├── __init__.py +├── conftest.py # pytest 配置和 fixtures +├── test_app.py # Flask 路由測試 +├── test_database.py # 資料庫操作測試 +├── test_scheduler.py # 排程器測試 +├── test_crawler.py # 爬蟲測試 +└── test_email.py # 郵件發送測試 +``` + +### 4.3 測試範例 + +#### `tests/conftest.py`(測試配置) +```python +import pytest +from app import app, db + +@pytest.fixture +def client(): + """Flask 測試客戶端""" + app.config['TESTING'] = True + app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:' + + with app.test_client() as client: + with app.app_context(): + db.create_all() + yield client + +@pytest.fixture +def auth_client(client): + """已登入的測試客戶端""" + client.post('/login', data={ + 'username': 'admin', + 'password': 'test_password' + }) + return client +``` + +#### `tests/test_app.py`(路由測試) +```python +def test_index_redirect(client): + """測試首頁重定向到登入頁面""" + response = client.get('/') + assert response.status_code == 302 + assert '/login' in response.location + +def test_login_success(client): + """測試登入成功""" + response = client.post('/login', data={ + 'username': 'admin', + 'password': 'correct_password' + }) + assert response.status_code == 302 + assert '/' in response.location + +def test_dashboard_access(auth_client): + """測試已登入用戶可訪問儀表板""" + response = auth_client.get('/dashboard') + assert response.status_code == 200 + assert b'dashboard' in response.data.lower() +``` + +#### `tests/test_database.py`(資料庫測試) +```python +from datetime import date + +def test_sales_query(auth_client): + """測試銷售數據查詢""" + # 插入測試數據 + from database.models import RealtimeSalesMonthly + test_data = RealtimeSalesMonthly( + 日期=date.today(), + 商品ID='TEST001', + 商品名稱='測試商品', + 銷售數量=100, + 銷售金額=5000 + ) + db.session.add(test_data) + db.session.commit() + + # 查詢測試 + result = RealtimeSalesMonthly.query.filter_by(商品ID='TEST001').first() + assert result is not None + assert result.銷售數量 == 100 +``` + +### 4.4 測試執行 + +```bash +# 執行所有測試 +pytest tests/ -v + +# 執行測試並生成覆蓋率報告 +pytest tests/ --cov=. --cov-report=html + +# 執行特定測試檔案 +pytest tests/test_app.py -v + +# 執行特定測試函數 +pytest tests/test_app.py::test_login_success -v +``` + +--- + +## 5. 零停機部署策略 + +### 5.1 Blue-Green 部署(推薦) + +**概念**:維護兩個相同的生產環境(Blue 和 Green),部署時切換流量。 + +**實作方式**: +```bash +# 1. 部署到 Green 環境(新版本) +gcloud compute ssh momo-server --command=" + # 複製當前版本到 blue 目錄 + cp -r ~/momo_pro_system ~/momo_pro_system_blue + + # 部署新版本到 green 目錄 + cd ~/momo_pro_system_green + git pull origin main + + # 啟動 Green 服務(Port 5001) + sudo systemctl restart momo-green +" + +# 2. 健康檢查 Green 環境 +curl http://localhost:5001/health + +# 3. 切換 Nginx 流量到 Green +# 修改 /etc/nginx/sites-available/momo +# proxy_pass http://127.0.0.1:5001; # 從 5000 切換到 5001 + +# 4. 重新載入 Nginx(無停機) +sudo nginx -s reload + +# 5. 停止 Blue 環境 +sudo systemctl stop momo-blue +``` + +**優點**: +- 完全零停機 +- 可快速回滾(切換回 Blue) +- 可進行 A/B 測試 + +**缺點**: +- 需要雙倍資源(兩個服務同時運行) +- 配置較複雜 + +--- + +### 5.2 Rolling 更新(簡化版,推薦用於小型專案) + +**概念**:先更新程式碼,然後快速重啟服務。 + +**實作方式**(現有 deploy.sh 已實現): +```bash +# 1. 備份資料庫 +bash ~/momo_pro_system/backup_system.py + +# 2. 上傳新程式碼 +gcloud compute scp *.py *.html momo-server:~/momo_pro_system/ + +# 3. 重啟服務(Gunicorn graceful reload) +sudo systemctl reload momo # 使用 reload 而非 restart + +# 4. 健康檢查 +sleep 5 +curl http://localhost:5000/health || { + echo "部署失敗,回滾..." + # 從備份還原 + bash ~/momo_pro_system/rollback_latest.sh +} +``` + +**優點**: +- 簡單易實作 +- 資源消耗少 +- 適合小型專案 + +**缺點**: +- 有短暫停機(約 2-5 秒) +- 回滾較慢 + +--- + +### 5.3 建議實作 + +**階段 1(立即可行)**:使用 Rolling 更新 + Gunicorn Graceful Reload +- 修改 `momo.service`,使用 `ExecReload=/bin/kill -HUP $MAINPID` +- 部署時執行 `systemctl reload momo` 而非 `restart` + +**階段 2(長期目標)**:Blue-Green 部署 +- 當業務量增長後,再實作完整的 Blue-Green 部署 + +--- + +## 6. 密鑰管理 + +### 6.1 GitHub Secrets 配置 + +需要在 GitHub Repository Settings > Secrets 中新增: + +| Secret 名稱 | 說明 | 取得方式 | +|------------|------|----------| +| `GCP_SA_KEY` | GCP Service Account JSON 金鑰 | 在 GCP Console 建立 Service Account 並下載 JSON | +| `GCP_PROJECT_ID` | GCP 專案 ID | GCP Console 查看 | +| `GCP_ZONE` | GCP VM 區域 | `asia-east1-a` | +| `GCP_INSTANCE` | GCP VM 實例名稱 | `momo-server` | +| `ADMIN_PASSWORD_HASH` | 管理員密碼雜湊 | 現有 `generate_password_hash.py` 生成 | + +### 6.2 GCP Service Account 權限 + +需要賦予 Service Account 以下權限: +- `Compute Instance Admin (v1)` - 管理 VM +- `Service Account User` - 使用 Service Account +- `Storage Object Viewer` - 讀取 Cloud Storage(如果使用) + +--- + +## 7. 實作階段規劃 + +### 階段 1:基礎設施準備(1-2 天) +- [ ] 將專案推送到 GitHub 私有倉庫 +- [ ] 建立 GCP Service Account 並配置權限 +- [ ] 在 GitHub 設定 Secrets +- [ ] 安裝 Self-hosted Runner(可選,節省成本) + +### 階段 2:建立測試框架(3-5 天) +- [ ] 安裝 pytest 和相關插件 +- [ ] 撰寫基本測試(路由測試、資料庫測試) +- [ ] 建立測試數據 fixtures +- [ ] 達到 >60% 測試覆蓋率 + +### 階段 3:設定 CI Pipeline(1-2 天) +- [ ] 建立 `.github/workflows/ci.yml` +- [ ] 配置程式碼檢查(flake8, bandit) +- [ ] 配置自動化測試 +- [ ] 測試 PR 觸發 CI + +### 階段 4:設定 CD Pipeline(2-3 天) +- [ ] 建立 `.github/workflows/deploy.yml` +- [ ] 整合現有 `deploy.sh` 腳本 +- [ ] 實作自動備份機制 +- [ ] 實作健康檢查和回滾 +- [ ] 測試自動部署流程 + +### 階段 5:優化與監控(持續進行) +- [ ] 整合 Prometheus 監控到 CI/CD +- [ ] 設定部署失敗通知(Slack / Email) +- [ ] 優化部署速度 +- [ ] 建立部署文件和 Runbook + +**總計時間**:約 1-2 週(根據團隊經驗調整) + +--- + +## 8. 成本分析 + +### 8.1 GitHub Actions(使用 Self-hosted Runner) +- **費用**:$0/月(完全免費) +- **說明**:所有 CI/CD 任務在 GCP VM 上執行,不消耗 GitHub 提供的分鐘數 + +### 8.2 GitHub Actions(使用 GitHub-hosted Runner) +- **免費額度**:2,000 分鐘/月(私有倉庫) +- **預估使用**: + - 每次 CI: 5 分鐘 + - 每次 CD: 5 分鐘 + - 每月約 100 次部署 = 1,000 分鐘 +- **費用**:$0/月(在免費額度內) + +### 8.3 GitLab CI(Self-hosted) +- **額外 VM 成本**:$20-50/月(需要額外的 Runner VM) +- **維護成本**:需要額外的系統維護時間 + +### 8.4 總結 +**建議使用 GitHub Actions + Self-hosted Runner**,完全免費且功能完整。 + +--- + +## 9. 風險評估 + +| 風險 | 影響 | 機率 | 緩解措施 | +|-----|------|------|----------| +| 部署失敗導致服務中斷 | 高 | 中 | 實作自動回滾機制 + 健康檢查 | +| 測試覆蓋不足,未發現 Bug | 中 | 高 | 逐步提升測試覆蓋率至 >80% | +| GCP Service Account 金鑰洩漏 | 高 | 低 | 使用 GitHub Secrets + 定期輪換金鑰 | +| CI/CD Pipeline 執行時間過長 | 低 | 中 | 使用 Self-hosted Runner + 快取依賴 | +| 資料庫遷移失敗 | 高 | 低 | 部署前自動備份 + 測試環境驗證 | + +--- + +## 10. 建議與結論 + +### 10.1 核心建議 +1. **採用 GitHub Actions** 作為 CI/CD 平台 +2. **使用 Self-hosted Runner** 節省成本(完全免費) +3. **分階段實作**,先建立 CI,再建立 CD +4. **優先建立測試框架**,確保程式碼品質 +5. **實作自動回滾機制**,降低部署風險 + +### 10.2 立即可執行的行動 +1. 將專案推送到 GitHub(私有倉庫) +2. 建立 `tests/` 目錄並撰寫基本測試 +3. 建立 `.github/workflows/ci.yml`(先實作 CI) +4. 驗證 CI 正常運作後,再建立 `deploy.yml`(實作 CD) + +### 10.3 長期目標 +- 測試覆蓋率 >80% +- 平均部署時間 <5 分鐘 +- 自動化部署成功率 >95% +- 零停機部署(Blue-Green) + +--- + +## 附錄 + +### A. 相關文件 +- [GitHub Actions 官方文件](https://docs.github.com/en/actions) +- [pytest 官方文件](https://docs.pytest.org/) +- [Gunicorn Graceful Reload](https://docs.gunicorn.org/en/stable/signals.html) +- [GCP Service Account 設定](https://cloud.google.com/iam/docs/service-accounts) + +### B. 參考專案 +- [Flask CI/CD 範例](https://github.com/actions/starter-workflows/blob/main/deployments/azure-webapps-python.yml) +- [Python 測試最佳實踐](https://realpython.com/pytest-python-testing/) + +--- + +**文件版本**:1.0 +**撰寫日期**:2026-01-14 +**下次更新**:實作完成後更新實際成果和經驗 diff --git a/docs/CRAWLER_OPTIMIZATION_ANALYSIS.md b/docs/CRAWLER_OPTIMIZATION_ANALYSIS.md new file mode 100644 index 0000000..119f062 --- /dev/null +++ b/docs/CRAWLER_OPTIMIZATION_ANALYSIS.md @@ -0,0 +1,202 @@ +# 爬蟲資源消耗分析與優化方案 + +**分析日期**:2026-01-14 +**問題**:爬蟲消耗過高 CPU 資源(單個 Chrome 程序可達 50%+ CPU) + +--- + +## 🔍 問題分析 + +### 當前配置(scheduler.py) + +```python +# Chrome Options 配置 +options.add_argument('--headless=new') +options.add_argument('--disable-gpu') +options.add_argument('--no-sandbox') +options.add_argument('--disable-dev-shm-usage') +options.add_argument('--force-device-scale-factor=3') # ⚠️ 3倍解析度 +options.add_argument('--lang=zh-TW') +options.add_argument('--high-dpi-support=1') +``` + +### 資源消耗分析 + +| 配置項目 | 作用 | CPU 影響 | 記憶體影響 | +|---------|------|---------|-----------| +| `--headless=new` | 無頭模式 | 低 | 低 | +| `--disable-gpu` | 禁用GPU | 中(CPU渲染) | 低 | +| `--no-sandbox` | 禁用沙盒 | 低 | 低 | +| **`--force-device-scale-factor=3`** | **3倍解析度** | **極高** | **高** | +| `--high-dpi-support=1` | 高DPI支援 | 中 | 中 | + +### 關鍵問題 + +1. **3倍解析度(`scale-factor=3`)**: + - 渲染面積增加 9 倍(3×3) + - Chrome 需要渲染 `5760×15000` 像素(原本 `1920×5000`) + - 單個頁面渲染可消耗 50%+ CPU + +2. **無GPU加速(`--disable-gpu`)**: + - 所有渲染由 CPU 執行 + - 配合 3 倍解析度,CPU 負擔極重 + +3. **缺少資源限制**: + - 無並發限制 + - 無單進程模式 + - 無記憶體限制 + +--- + +## 🎯 優化方案 + +### 方案 A:最小化資源消耗(推薦) + +**適用場景**:不需要高清截圖,只需要抓取商品資料 + +**修改項目**: +```python +# 1. 降低解析度倍數:3 → 1 +options.add_argument('--force-device-scale-factor=1') # 從 3 改為 1 + +# 2. 使用單進程模式(減少 Chrome 子程序) +options.add_argument('--single-process') + +# 3. 禁用軟體光柵化器 +options.add_argument('--disable-software-rasterizer') + +# 4. 限制記憶體使用 +options.add_argument('--max-old-space-size=512') +``` + +**預期效果**: +- CPU 使用:**降低 70-80%**(50% → 10%) +- 記憶體使用:**降低 60%**(500MB → 200MB) +- 頁面載入速度:**提升 50%** + +--- + +### 方案 B:平衡效能(需要截圖但降低負擔) + +**適用場景**:需要截圖功能,但可以接受較低解析度 + +**修改項目**: +```python +# 1. 降低解析度倍數:3 → 1.5 +options.add_argument('--force-device-scale-factor=1.5') # 從 3 改為 1.5 + +# 2. 啟用 GPU 加速(讓 GPU 分擔渲染) +# options.add_argument('--disable-gpu') # 註解掉此行 +options.add_argument('--enable-gpu-rasterization') + +# 3. 使用單進程模式 +options.add_argument('--single-process') +``` + +**預期效果**: +- CPU 使用:**降低 50-60%**(50% → 20%) +- 記憶體使用:**降低 40%**(500MB → 300MB) +- 截圖品質:仍然清晰(1.5倍解析度足夠) + +--- + +### 方案 C:降低執行頻率 + +**當前配置**:每小時執行 3 個爬蟲(商品看板、EDM、購物節) + +**優化建議**: +```python +# run_scheduler.py 修改執行頻率 +schedule.every(4).hours.do(run_momo_task) # 每 1 小時 → 每 4 小時 +schedule.every(4).hours.do(run_edm_task) # 每 1 小時 → 每 4 小時 +schedule.every(6).hours.do(run_festival_task) # 每 1 小時 → 每 6 小時 +``` + +**預期效果**: +- 平均 CPU 使用:**降低 75%** +- 爬蟲執行次數:從 24次/天 → 6次/天 + +--- + +### 方案 D:使用輕量級替代方案 + +**完全移除 Selenium**,改用: +- `requests` + `BeautifulSoup`(如果網站允許) +- `httpx` + `parsel`(更快的 HTTP 客戶端) +- `playwright`(比 Selenium 更輕量) + +**優缺點**: +- ✅ CPU 使用降低 90%+ +- ❌ 需要重寫爬蟲代碼 +- ❌ 可能無法處理 JavaScript 渲染的網站 + +--- + +## 📊 建議實施順序 + +### 第一階段:立即優化(方案 A + 方案 C) + +1. **修改 scheduler.py**: + - 降低解析度:`scale-factor=3` → `scale-factor=1` + - 增加 `--single-process` + - 增加 `--disable-software-rasterizer` + +2. **降低執行頻率**: + - 商品看板:每 4 小時 + - EDM爬蟲:每 4 小時 + - 購物節:每 6 小時 + +**預期結果**: +- 資源消耗降低 **80-85%** +- 仍能維持基本監控需求 + +--- + +### 第二階段:評估效果(1-2 天後) + +1. 檢查 Grafana 監控數據 +2. 確認爬蟲是否正常運作 +3. 評估是否需要進一步優化 + +--- + +### 第三階段:長期優化(可選) + +1. 考慮改用 Playwright(輕量級) +2. 實施任務隊列(Celery) +3. 分離爬蟲到獨立 VM(如需高頻率爬蟲) + +--- + +## ⚠️ 重要提醒 + +### 截圖功能影響 + +如果您的通知系統需要高清截圖(`force-device-scale-factor=3`),請選擇方案 B(1.5倍解析度)。 + +如果不需要高清截圖,建議選擇方案 A(1倍解析度),可大幅降低資源消耗。 + +### Systemd 服務 + +目前 `momo-scheduler.service` 已停用。如果需要重新啟動: +```bash +sudo systemctl start momo-scheduler # 啟動 +sudo systemctl enable momo-scheduler # 設為開機自啟動 +``` + +建議:優化完成後再啟動服務。 + +--- + +## 🔧 實施腳本 + +我已準備好優化後的代碼,請確認要使用哪個方案: +- **方案 A**(推薦):最大化降低資源消耗 +- **方案 B**:平衡效能與截圖品質 +- **方案 C**:僅降低執行頻率 + +--- + +**文件版本**:1.0 +**最後更新**:2026-01-14 +**維護人員**:MOMO Pro System Admin diff --git a/docs/DATABASE_OPTIMIZATION_GUIDE.md b/docs/DATABASE_OPTIMIZATION_GUIDE.md new file mode 100644 index 0000000..cbf8052 --- /dev/null +++ b/docs/DATABASE_OPTIMIZATION_GUIDE.md @@ -0,0 +1,224 @@ +# SQLite 資料庫優化指南 + +**創建日期**:2026-01-14 +**執行時機**:晚上 8 點後(低流量時段) + +--- + +## 📋 優化內容 + +### 1. 添加索引(總計 8 個) + +**products 表**: +- `idx_products_category` - 分類查詢優化 +- `idx_products_status` - 狀態篩選優化 +- `idx_products_updated_at` - 更新時間排序優化 + +**price_records 表**: +- `idx_price_records_product_id` - 商品價格歷史查詢 +- `idx_price_records_product_time` - 複合索引(商品+時間) + +**promo_products 表**: +- `idx_promo_crawled_at` - 爬取時間查詢 +- `idx_promo_batch_id` - 批次查詢 +- `idx_promo_status_change` - 狀態變更篩選 + +### 2. VACUUM 清理 +- 回收已刪除資料的空間 +- 優化資料庫內部結構 +- 預計可節省 5-10% 空間 + +### 3. ANALYZE 統計 +- 更新查詢規劃器統計資訊 +- 提升查詢效能 + +--- + +## ⏰ 執行時間建議 + +**最佳執行時間**:晚上 8:00 - 凌晨 2:00 + +**預計耗時**: +- 添加索引:1-2 分鐘 +- VACUUM 清理:30-60 秒 +- 總計:約 2-3 分鐘 + +**影響範圍**: +- ⚠️ 添加索引期間:該表寫入會被阻塞(1-10 秒/索引) +- 🚨 VACUUM 期間:整個資料庫無法讀寫(30-60 秒) + +--- + +## 🚀 執行方式 + +### 方式一:手動執行(推薦) + +#### 1. SSH 連線到伺服器 +```bash +gcloud compute ssh momo-server --zone=asia-east1-a +``` + +#### 2. 進入專案目錄 +```bash +cd ~/momo_pro_system +``` + +#### 3. 執行優化腳本 +```bash +python3 database_optimization.py +``` + +#### 4. 觀察執行過程 +腳本會即時顯示進度: +``` +[2026-01-14 20:00:15] [INFO] SQLite 資料庫優化腳本啟動 +[2026-01-14 20:00:15] [INFO] 當前資料庫大小: 208.45 MB +[2026-01-14 20:00:15] [INFO] 步驟 1/3: 添加索引 +[2026-01-14 20:00:16] [SUCCESS] ✓ 索引創建成功: idx_products_category (耗時 0.35 秒) +... +[2026-01-14 20:02:30] [SUCCESS] ✓ 資料庫優化完成! +``` + +--- + +### 方式二:使用 screen 背景執行 + +如果擔心 SSH 斷線,可以使用 screen: + +```bash +# 1. SSH 連線 +gcloud compute ssh momo-server --zone=asia-east1-a + +# 2. 創建 screen 會話 +screen -S db_optimization + +# 3. 執行腳本 +cd ~/momo_pro_system +python3 database_optimization.py + +# 4. 離開 screen(按 Ctrl+A 然後按 D) +# 腳本會繼續在背景執行 + +# 5. 重新連接 screen 查看進度 +screen -r db_optimization +``` + +--- + +### 方式三:定時任務(自動執行) + +如果想要自動在每週日凌晨執行: + +```bash +# 編輯 crontab +crontab -e + +# 添加以下行(每週日凌晨 2:00 執行) +0 2 * * 0 cd /home/ogt/momo_pro_system && /usr/bin/python3 database_optimization.py >> logs/db_optimization.log 2>&1 +``` + +--- + +## ✅ 執行後驗證 + +### 1. 檢查索引是否創建成功 +```bash +sqlite3 data/momo_database.db "SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%';" +``` + +應該看到 8 個新索引。 + +### 2. 檢查資料庫大小 +```bash +ls -lh data/momo_database.db +``` + +如果執行了 VACUUM,大小應該會略微減少。 + +### 3. 測試網站運行 +訪問 https://momo.wooo.work/,確認商品列表載入正常。 + +--- + +## 📊 預期效果 + +### 查詢效能提升 +- **分類篩選**:快 50-80% +- **價格歷史查詢**:快 70-90% +- **促銷商品查詢**:快 60-80% + +### 空間節省 +- 預計節省:10-20 MB +- 資料庫結構更緊湊 + +--- + +## ⚠️ 注意事項 + +1. **執行前備份**(可選,但建議) + ```bash + cp data/momo_database.db data/momo_database_backup_$(date +%Y%m%d).db + ``` + +2. **確認服務運行正常** + ```bash + systemctl status momo + ``` + +3. **執行期間避免**: + - ❌ 不要手動觸發爬蟲任務 + - ❌ 不要進行大量資料匯入 + - ❌ 不要重啟服務 + +4. **執行後檢查日誌** + ```bash + tail -100 logs/gunicorn-error.log + ``` + +--- + +## 🆘 問題排查 + +### 問題 1:資料庫被鎖定 +**錯誤**:`database is locked` + +**原因**:有其他程序正在使用資料庫 + +**解決**: +```bash +# 檢查是否有爬蟲程序在運行 +ps aux | grep python | grep -E 'crawler|scheduler' + +# 如果有,先停止 +pkill -f 'run_scheduler.py' +``` + +### 問題 2:VACUUM 執行時間過長 +**現象**:超過 5 分鐘還在執行 + +**解決**: +- 正常情況,耐心等待 +- 不要中斷程序(Ctrl+C) +- 如果超過 10 分鐘,可能需要檢查磁碟空間 + +### 問題 3:執行後網站載入變慢 +**可能原因**:SQLite 緩存尚未建立 + +**解決**: +- 等待 5-10 分鐘,讓系統重新建立緩存 +- 或手動刷新幾次頁面 + +--- + +## 📞 聯絡支援 + +如有問題,請檢查: +1. 執行腳本的完整輸出 +2. Gunicorn 錯誤日誌:`logs/gunicorn-error.log` +3. 系統負載:`uptime` + +--- + +**文件版本**:1.0 +**最後更新**:2026-01-14 +**維護人員**:MOMO Pro System Admin diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..956f048 --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,407 @@ +# MOMO Pro System - 資料庫結構 + +> 最後更新:2026-01-29 + +--- + +## 📊 ERD 關聯圖 + +```mermaid +erDiagram + %% 核心商品模組 + categories ||--o{ products : has + products ||--o{ price_records : has + + %% 用戶模組 + users ||--o{ login_history : has + users ||--o{ user_permissions : has + roles ||--o{ role_permissions : has + permissions ||--o{ role_permissions : has + permissions ||--o{ user_permissions : has + + %% 廠商模組 + vendor_list ||--o{ vendor_emails : has + vendor_list ||--o{ email_send_log : has + vendor_stockout ||--o{ email_send_log : references + + %% AI 模組 + users ||--o{ ai_generation_history : creates + users ||--o{ ai_usage_tracking : creates + users ||--o{ ai_prompt_templates : creates + + %% 匯入模組 + import_jobs }o--|| import_config : uses + + %% 通知模組 + notification_templates ||--o{ notification_logs : uses + + %% 核心表定義 + categories { + int id PK + string name UK + } + + products { + int id PK + string i_code UK + string name + string url + text image_url + string category + string status + int category_id FK + datetime created_at + datetime updated_at + } + + price_records { + int id PK + int product_id FK + float price + datetime timestamp + } + + %% 用戶表 + users { + int id PK + string username UK + string email UK + string password_hash + string role + string display_name + bool is_active + datetime password_changed_at + datetime created_at + datetime updated_at + } + + login_history { + int id PK + int user_id FK + string username_attempted + datetime login_time + string ip_address + string user_agent + string status + string failure_reason + } + + %% 廠商表 + vendor_stockout { + int id PK + string batch_id + date import_date + string department + string section + string pm_name + string product_code + string product_name + string vendor_code + string vendor_name + int monthly_sales_qty + int current_stock + string status + bool is_duplicate + datetime sent_date + } + + vendor_list { + int id PK + string vendor_code UK + string vendor_name + bool is_active + } + + vendor_emails { + int id PK + int vendor_id FK + string email + string contact_name + string email_type + bool is_active + } + + email_send_log { + int id PK + int vendor_id FK + int stockout_id FK + string batch_id + string sender_email + string recipient_email + string subject + int product_count + string status + text error_message + datetime sent_at + } + + %% AI 表 + ai_generation_history { + int id PK + string generation_type + string product_name + text input_keywords + string input_style + text output_content + string model_name + float generation_duration + string ai_provider + int input_tokens + int output_tokens + int rating + bool is_favorite + bool is_used + int created_by FK + datetime created_at + } + + ai_usage_tracking { + int id PK + string provider + string model_name + string usage_type + int input_tokens + int output_tokens + float total_cost + date request_date + int created_by FK + } + + ai_prompt_templates { + int id PK + string name UK + string description + string template_type + text system_prompt + text user_prompt_template + float default_temperature + bool is_active + bool is_system + } + + %% 匯入表 + import_jobs { + int id PK + string job_type + string status + string drive_file_id + string drive_file_name + float progress_percent + int total_rows + int processed_rows + datetime created_at + datetime completed_at + } + + %% 月份總表 + monthly_summary_analysis { + int id PK + int year + int month + string department + string division + string pm_name + string brand_name + int vendor_id + int sales_amt_curr + int sales_amt_prev + int profit_amt_curr + float conv_rate + } +``` + +--- + +## 📋 資料表清單 + +### 核心模組 (4 表) + +| 資料表 | 說明 | 記錄數 (估計) | +|--------|------|--------------| +| `categories` | 商品分類 | ~20 | +| `products` | 商品主檔 | ~6,000 | +| `price_records` | 價格歷史記錄 | ~500,000 | +| `monthly_summary_analysis` | 月份業績總表 | ~50,000 | + +--- + +### 用戶模組 (2 表) + +| 資料表 | 說明 | +|--------|------| +| `users` | 用戶帳號 | +| `login_history` | 登入歷史記錄 | + +#### users 欄位詳情 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| id | INTEGER PK | 用戶 ID | +| username | VARCHAR(50) UK | 帳號名稱 | +| email | VARCHAR(120) UK | 電子郵件 | +| password_hash | VARCHAR(256) | bcrypt 雜湊密碼 | +| role | VARCHAR(20) | 角色 (admin/manager/user) | +| display_name | VARCHAR(100) | 顯示名稱 | +| is_active | BOOLEAN | 是否啟用 | +| password_changed_at | DATETIME | 密碼變更時間 | +| created_at | DATETIME | 建立時間 | +| updated_at | DATETIME | 更新時間 | + +--- + +### 廠商模組 (4 表) + +| 資料表 | 說明 | +|--------|------| +| `vendor_stockout` | 廠商缺貨記錄 | +| `vendor_list` | 廠商清單 | +| `vendor_emails` | 廠商聯絡郵件 | +| `email_send_log` | 郵件發送記錄 | + +#### vendor_stockout 欄位詳情 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| id | INTEGER PK | 記錄 ID | +| batch_id | VARCHAR(50) | 批次編號 (YYYYMMDD_HHMMSS) | +| import_date | DATE | 匯入日期 | +| department | VARCHAR(100) | 部別 | +| section | VARCHAR(100) | 課別 | +| pm_name | VARCHAR(100) | PM 姓名 | +| product_code | VARCHAR(100) | 商品料號 | +| product_name | VARCHAR(500) | 商品名稱 | +| vendor_code | VARCHAR(100) | 廠商代碼 | +| vendor_name | VARCHAR(200) | 廠商名稱 | +| monthly_sales_qty | INTEGER | 全月銷量 | +| current_stock | INTEGER | 現有庫存 | +| stockout_days | INTEGER | 缺貨天數 | +| status | VARCHAR(20) | 狀態 (pending/sent/failed) | +| is_duplicate | BOOLEAN | 是否重複 | + +--- + +### AI 模組 (3 表) + +| 資料表 | 說明 | +|--------|------| +| `ai_generation_history` | AI 生成歷史 | +| `ai_usage_tracking` | AI 用量追蹤 | +| `ai_prompt_templates` | AI 提示模板 | + +#### ai_generation_history 欄位詳情 + +| 欄位 | 類型 | 說明 | +|------|------|------| +| id | INTEGER PK | 記錄 ID | +| generation_type | VARCHAR(50) | 類型 (copy/recommend/weather_analysis) | +| product_name | VARCHAR(255) | 商品名稱 | +| input_keywords | TEXT (JSON) | 輸入關鍵字 | +| input_style | VARCHAR(50) | 文案風格 | +| output_content | TEXT | 生成內容 | +| model_name | VARCHAR(100) | 模型名稱 | +| generation_duration | FLOAT | 生成耗時 (秒) | +| ai_provider | VARCHAR(20) | AI 提供者 (ollama/gemini) | +| input_tokens | INTEGER | 輸入 Token 數 | +| output_tokens | INTEGER | 輸出 Token 數 | +| rating | INTEGER | 用戶評分 (1-5) | +| is_favorite | BOOLEAN | 是否收藏 | +| is_used | BOOLEAN | 是否已使用 | +| created_by | INTEGER FK | 建立者 | +| created_at | DATETIME | 建立時間 | + +--- + +### 匯入模組 (2 表) + +| 資料表 | 說明 | +|--------|------| +| `import_jobs` | 匯入任務 | +| `import_config` | 匯入配置 | + +--- + +### 權限模組 (3 表) + +| 資料表 | 說明 | +|--------|------| +| `permissions` | 權限定義 | +| `role_permissions` | 角色權限關聯 | +| `user_permissions` | 用戶權限關聯 | + +--- + +### 通知模組 (2 表) + +| 資料表 | 說明 | +|--------|------| +| `notification_templates` | 通知模板 | +| `notification_logs` | 通知發送記錄 | + +--- + +### 趨勢模組 (3 表) + +| 資料表 | 說明 | +|--------|------| +| `trend_products` | 趨勢商品 | +| `trend_categories` | 趨勢分類 | +| `trend_keywords` | 趨勢關鍵字 | + +--- + +## 🔑 索引設計 + +### 高頻查詢索引 + +```sql +-- 商品查詢 +CREATE INDEX idx_products_i_code ON products(i_code); +CREATE INDEX idx_products_category ON products(category); +CREATE INDEX idx_products_status ON products(status); + +-- 價格記錄查詢 +CREATE INDEX idx_price_records_product_id ON price_records(product_id); +CREATE INDEX idx_price_records_timestamp ON price_records(timestamp); + +-- 用戶查詢 +CREATE INDEX idx_users_username ON users(username); +CREATE INDEX idx_users_role ON users(role); + +-- 廠商缺貨查詢 +CREATE INDEX idx_vendor_stockout_batch_id ON vendor_stockout(batch_id); +CREATE INDEX idx_vendor_stockout_vendor_code ON vendor_stockout(vendor_code); +CREATE INDEX idx_vendor_stockout_status ON vendor_stockout(status); + +-- AI 歷史查詢 +CREATE INDEX idx_ai_history_type_created ON ai_generation_history(generation_type, created_at); +CREATE INDEX idx_ai_history_favorite ON ai_generation_history(is_favorite, created_at); +``` + +--- + +## 📈 資料成長預估 + +| 資料表 | 日增長量 | 月增長量 | 保留策略 | +|--------|----------|----------|----------| +| price_records | ~6,000 | ~180,000 | 保留 1 年 | +| login_history | ~100 | ~3,000 | 保留 90 天 | +| email_send_log | ~50 | ~1,500 | 永久保留 | +| ai_generation_history | ~200 | ~6,000 | 永久保留 | +| ai_usage_tracking | ~200 | ~6,000 | 永久保留 | + +--- + +## 🔄 資料庫遷移 + +### 使用 Alembic 管理 + +```bash +# 建立遷移 +alembic revision --autogenerate -m "description" + +# 執行遷移 +alembic upgrade head + +# 回滾遷移 +alembic downgrade -1 +``` diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000..c0acb89 --- /dev/null +++ b/docs/DEPLOYMENT_GUIDE.md @@ -0,0 +1,342 @@ +# Momo Pro System 部署指南 + +## 目錄 +1. [環境資訊](#環境資訊) +2. [部署流程概覽](#部署流程概覽) +3. [本地開發環境](#本地開發環境) +4. [UAT 環境部署](#uat-環境部署) +5. [部署腳本說明](#部署腳本說明) +6. [常用指令](#常用指令) +7. [故障排除](#故障排除) + +--- + +## 環境資訊 + +### 本地開發環境 +- **路徑**: `/Users/ogt/momo_pro_system` +- **平台**: macOS (Darwin) + +### UAT 伺服器 +- **主機**: `192.168.0.110` +- **用戶**: `wooo` +- **路徑**: `/home/wooo/momo_pro_system` +- **存取 URL**: http://192.168.0.110:5000 + +### Docker 資訊 +- **映像名稱**: `wooo/momo-pro-system:latest` +- **容器名稱**: `momo-pro-system` +- **暴露埠號**: 5000 + +--- + +## 部署流程概覽 + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 本地開發 │ ──▶ │ Git 提交 │ ──▶ │ 部署到 UAT │ +│ 修改程式碼 │ │ 版本控制 │ │ Docker 重建 │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### 標準部署步驟 + +1. **本地修改並測試** +2. **Git 提交變更** + ```bash + git add . + git commit -m "描述變更內容" + ``` +3. **執行部署腳本** + ```bash + ./deploy_docker_uat.sh deploy + ``` + +--- + +## 本地開發環境 + +### 專案結構 +``` +momo_pro_system/ +├── app.py # Flask 主應用 +├── auth.py # 認證模組 +├── config.py # 配置檔案 +├── scheduler.py # 排程器 +├── requirements.txt # Python 依賴 +├── Dockerfile # Docker 映像定義 +├── docker-compose.yml # Docker Compose 配置 +├── .dockerignore # Docker 忽略檔案 +├── .env # 環境變數(不上傳) +├── .env.example # 環境變數範例 +├── *.html # 前端模板 +├── static/ # 靜態資源 +│ └── images/ # 圖片資源 +│ ├── logo.png # 原始 Logo +│ ├── logo_navbar.svg # 導航列圓形 Logo +│ └── logo_circle.svg # 圓形 Logo +├── services/ # 服務模組 +├── database/ # 資料庫模組 +├── utils/ # 工具模組 +├── web/ # Web 模板 +├── docker/ # Docker 相關配置 +├── deploy_scripts/ # 部署腳本 +├── docs/ # 文件 +└── data/ # 資料庫檔案 +``` + +### 本地啟動 + +#### 方式一:直接執行 +```bash +python app.py +``` + +#### 方式二:Docker Compose +```bash +docker-compose up -d +``` + +--- + +## UAT 環境部署 + +### 前置要求 + +1. **SSH 金鑰設定** + - 確保本地有 SSH 金鑰(`~/.ssh/id_rsa` 或 `~/.ssh/uat_key`) + - 公鑰已加入 UAT 伺服器的 `~/.ssh/authorized_keys` + +2. **UAT 伺服器環境** + - Docker 已安裝 + - Docker Compose 已安裝(可選) + - 專案目錄已建立:`/home/wooo/momo_pro_system` + +### 一鍵部署 + +```bash +./deploy_docker_uat.sh deploy +``` + +### 部署流程詳解 + +執行 `deploy_docker_uat.sh` 腳本會自動完成以下步驟: + +#### 步驟 1/6: 同步檔案到 UAT +```bash +# 上傳 Docker 配置 +scp Dockerfile wooo@192.168.0.110:/home/wooo/momo_pro_system/ +scp docker-compose.yml wooo@192.168.0.110:/home/wooo/momo_pro_system/ +scp .dockerignore wooo@192.168.0.110:/home/wooo/momo_pro_system/ + +# 上傳 Docker 目錄 +scp -r docker wooo@192.168.0.110:/home/wooo/momo_pro_system/ + +# 上傳核心 Python 檔案 +scp app.py auth.py config.py scheduler.py requirements.txt wooo@192.168.0.110:/home/wooo/momo_pro_system/ + +# 上傳 HTML 模板 +scp *.html wooo@192.168.0.110:/home/wooo/momo_pro_system/ + +# 上傳模組目錄 +scp -r services database utils static web wooo@192.168.0.110:/home/wooo/momo_pro_system/ +``` + +#### 步驟 2/6: 停止現有服務 +```bash +ssh wooo@192.168.0.110 "sudo systemctl stop momo 2>/dev/null || true" +ssh wooo@192.168.0.110 "docker stop momo-pro-system 2>/dev/null || true" +ssh wooo@192.168.0.110 "docker rm momo-pro-system 2>/dev/null || true" +``` + +#### 步驟 3/6: 建置 Docker Image +```bash +ssh wooo@192.168.0.110 "cd /home/wooo/momo_pro_system && docker build -t wooo/momo-pro-system:latest ." +``` + +#### 步驟 4/6: 啟動 Docker 容器 +```bash +ssh wooo@192.168.0.110 "cd /home/wooo/momo_pro_system && docker run -d \ + --name momo-pro-system \ + -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 \ + wooo/momo-pro-system:latest" +``` + +#### 步驟 5/6: 等待服務啟動 +等待 30 秒讓服務完全啟動 + +#### 步驟 6/6: 驗證部署 +```bash +# 檢查容器狀態 +ssh wooo@192.168.0.110 "docker ps --filter name=momo-pro-system" + +# 測試健康檢查端點 +ssh wooo@192.168.0.110 "curl -s http://localhost:5000/health" + +# 測試首頁 +ssh wooo@192.168.0.110 "curl -s -o /dev/null -w '%{http_code}' http://localhost:5000/" +``` + +--- + +## 部署腳本說明 + +### deploy_docker_uat.sh + +主要的 UAT 部署腳本,支援以下指令: + +| 指令 | 說明 | +|------|------| +| `./deploy_docker_uat.sh deploy` | 執行完整部署(預設) | +| `./deploy_docker_uat.sh status` | 檢查 UAT 容器狀態 | +| `./deploy_docker_uat.sh logs` | 查看容器日誌 | +| `./deploy_docker_uat.sh restart` | 重啟容器 | +| `./deploy_docker_uat.sh stop` | 停止容器 | +| `./deploy_docker_uat.sh help` | 顯示說明 | + +### 其他部署腳本 + +| 腳本 | 說明 | +|------|------| +| `deploy.sh` | GCP 部署腳本(使用 gcloud) | +| `sync_env.sh` | 環境同步腳本 | +| `deploy_scripts/setup_autostart.sh` | 設定 systemd 自動啟動 | + +--- + +## 常用指令 + +### UAT 伺服器操作 + +```bash +# SSH 連線到 UAT +ssh wooo@192.168.0.110 + +# 查看容器狀態 +ssh wooo@192.168.0.110 'docker ps' + +# 查看容器日誌(即時) +ssh wooo@192.168.0.110 'docker logs -f momo-pro-system' + +# 查看最近 100 行日誌 +ssh wooo@192.168.0.110 'docker logs --tail 100 momo-pro-system' + +# 重啟容器 +ssh wooo@192.168.0.110 'docker restart momo-pro-system' + +# 停止容器 +ssh wooo@192.168.0.110 'docker stop momo-pro-system' + +# 進入容器 shell +ssh wooo@192.168.0.110 'docker exec -it momo-pro-system /bin/bash' +``` + +### Docker 相關 + +```bash +# 查看所有映像 +docker images + +# 清理未使用的映像 +docker image prune -f + +# 查看容器資源使用 +docker stats momo-pro-system + +# 查看容器詳細資訊 +docker inspect momo-pro-system +``` + +### Git 相關 + +```bash +# 查看變更 +git status +git diff + +# 提交變更 +git add . +git commit -m "描述變更" + +# 查看提交歷史 +git log --oneline -10 +``` + +--- + +## 故障排除 + +### 常見問題 + +#### 1. SSH 連線失敗 +```bash +# 檢查 SSH 連線 +ssh -v wooo@192.168.0.110 + +# 確認金鑰權限 +chmod 600 ~/.ssh/id_rsa +chmod 644 ~/.ssh/id_rsa.pub +``` + +#### 2. Docker 建置失敗 +```bash +# 檢查 Docker 狀態 +ssh wooo@192.168.0.110 'docker info' + +# 查看建置錯誤 +ssh wooo@192.168.0.110 'cd /home/wooo/momo_pro_system && docker build --no-cache -t wooo/momo-pro-system:latest .' +``` + +#### 3. 容器啟動失敗 +```bash +# 查看容器日誌 +ssh wooo@192.168.0.110 'docker logs momo-pro-system' + +# 檢查 .env 檔案 +ssh wooo@192.168.0.110 'cat /home/wooo/momo_pro_system/.env' +``` + +#### 4. 服務無法存取 +```bash +# 檢查容器是否運行 +ssh wooo@192.168.0.110 'docker ps -a' + +# 檢查埠號綁定 +ssh wooo@192.168.0.110 'docker port momo-pro-system' + +# 檢查防火牆 +ssh wooo@192.168.0.110 'sudo ufw status' +``` + +#### 5. 資料庫問題 +```bash +# 進入容器檢查資料庫 +ssh wooo@192.168.0.110 'docker exec -it momo-pro-system ls -la /app/data/' +``` + +--- + +## 部署紀錄 + +### 2026-01-18: 導航列 Logo 更新 +- **變更內容**: 更新所有 HTML 頁面的導航列 Logo 為圓形 SVG 設計 +- **Commit**: `037bba3` +- **部署時間**: 13:31:43 +- **部署結果**: 成功 +- **驗證結果**: + - 容器狀態: healthy + - 健康檢查: HTTP 200 + - 首頁: HTTP 200 + +--- + +## 維護人員 + +- WOOO TECH 開發團隊 +- 最後更新: 2026-01-18 diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 0000000..6e9b628 --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,343 @@ +# MOMO Pro System - 設計系統 + +> 最後更新:2026-01-29 + +--- + +## 🎨 色彩系統 + +### 主要色彩 + +| 名稱 | 色碼 | 用途 | +|------|------|------| +| **Primary** | `#667eea` | 主要按鈕、連結、強調 | +| **Primary Gradient** | `#667eea → #764ba2` | 卡片、標題背景 | +| **Secondary** | `#6c757d` | 次要按鈕、文字 | + +### 狀態色彩 + +| 名稱 | 色碼 | 用途 | +|------|------|------| +| **Success** | `#28a745` | 成功、降價、正面 | +| **Danger** | `#dc3545` | 錯誤、漲價、警告 | +| **Warning** | `#ffc107` | 提醒、注意 | +| **Info** | `#17a2b8` | 資訊、提示 | + +### 背景色彩 + +| 名稱 | 色碼 | 用途 | +|------|------|------| +| **Background** | `#f8f9fa` | 頁面背景 | +| **Card** | `#ffffff` | 卡片背景 | +| **Navbar** | `#1f2937 → #374151` | 導航列 | + +### 文字色彩 + +| 名稱 | 色碼 | 用途 | +|------|------|------| +| **Text Primary** | `#2c3e50` | 主要文字 | +| **Text Secondary** | `#6c757d` | 次要文字、說明 | +| **Text Muted** | `#9ca3af` | 淡化文字 | +| **Text White** | `#ffffff` | 深色背景上的文字 | + +--- + +## 📝 字型系統 + +### 字型家族 + +```css +/* 主要字型 */ +font-family: 'Inter', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, + 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + +/* 等寬字型 (程式碼) */ +font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; +``` + +### 字型大小 + +| 層級 | 大小 | 用途 | +|------|------|------| +| **Display** | 3rem (48px) | 大標題 | +| **H1** | 2.5rem (40px) | 頁面標題 | +| **H2** | 2rem (32px) | 區塊標題 | +| **H3** | 1.5rem (24px) | 卡片標題 | +| **H4** | 1.25rem (20px) | 小標題 | +| **H5** | 1rem (16px) | 強調文字 | +| **Body** | 1rem (16px) | 內文 | +| **Small** | 0.875rem (14px) | 輔助文字 | +| **XSmall** | 0.75rem (12px) | 標籤、caption | + +### 字重 + +| 名稱 | 數值 | 用途 | +|------|------|------| +| Regular | 400 | 內文 | +| Medium | 500 | 導航、按鈕 | +| SemiBold | 600 | 標題、強調 | +| Bold | 700 | 數字、KPI | +| ExtraBold | 800 | 大數字顯示 | + +--- + +## 📐 間距系統 + +### 基礎單位 + +```css +--spacing-unit: 0.25rem; /* 4px */ +``` + +### 間距比例 + +| 名稱 | 值 | CSS 變數 | +|------|------|---------| +| **XXS** | 0.25rem (4px) | `--spacing-1` | +| **XS** | 0.5rem (8px) | `--spacing-2` | +| **SM** | 0.75rem (12px) | `--spacing-3` | +| **MD** | 1rem (16px) | `--spacing-4` | +| **LG** | 1.5rem (24px) | `--spacing-6` | +| **XL** | 2rem (32px) | `--spacing-8` | +| **XXL** | 3rem (48px) | `--spacing-12` | + +--- + +## 🔲 元件規範 + +### 按鈕 + +```html + + + + + + + + + + + +``` + +**按鈕規格:** +| 類型 | 高度 | 內距 | 字體大小 | +|------|------|------|---------| +| 預設 | 38px | 0.75rem 1.5rem | 16px | +| 小型 | 31px | 0.4rem 0.8rem | 14px | +| 大型 | 48px | 1rem 2rem | 18px | + +### 卡片 + +```html +
+
+
標題
+

內容

+
+
+``` + +**卡片規格:** +| 屬性 | 值 | +|------|------| +| 圓角 | 12px | +| 陰影 | `0 2px 10px rgba(0,0,0,0.08)` | +| 內距 | 1.5rem | +| 邊框 | none | + +### 表格 + +```html + + + + + + + + + + + +
欄位
資料
+``` + +**表格規格:** +| 屬性 | 值 | +|------|------| +| 表頭背景 | `#f8f9fa` | +| 行高 | 48px | +| Hover 背景 | `rgba(0,0,0,0.02)` | +| 邊框 | `1px solid #e9ecef` | + +### 表單 + +```html +
+ + +
+``` + +**輸入框規格:** +| 屬性 | 值 | +|------|------| +| 高度 | 38px | +| 圓角 | 6px | +| 邊框 | `1px solid #ced4da` | +| Focus 邊框 | `#667eea` | + +### 徽章 (Badge) + +```html + +成功 +失敗 +待處理 + + +美妝 +``` + +--- + +## 📊 KPI 卡片 + +### 結構 + +```html +
+
+
漲價
+
45
+
件商品
+
+
+``` + +### 樣式 + +```css +.kpi-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.kpi-item { + background: #f8f9fa; + padding: 1rem; + border-radius: 10px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.kpi-item:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0,0,0,0.1); +} + +.kpi-item.increase .kpi-item-value { + color: #dc3545; +} + +.kpi-item.decrease .kpi-item-value { + color: #28a745; +} +``` + +--- + +## 📱 響應式斷點 + +| 名稱 | 最小寬度 | 類別前綴 | +|------|----------|---------| +| **XS** | 0 | - | +| **SM** | 576px | `sm-` | +| **MD** | 768px | `md-` | +| **LG** | 992px | `lg-` | +| **XL** | 1200px | `xl-` | +| **XXL** | 1400px | `xxl-` | + +### 容器寬度 + +| 斷點 | 容器最大寬度 | +|------|-------------| +| SM | 540px | +| MD | 720px | +| LG | 960px | +| XL | 1140px | +| XXL | 1320px | + +--- + +## 🌙 深色模式 (未來) + +```css +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --text-primary: #2c3e50; +} + +[data-theme="dark"] { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --text-primary: #e8e8e8; +} +``` + +--- + +## 🎭 動畫 + +### 過渡效果 + +```css +/* 預設過渡 */ +transition: all 0.2s ease; + +/* 按鈕 Hover */ +transition: transform 0.15s, box-shadow 0.15s; + +/* 卡片 Hover */ +transition: transform 0.2s, box-shadow 0.2s; +``` + +### 載入動畫 + +```css +.spinner-border { + width: 1.5rem; + height: 1.5rem; + border-width: 0.2em; +} +``` + +--- + +## 🖼️ 圖標 + +### 使用 Font Awesome 6 + +```html + + + + + + + +``` + +### 圖標大小 + +| 類別 | 大小 | +|------|------| +| 預設 | 1em | +| `fa-lg` | 1.33em | +| `fa-2x` | 2em | +| `fa-3x` | 3em | diff --git a/docs/GIT_CONVENTION.md b/docs/GIT_CONVENTION.md new file mode 100644 index 0000000..9fb7836 --- /dev/null +++ b/docs/GIT_CONVENTION.md @@ -0,0 +1,381 @@ +# MOMO Pro System - Git 開發規範 + +> 最後更新:2026-01-29 + +--- + +## 🌿 分支策略 + +### 分支類型 + +``` +main # 生產分支 (受保護) +├── develop # 開發整合分支 +├── feature/* # 功能開發分支 +├── hotfix/* # 緊急修復分支 +├── release/* # 發布準備分支 +└── bugfix/* # 錯誤修復分支 +``` + +### 分支命名規範 + +| 類型 | 格式 | 範例 | +|------|------|------| +| 功能 | `feature/-` | `feature/123-add-ai-history` | +| 修復 | `bugfix/-` | `bugfix/456-fix-login-error` | +| 緊急 | `hotfix/-` | `hotfix/20260129-fix-crawler` | +| 發布 | `release/v` | `release/v2.0.0` | + +### Git Flow 流程 + +```mermaid +gitGraph + commit id: "init" + branch develop + commit id: "dev-1" + branch feature/123-new-feature + commit id: "feat-1" + commit id: "feat-2" + checkout develop + merge feature/123-new-feature + branch release/v2.0 + commit id: "bump version" + checkout main + merge release/v2.0 tag: "v2.0.0" + checkout develop + merge release/v2.0 +``` + +--- + +## 📝 Commit 規範 + +### Commit Message 格式 + +``` +(): + + + +