This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import declarative_base, sessionmaker
from models.db_config import (
build_database_url,
create_database_engine,
read_database_config,
)
_config = read_database_config()
DATABASE_URL = build_database_url(_config)
engine = create_database_engine(_config)
AsyncSessionLocal = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+252
View File
@@ -0,0 +1,252 @@
"""数据库连接配置:支持 SQLite / MySQL / PostgreSQL。"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus
from dotenv import load_dotenv
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
BACKEND_DIR = Path(__file__).resolve().parent.parent
PROJECT_ROOT = BACKEND_DIR.parent
ENV_FILE = PROJECT_ROOT / ".env"
load_dotenv(ENV_FILE)
SUPPORTED_DB_TYPES = ("sqlite", "mysql", "postgresql")
PASSWORD_PLACEHOLDER = "******"
DEFAULT_SQLITE_PATH = BACKEND_DIR / "kefu.db"
def _env(name: str, default: str = "") -> str:
return (os.getenv(name) or default).strip()
def _env_int(name: str, default: int) -> int:
raw = _env(name)
if not raw:
return default
try:
return int(raw)
except ValueError:
return default
def normalize_db_type(value: str | None) -> str:
raw = (value or "sqlite").strip().lower()
if raw in ("postgres", "pgsql"):
return "postgresql"
if raw in SUPPORTED_DB_TYPES:
return raw
return "sqlite"
@dataclass
class DatabaseConfig:
db_type: str = "sqlite"
db_host: str = "127.0.0.1"
db_port: int = 0
db_user: str = ""
db_password: str = ""
db_name: str = "kefu"
db_path: str = ""
database_url: str = ""
def normalized_type(self) -> str:
return normalize_db_type(self.db_type)
def resolved_port(self) -> int:
if self.db_port:
return self.db_port
if self.normalized_type() == "mysql":
return 3306
if self.normalized_type() == "postgresql":
return 5432
return 0
def sqlite_path(self) -> Path:
if self.db_path:
path = Path(self.db_path)
if not path.is_absolute():
path = BACKEND_DIR / path
return path
if self.database_url and self.database_url.startswith("sqlite"):
# sqlite+aiosqlite:///path
raw = self.database_url.split("///", 1)[-1]
return Path(raw)
return DEFAULT_SQLITE_PATH
def read_database_config() -> DatabaseConfig:
explicit_url = _env("KEFU_DATABASE_URL")
db_type = normalize_db_type(_env("KEFU_DB_TYPE", "sqlite"))
db_path = _env("KEFU_DB_PATH")
if explicit_url and not _env("KEFU_DB_TYPE"):
lowered = explicit_url.lower()
if lowered.startswith("mysql"):
db_type = "mysql"
elif lowered.startswith("postgresql") or lowered.startswith("postgres"):
db_type = "postgresql"
elif lowered.startswith("sqlite"):
db_type = "sqlite"
return DatabaseConfig(
db_type=db_type,
db_host=_env("KEFU_DB_HOST", "127.0.0.1"),
db_port=_env_int("KEFU_DB_PORT", 0),
db_user=_env("KEFU_DB_USER"),
db_password=_env("KEFU_DB_PASSWORD"),
db_name=_env("KEFU_DB_NAME", "kefu"),
db_path=db_path,
database_url=explicit_url,
)
def build_database_url(config: DatabaseConfig | None = None) -> str:
cfg = config or read_database_config()
if cfg.database_url:
return cfg.database_url
db_type = cfg.normalized_type()
if db_type == "sqlite":
path = cfg.sqlite_path()
path.parent.mkdir(parents=True, exist_ok=True)
return f"sqlite+aiosqlite:///{path.as_posix()}"
user = quote_plus(cfg.db_user or "")
password = quote_plus(cfg.db_password or "")
host = cfg.db_host or "127.0.0.1"
port = cfg.resolved_port()
db_name = cfg.db_name or "kefu"
if db_type == "mysql":
auth = f"{user}:{password}@" if user else ""
return (
f"mysql+asyncmy://{auth}{host}:{port}/{db_name}"
"?charset=utf8mb4"
)
auth = f"{user}:{password}@" if user else ""
return f"postgresql+asyncpg://{auth}{host}:{port}/{db_name}"
def engine_kwargs_for_url(url: str) -> dict[str, Any]:
kwargs: dict[str, Any] = {"echo": False}
if not url.startswith("sqlite"):
kwargs["pool_pre_ping"] = True
kwargs["pool_recycle"] = 3600
# 默认连接池仅 pool_size=5 + max_overflow=10。多账号托管 + 前端并发请求时
# 容易耗尽连接导致请求阻塞/失败,这里放大连接池(可用环境变量覆盖)。
try:
_pool = int(os.getenv("KEFU_DB_POOL_SIZE", "20") or 20)
except ValueError:
_pool = 20
try:
_overflow = int(os.getenv("KEFU_DB_MAX_OVERFLOW", "40") or 40)
except ValueError:
_overflow = 40
kwargs["pool_size"] = max(5, _pool)
kwargs["max_overflow"] = max(0, _overflow)
kwargs["pool_timeout"] = 30
return kwargs
def create_database_engine(config: DatabaseConfig | None = None) -> AsyncEngine:
url = build_database_url(config)
return create_async_engine(url, **engine_kwargs_for_url(url))
def mask_database_url(url: str) -> str:
if "@" not in url or "://" not in url:
return url
scheme, rest = url.split("://", 1)
if "@" not in rest:
return url
creds, host_part = rest.rsplit("@", 1)
if ":" in creds:
user = creds.split(":", 1)[0]
return f"{scheme}://{user}:{PASSWORD_PLACEHOLDER}@{host_part}"
return f"{scheme}://{PASSWORD_PLACEHOLDER}@{host_part}"
def database_config_to_response(cfg: DatabaseConfig | None = None) -> dict[str, Any]:
cfg = cfg or read_database_config()
url = build_database_url(cfg)
return {
"db_type": cfg.normalized_type(),
"db_host": cfg.db_host,
"db_port": cfg.resolved_port(),
"db_user": cfg.db_user,
"db_name": cfg.db_name,
"db_path": str(cfg.sqlite_path()) if cfg.normalized_type() == "sqlite" else "",
"db_password": PASSWORD_PLACEHOLDER if cfg.db_password else "",
"db_password_configured": bool(cfg.db_password),
"database_url_display": mask_database_url(url),
"supported_types": list(SUPPORTED_DB_TYPES),
}
def persist_database_config(payload: dict[str, Any]) -> None:
"""将数据库配置写入项目根 .env 文件。"""
from dotenv import set_key
if not ENV_FILE.exists():
ENV_FILE.write_text("", encoding="utf-8")
db_type = normalize_db_type(payload.get("db_type"))
env_map = {
"KEFU_DB_TYPE": db_type,
"KEFU_DB_HOST": (payload.get("db_host") or "127.0.0.1").strip(),
"KEFU_DB_PORT": str(payload.get("db_port") or ""),
"KEFU_DB_USER": (payload.get("db_user") or "").strip(),
"KEFU_DB_NAME": (payload.get("db_name") or "kefu").strip(),
"KEFU_DB_PATH": (payload.get("db_path") or "").strip(),
}
password = payload.get("db_password")
if password and str(password).strip() not in ("", PASSWORD_PLACEHOLDER):
env_map["KEFU_DB_PASSWORD"] = str(password).strip()
# 清除完整 URL,避免与分项配置冲突
set_key(str(ENV_FILE), "KEFU_DATABASE_URL", "")
for key, value in env_map.items():
set_key(str(ENV_FILE), key, value or "")
# 让当前进程也能读到新值(重启后才会重建 engine)
for key, value in env_map.items():
os.environ[key] = value or ""
os.environ.pop("KEFU_DATABASE_URL", None)
async def test_database_connection(payload: dict[str, Any]) -> None:
cfg = DatabaseConfig(
db_type=normalize_db_type(payload.get("db_type")),
db_host=(payload.get("db_host") or "127.0.0.1").strip(),
db_port=int(payload.get("db_port") or 0),
db_user=(payload.get("db_user") or "").strip(),
db_password=(payload.get("db_password") or "").strip(),
db_name=(payload.get("db_name") or "kefu").strip(),
db_path=(payload.get("db_path") or "").strip(),
)
current = read_database_config()
if cfg.db_password in ("", PASSWORD_PLACEHOLDER):
cfg.db_password = current.db_password
url = build_database_url(cfg)
engine = create_async_engine(url, **engine_kwargs_for_url(url))
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
except ModuleNotFoundError as exc:
driver = "asyncmy" if cfg.normalized_type() == "mysql" else "asyncpg"
raise RuntimeError(
f"缺少数据库驱动,请执行: pip install {driver}"
) from exc
finally:
await engine.dispose()
+223
View File
@@ -0,0 +1,223 @@
"""跨数据库兼容的轻量 schema 迁移。"""
from __future__ import annotations
from sqlalchemy import inspect, text
def _table_columns(conn, table: str) -> set[str]:
try:
insp = inspect(conn)
return {col["name"] for col in insp.get_columns(table)}
except Exception:
return set()
def _dialect(conn) -> str:
return conn.dialect.name
def _bool_default(conn, value: bool = True) -> str:
if _dialect(conn) == "postgresql":
return "TRUE" if value else "FALSE"
return "1" if value else "0"
def add_column_if_missing(conn, table: str, column: str, ddl_by_dialect: dict[str, str]) -> None:
cols = _table_columns(conn, table)
if not cols or column in cols:
return
dialect = _dialect(conn)
ddl = ddl_by_dialect.get(dialect) or ddl_by_dialect.get("default")
if ddl:
conn.execute(text(ddl))
def migrate_accounts_table(conn) -> None:
add_column_if_missing(
conn,
"accounts",
"cookie_data",
{"default": "ALTER TABLE accounts ADD COLUMN cookie_data TEXT"},
)
add_column_if_missing(
conn,
"accounts",
"cookie_updated_at",
{
"default": "ALTER TABLE accounts ADD COLUMN cookie_updated_at DATETIME",
"postgresql": "ALTER TABLE accounts ADD COLUMN cookie_updated_at TIMESTAMP",
},
)
add_column_if_missing(
conn,
"accounts",
"im_session_data",
{"default": "ALTER TABLE accounts ADD COLUMN im_session_data TEXT"},
)
add_column_if_missing(
conn,
"accounts",
"reply_delay_seconds",
{"default": "ALTER TABLE accounts ADD COLUMN reply_delay_seconds INTEGER DEFAULT 0"},
)
add_column_if_missing(
conn,
"accounts",
"reply_cooldown_seconds",
{"default": "ALTER TABLE accounts ADD COLUMN reply_cooldown_seconds INTEGER"},
)
add_column_if_missing(
conn,
"accounts",
"follow_welcome_enabled",
{
"default": "ALTER TABLE accounts ADD COLUMN follow_welcome_enabled BOOLEAN DEFAULT 0",
"postgresql": "ALTER TABLE accounts ADD COLUMN follow_welcome_enabled BOOLEAN DEFAULT FALSE",
},
)
add_column_if_missing(
conn,
"accounts",
"follow_welcome_content",
{"default": "ALTER TABLE accounts ADD COLUMN follow_welcome_content TEXT"},
)
add_column_if_missing(
conn,
"accounts",
"owner_id",
{"default": "ALTER TABLE accounts ADD COLUMN owner_id INTEGER"},
)
add_column_if_missing(
conn,
"accounts",
"user_agent",
{"default": "ALTER TABLE accounts ADD COLUMN user_agent TEXT"},
)
add_column_if_missing(
conn,
"accounts",
"avatar_url",
{"default": "ALTER TABLE accounts ADD COLUMN avatar_url TEXT"},
)
add_column_if_missing(
conn,
"accounts",
"douyin_uid",
{"default": "ALTER TABLE accounts ADD COLUMN douyin_uid VARCHAR(64)"},
)
def migrate_account_videos_table(conn) -> None:
add_column_if_missing(
conn,
"account_videos",
"media_type",
{"default": "ALTER TABLE account_videos ADD COLUMN media_type VARCHAR(20)"},
)
def migrate_message_logs_table(conn) -> None:
add_column_if_missing(
conn,
"message_logs",
"sender_avatar",
{"default": "ALTER TABLE message_logs ADD COLUMN sender_avatar TEXT"},
)
def migrate_rules_table(conn) -> None:
add_column_if_missing(
conn,
"rules",
"owner_id",
{"default": "ALTER TABLE rules ADD COLUMN owner_id INTEGER"},
)
add_column_if_missing(
conn,
"rules",
"sort_order",
{"default": "ALTER TABLE rules ADD COLUMN sort_order INTEGER DEFAULT 0"},
)
cols = _table_columns(conn, "rules")
if cols and "sort_order" in cols:
conn.execute(
text("UPDATE rules SET sort_order = id WHERE sort_order IS NULL OR sort_order = 0")
)
def migrate_users_table(conn) -> None:
add_column_if_missing(
conn,
"users",
"email",
{"default": "ALTER TABLE users ADD COLUMN email VARCHAR(255)"},
)
add_column_if_missing(
conn,
"users",
"email_verified",
{
"default": "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0",
"postgresql": "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE",
},
)
add_column_if_missing(
conn,
"users",
"email_verified_at",
{
"default": "ALTER TABLE users ADD COLUMN email_verified_at DATETIME",
"postgresql": "ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMP",
},
)
add_column_if_missing(
conn,
"users",
"max_accounts",
{"default": "ALTER TABLE users ADD COLUMN max_accounts INTEGER DEFAULT 3"},
)
cols = _table_columns(conn, "users")
if not cols:
return
verified = _bool_default(conn, True)
conn.execute(text(f"UPDATE users SET email_verified = {verified} WHERE email_verified IS NULL"))
conn.execute(text("UPDATE users SET max_accounts = -1 WHERE role = 'admin'"))
conn.execute(text("UPDATE users SET max_accounts = 3 WHERE max_accounts IS NULL"))
def migrate_payment_orders_table(conn) -> None:
cols = _table_columns(conn, "payment_orders")
if not cols:
return
add_column_if_missing(
conn,
"payment_orders",
"slots_applied",
{
"default": "ALTER TABLE payment_orders ADD COLUMN slots_applied BOOLEAN DEFAULT 0",
"postgresql": "ALTER TABLE payment_orders ADD COLUMN slots_applied BOOLEAN DEFAULT FALSE",
},
)
applied = _bool_default(conn, True)
conn.execute(
text(
"UPDATE payment_orders SET slots_applied = "
f"{applied} WHERE status = 'paid' AND (slots_applied IS NULL OR slots_applied = 0)"
)
)
def migrate_accounts_quota_disabled(conn) -> None:
cols = _table_columns(conn, "accounts")
if not cols:
return
add_column_if_missing(
conn,
"accounts",
"quota_disabled",
{
"default": "ALTER TABLE accounts ADD COLUMN quota_disabled BOOLEAN DEFAULT 0",
"postgresql": "ALTER TABLE accounts ADD COLUMN quota_disabled BOOLEAN DEFAULT FALSE",
},
)
+323
View File
@@ -0,0 +1,323 @@
"""从 SQLite 源库迁移数据到目标数据库。"""
from __future__ import annotations
from datetime import date, datetime
from pathlib import Path
from typing import Any
from sqlalchemy import MetaData, insert, inspect, text
from sqlalchemy.ext.asyncio import AsyncEngine
from models.database import Base
import models.models # noqa: F401 — 注册 ORM 表结构
from models.db_config import (
PASSWORD_PLACEHOLDER,
DatabaseConfig,
build_database_url,
create_database_engine,
mask_database_url,
read_database_config,
)
from models.db_migrate import (
migrate_accounts_quota_disabled,
migrate_accounts_table,
migrate_message_logs_table,
migrate_payment_orders_table,
migrate_rules_table,
migrate_users_table,
)
# 按外键依赖顺序导入
MIGRATION_TABLES: tuple[str, ...] = (
"users",
"email_verification_tokens",
"password_reset_tokens",
"app_config",
"accounts",
"rules",
"message_logs",
"received_message_logs",
"system_logs",
"payment_orders",
)
BATCH_SIZE = 400
def _parse_datetime(value: Any) -> Any:
if value is None or isinstance(value, (datetime, date)):
return value
if isinstance(value, str):
text_value = value.strip()
if not text_value:
return None
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(text_value, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(text_value.replace("Z", "+00:00"))
except ValueError:
return value
return value
def _normalize_rows(rows: list[dict[str, Any]], tbl) -> list[dict[str, Any]]:
if not rows:
return rows
col_types = {column.name: column.type for column in tbl.columns}
normalized: list[dict[str, Any]] = []
for row in rows:
item = dict(row)
for key, col_type in col_types.items():
if key not in item or item[key] is None:
continue
type_name = col_type.__class__.__name__.lower()
if "datetime" in type_name or "timestamp" in type_name:
item[key] = _parse_datetime(item[key])
elif "boolean" in type_name and not isinstance(item[key], bool):
if isinstance(item[key], (int, float)):
item[key] = bool(item[key])
elif isinstance(item[key], str):
item[key] = item[key].strip().lower() in ("1", "true", "t", "yes")
normalized.append(item)
return normalized
def resolve_sqlite_source_path(source_path: str | None = None) -> Path:
if source_path and str(source_path).strip():
path = Path(source_path.strip())
if not path.is_absolute():
from models.db_config import BACKEND_DIR
path = BACKEND_DIR / path
return path
from models.db_config import DEFAULT_SQLITE_PATH
return DEFAULT_SQLITE_PATH
def build_sqlite_config(path: Path) -> DatabaseConfig:
return DatabaseConfig(db_type="sqlite", db_path=str(path))
def _same_sqlite_target(source: Path, target: DatabaseConfig) -> bool:
if target.normalized_type() != "sqlite":
return False
try:
return source.resolve() == target.sqlite_path().resolve()
except OSError:
return str(source) == str(target.sqlite_path())
async def inspect_sqlite_source(source_path: str | None = None) -> dict[str, Any]:
path = resolve_sqlite_source_path(source_path)
if not path.exists():
return {
"source_path": str(path),
"exists": False,
"tables": {},
"total_rows": 0,
}
cfg = build_sqlite_config(path)
engine = create_database_engine(cfg)
tables: dict[str, int] = {}
total_rows = 0
try:
async with engine.connect() as conn:
for table in MIGRATION_TABLES:
if not await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
tables[table] = 0
continue
count = await conn.scalar(text(f"SELECT COUNT(*) FROM {table}"))
row_count = int(count or 0)
tables[table] = row_count
total_rows += row_count
finally:
await engine.dispose()
return {
"source_path": str(path),
"exists": True,
"tables": tables,
"total_rows": total_rows,
}
async def _ensure_target_schema(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(migrate_accounts_table)
await conn.run_sync(migrate_rules_table)
await conn.run_sync(migrate_message_logs_table)
await conn.run_sync(migrate_users_table)
await conn.run_sync(migrate_payment_orders_table)
await conn.run_sync(migrate_accounts_quota_disabled)
async def _count_target_rows(engine: AsyncEngine) -> dict[str, int]:
counts: dict[str, int] = {}
async with engine.connect() as conn:
for table in MIGRATION_TABLES:
if not await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
counts[table] = 0
continue
count = await conn.scalar(text(f"SELECT COUNT(*) FROM {table}"))
counts[table] = int(count or 0)
return counts
async def _fetch_table_rows(engine: AsyncEngine, table: str) -> list[dict[str, Any]]:
async with engine.connect() as conn:
if not await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
return []
result = await conn.execute(text(f"SELECT * FROM {table} ORDER BY id"))
return [dict(row) for row in result.mappings()]
async def _clear_target_tables(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
dialect = conn.dialect.name
if dialect == "mysql":
await conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
for table in reversed(MIGRATION_TABLES):
if await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
await conn.execute(text(f"DELETE FROM {table}"))
await conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
elif dialect == "postgresql":
existing = []
for table in MIGRATION_TABLES:
if await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
existing.append(table)
if existing:
await conn.execute(
text(f"TRUNCATE {', '.join(existing)} RESTART IDENTITY CASCADE")
)
else:
await conn.execute(text("PRAGMA foreign_keys=OFF"))
for table in reversed(MIGRATION_TABLES):
if await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
await conn.execute(text(f"DELETE FROM {table}"))
await conn.execute(text("PRAGMA foreign_keys=ON"))
async def _insert_rows(engine: AsyncEngine, table: str, rows: list[dict[str, Any]]) -> int:
if not rows:
return 0
tbl = Base.metadata.tables.get(table)
if tbl is None:
metadata = MetaData()
def _reflect(sync_conn) -> None:
metadata.reflect(sync_conn, only=[table])
async with engine.connect() as conn:
await conn.run_sync(_reflect)
tbl = metadata.tables[table]
inserted = 0
prepared_rows = _normalize_rows(rows, tbl)
async with engine.begin() as conn:
for offset in range(0, len(prepared_rows), BATCH_SIZE):
batch = prepared_rows[offset : offset + BATCH_SIZE]
await conn.execute(insert(tbl), batch)
inserted += len(batch)
return inserted
async def _reset_auto_increment(engine: AsyncEngine) -> None:
async with engine.begin() as conn:
dialect = conn.dialect.name
for table in MIGRATION_TABLES:
if not await conn.run_sync(lambda sync_conn, t=table: inspect(sync_conn).has_table(t)):
continue
max_id = await conn.scalar(text(f"SELECT COALESCE(MAX(id), 0) FROM {table}"))
if not max_id:
continue
next_id = int(max_id) + 1
if dialect == "mysql":
await conn.execute(text(f"ALTER TABLE {table} AUTO_INCREMENT = {next_id}"))
elif dialect == "postgresql":
await conn.execute(
text(
"SELECT setval("
f"pg_get_serial_sequence('{table}', 'id'), "
f"{next_id}, false)"
)
)
def _build_target_config(payload: dict[str, Any]) -> DatabaseConfig:
current = read_database_config()
password = (payload.get("db_password") or "").strip()
if password in ("", PASSWORD_PLACEHOLDER):
password = current.db_password
return DatabaseConfig(
db_type=payload.get("db_type") or current.db_type,
db_host=(payload.get("db_host") or current.db_host or "127.0.0.1").strip(),
db_port=int(payload.get("db_port") or current.resolved_port() or 0),
db_user=(payload.get("db_user") or current.db_user).strip(),
db_password=password,
db_name=(payload.get("db_name") or current.db_name or "kefu").strip(),
db_path=(payload.get("db_path") or current.db_path).strip(),
)
async def migrate_sqlite_to_target(
payload: dict[str, Any],
*,
clear_target: bool = False,
) -> dict[str, Any]:
source_path = resolve_sqlite_source_path(payload.get("source_db_path"))
if not source_path.exists():
raise FileNotFoundError(f"SQLite 源文件不存在: {source_path}")
target_cfg = _build_target_config(payload)
if _same_sqlite_target(source_path, target_cfg):
raise ValueError("目标库不能与 SQLite 源文件相同,请指定不同的目标数据库")
source_cfg = build_sqlite_config(source_path)
source_engine = create_database_engine(source_cfg)
target_engine = create_database_engine(target_cfg)
try:
preview = await inspect_sqlite_source(str(source_path))
if preview["total_rows"] == 0:
raise ValueError("SQLite 源库中没有可迁移的数据")
await _ensure_target_schema(target_engine)
target_counts = await _count_target_rows(target_engine)
target_total = sum(target_counts.values())
if target_total > 0 and not clear_target:
raise ValueError(
f"目标库已有 {target_total} 条数据,请勾选「清空目标库后再导入」或先手动清空"
)
if clear_target and target_total > 0:
await _clear_target_tables(target_engine)
copied: dict[str, int] = {}
total_rows = 0
for table in MIGRATION_TABLES:
rows = await _fetch_table_rows(source_engine, table)
copied[table] = await _insert_rows(target_engine, table, rows)
total_rows += copied[table]
await _reset_auto_increment(target_engine)
finally:
await source_engine.dispose()
await target_engine.dispose()
target_type = target_cfg.normalized_type()
return {
"message": f"已从 SQLite 迁移 {total_rows} 条记录到 {target_type} 数据库",
"source_path": str(source_path),
"target_type": target_type,
"target_url": mask_database_url(build_database_url(target_cfg)),
"tables": copied,
"total_rows": total_rows,
}
+278
View File
@@ -0,0 +1,278 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(255), unique=True, index=True, nullable=True)
password_hash = Column(String(255), nullable=False)
display_name = Column(String(100), nullable=True)
role = Column(String(20), default="operator", index=True) # admin, operator, viewer
is_active = Column(Boolean, default=True)
email_verified = Column(Boolean, default=False)
email_verified_at = Column(DateTime, nullable=True)
max_accounts = Column(Integer, default=3)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
accounts = relationship("Account", back_populates="owner")
verification_tokens = relationship(
"EmailVerificationToken",
back_populates="user",
cascade="all, delete-orphan",
)
password_reset_tokens = relationship(
"PasswordResetToken",
back_populates="user",
cascade="all, delete-orphan",
)
class EmailVerificationToken(Base):
__tablename__ = "email_verification_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token = Column(String(128), unique=True, index=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="verification_tokens")
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token = Column(String(128), unique=True, index=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="password_reset_tokens")
class AppConfig(Base):
"""系统功能配置(单例行 id=1,JSON 存储)。"""
__tablename__ = "app_config"
id = Column(Integer, primary_key=True)
data = Column(Text, nullable=False, default="{}")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Account(Base):
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
username = Column(String(100), unique=True, index=True, nullable=True) # 登录后获取的用户名/ID
avatar_url = Column(String(512), nullable=True) # 登录后获取的头像 URL
douyin_uid = Column(String(64), nullable=True, index=True) # 抖音 UID
phone = Column(String(20), nullable=True) # 绑定的手机号(可选)
status = Column(String(50), default="offline") # offline, logging_in, online, error
cookie_path = Column(String(255), nullable=True) # 存储 cookie/session 的路径
cookie_data = Column(Text, nullable=True) # Playwright storage_state JSON
cookie_updated_at = Column(DateTime, nullable=True) # Cookie 最近更新时间
im_session_data = Column(Text, nullable=True) # IM 直连会话 (WS URL / device_id 等)
reply_delay_seconds = Column(Integer, default=0) # 账号回复排队间隔;0/NULL=继承系统默认
reply_cooldown_seconds = Column(Integer, nullable=True) # 自动回复冷却秒数;NULL=继承全局设置
follow_welcome_enabled = Column(Boolean, default=False) # 新粉丝关注后自动发送欢迎语
follow_welcome_content = Column(Text, nullable=True) # 关注欢迎语内容(空=不发)
user_agent = Column(Text, nullable=True) # 伪装设备头(User-Agent),空=默认
qr_code_base64 = Column(Text, nullable=True) # 当前登录二维码的 base64 字符串
error_message = Column(Text, nullable=True) # 错误信息
quota_disabled = Column(Boolean, default=False, index=True) # 额度不足被停用
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 关联
owner = relationship("User", back_populates="accounts")
rules = relationship("AutoReplyRule", back_populates="account", cascade="all, delete-orphan")
logs = relationship("MessageLog", back_populates="account", cascade="all, delete-orphan")
profile_detail = relationship(
"AccountProfileDetail",
back_populates="account",
uselist=False,
cascade="all, delete-orphan",
)
videos = relationship(
"AccountVideo",
back_populates="account",
cascade="all, delete-orphan",
order_by="AccountVideo.sort_order",
)
class AccountProfileDetail(Base):
"""托管账号抖音详细资料(本地缓存)。"""
__tablename__ = "account_profile_details"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
uid = Column(String(64), nullable=True)
nickname = Column(String(200), nullable=True)
avatar_url = Column(String(512), nullable=True)
unique_id = Column(String(100), nullable=True)
signature = Column(Text, nullable=True)
sec_user_id = Column(String(255), nullable=True)
video_count = Column(Integer, nullable=True)
follower_count = Column(Integer, nullable=True)
following_count = Column(Integer, nullable=True)
total_favorited = Column(Integer, nullable=True)
favoriting_count = Column(Integer, nullable=True)
sync_message = Column(Text, nullable=True)
synced_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
account = relationship("Account", back_populates="profile_detail")
class AccountVideo(Base):
"""托管账号已发布作品(本地缓存)。"""
__tablename__ = "account_videos"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
aweme_id = Column(String(64), nullable=False, index=True)
title = Column(Text, nullable=True)
cover_url = Column(String(1024), nullable=True)
video_url = Column(String(2048), nullable=True)
share_url = Column(String(1024), nullable=True)
create_time = Column(DateTime, nullable=True)
digg_count = Column(Integer, nullable=True)
comment_count = Column(Integer, nullable=True)
play_count = Column(Integer, nullable=True)
media_type = Column(String(20), nullable=True) # video, image, other
sort_order = Column(Integer, default=0, index=True)
synced_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
account = relationship("Account", back_populates="videos")
class LinkCardPage(Base):
"""规则回复用的链接卡片落地页(带 SEO meta,打开后跳转至目标 URL)。"""
__tablename__ = "link_card_pages"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
slug = Column(String(64), unique=True, index=True, nullable=False)
title = Column(String(200), nullable=False)
content = Column(Text, nullable=True)
target_url = Column(String(2000), nullable=False)
image_path = Column(String(512), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class AutoReplyRule(Base):
__tablename__ = "rules"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True) # 为空代表全局规则
keyword = Column(String(255), index=True) # 触发关键词,或者空代表兜底
reply_content = Column(Text) # 回复内容
match_type = Column(String(50), default="contains") # exact (精确), contains (包含), regex (正则), default (兜底)
sort_order = Column(Integer, default=0, index=True) # 规则优先级,越小越优先
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
account = relationship("Account", back_populates="rules")
class MessageLog(Base):
__tablename__ = "message_logs"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"))
sender_name = Column(String(100)) # 发送者名字
sender_id = Column(String(100), nullable=True) # 发送者唯一ID
sender_avatar = Column(String(512), nullable=True) # 发送者头像 URL
message_content = Column(Text) # 接收到的消息
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)
account = relationship("Account", back_populates="logs")
class ReceivedMessageLog(Base):
"""接收消息原始日志:仅记录收到的消息,内容原样保存。"""
__tablename__ = "received_message_logs"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
conversation_id = Column(String(128), nullable=True, index=True)
sender_id = Column(String(100), nullable=True, index=True)
sender_name = Column(String(100), nullable=True)
sender_avatar = Column(String(512), nullable=True)
message_type = Column(Integer, nullable=True)
server_message_id = Column(String(64), nullable=True, index=True)
raw_content = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class FollowWelcomeLog(Base):
"""关注欢迎语去重表:每个账号对每个新粉丝只发送一次欢迎语(重启后仍生效)。"""
__tablename__ = "follow_welcome_logs"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
follower_uid = Column(String(64), nullable=False, index=True) # 新粉丝的抖音 UID
status = Column(String(20), default="sent") # sent, failed
detail = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
UniqueConstraint("account_id", "follower_uid", name="uq_follow_welcome_account_follower"),
)
class SystemLog(Base):
"""系统诊断日志:记录私信收发/连接/鉴权等链路事件,便于排查失败原因。"""
__tablename__ = "system_logs"
id = Column(Integer, primary_key=True, index=True)
account_id = Column(Integer, nullable=True, index=True) # 关联账号(可空,全局事件)
level = Column(String(20), default="info", index=True) # info, success, warning, error
category = Column(String(40), default="system", index=True) # ws, send, recv, auth, poll, system
event = Column(String(255)) # 事件标题
detail = Column(Text, nullable=True) # 详细原因
created_at = Column(DateTime, default=datetime.utcnow, index=True)
class PaymentOrder(Base):
"""账号额度购买订单。"""
__tablename__ = "payment_orders"
id = Column(Integer, primary_key=True, index=True)
order_no = Column(String(64), unique=True, index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
channel = Column(String(20), nullable=False) # wechat, alipay
slots = Column(Integer, nullable=False)
amount_fen = Column(Integer, nullable=False)
status = Column(String(20), default="pending", index=True) # pending, paid, expired, cancelled, refunded
slots_applied = Column(Boolean, default=False)
qr_code = Column(Text, nullable=True)
pay_url = Column(Text, nullable=True)
trade_no = Column(String(128), nullable=True)
notify_payload = Column(Text, nullable=True)
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)