更新
This commit is contained in:
@@ -9,8 +9,10 @@ from typing import Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import event, text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.pool import AsyncAdaptedQueuePool, StaticPool
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
PROJECT_ROOT = BACKEND_DIR.parent
|
||||
@@ -37,6 +39,10 @@ def _env_int(name: str, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _bounded_env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||||
return max(minimum, min(maximum, _env_int(name, default)))
|
||||
|
||||
|
||||
def normalize_db_type(value: str | None) -> str:
|
||||
raw = (value or "sqlite").strip().lower()
|
||||
if raw in ("postgres", "pgsql"):
|
||||
@@ -136,7 +142,35 @@ def build_database_url(config: DatabaseConfig | None = None) -> str:
|
||||
|
||||
def engine_kwargs_for_url(url: str) -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {"echo": False}
|
||||
if not url.startswith("sqlite"):
|
||||
if url.startswith("sqlite"):
|
||||
# SQLAlchemy 2.0.30 defaults file-backed aiosqlite to NullPool. At
|
||||
# hundreds of hosted accounts that creates and tears down an aiosqlite
|
||||
# worker thread for every short query. Reuse a small bounded pool;
|
||||
# SQLite still serializes writers, so a large pool only adds lock
|
||||
# contention and does not improve throughput.
|
||||
busy_timeout_ms = _bounded_env_int(
|
||||
"KEFU_SQLITE_BUSY_TIMEOUT_MS", 30_000, 1_000, 120_000
|
||||
)
|
||||
kwargs["connect_args"] = {"timeout": busy_timeout_ms / 1000.0}
|
||||
sqlite_database = make_url(url).database
|
||||
is_memory_database = (
|
||||
not sqlite_database
|
||||
or sqlite_database == ":memory:"
|
||||
or "mode=memory" in url.lower()
|
||||
)
|
||||
if is_memory_database:
|
||||
# Every connection to an in-memory SQLite URL otherwise receives a
|
||||
# different database. StaticPool preserves the single shared
|
||||
# connection expected by tests and utility callers.
|
||||
kwargs["poolclass"] = StaticPool
|
||||
else:
|
||||
kwargs["poolclass"] = AsyncAdaptedQueuePool
|
||||
kwargs["pool_size"] = _bounded_env_int(
|
||||
"KEFU_SQLITE_POOL_SIZE", 5, 1, 10
|
||||
)
|
||||
kwargs["max_overflow"] = 0
|
||||
kwargs["pool_timeout"] = max(5.0, busy_timeout_ms / 1000.0)
|
||||
else:
|
||||
kwargs["pool_pre_ping"] = True
|
||||
kwargs["pool_recycle"] = 3600
|
||||
# 默认连接池仅 pool_size=5 + max_overflow=10。多账号托管 + 前端并发请求时
|
||||
@@ -155,9 +189,33 @@ def engine_kwargs_for_url(url: str) -> dict[str, Any]:
|
||||
return kwargs
|
||||
|
||||
|
||||
def _configure_sqlite_connection(dbapi_connection, _connection_record) -> None:
|
||||
"""Apply process-wide SQLite settings to every pooled connection.
|
||||
|
||||
WAL lets readers continue while the single writer commits. NORMAL avoids
|
||||
a full disk sync for every small log/status transaction while retaining
|
||||
WAL crash consistency. busy_timeout turns transient writer contention
|
||||
into bounded waiting instead of immediate ``database is locked`` errors.
|
||||
"""
|
||||
busy_timeout_ms = _bounded_env_int(
|
||||
"KEFU_SQLITE_BUSY_TIMEOUT_MS", 30_000, 1_000, 120_000
|
||||
)
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute(f"PRAGMA busy_timeout={busy_timeout_ms}")
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def create_database_engine(config: DatabaseConfig | None = None) -> AsyncEngine:
|
||||
url = build_database_url(config)
|
||||
return create_async_engine(url, **engine_kwargs_for_url(url))
|
||||
engine = create_async_engine(url, **engine_kwargs_for_url(url))
|
||||
if url.startswith("sqlite"):
|
||||
event.listen(engine.sync_engine, "connect", _configure_sqlite_connection)
|
||||
return engine
|
||||
|
||||
|
||||
def mask_database_url(url: str) -> str:
|
||||
|
||||
@@ -33,6 +33,26 @@ def add_column_if_missing(conn, table: str, column: str, ddl_by_dialect: dict[st
|
||||
conn.execute(text(ddl))
|
||||
|
||||
|
||||
def add_index_if_missing(
|
||||
conn,
|
||||
table: str,
|
||||
index_name: str,
|
||||
columns: tuple[str, ...],
|
||||
) -> None:
|
||||
"""Create one portable index without relying on dialect-specific IF NOT EXISTS."""
|
||||
try:
|
||||
insp = inspect(conn)
|
||||
if not insp.has_table(table):
|
||||
return
|
||||
existing = {item.get("name") for item in insp.get_indexes(table)}
|
||||
except Exception:
|
||||
return
|
||||
if index_name in existing:
|
||||
return
|
||||
safe_columns = ", ".join(columns)
|
||||
conn.execute(text(f"CREATE INDEX {index_name} ON {table} ({safe_columns})"))
|
||||
|
||||
|
||||
def migrate_accounts_table(conn) -> None:
|
||||
add_column_if_missing(
|
||||
conn,
|
||||
@@ -124,6 +144,36 @@ def migrate_message_logs_table(conn) -> None:
|
||||
"sender_avatar",
|
||||
{"default": "ALTER TABLE message_logs ADD COLUMN sender_avatar TEXT"},
|
||||
)
|
||||
add_index_if_missing(
|
||||
conn,
|
||||
"message_logs",
|
||||
"ix_message_logs_account_created_at",
|
||||
("account_id", "created_at"),
|
||||
)
|
||||
add_index_if_missing(
|
||||
conn,
|
||||
"message_logs",
|
||||
"ix_message_logs_created_at",
|
||||
("created_at",),
|
||||
)
|
||||
add_index_if_missing(
|
||||
conn,
|
||||
"message_logs",
|
||||
"ix_message_logs_status_account_id",
|
||||
("status", "account_id"),
|
||||
)
|
||||
add_index_if_missing(
|
||||
conn,
|
||||
"received_message_logs",
|
||||
"ix_received_message_logs_account_created_at",
|
||||
("account_id", "created_at"),
|
||||
)
|
||||
add_index_if_missing(
|
||||
conn,
|
||||
"system_logs",
|
||||
"ix_system_logs_account_created_at",
|
||||
("account_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
def migrate_rules_table(conn) -> None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship, validates
|
||||
from .database import Base
|
||||
from utils.log_limits import bound_error_log_content, bound_message_log_content
|
||||
|
||||
|
||||
class User(Base):
|
||||
@@ -204,10 +205,23 @@ class MessageLog(Base):
|
||||
reply_content = Column(Text, nullable=True) # 回复的消息
|
||||
status = Column(String(50), default="received") # received, replied, ignored, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
account = relationship("Account", back_populates="logs")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_message_logs_account_created_at", "account_id", "created_at"),
|
||||
Index("ix_message_logs_status_account_id", "status", "account_id"),
|
||||
)
|
||||
|
||||
@validates("message_content", "reply_content")
|
||||
def _bound_message_content(self, _key, value):
|
||||
return bound_message_log_content(value) if value is not None else None
|
||||
|
||||
@validates("error_message")
|
||||
def _bound_error_content(self, _key, value):
|
||||
return bound_error_log_content(value) if value is not None else None
|
||||
|
||||
|
||||
class ReceivedMessageLog(Base):
|
||||
"""接收消息原始日志:仅记录收到的消息,内容原样保存。"""
|
||||
@@ -225,6 +239,14 @@ class ReceivedMessageLog(Base):
|
||||
raw_content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_received_message_logs_account_created_at",
|
||||
"account_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FollowWelcomeLog(Base):
|
||||
"""关注欢迎语去重表:每个账号对每个新粉丝只发送一次欢迎语(重启后仍生效)。"""
|
||||
@@ -255,6 +277,10 @@ class SystemLog(Base):
|
||||
detail = Column(Text, nullable=True) # 详细原因
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_system_logs_account_created_at", "account_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class PaymentOrder(Base):
|
||||
"""账号额度购买订单。"""
|
||||
|
||||
Reference in New Issue
Block a user