#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Bot API 路由模組 提供給 Clawdbot/Telegram Bot 使用的 API 端點 使用 API Token 認證,不需要 session """ import os from datetime import datetime, timezone, timedelta from functools import wraps from flask import Blueprint, request, jsonify from sqlalchemy import func, desc, text from config import BASE_DIR from database.manager import DatabaseManager from services.logger_manager import SystemLogger # 時區設定 TAIPEI_TZ = timezone(timedelta(hours=8)) # Logger sys_log = SystemLogger("BotAPI").get_logger() # Blueprint 定義 bot_api_bp = Blueprint('bot_api', __name__) # API Token (從環境變數讀取,無預設值) BOT_API_TOKEN = os.getenv('BOT_API_TOKEN') if not BOT_API_TOKEN: import logging as _log _log.warning("[BotAPI] BOT_API_TOKEN 未設定,Bot API 端點將拒絕所有請求") def require_api_token(f): """API Token 認證裝飾器""" @wraps(f) def decorated_function(*args, **kwargs): # 從 header 或 query string 取得 token token = request.headers.get('X-API-Token') or request.args.get('token') if not token: return jsonify({ 'success': False, 'error': 'Missing API token' }), 401 if token != BOT_API_TOKEN: sys_log.warning(f"[BotAPI] Invalid token attempt from {request.remote_addr}") return jsonify({ 'success': False, 'error': 'Invalid API token' }), 403 return f(*args, **kwargs) return decorated_function @bot_api_bp.route('/bot/api/status') def bot_api_status(): """API 狀態檢查(不需要認證)""" return jsonify({ 'success': True, 'service': 'MOMO Pro Bot API', 'version': '1.0.0', 'timestamp': datetime.now(TAIPEI_TZ).isoformat() }) @bot_api_bp.route('/bot/api/daily_sales') @require_api_token def bot_daily_sales(): """ 查詢每日業績 Query Parameters: - date: 日期 (YYYY-MM-DD),預設今天 Returns: - 業績總額、訂單數、商品數等統計 """ try: date_str = request.args.get('date') if date_str: try: query_date = datetime.strptime(date_str, '%Y-%m-%d').date() except ValueError: return jsonify({ 'success': False, 'error': f'Invalid date format: {date_str}. Use YYYY-MM-DD' }), 400 else: query_date = datetime.now(TAIPEI_TZ).date() db = DatabaseManager() engine = db.engine # 查詢當日業績 query = text(""" SELECT COUNT(*) as total_records, COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) as total_revenue, COUNT(DISTINCT "訂單編號") as order_count, COUNT(DISTINCT "商品ID") as product_count FROM daily_sales_snapshot WHERE "日期" = :query_date """) with engine.connect() as conn: result = conn.execute(query, {'query_date': str(query_date)}) row = result.fetchone() if row and row[0] > 0: return jsonify({ 'success': True, 'date': str(query_date), 'data': { 'total_records': row[0], 'total_revenue': round(row[1], 2), 'order_count': row[2], 'product_count': row[3], 'formatted_revenue': f"${row[1]:,.0f}" } }) else: return jsonify({ 'success': True, 'date': str(query_date), 'data': None, 'message': f'No sales data for {query_date}' }) except Exception as e: sys_log.error(f"[BotAPI] daily_sales error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/sales_summary') @require_api_token def bot_sales_summary(): """ 查詢業績摘要(今日 vs 昨日 vs 上週同日) Returns: - 今日、昨日、上週同日的業績比較 """ try: today = datetime.now(TAIPEI_TZ).date() yesterday = today - timedelta(days=1) last_week = today - timedelta(days=7) db = DatabaseManager() engine = db.engine def get_daily_revenue(date): query = text(""" SELECT COALESCE(SUM(CAST("總業績" AS FLOAT)), 0) FROM daily_sales_snapshot WHERE "日期" = :query_date """) with engine.connect() as conn: result = conn.execute(query, {'query_date': str(date)}) row = result.fetchone() return row[0] if row else 0 today_revenue = get_daily_revenue(today) yesterday_revenue = get_daily_revenue(yesterday) last_week_revenue = get_daily_revenue(last_week) # 計算成長率 dod_growth = ((today_revenue - yesterday_revenue) / yesterday_revenue * 100) if yesterday_revenue > 0 else 0 wow_growth = ((today_revenue - last_week_revenue) / last_week_revenue * 100) if last_week_revenue > 0 else 0 return jsonify({ 'success': True, 'data': { 'today': { 'date': str(today), 'revenue': round(today_revenue, 2), 'formatted': f"${today_revenue:,.0f}" }, 'yesterday': { 'date': str(yesterday), 'revenue': round(yesterday_revenue, 2), 'formatted': f"${yesterday_revenue:,.0f}" }, 'last_week': { 'date': str(last_week), 'revenue': round(last_week_revenue, 2), 'formatted': f"${last_week_revenue:,.0f}" }, 'growth': { 'dod': round(dod_growth, 2), # Day over Day 'wow': round(wow_growth, 2), # Week over Week 'dod_emoji': '📈' if dod_growth > 0 else ('📉' if dod_growth < 0 else '➡️'), 'wow_emoji': '📈' if wow_growth > 0 else ('📉' if wow_growth < 0 else '➡️') } } }) except Exception as e: sys_log.error(f"[BotAPI] sales_summary error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/stockout') @require_api_token def bot_stockout(): """ 查詢缺貨商品 Query Parameters: - status: pending/sent/all (預設 pending) - limit: 數量限制 (預設 10) Returns: - 缺貨商品清單 """ try: status = request.args.get('status', 'pending') limit = min(int(request.args.get('limit', 10)), 50) # 最多 50 筆 db = DatabaseManager() engine = db.engine # 根據狀態查詢 if status == 'all': status_condition = "" elif status == 'sent': status_condition = "WHERE sent_date IS NOT NULL" else: # pending status_condition = "WHERE sent_date IS NULL" query = text(f""" SELECT id, vendor_name, product_code, product_name, current_stock, stockout_days, created_at, sent_date FROM vendor_stockout {status_condition} ORDER BY created_at DESC LIMIT :limit """) with engine.connect() as conn: result = conn.execute(query, {'limit': limit}) rows = result.fetchall() items = [] for row in rows: items.append({ 'id': row[0], 'vendor_name': row[1], 'product_code': row[2], 'product_name': row[3], 'current_stock': row[4], 'stockout_days': row[5], 'created_at': str(row[6]) if row[6] else None, 'sent': row[7] is not None }) return jsonify({ 'success': True, 'status_filter': status, 'count': len(items), 'data': items }) except Exception as e: sys_log.error(f"[BotAPI] stockout error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/product_search') @require_api_token def bot_product_search(): """ 搜尋商品 Query Parameters: - q: 搜尋關鍵字(商品名稱或貨號) - limit: 數量限制 (預設 10) Returns: - 符合的商品清單 """ try: keyword = request.args.get('q', '').strip() limit = min(int(request.args.get('limit', 10)), 20) if not keyword: return jsonify({ 'success': False, 'error': 'Missing search keyword (q parameter)' }), 400 db = DatabaseManager() engine = db.engine # 查詢商品並取得最新價格 query = text(""" SELECT p.id, p.i_code, p.name, pr.price as current_price, p.status, p.category, p.updated_at FROM products p LEFT JOIN LATERAL ( SELECT price FROM price_records WHERE product_id = p.id ORDER BY timestamp DESC LIMIT 1 ) pr ON true WHERE p.name ILIKE :keyword OR p.i_code ILIKE :keyword ORDER BY p.updated_at DESC LIMIT :limit """) with engine.connect() as conn: result = conn.execute(query, { 'keyword': f'%{keyword}%', 'limit': limit }) rows = result.fetchall() items = [] for row in rows: items.append({ 'id': row[0], 'i_code': row[1], 'name': row[2], 'current_price': row[3], 'status': row[4], 'category': row[5], 'updated_at': str(row[6]) if row[6] else None }) return jsonify({ 'success': True, 'keyword': keyword, 'count': len(items), 'data': items }) except Exception as e: sys_log.error(f"[BotAPI] product_search error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/top_products') @require_api_token def bot_top_products(): """ 查詢熱銷商品 Query Parameters: - date: 日期 (YYYY-MM-DD),預設今天 - limit: 數量限制 (預設 10) Returns: - 熱銷商品排行 """ try: date_str = request.args.get('date') limit = min(int(request.args.get('limit', 10)), 20) if date_str: try: query_date = datetime.strptime(date_str, '%Y-%m-%d').date() except ValueError: return jsonify({ 'success': False, 'error': f'Invalid date format: {date_str}. Use YYYY-MM-DD' }), 400 else: query_date = datetime.now(TAIPEI_TZ).date() db = DatabaseManager() engine = db.engine query = text(""" SELECT "商品ID", "商品名稱", SUM(CAST("總業績" AS FLOAT)) as total_revenue, SUM(CAST("數量" AS INTEGER)) as total_quantity FROM daily_sales_snapshot WHERE "日期" = :query_date GROUP BY "商品ID", "商品名稱" ORDER BY total_revenue DESC LIMIT :limit """) with engine.connect() as conn: result = conn.execute(query, { 'query_date': str(query_date), 'limit': limit }) rows = result.fetchall() items = [] for i, row in enumerate(rows, 1): items.append({ 'rank': i, 'product_code': row[0], 'product_name': row[1], 'total_revenue': round(row[2], 2), 'total_quantity': row[3], 'formatted_revenue': f"${row[2]:,.0f}" }) return jsonify({ 'success': True, 'date': str(query_date), 'count': len(items), 'data': items }) except Exception as e: sys_log.error(f"[BotAPI] top_products error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 # ===== AI 助手 API ===== @bot_api_bp.route('/bot/api/ai/status') @require_api_token def bot_ai_status(): """ 查詢 AI 服務狀態 Returns: - Ollama 和 Gemini 的連線狀態 """ try: from services.ai_provider import get_ai_status status = get_ai_status(force_refresh=True) return jsonify({ 'success': True, 'data': { 'default_provider': status.get('default_provider', 'ollama'), 'ollama': { 'connected': status.get('ollama', {}).get('connected', False), 'model': status.get('ollama', {}).get('model', 'unknown') }, 'gemini': { 'connected': status.get('gemini', {}).get('connected', False), 'model': status.get('gemini', {}).get('model', 'unknown') } } }) except Exception as e: sys_log.error(f"[BotAPI] ai_status error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/ai/generate_copy', methods=['POST']) @require_api_token def bot_generate_copy(): """ 生成銷售文案 Request JSON: - product_name: 商品名稱 (必填) - trend_keywords: 趨勢關鍵字 (選填, 陣列) - style: 文案風格 (選填, 預設 '吸睛') - provider: AI 提供者 (選填;一律 Ollama-first,Gemini 僅作失敗備援) Returns: - 生成的銷售文案 """ try: data = request.get_json() or {} product_name = data.get('product_name', '') trend_keywords = data.get('trend_keywords', []) style = data.get('style', '吸睛') provider = data.get('provider', None) if not product_name: return jsonify({ 'success': False, 'error': 'Missing product_name' }), 400 from services.ai_provider import ai_provider_service result = ai_provider_service.generate_sales_copy( product_name=product_name, provider=provider, trend_keywords=trend_keywords, style=style ) if result.success: return jsonify({ 'success': True, 'data': { 'copy': result.content, 'model': result.model, 'provider': result.provider, 'duration': round(result.total_duration, 2) if result.total_duration else None } }) else: return jsonify({ 'success': False, 'error': result.error }), 500 except Exception as e: sys_log.error(f"[BotAPI] generate_copy error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/ai/trends') @require_api_token def bot_ai_trends(): """ 查詢趨勢資料 Query Parameters: - categories: 分類 (選填, 可多個) - time_range: 時間範圍 day/week/month (預設 week) Returns: - 新聞、社群熱門話題、關鍵字 """ try: from services.trend_crawler import TrendCrawler categories = request.args.getlist('categories') or ['時尚美妝', '生活居家', '健康保健'] time_range = request.args.get('time_range', 'week') if time_range not in ['day', 'week', 'month']: time_range = 'week' trend_crawler = TrendCrawler() trend_data = trend_crawler.get_all_trends( categories=categories, time_range=time_range, include_social=True ) # 簡化輸出供 Bot 使用 result = { 'timestamp': trend_data.timestamp.isoformat(), 'time_range': time_range, 'keywords': trend_data.keywords[:10], 'news': [ { 'title': n.title, 'source': n.source, 'category': n.category } for n in trend_data.news_items[:10] ], 'social': [ { 'title': p.title, 'source': p.source, 'likes': p.likes } for p in trend_data.social_posts[:10] ] } return jsonify({ 'success': True, 'data': result }) except Exception as e: sys_log.error(f"[BotAPI] ai_trends error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/ai/weather') @require_api_token def bot_ai_weather(): """ 查詢天氣資訊 Query Parameters: - location: 地點 (預設 臺北市) Returns: - 天氣資訊和行銷建議 """ try: from services.trend_crawler import TrendCrawler location = request.args.get('location', '臺北市') trend_crawler = TrendCrawler() weather = trend_crawler.fetch_weather(location) if weather: return jsonify({ 'success': True, 'data': { 'location': weather.location, 'date': weather.date, 'description': weather.weather_description, 'temp_range': f"{weather.min_temp}°C ~ {weather.max_temp}°C", 'rain_probability': weather.rain_probability, 'humidity': weather.humidity, 'marketing_suggestions': weather.marketing_suggestions } }) else: return jsonify({ 'success': False, 'error': '無法獲取天氣資訊' }), 500 except Exception as e: sys_log.error(f"[BotAPI] ai_weather error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 @bot_api_bp.route('/bot/api/ai/suggest', methods=['POST']) @require_api_token def bot_ai_suggest(): """ 生成銷售策略建議 Request JSON: - product_name: 商品名稱 (必填) - trend_keywords: 趨勢關鍵字 (選填) Returns: - 銷售策略建議 """ try: data = request.get_json() or {} product_name = data.get('product_name', '') trend_keywords = data.get('trend_keywords', []) if not product_name: return jsonify({ 'success': False, 'error': 'Missing product_name' }), 400 from services.ollama_service import OllamaService ollama_service = OllamaService() system_prompt = """你是一位資深電商銷售策略顧問,專精於台灣市場。 請用繁體中文回答,簡潔明瞭。""" context = f"熱門趨勢:{', '.join(trend_keywords)}" if trend_keywords else "無特定趨勢" prompt = f"""請為「{product_name}」提供簡短銷售建議: 市場資訊:{context} 請提供: 1. 目標客群(一句話) 2. 主打賣點(一句話) 3. 促銷建議(一句話) 請簡潔回答,總共不超過 100 字。""" result = ollama_service.generate(prompt, system_prompt=system_prompt, temperature=0.6) if result.success: return jsonify({ 'success': True, 'data': { 'suggestion': result.content, 'model': result.model, 'duration': round(result.total_duration, 2) if result.total_duration else None } }) else: return jsonify({ 'success': False, 'error': result.error }), 500 except Exception as e: sys_log.error(f"[BotAPI] ai_suggest error: {e}") return jsonify({ 'success': False, 'error': str(e) }), 500 # ===== 降價決策通知 ===== @bot_api_bp.route('/bot/api/price-decision/notify', methods=['POST']) @require_api_token def price_decision_notify(): """ 觸發 Telegram 降價決策通知(推送給所有 is_admin=True 的用戶) Request JSON: - product_sku: 商品貨號(必填) - product_name: 商品名稱(必填) - current_price: 現價(數字,必填) - suggested_price: 建議降至(數字,必填) - reason: AI 理由(必填) - insight_id: ai_insights 表的 ID(必填,供回調按鈕使用) - report_url: 分析報表連結(選填) """ from sqlalchemy import text as sa_text data = request.get_json() or {} required_fields = ['product_sku', 'product_name', 'current_price', 'suggested_price', 'reason', 'insight_id'] missing = [f for f in required_fields if data.get(f) is None] if missing: return jsonify({'success': False, 'error': f'Missing fields: {", ".join(missing)}'}), 400 product_sku = data['product_sku'] product_name = data['product_name'] insight_id = int(data['insight_id']) report_url = data.get('report_url', '') try: current_price = float(data['current_price']) suggested_price = float(data['suggested_price']) except (ValueError, TypeError) as e: return jsonify({'success': False, 'error': f'Invalid price value: {e}'}), 400 token = os.getenv('TELEGRAM_BOT_TOKEN') if not token: return jsonify({'success': False, 'error': 'TELEGRAM_BOT_TOKEN not configured'}), 500 from services.telegram_templates import price_decision, send_telegram_with_result message, keyboard = price_decision( product_name=product_name, product_sku=product_sku, current_price=current_price, suggested_price=suggested_price, reason=data['reason'], insight_id=insight_id, report_url=report_url or None, ) db = DatabaseManager() sent_count = 0 errors = [] try: with db.engine.connect() as conn: rows = conn.execute(sa_text( "SELECT telegram_id FROM telegram_users WHERE is_active = true AND is_admin = true" )).fetchall() except Exception as e: sys_log.error(f"[BotAPI] price_decision_notify DB error: {e}") return jsonify({'success': False, 'error': f'DB error: {e}'}), 500 admin_chat_ids = [row[0] for row in rows] if admin_chat_ids: result = send_telegram_with_result( message, chat_ids=admin_chat_ids, reply_markup=keyboard, parse_mode="HTML", ) sent_count = int(result.get("sent", 0)) errors = list(result.get("errors", [])) sys_log.info(f"[BotAPI] price_decision_notify sent={sent_count}/{len(rows)} insight_id={insight_id}") return jsonify({ 'success': True, 'insight_id': insight_id, 'sent': sent_count, 'total_admins': len(rows), 'errors': errors, })