Files
ewoooc/routes/edm_routes.py
OoO 75de76ac12
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(momo): block EC404 auto-open with end-to-end URL guard
- normalize URLs at write time (scheduler crawlers, routes) to drop
  javascript:/EC404/placeholder i_code (momo_/manual_/pchome_)
- add global click+auxclick guard in base.html and ewoooc_base.html
  that intercepts blocked MOMO URLs and redirects to safe i_code URL
- per-page dashboards reuse the same isLikelyMomoIcode validation
- /api/track_momo_link records blocked events for diagnosis
- ship sanitize_momo_urls.py to clean existing polluted DB rows

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:00:34 +08:00

559 lines
23 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
EDM 與節慶促銷路由模組
包含:限時搶購儀表板、節慶活動儀表板
"""
import hashlib
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, render_template, url_for
from auth import login_required
from sqlalchemy import func, desc
from config import BASE_DIR, public_url, DATABASE_TYPE
from database.manager import DatabaseManager
from database.models import Product
from database.edm_models import PromoProduct
from services.logger_manager import SystemLogger
from utils.momo_url_utils import build_momo_product_url, normalize_momo_product_url
# 時區設定
TAIPEI_TZ = timezone(timedelta(hours=8))
# Logger
sys_log = SystemLogger("EDMRoutes").get_logger()
# Blueprint 定義
edm_bp = Blueprint('edm', __name__)
# ==========================================
# 輔助函數
# ==========================================
def slugify(text):
"""將文字轉換為 URL 友善格式"""
if not text:
return ""
return str(text).replace(' ', '_').replace(':', '').replace('!', '').replace('?', '').replace('/', '').replace('&', '').replace('(', '').replace(')', '').replace('+', '_').replace('.', '_').replace('%', '').replace("'", "")
def get_color_for_string(s):
"""為字串生成一個穩定且美觀的 HSL 顏色"""
if not s:
return "hsl(0, 0%, 85%)" # 預設灰色
hash_val = int(hashlib.md5(s.encode('utf-8'), usedforsecurity=False).hexdigest(), 16)
hue = hash_val % 360
return f"hsl({hue}, 60%, 88%)"
def load_scheduler_stats():
"""讀取排程統計資料"""
import os
import json
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 _build_promo_dashboard_data(session, page_type, page_name, sort_by, order):
"""
通用的促銷儀表板數據建構函數
用於 edm 和 festival 兩種頁面類型
"""
# 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. 查詢最新批次
latest_batch = session.query(PromoProduct.batch_id).filter(
PromoProduct.page_type == page_type
).order_by(desc(PromoProduct.crawled_at)).first()
current_batch_id = latest_batch[0] if latest_batch else None
# 3. 查詢「全商品的最新狀態快照」
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()
# 4. 過濾顯示列表
items_in_batch = []
today_start = datetime.now(TAIPEI_TZ).replace(
hour=0, minute=0, second=0, microsecond=0
) # 保持時區資訊 (台北 +8)
for item in latest_records:
# 確保 crawled_at 有時區資訊,若無則假設為台北時區
item_crawled_at = item.crawled_at
if item_crawled_at.tzinfo is None:
item_crawled_at = item_crawled_at.replace(tzinfo=TAIPEI_TZ)
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)
# 5. 按時段分組
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()))
# 6. 決定預設顯示的頁籤
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))
# 7. 計算在架天數與總銷量
all_icodes_in_batch = [item.i_code for item in items_in_batch]
product_categories = {}
days_on_shelf_map = {}
total_sold_map = {}
history_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}
# 計算上架天數 (兼容 SQLite 和 PostgreSQL)
if DATABASE_TYPE == 'postgresql':
# PostgreSQL: 使用 DATE() 函數
days_on_shelf_q = session.query(
PromoProduct.i_code,
func.count(func.distinct(func.date(PromoProduct.crawled_at)))
).filter(
PromoProduct.i_code.in_(all_icodes_in_batch),
PromoProduct.page_type == page_type
).group_by(PromoProduct.i_code).all()
else:
# SQLite: 使用 strftime
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}
# 只有 edm 頁面需要計算總銷量和庫存歷程
if page_type == 'edm':
# 計算總銷量
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()
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}
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
# 準備銷售歷程資料
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})
# 8. 附加分類資訊到每個 item
for item in items_in_batch:
item.safe_product_url = normalize_momo_product_url(item.url, item.i_code) or build_momo_product_url(item.i_code)
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)
item.total_sold = total_sold_map.get(item.i_code, 0)
item.qty_history = history_map.get((item.i_code, item.time_slot), [])
# 9. 排序邏輯
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':
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)
# 10. 時段統計
slot_stats = {}
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
return {
'sorted_grouped_items': sorted_grouped_items,
'slot_stats': slot_stats,
'items_in_batch': items_in_batch,
'last_update_str': last_update_str,
'activity_time': activity_time,
'active_tab': active_tab,
'current_batch_id': current_batch_id
}
# ==========================================
# EDM 儀表板路由
# ==========================================
@edm_bp.route('/edm')
@login_required
def edm_dashboard():
"""MOMO 限時搶購 (EDM) 專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
data = _build_promo_dashboard_data(session, 'edm', '限時搶購', sort_by, order)
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'},
{'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'},
{'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'},
{'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'}
]
scheduler_stats = load_scheduler_stats()
now_taipei = datetime.now(TAIPEI_TZ)
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
current_promo_page='edm',
page_title='MOMO 限時搶購',
grouped_items=data['sorted_grouped_items'],
slot_stats=data['slot_stats'],
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
active_tab=data['active_tab'],
current_batch_id=data['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'),
active_page='edm')
except Exception as e:
sys_log.error(f"EDM Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@edm_bp.route('/festival')
@login_required
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:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order)
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'},
{'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'},
{'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'},
{'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'}
]
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
current_promo_page='festival',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
slot_stats=data['slot_stats'],
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
active_tab=data['active_tab'],
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify,
active_page='edm')
except Exception as e:
sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@edm_bp.route('/mothers_day')
@login_required
def mothers_day_dashboard():
"""母親節促銷活動專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
PAGE_TYPE = "mothers_day"
PAGE_NAME = "母親節超值限時購"
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order)
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'},
{'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'},
{'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'},
{'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'}
]
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
current_promo_page='mothers_day',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
slot_stats=data['slot_stats'],
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
active_tab=data['active_tab'],
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify,
active_page='edm')
except Exception as e:
sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@edm_bp.route('/valentine_520')
@login_required
def valentine_520_dashboard():
"""520情人節促銷活動專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
PAGE_TYPE = "valentine_520"
PAGE_NAME = "520情人節限定購物"
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order)
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'},
{'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'},
{'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'},
{'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'}
]
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
current_promo_page='valentine_520',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
slot_stats=data['slot_stats'],
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
active_tab=data['active_tab'],
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify,
active_page='edm')
except Exception as e:
sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()
@edm_bp.route('/labor_day')
@login_required
def labor_day_dashboard():
"""勞動節促銷活動專屬儀表板"""
db = DatabaseManager()
session = db.get_session()
PAGE_TYPE = "labor_day"
PAGE_NAME = "勞動節購物優惠"
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order)
# 建立儀表板頁籤
promo_pages = [
{'url': url_for('edm.edm_dashboard'), 'name': '限時搶購', 'id': 'edm'},
{'url': url_for('edm.festival_dashboard'), 'name': '1.1狂歡購物節', 'id': 'festival'},
{'url': url_for('edm.mothers_day_dashboard'), 'name': '母親節', 'id': 'mothers_day'},
{'url': url_for('edm.valentine_520_dashboard'), 'name': '520情人節', 'id': 'valentine_520'},
{'url': url_for('edm.labor_day_dashboard'), 'name': '勞動節', 'id': 'labor_day'}
]
scheduler_stats = load_scheduler_stats()
template_name = 'edm_dashboard.html' if request.args.get('ui') == 'legacy' else 'edm_dashboard_v2.html'
return render_template(template_name,
promo_pages=promo_pages,
current_promo_page='labor_day',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
slot_stats=data['slot_stats'],
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
active_tab=data['active_tab'],
public_url=public_url,
scheduler_stats=scheduler_stats,
current_sort=sort_by,
current_order=order,
slugify=slugify,
active_page='edm')
except Exception as e:
sys_log.error(f"{PAGE_NAME} Dashboard 渲染錯誤: {e}")
return f"系統錯誤: {e}"
finally:
session.close()