feat: AI虚拟用户新闻互动系统 v1.3.0 初始提交
- 虚拟用户管理(昵称/头像/性别/简介/邮箱同步到目标平台) - AI互动调度(点赞/收藏/评论/转发) - 日志时间改为北京时间 - 评论达上限后继续执行点赞收藏转发 - 一键登出全部功能 - 浅色主题UI
This commit is contained in:
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app.core package
|
||||
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""系统配置"""
|
||||
import os
|
||||
from urllib.parse import quote_plus
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 数据库
|
||||
DB_HOST: str = os.getenv("DB_HOST", "localhost")
|
||||
DB_PORT: int = int(os.getenv("DB_PORT", "3306"))
|
||||
DB_USER: str = os.getenv("DB_USER", "aivirtual")
|
||||
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "AiVirtual2024")
|
||||
DB_NAME: str = os.getenv("DB_NAME", "ai_virtual_news")
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
# 安全
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
|
||||
AES_KEY: str = os.getenv("AES_KEY", "your-aes-key-32-chars-change-now!")
|
||||
|
||||
# 新闻平台
|
||||
NEWS_PLATFORM_BASE_URL: str = os.getenv(
|
||||
"NEWS_PLATFORM_BASE_URL", "http://192.168.1.200:63120"
|
||||
)
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR: str = "/app/logs"
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
# 对密码做 URL 编码,防止 @ # ! 等特殊字符破坏连接字符串
|
||||
pwd = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+aiomysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
@property
|
||||
def SYNC_DATABASE_URL(self) -> str:
|
||||
pwd = quote_plus(self.DB_PASSWORD)
|
||||
return f"mysql+pymysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
72
backend/app/core/database.py
Normal file
72
backend/app/core/database.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""数据库连接管理"""
|
||||
import asyncio
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
async def wait_for_db(max_retries: int = 30, interval: int = 2):
|
||||
"""等待 MySQL 就绪,最多重试 max_retries 次"""
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(__import__("sqlalchemy").text("SELECT 1"))
|
||||
logger.info(f"✅ 数据库连接成功(第 {attempt} 次尝试)")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == max_retries:
|
||||
logger.error(f"数据库连接失败,已重试 {max_retries} 次: {e}")
|
||||
raise
|
||||
logger.warning(f"数据库未就绪,{interval}s 后重试({attempt}/{max_retries}): {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""初始化数据库 - 等待 MySQL 就绪并注册所有模型"""
|
||||
try:
|
||||
# 等待 MySQL 容器真正就绪
|
||||
await wait_for_db(max_retries=30, interval=2)
|
||||
|
||||
# 导入所有模型类,确保 SQLAlchemy ORM 元数据注册
|
||||
from app.models import (
|
||||
VirtualUser, UserPersonality, InteractionRecord,
|
||||
TokenStat, AIModelConfig, SystemConfig, LoginLog
|
||||
)
|
||||
logger.info("✅ 数据库模型注册成功")
|
||||
logger.info("✅ 数据库初始化完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"数据库初始化失败: {e}")
|
||||
raise
|
||||
48
backend/app/core/logger.py
Normal file
48
backend/app/core/logger.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""日志配置"""
|
||||
import sys
|
||||
import os
|
||||
from loguru import logger
|
||||
|
||||
LOG_DIR = os.getenv("LOG_DIR", "./logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
# 移除默认处理器
|
||||
logger.remove()
|
||||
|
||||
# 控制台输出
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
level="INFO",
|
||||
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||
)
|
||||
|
||||
# 通用日志文件
|
||||
logger.add(
|
||||
f"{LOG_DIR}/app_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="INFO",
|
||||
encoding="utf-8",
|
||||
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||
)
|
||||
|
||||
# 错误日志文件
|
||||
logger.add(
|
||||
f"{LOG_DIR}/error_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="ERROR",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# AI调用日志
|
||||
logger.add(
|
||||
f"{LOG_DIR}/ai_{{time:YYYY-MM-DD}}.log",
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
level="INFO",
|
||||
encoding="utf-8",
|
||||
filter=lambda record: "ai_call" in record["extra"],
|
||||
)
|
||||
|
||||
__all__ = ["logger"]
|
||||
81
backend/app/core/redis_client.py
Normal file
81
backend/app/core/redis_client.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Redis缓存客户端"""
|
||||
import json
|
||||
import redis.asyncio as aioredis
|
||||
from app.core.config import settings
|
||||
from app.core.logger import logger
|
||||
|
||||
_redis_client = None
|
||||
|
||||
|
||||
async def get_redis() -> aioredis.Redis:
|
||||
global _redis_client
|
||||
if _redis_client is None:
|
||||
_redis_client = aioredis.from_url(
|
||||
f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}",
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
)
|
||||
return _redis_client
|
||||
|
||||
|
||||
# Session键前缀
|
||||
SESSION_PREFIX = "session:"
|
||||
LOCK_PREFIX = "lock:"
|
||||
RATE_PREFIX = "rate:"
|
||||
|
||||
|
||||
async def set_session(user_id: int, session_data: dict, expire: int = 86400):
|
||||
"""存储用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
await r.setex(key, expire, json.dumps(session_data, ensure_ascii=False))
|
||||
|
||||
|
||||
async def get_session(user_id: int) -> dict | None:
|
||||
"""获取用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
data = await r.get(key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
|
||||
|
||||
async def delete_session(user_id: int):
|
||||
"""删除用户会话"""
|
||||
r = await get_redis()
|
||||
key = f"{SESSION_PREFIX}{user_id}"
|
||||
await r.delete(key)
|
||||
|
||||
|
||||
async def acquire_lock(name: str, expire: int = 60) -> bool:
|
||||
"""获取分布式锁"""
|
||||
r = await get_redis()
|
||||
key = f"{LOCK_PREFIX}{name}"
|
||||
result = await r.set(key, "1", nx=True, ex=expire)
|
||||
return result is True
|
||||
|
||||
|
||||
async def release_lock(name: str):
|
||||
"""释放分布式锁"""
|
||||
r = await get_redis()
|
||||
key = f"{LOCK_PREFIX}{name}"
|
||||
await r.delete(key)
|
||||
|
||||
|
||||
async def incr_rate(key: str, expire: int = 86400) -> int:
|
||||
"""限流计数"""
|
||||
r = await get_redis()
|
||||
rate_key = f"{RATE_PREFIX}{key}"
|
||||
count = await r.incr(rate_key)
|
||||
if count == 1:
|
||||
await r.expire(rate_key, expire)
|
||||
return count
|
||||
|
||||
|
||||
async def get_counter(key: str) -> int:
|
||||
"""获取计数"""
|
||||
r = await get_redis()
|
||||
rate_key = f"{RATE_PREFIX}{key}"
|
||||
val = await r.get(rate_key)
|
||||
return int(val) if val else 0
|
||||
Reference in New Issue
Block a user