"""SQLAlchemy 数据模型 + db 初始化(用户/设备分组/任务计划)。 为什么放这里: web_server 和 task_manager 都要访问这些模型,放 core/ 避免循环依赖。 web_server 负责初始化 db(app context),task_manager 只读写数据。 模型说明: User — 后台用户(Flask-Login 认证) DeviceGroup — 设备分组(持久化,替代旧 groups.json) TaskJob — 任务计划(持久化,替代旧 jobs.json) 字段设计: DeviceGroup.serials 用 JSON 存列表(SQLite 无数组类型) TaskJob.target/params/schedule/retry 用 JSON 存嵌套结构 Flask-Admin 默认用 TextArea 编辑 JSON,够用且通用 """ import os import json import hashlib from flask_sqlalchemy import SQLAlchemy from flask_login import UserMixin from core.logger import get_logger _log = get_logger("core.models") db = SQLAlchemy() class User(UserMixin, db.Model): """后台用户。""" id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) password_hash = db.Column(db.String(120), nullable=False) is_admin = db.Column(db.Boolean, default=True) def set_password(self, password): self.password_hash = hashlib.sha256(password.encode()).hexdigest() def check_password(self, password): return self.password_hash == hashlib.sha256(password.encode()).hexdigest() def __repr__(self): return f"" class DeviceGroup(db.Model): """设备分组(替代旧 groups.json 的 DeviceGroup 类)。""" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=True, nullable=False) serials = db.Column(db.Text, default="[]") # JSON 列表 description = db.Column(db.Text, default="") def get_serials(self): try: return json.loads(self.serials or "[]") except Exception: return [] def set_serials(self, lst): self.serials = json.dumps(lst or [], ensure_ascii=False) def to_dict(self): return {"name": self.name, "serials": self.get_serials(), "description": self.description or ""} def __repr__(self): return f"" class TaskJob(db.Model): """任务计划(替代旧 jobs.json 的 TaskJob 类)。 字段含义和旧 TaskJob 一致,只是持久化方式从 JSON 文件改到 SQLite。 """ id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位 name = db.Column(db.String(120), nullable=False) task_type = db.Column(db.String(60), default="douyin_nurture") target = db.Column(db.Text, default='{"mode":"all"}') # JSON params = db.Column(db.Text, default="{}") # JSON schedule = db.Column(db.Text, default='{"mode":"once"}') # JSON retry = db.Column(db.Text, default='{"max_attempts":1,"delay":60}') # JSON enabled = db.Column(db.Boolean, default=True) def _load_json(self, field, default): try: return json.loads(getattr(self, field) or default) except Exception: return json.loads(default) def _dump_json(self, field, value): setattr(self, field, json.dumps(value or {}, ensure_ascii=False)) def get_target(self): return self._load_json("target", '{"mode":"all"}') def set_target(self, v): self._dump_json("target", v) def get_params(self): return self._load_json("params", "{}") def set_params(self, v): self._dump_json("params", v) def get_schedule(self): return self._load_json("schedule", '{"mode":"once"}') def set_schedule(self, v): self._dump_json("schedule", v) def get_retry(self): return self._load_json("retry", '{"max_attempts":1,"delay":60}') def set_retry(self, v): self._dump_json("retry", v) def to_dict(self): return {"id": self.id, "name": self.name, "task_type": self.task_type, "target": self.get_target(), "params": self.get_params(), "schedule": self.get_schedule(), "retry": self.get_retry(), "enabled": self.enabled} def __repr__(self): return f"" class ApkFile(db.Model): """上传的 APK 文件元信息(应用管理功能)。""" id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位 filename = db.Column(db.String(255), nullable=False) # 磁盘文件名 (id.apk) display_name = db.Column(db.String(120), default="") # 应用名 package_name = db.Column(db.String(200), default="") # 包名 version_name = db.Column(db.String(50), default="") # 版本号 version_code = db.Column(db.Integer, default=0) # 版本码 size = db.Column(db.Integer, default=0) # 文件大小(字节) upload_time = db.Column(db.String(20), default="") # 上传时间 def to_dict(self): return {"id": self.id, "filename": self.filename, "display_name": self.display_name or "", "package_name": self.package_name or "", "version_name": self.version_name or "", "version_code": self.version_code or 0, "size": self.size or 0, "upload_time": self.upload_time or ""} def __repr__(self): return f"" def init_db(app): """在 Flask app context 里初始化数据库 + 创建默认管理员。 web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到 SQLite。 """ db.init_app(app) with app.app_context(): db.create_all() _ensure_default_admin() _migrate_old_json() def _ensure_default_admin(): """首次启动创建默认管理员 admin/admin123。""" if not User.query.filter_by(username="admin").first(): u = User(username="admin", is_admin=True) u.set_password("admin123") db.session.add(u) db.session.commit() _log.info("已创建默认管理员 admin/admin123,请及时改密码") def _migrate_old_json(): """把旧 groups.json / jobs.json 迁移到 SQLite(仅首次)。 迁移策略: 1. 仅当数据库对应表为空时才迁移(首次启动场景) 2. 迁移成功后立即把 JSON 文件重命名为 .json.migrated, 保留备份但永不再迁移——避免"用户删完全部任务后重启又从旧文件复原" 3. 若数据库已有数据但 JSON 文件仍在(历史残留),直接归档, 避免将来数据库被清空后又触发迁移导致已删任务复原 4. 迁移任务时顺手剔除已废弃的 comment action 配置, 避免 _load 阶段还要再写回一次 """ from config import DATA_DIR # 迁移分组 groups_file = os.path.join(DATA_DIR, "groups.json") if os.path.exists(groups_file) and DeviceGroup.query.count() == 0: try: with open(groups_file, encoding="utf-8") as f: groups = json.load(f) for g in groups: if not DeviceGroup.query.filter_by(name=g["name"]).first(): row = DeviceGroup(name=g["name"], description=g.get("description", "")) row.set_serials(g.get("serials", [])) db.session.add(row) db.session.commit() _log.info(f"已迁移 {len(groups)} 个分组到数据库") _archive_migrated(groups_file) except Exception as e: _log.error(f"迁移 groups.json 失败: {e}") # 迁移任务 jobs_file = os.path.join(DATA_DIR, "jobs.json") if os.path.exists(jobs_file) and TaskJob.query.count() == 0: try: with open(jobs_file, encoding="utf-8") as f: jobs = json.load(f) for j in jobs: if TaskJob.query.get(j["id"]): continue params = j.get("params", {}) # 剔除已废弃的 comment action,避免带入数据库 actions = params.get("actions", {}) if "comment" in actions: del actions["comment"] _log.info(f"迁移任务 {j.get('name')}: 已剔除废弃的 comment 配置") row = TaskJob(id=j["id"], name=j["name"], task_type=j.get("task_type", "douyin_nurture"), enabled=j.get("enabled", True)) row.set_target(j.get("target", {"mode": "all"})) row.set_params(params) row.set_schedule(j.get("schedule", {"mode": "once"})) row.set_retry(j.get("retry", {"max_attempts": 1, "delay": 60})) db.session.add(row) db.session.commit() _log.info(f"已迁移 {len(jobs)} 个任务到数据库") _archive_migrated(jobs_file) except Exception as e: _log.error(f"迁移 jobs.json 失败: {e}") # 清理历史残留:数据库已有数据但 JSON 文件仍在(修复前遗留下来的文件)。 # 不归档的话,用户哪天删光所有任务/分组,count==0 又会触发迁移导致已删数据复原。 if DeviceGroup.query.count() > 0 and os.path.exists(groups_file): _archive_migrated(groups_file) if TaskJob.query.count() > 0 and os.path.exists(jobs_file): _archive_migrated(jobs_file) def _archive_migrated(file_path): """把已迁移的 JSON 文件重命名为 .migrated,避免下次重启再迁移。 保留备份以便排查,但 _migrate_old_json 的 exists 判断会跳过它。 重命名失败只警告不抛出,不影响启动。 """ archived = file_path + ".migrated" try: if os.path.exists(archived): os.remove(archived) os.rename(file_path, archived) _log.info(f"已归档迁移文件: {os.path.basename(file_path)} -> {os.path.basename(archived)}") except Exception as e: _log.warning(f"归档 {file_path} 失败(不影响运行): {e}")