from __future__ import annotations import json import os import sqlite3 from contextlib import contextmanager from datetime import datetime, timedelta, timezone from typing import Any, Iterator from app.auth import hash_password from app.config import Settings UTC = timezone.utc def now_iso() -> str: return datetime.now(UTC).replace(microsecond=0).isoformat() def to_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")) def from_json(value: str | None, default: Any = None) -> Any: if not value: return default return json.loads(value) class Database: def __init__(self, settings: Settings): self.path = settings.database_path os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) @contextmanager def connect(self) -> Iterator[sqlite3.Connection]: conn = sqlite3.connect(self.path, timeout=30) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA busy_timeout = 30000") try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def migrate(self, settings: Settings) -> None: with self.connect() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS admin_settings ( id INTEGER PRIMARY KEY CHECK (id = 1), password_hash TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS webhook_targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, webhook_url TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS routing_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, timeframe TEXT NOT NULL, symbol TEXT NOT NULL, strategy TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100, enabled INTEGER NOT NULL DEFAULT 1, target_ids TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS message_templates ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', card_title_template TEXT NOT NULL, card_body_template TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_rules_match ON routing_rules(enabled, timeframe, symbol, strategy, priority); CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, timeframe TEXT NOT NULL, symbol TEXT NOT NULL, strategy TEXT NOT NULL, action TEXT, price REAL, payload TEXT NOT NULL, matched_rule_id INTEGER, status TEXT NOT NULL, error TEXT, created_at TEXT NOT NULL, FOREIGN KEY(matched_rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS deliveries ( id INTEGER PRIMARY KEY AUTOINCREMENT, alert_id INTEGER NOT NULL, rule_id INTEGER, target_id INTEGER, target_name TEXT NOT NULL, webhook_url TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at TEXT, last_attempt_at TEXT, response_code INTEGER, response_body TEXT, error TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(alert_id) REFERENCES alerts(id) ON DELETE CASCADE, FOREIGN KEY(rule_id) REFERENCES routing_rules(id) ON DELETE SET NULL, FOREIGN KEY(target_id) REFERENCES webhook_targets(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS delivery_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id INTEGER NOT NULL, alert_id INTEGER NOT NULL, attempt_no INTEGER NOT NULL, status TEXT NOT NULL, response_code INTEGER, response_body TEXT, error TEXT, attempted_at TEXT NOT NULL, next_attempt_at TEXT, FOREIGN KEY(delivery_id) REFERENCES deliveries(id) ON DELETE CASCADE, FOREIGN KEY(alert_id) REFERENCES alerts(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_deliveries_retry ON deliveries(status, next_attempt_at); CREATE INDEX IF NOT EXISTS idx_delivery_attempts_delivery ON delivery_attempts(delivery_id, attempt_no); """ ) existing_columns = { row["name"] for row in conn.execute("PRAGMA table_info(routing_rules)").fetchall() } if "message_type" not in existing_columns: conn.execute("ALTER TABLE routing_rules ADD COLUMN message_type TEXT NOT NULL DEFAULT 'card'") if "card_title_template" not in existing_columns: conn.execute( "ALTER TABLE routing_rules ADD COLUMN card_title_template TEXT NOT NULL DEFAULT 'TradingView {{symbol}} {{action}}'" ) if "card_body_template" not in existing_columns: conn.execute( "ALTER TABLE routing_rules ADD COLUMN card_body_template TEXT NOT NULL DEFAULT '{{symbol}} {{timeframe}} {{strategy}} {{action}} @ {{price}}'" ) now = now_iso() conn.execute("UPDATE webhook_targets SET enabled = 1 WHERE enabled <> 1") template_count = conn.execute("SELECT COUNT(*) AS c FROM message_templates").fetchone()["c"] if template_count == 0: conn.execute( """ INSERT INTO message_templates ( name, description, card_title_template, card_body_template, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?) """, ( "供需系统通用卡片", "适合支撑/阻力区域、确认、成交、浮盈保护等供需系统信号。", "{{title}}", "**{{symbol}} · {{timeframe}} · {{direction}}**\n\n" "> {{signal_type}}\n\n" "**价格区**\n" "- 试仓位:{{probe_entry}}\n" "- 优先位:{{priority_entry}}\n" "- 防守位:{{defense_price}}\n" "- 止损位:{{stop_loss}}\n" "- 当前价:{{current_price}}\n\n" "**确认状态**\n" "- 区域评分:{{zone_score}}\n" "- 触碰次数:{{touch_count}}\n" "- 确认状态:{{confirmation_label}}\n" "- 交易准备:{{trade_ready}}\n\n" "**说明**\n" "{{description}}\n\n" "**风险提示**\n" "{{risk_tip}}\n\n" "---\n" "{{source}} · {{strategy}} · {{time}}", now, now, ), ) conn.execute( """ INSERT OR IGNORE INTO admin_settings (id, password_hash, created_at, updated_at) VALUES (1, ?, ?, ?) """, (hash_password(settings.admin_password), now, now), ) def cleanup_old_logs(self, retention_days: int) -> int: cutoff = (datetime.now(UTC) - timedelta(days=retention_days)).replace(microsecond=0).isoformat() with self.connect() as conn: cur = conn.execute("DELETE FROM alerts WHERE created_at < ?", (cutoff,)) return cur.rowcount