from datetime import datetime from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Index, Text, UniqueConstraint from sqlalchemy.dialects.mysql import LONGTEXT from sqlalchemy.orm import relationship, validates from .database import Base from utils.log_limits import bound_error_log_content, bound_message_log_content class Role(Base): """Assignable role with a fixed permission-code list. ``users.role`` stores ``Role.code``. Built-in roles are seeded on startup; custom roles are owned by admins via the users.manage / roles.manage permissions. """ __tablename__ = "roles" id = Column(Integer, primary_key=True, index=True) code = Column(String(50), unique=True, index=True, nullable=False) label = Column(String(100), nullable=False) description = Column(String(255), nullable=True) is_system = Column(Boolean, default=False) # Global data scope + unlimited account quota. Only the built-in admin # role may be true; custom roles are always own-scoped. is_admin = Column(Boolean, default=False) permissions = Column(Text, nullable=False, default="[]") # JSON string list created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 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(50), default="operator", index=True) # roles.code 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) # User who created this account via「用户管理」; null for self-register / seeded. created_by = Column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True, ) 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().with_variant(LONGTEXT(), "mysql"), nullable=True) # Playwright storage_state JSON cookie_updated_at = Column(DateTime, nullable=True) # Cookie 最近更新时间 im_session_data = Column(Text().with_variant(LONGTEXT(), "mysql"), 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),空=默认 egress_public_ip = Column(String(64), nullable=True) # 指定公网出口;空=自动选择 egress_auto_attempts = Column(Integer, nullable=False, default=1) # 发送失败时最多串行尝试的出口数 qr_code_base64 = Column(Text().with_variant(LONGTEXT(), "mysql"), 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)