This commit is contained in:
Your Name
2026-07-28 15:04:17 +08:00
parent ac406a5f99
commit 8f68af1c2c
27 changed files with 3442 additions and 296 deletions
+61 -3
View File
@@ -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:
+50
View File
@@ -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:
+29 -3
View File
@@ -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):
"""账号额度购买订单。"""