334 lines
16 KiB
Python
334 lines
16 KiB
Python
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 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, 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)
|