更新
This commit is contained in:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user