from datetime import datetime 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): __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, 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): """接收消息原始日志:仅记录收到的消息,内容原样保存。""" __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) __table_args__ = ( Index( "ix_received_message_logs_account_created_at", "account_id", "created_at", ), ) 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) __table_args__ = ( Index("ix_system_logs_account_created_at", "account_id", "created_at"), ) 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)