一、平台侧(账号 → 发布计划页) - 新表 video_plan(schema v9→v10):账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接; 状态机 pending/ready/pushing/publishing/done/failed/unknown/skipped(**failed 与 unknown 必须分开**: 推送阶段的失败可安全重试;碰过抖音之后的岔子只能算"结果未知",绝不自动重发) - 素材上传:文件名 `手机号_日期_编号`(编号可省)解析配对;标题 txt `标题内容_手机号_日期_编号`; 内容寻址落盘 data/videos/YYYY-MM/(sha1 分块算,同名不存两份),**不进整库备份**但进 manifest 反查 - 新蓝图 web/video_plan_api.py:上传/时间线/统计/单条增删改/推送到手机/标记结果/裁决/链接导出 CSV/ 任务列表与一键新建、**就地编辑**(GET/PUT /tasks/<id>)、**一键推送**(POST /push_all,按设备分组、设备内串行) - 账号页拆子分栏(台账 / 发布计划)+ static/admin/release.js;清理 job(04:41 僵尸回收+过期行、04:47 素材文件) - 上传体积:MAX_CONTENT_LENGTH(默认 2GiB)+ 413 JSON + nginx client_max_body_size(修现有 APK 上传隐患) 二、任务侧(平台推素材,抖音流程你自己写) - 新步骤 push_release「推送发布视频」:原子占位 → adb push → **touch 改成"现在"** → 清旧目录同名副本 → 触发扫描并**按路径**校验相册索引 → 标题写进剪贴板;默认目录 /sdcard/DCIM/Camera - 新步骤 mark_release「标记发布结果」:回写 done/failed/unknown,成功时抓作品分享链接、删手机素材 - input_text 支持 text_source=release_title(自动取计划标题 + 回读校验); if_el 的候选值来源新增 release(**本机当前发布计划**的抖音号/昵称,发布前校验"登的是不是要发的号") - build_release_steps 骨架 15 步:⓪ 亮屏 → ① 打开抖音(等首页) → ② 点「我」→ ③ 等抖音号出现 → ④ 条件判断(账号) → then ⑤ 推送 ⑥⑦⑧⑨⑩⑪⑫ 抖音点击/填标题 → ⑬ 标记 / else 发通知跳过 三、修(推送这一路的检测机制) - **uiautomator2 3.x 的 d.shell() 返回 ShellResponse(tuple 子类)不是 str**:`'x' in resp` 恒 False、 `.strip()` 不存在 → "推上去的文件大小不对"每次都判失败(文件其实推上去了)、相册校验永远报没进、 删除确认永远判没删掉。新增 publish_flow._sh() 统一取 .output;大小改成解析 ls -l 的大小列 - **adb push 保留本地 mtime** → 推 3 天前上传的素材在按时间排序的相册里排不到最前, "点第一个 = 刚推的那个"不成立 → 推完 touch - 相册校验**按路径**比(MediaStore 的 _data 会把目录小写、/storage/emulated/0 ≡ /sdcard), 只比文件名会被老目录的同名残留骗过去 - 屏幕没亮就启动抖音会永远停在启动页(UI 树为空)→ 后面"点我/等抖音号"必然 miss, 最后报成误导人的"账号不符" → 骨架第一步固定加「亮屏」,open_app 等「首页」出现 四、其它 - core/ledger.serial_of():设备名 → 当前地址(设备换 IP 后快照是错的) - 通知事件 task.video.published / task.video.failed;备份清单加 video_plan 与素材统计 - 文档同步:DATA_MODEL §2.11 + schema v10、API(新接口与语义)、TASK_DEV §4.7 专章、 ARCHITECTURE(账号页子分栏/release.js/两个 job)、DEPLOY(表数/nginx)、NOTIFY、DEVELOPMENT、README
873 lines
42 KiB
Python
873 lines
42 KiB
Python
"""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
|
||
import sqlite3
|
||
|
||
from flask_sqlalchemy import SQLAlchemy
|
||
from flask_login import UserMixin
|
||
from sqlalchemy import event, text
|
||
from sqlalchemy.engine import Engine
|
||
from werkzeug.security import generate_password_hash, check_password_hash
|
||
|
||
from core.logger import get_logger
|
||
|
||
_log = get_logger("core.models")
|
||
|
||
db = SQLAlchemy()
|
||
|
||
|
||
def _long_text():
|
||
"""长文本列:SQLite 用 TEXT,MySQL 用 MEDIUMTEXT。
|
||
|
||
裸 TEXT 在 MySQL 只有 64KB(而且是"字节"),AI 会话消息、任务参数这类
|
||
JSON 文本很容易超;超了在严格模式下直接报错(不是截断)。
|
||
"""
|
||
from sqlalchemy.dialects.mysql import MEDIUMTEXT
|
||
return db.Text().with_variant(MEDIUMTEXT(), "mysql")
|
||
|
||
|
||
def _DOUBLE():
|
||
"""REAL/DOUBLE:MySQL 的 FLOAT 是单精度,评分这类值会出现 7.8000001 这种尾巴。"""
|
||
from sqlalchemy.dialects.mysql import DOUBLE
|
||
return DOUBLE()
|
||
|
||
|
||
@event.listens_for(Engine, "connect")
|
||
def _sqlite_pragma(dbapi_connection, connection_record):
|
||
"""SQLite 并发写优化:WAL 模式 + 忙等待超时 + 降同步级别。
|
||
|
||
多 worker 后台线程同时写库(任务参数/分组)时,避免 database is locked。
|
||
|
||
注意:这个监听器挂在 Engine 基类上,对**所有方言**的连接都会触发;
|
||
MySQL 下执行 PRAGMA 会直接报语法错误导致连接失败,所以必须先判类型。
|
||
"""
|
||
if not isinstance(dbapi_connection, sqlite3.Connection):
|
||
return
|
||
cursor = dbapi_connection.cursor()
|
||
cursor.execute("PRAGMA journal_mode=WAL")
|
||
cursor.execute("PRAGMA busy_timeout=5000")
|
||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||
cursor.close()
|
||
|
||
|
||
class User(UserMixin, db.Model):
|
||
"""后台用户。
|
||
|
||
权限模型:
|
||
- is_admin=True:管理员,拥有全部权限,不受 perms 限制
|
||
- is_admin=False:按 perms 授权(JSON 数组,如 ["tasks","devices"])
|
||
- 权限位:tasks=任务管理, devices=设备控制, apks=应用管理, logs=日志查看
|
||
- 用户管理本身只有管理员能做(web_server 层强制),不设普通权限位
|
||
"""
|
||
id = db.Column(db.Integer, primary_key=True)
|
||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||
password_hash = db.Column(db.String(255), nullable=False)
|
||
is_admin = db.Column(db.Boolean, default=True)
|
||
perms = db.Column(db.Text, default="[]") # JSON 数组:业务权限位
|
||
|
||
def get_perms(self):
|
||
try:
|
||
return json.loads(self.perms or "[]")
|
||
except Exception:
|
||
return []
|
||
|
||
def set_perms(self, lst):
|
||
"""设置权限位。管理员强制为全部权限(内部不区分)。"""
|
||
self.perms = json.dumps(list(lst or []), ensure_ascii=False)
|
||
|
||
def has_perm(self, perm):
|
||
"""是否拥有指定权限。管理员恒为 True。"""
|
||
if self.is_admin:
|
||
return True
|
||
return perm in self.get_perms()
|
||
|
||
def set_password(self, password):
|
||
self.password_hash = generate_password_hash(password)
|
||
|
||
def check_password(self, password):
|
||
"""校验密码。兼容旧 SHA-256 哈希(匹配则自动升级为新哈希)。"""
|
||
ph = self.password_hash or ""
|
||
if ph and not ph.startswith(("pbkdf2:", "scrypt:")):
|
||
# 旧版裸 SHA-256:比对通过则升级为加盐哈希
|
||
if hashlib.sha256(password.encode()).hexdigest() == ph:
|
||
self.set_password(password)
|
||
try:
|
||
db.session.commit()
|
||
except Exception:
|
||
pass
|
||
return True
|
||
return False
|
||
return check_password_hash(ph, password)
|
||
|
||
def __repr__(self):
|
||
return f"<User {self.username}>"
|
||
|
||
|
||
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"<Group {self.name}>"
|
||
|
||
|
||
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="generic_steps")
|
||
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"<TaskJob {self.name}>"
|
||
|
||
|
||
class CustomAction(db.Model):
|
||
"""自定义动作(把一系列步骤打包成一个可复用的动作)。
|
||
|
||
steps 字段存 JSON 数组,格式同 generic_steps 的 step schema。
|
||
前端拖拽到画布时,展开为 group 步骤(type='group')。
|
||
"""
|
||
id = db.Column(db.String(32), primary_key=True)
|
||
name = db.Column(db.String(120), nullable=False)
|
||
icon = db.Column(db.String(4), default="📦")
|
||
steps = db.Column(db.Text, default="[]") # JSON 数组
|
||
created_at = db.Column(db.String(20), default="")
|
||
|
||
def get_steps(self):
|
||
try:
|
||
return json.loads(self.steps or "[]")
|
||
except Exception:
|
||
return []
|
||
|
||
def set_steps(self, v):
|
||
self.steps = json.dumps(v or [], ensure_ascii=False)
|
||
|
||
def to_dict(self):
|
||
return {"id": self.id, "name": self.name, "icon": self.icon or "📦",
|
||
"steps": self.get_steps(), "created_at": self.created_at or ""}
|
||
|
||
def __repr__(self):
|
||
return f"<CustomAction {self.name}>"
|
||
|
||
|
||
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"<ApkFile {self.display_name}>"
|
||
|
||
|
||
class Device(db.Model):
|
||
"""设备池(本地设备清单,替代 STF 池作为调度数据源)。
|
||
|
||
serial 即 adb 序列号(IP:5555 或 USB 序列号);enabled=False 不参与调度。
|
||
model 为在线时自动采集的型号(如 Redmi 12C),供管理页/监控页区分设备。
|
||
"""
|
||
serial = db.Column(db.String(120), primary_key=True)
|
||
name = db.Column(db.String(80), default="") # 设备名(唯一,人可读标识)
|
||
model = db.Column(db.String(120), default="") # 型号(自动采集)
|
||
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
|
||
note = db.Column(db.Text, default="") # 备注
|
||
created_at = db.Column(db.String(20), default="") # 添加时间
|
||
# 设备指纹(ro.serialno):识别"同一台物理设备"的稳定标识。
|
||
# 网络设备(serial=IP:5555)换 IP 后靠它认领回原记录,名称/分组/任务引用都不丢。
|
||
fingerprint = db.Column(db.String(120), default="")
|
||
|
||
def to_dict(self):
|
||
return {"serial": self.serial, "name": self.name or "",
|
||
"model": self.model or "", "enabled": bool(self.enabled),
|
||
"note": self.note or "", "created_at": self.created_at or "",
|
||
"fingerprint": self.fingerprint or ""}
|
||
|
||
def __repr__(self):
|
||
return f"<Device {self.serial}>"
|
||
|
||
|
||
class PendingDevice(db.Model):
|
||
"""自动发现待连接池:扫描验证通过的设备,等待用户确认后才入正式池。
|
||
|
||
与 Device 表的区别:只代表"被扫描到、可连接",不参与任务调度。
|
||
"""
|
||
serial = db.Column(db.String(120), primary_key=True) # 如 192.168.20.5:5555
|
||
source = db.Column(db.String(20), default="") # lan / tailscale
|
||
first_seen = db.Column(db.String(20), default="") # 首次发现时间
|
||
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
|
||
# 扫描时顺带读取的设备指纹:与设备池中已有记录比对,用于提示
|
||
# 「这台其实就是 <名称>(原 IP 变了)」而不是让用户在一堆陌生 IP 里猜
|
||
fingerprint = db.Column(db.String(120), default="")
|
||
|
||
def to_dict(self):
|
||
return {"serial": self.serial, "source": self.source or "",
|
||
"first_seen": self.first_seen or "", "last_seen": self.last_seen or "",
|
||
"fingerprint": self.fingerprint or ""}
|
||
|
||
|
||
class AppMeta(db.Model):
|
||
"""全局 KV 配置(AI 配置、设备发现参数、schema_version、库环境标签…)。
|
||
|
||
⚠️ 列名沿用历史的 `key`/`value`:**`key` 在 MySQL 里是保留字**,
|
||
因此不要直接拼裸 SQL 读写本表,统一走 `core/db_config.meta_get/meta_set`
|
||
(方言中立、自动按方言加引号)。
|
||
|
||
ORM 属性名用 `k`:`key` 在 SQLAlchemy 声明式 API 里是保留名,不能直接当属性。
|
||
"""
|
||
__tablename__ = "app_meta"
|
||
k = db.Column("key", db.String(64), primary_key=True)
|
||
value = db.Column(db.Text)
|
||
|
||
def __repr__(self):
|
||
return f"<AppMeta {self.k}>"
|
||
|
||
|
||
class AgentExperience(db.Model):
|
||
"""AI 控制台经验记忆:任务成功后的操作配方,下次相似任务检索注入。
|
||
|
||
recipe/tool_seq 存 JSON 文本;hits 是被召回引用的次数(巡检按它判重要性)。
|
||
"""
|
||
__tablename__ = "agent_experience"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
task_prompt = db.Column(_long_text(), default="")
|
||
recipe = db.Column(_long_text(), default="")
|
||
tool_seq = db.Column(_long_text(), default="")
|
||
hits = db.Column(db.Integer, default=0)
|
||
created_at = db.Column(db.String(20), default="")
|
||
|
||
def __repr__(self):
|
||
return f"<AgentExperience {self.id}>"
|
||
|
||
|
||
class ExperienceAudit(db.Model):
|
||
"""经验巡检记录(AI 质检):疑似有问题的经验标 pending,删除只走人工确认。"""
|
||
__tablename__ = "experience_audit"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
exp_id = db.Column(db.Integer, nullable=False) # 对应的 agent_experience.id
|
||
verdict = db.Column(db.String(20), default="") # keep / delete
|
||
score = db.Column(db.Float().with_variant(_DOUBLE(), "mysql"), default=0)
|
||
reason = db.Column(_long_text(), default="")
|
||
hits = db.Column(db.Integer, default=0) # 巡检时的引用次数
|
||
action = db.Column(db.String(20), default="pending") # pending/kept/deleted
|
||
audited_at = db.Column(db.String(20), default="")
|
||
|
||
|
||
class AgentAction(db.Model):
|
||
"""动作库:带语义名的可复用动作单元(含元素定位、不含坐标)。
|
||
|
||
与任务级配方(agent_experience)互补;执行前按 name/别名/App 召回注入。
|
||
"""
|
||
__tablename__ = "agent_action"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
name = db.Column(db.String(120), nullable=False)
|
||
app = db.Column(db.String(80), default="")
|
||
aliases = db.Column(_long_text(), default="[]")
|
||
params = db.Column(_long_text(), default="[]")
|
||
steps = db.Column(_long_text(), nullable=False)
|
||
preconditions = db.Column(_long_text(), default="")
|
||
hits = db.Column(db.Integer, default=0)
|
||
source_prompt = db.Column(_long_text(), default="")
|
||
created_at = db.Column(db.String(20), default="")
|
||
updated_at = db.Column(db.String(20), default="")
|
||
|
||
|
||
class DeviceInstallLog(db.Model):
|
||
"""设备端应用商店的下载/安装记录(设备上的 Agent 上报,平台侧展示)。
|
||
|
||
平台**不主动**发起这条通道的安装:设备自己拉清单、自己下载、自己调
|
||
PackageInstaller 安装——这样走的是普通应用安装流程,不会触发 MIUI 针对
|
||
`adb install` 的「USB 安装」拦截(USER_RESTRICTED)。
|
||
"""
|
||
__tablename__ = "device_install_log"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
fingerprint = db.Column(db.String(120), default="") # ro.serialno,识别物理设备
|
||
serial = db.Column(db.String(120), default="") # 上报时的地址(可能换过)
|
||
device_name = db.Column(db.String(80), default="") # 平台侧的名称(快照)
|
||
apk_id = db.Column(db.String(32), default="")
|
||
package_name = db.Column(db.String(200), default="")
|
||
version_name = db.Column(db.String(50), default="")
|
||
action = db.Column(db.String(20), default="") # download / install_ok / install_fail
|
||
message = db.Column(db.String(500), default="") # 失败原因等
|
||
created_at = db.Column(db.String(20), default="")
|
||
|
||
|
||
class TaskStepLog(db.Model):
|
||
"""任务步骤明细:每一次步骤执行的落库记录(「日志 → 步骤明细」页)。
|
||
|
||
与 `logs/task.log` 的分工:文本日志是**排障时的原始现场**(什么都往里写、
|
||
10MB 滚动),本表是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,
|
||
所以能按设备、任务、时间、结果过滤和统计,文本日志只能 grep。
|
||
|
||
写入方是 `core/step_log.py` 的专用写线程(异步批量落库),**任务线程不直接
|
||
写库**:一次运行可能上万步,每步一次 INSERT 会拖慢热路径。
|
||
|
||
保留期由 `core/step_log.py` 的清理任务控制(默认 14 天,见该模块常量)。
|
||
"""
|
||
__tablename__ = "task_step_log"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
run_id = db.Column(db.String(24), default="", index=True) # 一次运行=设备×任务×第几次尝试
|
||
job_id = db.Column(db.String(32), default="")
|
||
job_name = db.Column(db.String(120), default="")
|
||
serial = db.Column(db.String(120), default="")
|
||
device_name = db.Column(db.String(80), default="")
|
||
step_path = db.Column(db.String(32), default="") # 嵌套位置,如 "2.1.3"
|
||
step_label = db.Column(db.String(120), default="")
|
||
step_type = db.Column(db.String(40), default="") # click_el / loop / input_text …
|
||
selector = db.Column(db.String(300), default="") # 元素选择器(长选择器截断)
|
||
result = db.Column(db.String(16), default="") # ok/skip/miss/error/unknown/cap
|
||
detail = db.Column(db.String(500), default="") # 异常消息、跳过原因等
|
||
duration_ms = db.Column(db.Integer, default=0)
|
||
created_at = db.Column(db.String(20), default="", index=True)
|
||
|
||
__table_args__ = (
|
||
db.Index("ix_step_log_serial_ts", "serial", "created_at"),
|
||
db.Index("ix_step_log_job_ts", "job_id", "created_at"),
|
||
)
|
||
|
||
|
||
class DoneMark(db.Model):
|
||
"""「已做过」账本:跨设备幂等的标记(「任务 → 去重记录」页)。
|
||
|
||
要解决的问题(用户场景):一台手机登录多个账号、多台手机跑同一个任务,
|
||
任务被反复重跑(因为不知道什么时候跑完)→ 同一个号被做两次、有的号还没做。
|
||
|
||
**判据只有一条:`scope_key` 的唯一索引。**
|
||
多台设备可能同时判断"没做过","先查后插"会两台都插进去;
|
||
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**
|
||
才是原子的(见 `core/dedup.py` 的 `mark()`)。
|
||
|
||
写入方是任务步骤 `_exec_mark_done`(**成功之后才记账**):动作失败就不记账,
|
||
下次重跑还会重试该设备——这是"失败不丢"的关键。
|
||
|
||
`kind` 是有效期策略(`day`/`hours`/`all`),清理时**只删 day/hours**:
|
||
`all` 代表"只做一次",删掉就等于去重失效。
|
||
"""
|
||
__tablename__ = "done_mark"
|
||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||
scope_key = db.Column(db.String(300), unique=True) # 幂等的全部依据(唯一索引)
|
||
kind = db.Column(db.String(12), default="day") # day / hours / all
|
||
job_id = db.Column(db.String(32), default="", index=True)
|
||
job_name = db.Column(db.String(120), default="")
|
||
serial = db.Column(db.String(120), default="")
|
||
device_name = db.Column(db.String(80), default="")
|
||
identity = db.Column(db.String(200), default="") # 身份值(如抖音号)
|
||
created_at = db.Column(db.String(20), default="", index=True)
|
||
|
||
__table_args__ = (
|
||
db.Index("ix_done_mark_job_ts", "job_id", "created_at"),
|
||
)
|
||
|
||
|
||
class DeviceAccount(db.Model):
|
||
"""账号台账:一台设备上登录着哪些账号(「账号」页)。
|
||
|
||
要解决的问题(用户场景):一台手机登好几个抖音号、十几台手机四五十个号,
|
||
账号信息散在一张电子表格里 —— 换 IP、加号、想查"这台登的是哪几个号"都要翻表。
|
||
这里把台账落进平台:web 能查能改(支持从表格粘贴导入),
|
||
手机端 Agent 的「身份大字页」顺带显示本机账号,任务的「条件判断」
|
||
也可以直接从台账取号(不用再把号一个个手写进 cmp_value)。
|
||
|
||
⚠ **`douyin_id` 是纯号(`35377983067`),而元素原文是 `抖音号:35377983067`。**
|
||
- 当条件判断的**比对值**可以 ✓(运算符用「包含」,纯号是子串)
|
||
- **绝不能当去重身份** ✗ —— `done_mark.identity` 存的是元素原文、逐字算 key,
|
||
格式不一致会让去重**静默失效**(见 `core/dedup.py`)。
|
||
|
||
`device_name` 是设备号(平台设备名,如 A01);`serial` 是**录入时的地址快照**,
|
||
设备换 IP 或改名后,台账靠任一侧都能找回来(见 `core/ledger.for_device`)。
|
||
同一抖音号不允许两条 —— 唯一性由服务层保证(号可能为空,DB 层要做
|
||
"部分唯一索引"三处方言适配,人工维护的几十条不值当)。
|
||
"""
|
||
__tablename__ = "device_account"
|
||
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||
device_name = db.Column(db.String(80), default="", index=True) # 设备号(平台设备名)
|
||
serial = db.Column(db.String(120), default="") # 录入时的地址快照
|
||
phone = db.Column(db.String(32), default="", index=True) # 手机号
|
||
nickname = db.Column(db.String(80), default="") # 账号名称
|
||
douyin_id = db.Column(db.String(64), default="", index=True) # 抖音号(纯号,不带前缀)
|
||
registered_at = db.Column(db.String(20), default="") # 注册时间(原样存文本)
|
||
sim_in_device = db.Column(db.Boolean, default=False) # 卡在机内(空=否)
|
||
can_post_video = db.Column(db.Boolean, default=False) # 可发视频(空=否)
|
||
bio = db.Column(db.Text, default="") # 简介
|
||
note = db.Column(db.Text, default="") # 备注
|
||
created_at = db.Column(db.String(20), default="")
|
||
updated_at = db.Column(db.String(20), default="")
|
||
|
||
def to_dict(self):
|
||
return {"id": self.id, "device_name": self.device_name or "",
|
||
"serial": self.serial or "", "phone": self.phone or "",
|
||
"nickname": self.nickname or "", "douyin_id": self.douyin_id or "",
|
||
"registered_at": self.registered_at or "",
|
||
"sim_in_device": bool(self.sim_in_device),
|
||
"can_post_video": bool(self.can_post_video),
|
||
"bio": self.bio or "", "note": self.note or "",
|
||
"created_at": self.created_at or "", "updated_at": self.updated_at or ""}
|
||
|
||
def __repr__(self):
|
||
return f"<DeviceAccount {self.device_name} {self.nickname}>"
|
||
|
||
|
||
class VideoPlan(db.Model):
|
||
"""视频发布计划:账号 × 发布日期 × 编号 → 一个视频素材 + 一条标题 + 发布结果。
|
||
|
||
一条记录 = **一个账号在某天要发的一个视频**(素材与计划天然 1:1,所以不拆两张表;
|
||
但上传是分两步的 —— 先视频后标题或反过来 —— 靠 `status` 的 `pending` 态兜住)。
|
||
|
||
**状态机**(这是本表的灵魂,不要简化):
|
||
|
||
| status | 含义 |
|
||
|---|---|
|
||
| `pending` | 有视频、还没标题 |
|
||
| `ready` | 素材齐,等发布日期 |
|
||
| `pushing` | 已原子占位,正在把视频推到手机 |
|
||
| `publishing` | 已推到手机,正在走抖音发布流程 |
|
||
| `done` | 发布成功(终态) |
|
||
| `failed` | 失败在 `push`/`scan` 阶段 —— 还没碰抖音,**可安全重试** |
|
||
| `unknown` | 失败在 `post`/`verify` 阶段 —— **可能已经发出去了,绝不自动重试**,要人工裁决 |
|
||
| `skipped` | 人工跳过(终态) |
|
||
|
||
⚠ **`failed` 与 `unknown` 必须分开**:把"不知道自己发没发"混成"知道自己没发",
|
||
就是重复发布的来源。`stage` 记录失败发生在哪一步,是这两者互相转换的唯一依据。
|
||
|
||
**唯一性**:`(phone, release_date, seq)` 由服务层(`core/video_plan.py`)保证,
|
||
**不加 DB 唯一索引** —— `seq` 从 1 起、没有"空值"可言,做部分唯一索引要写三处方言适配
|
||
(见 §唯一索引那段注释与 `_ensure_unique_indexes`),收益不匹配;违反的代价只是
|
||
低频人工上传产生的重复行,可见、可删。
|
||
|
||
**分享链接**:发布成功后抓作品的分享链接存 `share_url` —— 平台**不长期囤视频**
|
||
(存不下),链接才是长期资产,也方便后续拿它去铺评论。
|
||
"""
|
||
__tablename__ = "video_plan"
|
||
id = db.Column(db.String(32), primary_key=True) # uuid 前 8 位
|
||
account_id = db.Column(db.String(32), default="", index=True) # → device_account.id(不做外键)
|
||
phone = db.Column(db.String(32), default="", index=True) # 配对键(冗余存:账号删了也留痕)
|
||
device_name = db.Column(db.String(80), default="", index=True) # 设备号快照(聚合/下发免 join)
|
||
nickname = db.Column(db.String(80), default="") # 账号名称快照(时间线卡片直接显示)
|
||
douyin_id = db.Column(db.String(64), default="") # 抖音号快照
|
||
serial = db.Column(db.String(120), default="") # 地址快照
|
||
release_date = db.Column(db.String(10), default="", index=True) # "YYYY-MM-DD"(纯日期,等值比较)
|
||
seq = db.Column(db.Integer, default=1) # 编号,从 1 起(不用 0 表示"无")
|
||
seq_auto = db.Column(db.Boolean, default=True) # 编号是自动分配出来的(界面要提示)
|
||
title = db.Column(db.Text, default="") # 文案
|
||
video_file = db.Column(db.String(120), default="") # 平台落盘文件名(不含绝对路径)
|
||
video_name = db.Column(db.String(200), default="") # 原始上传名(排查用)
|
||
video_size = db.Column(db.Integer, default=0)
|
||
video_sha1 = db.Column(db.String(40), default="") # 内容指纹(重复上传的判据)
|
||
status = db.Column(db.String(16), default="", index=True)
|
||
stage = db.Column(db.String(16), default="") # push/scan/post/verify(决定 failed vs unknown)
|
||
attempts = db.Column(db.Integer, default=0) # 尝试次数(超上限不再自动取)
|
||
published_at = db.Column(db.String(20), default="")
|
||
share_url = db.Column(db.String(300), default="") # 作品分享链接(发布后抓取)
|
||
link_at = db.Column(db.String(20), default="") # 抓到链接的时刻
|
||
video_deleted_at = db.Column(db.String(20), default="") # 平台素材文件何时被清理
|
||
push_verify = db.Column(db.String(16), default="") # 推送后的相册校验:ok=进索引 / no_index=没进 / nofile=文件不在
|
||
push_remote = db.Column(db.String(200), default="") # 推到手机上的绝对路径(删它/排查用)
|
||
last_error = db.Column(db.String(500), default="")
|
||
note = db.Column(db.Text, default="")
|
||
created_at = db.Column(db.String(20), default="", index=True)
|
||
updated_at = db.Column(db.String(20), default="")
|
||
|
||
__table_args__ = (
|
||
db.Index("ix_video_plan_date_status", "release_date", "status"),
|
||
db.Index("ix_video_plan_acct_date", "account_id", "release_date"),
|
||
db.Index("ix_video_plan_phone_slot", "phone", "release_date", "seq"),
|
||
db.Index("ix_video_plan_file", "video_file"),
|
||
)
|
||
|
||
def to_dict(self):
|
||
return {"id": self.id, "account_id": self.account_id or "",
|
||
"phone": self.phone or "", "device_name": self.device_name or "",
|
||
"nickname": self.nickname or "", "douyin_id": self.douyin_id or "",
|
||
"serial": self.serial or "", "release_date": self.release_date or "",
|
||
"seq": int(self.seq or 1), "seq_auto": bool(self.seq_auto),
|
||
"title": self.title or "", "video_file": self.video_file or "",
|
||
"video_name": self.video_name or "", "video_size": int(self.video_size or 0),
|
||
"video_sha1": self.video_sha1 or "", "status": self.status or "",
|
||
"stage": self.stage or "", "attempts": int(self.attempts or 0),
|
||
"published_at": self.published_at or "", "share_url": self.share_url or "",
|
||
"link_at": self.link_at or "", "video_deleted_at": self.video_deleted_at or "",
|
||
"push_verify": self.push_verify or "", "push_remote": self.push_remote or "",
|
||
"last_error": self.last_error or "", "note": self.note or "",
|
||
"created_at": self.created_at or "", "updated_at": self.updated_at or ""}
|
||
|
||
def __repr__(self):
|
||
return f"<VideoPlan {self.phone} {self.release_date} #{self.seq} {self.status}>"
|
||
|
||
|
||
class AgentConversation(db.Model):
|
||
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
|
||
__tablename__ = "agent_conversation"
|
||
id = db.Column(db.String(20), primary_key=True)
|
||
title = db.Column(db.String(100), default="")
|
||
messages = db.Column(_long_text(), default="[]")
|
||
created_at = db.Column(db.String(20), default="")
|
||
updated_at = db.Column(db.String(20), default="")
|
||
|
||
|
||
# 版本化 schema 迁移账本 (版本号, 说明, 数据回填 SQL 或 None)
|
||
#
|
||
# 注意:**建表与补列不再由这张表驱动**——改由 db.create_all() + _sync_columns()
|
||
# 按模型定义自动完成(SQLite / MySQL 两种方言都正确)。这里只保留:
|
||
# - 版本号(写进 app_meta.schema_version,备份/恢复时用来判断新旧)
|
||
# - 未来可能需要的数据回填语句(纯数据操作,与方言无关)
|
||
# 历史条目(v1~v6 建表/加列)保留在账本里以便追溯,SQL 位置一律为 None。
|
||
SCHEMA_MIGRATIONS = [
|
||
(1, "用户权限位:user 表新增 perms 列(JSON 数组,默认空=无业务权限,管理员不受限)", None),
|
||
(2, "设备池:device 表(本地设备清单,替代 STF 池)", None),
|
||
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)", None),
|
||
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None),
|
||
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None),
|
||
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None),
|
||
(7, "去重账本:done_mark 表(跨设备幂等的「已做过」标记,唯一索引 scope_key)", None),
|
||
(8, "账号台账:device_account 表(设备号/手机号/账号名称/抖音号/注册时间/卡在机内/可发视频/简介/备注)", None),
|
||
(9, "视频发布计划:video_plan 表(账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接)", None),
|
||
(10, "视频发布计划:video_plan 新增 push_verify(推送后相册校验:ok/no_index/nofile)"
|
||
"与 push_remote(手机上的绝对路径,删它/排查用)", None),
|
||
]
|
||
|
||
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
|
||
CURRENT_SCHEMA_VERSION = max(v for v, _, _ in SCHEMA_MIGRATIONS)
|
||
|
||
# 唯一索引(语义 = 部分索引:空值不参与唯一约束,兼容历史未命名/未采指纹的老数据)
|
||
# 名称唯一 = 设备的人可读标识;指纹唯一 = 一台物理设备在池中只能有一条记录
|
||
#
|
||
# SQLite 支持带 WHERE 的部分索引,直接写;MySQL 5.7 不支持过滤索引,
|
||
# 改用「虚拟生成列 + 唯一索引」复刻同一语义(唯一索引允许多个 NULL)——
|
||
# 见 _ensure_unique_indexes() 的 MySQL 分支。
|
||
_UNIQUE_INDEXES_SQLITE = (
|
||
("ux_device_name", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_name "
|
||
"ON device(name) WHERE name IS NOT NULL AND name <> ''"),
|
||
("ux_device_fingerprint", "CREATE UNIQUE INDEX IF NOT EXISTS ux_device_fingerprint "
|
||
"ON device(fingerprint) WHERE fingerprint IS NOT NULL "
|
||
"AND fingerprint <> ''"),
|
||
)
|
||
|
||
# MySQL:为空值生成 NULL 的伴随列,唯一索引建在它上面
|
||
_MYSQL_UQ_COLUMNS = (
|
||
# (表, 源列, 生成列名, 生成列类型)
|
||
("device", "name", "name_uq", "VARCHAR(80)"),
|
||
("device", "fingerprint", "fingerprint_uq", "VARCHAR(120)"),
|
||
)
|
||
_MYSQL_UNIQUE_INDEXES = (
|
||
("ux_device_name", "device", "name_uq"),
|
||
("ux_device_fingerprint", "device", "fingerprint_uq"),
|
||
)
|
||
|
||
|
||
def init_db(app):
|
||
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
|
||
|
||
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到数据库。
|
||
|
||
顺序:建表(create_all)→ 补列(_sync_columns)→ 版本账本 → 唯一索引 → 管理员/旧 JSON。
|
||
建表与补列都以模型定义为准,SQLite / MySQL 两种方言自动匹配,不再手写 DDL。
|
||
"""
|
||
db.init_app(app)
|
||
with app.app_context():
|
||
db.create_all()
|
||
_sync_columns()
|
||
_migrate_schema()
|
||
_ensure_unique_indexes()
|
||
_ensure_default_admin()
|
||
_migrate_old_json()
|
||
|
||
|
||
def _sync_columns():
|
||
"""按模型定义补齐"实表缺的列"(幂等)。
|
||
|
||
取代原来靠 "duplicate column name" 报错文本判断的老写法:那是 SQLite 时代的
|
||
容错路径,换 MySQL 后报错文本/错误码都不同,靠字符串匹配太脆。
|
||
现在直接读数据库元数据(sqlalchemy.inspect)比对,缺什么补什么。
|
||
"""
|
||
from sqlalchemy import inspect
|
||
from sqlalchemy.schema import CreateColumn
|
||
|
||
try:
|
||
insp = inspect(db.engine)
|
||
existing = set(insp.get_table_names())
|
||
except Exception as e:
|
||
_log.error(f"读取表结构失败,跳过补列: {e}")
|
||
return
|
||
|
||
added = []
|
||
for table in db.metadata.sorted_tables:
|
||
if table.name not in existing:
|
||
continue # 缺整表交给 create_all
|
||
try:
|
||
have = {c["name"] for c in insp.get_columns(table.name)}
|
||
except Exception as e:
|
||
_log.warning(f"读取 {table.name} 列失败: {e}")
|
||
continue
|
||
for col in table.columns:
|
||
if col.name in have:
|
||
continue
|
||
try:
|
||
ddl = CreateColumn(col).compile(dialect=db.engine.dialect)
|
||
db.session.execute(
|
||
text("ALTER TABLE {} ADD COLUMN {}".format(table.name, ddl)))
|
||
db.session.commit()
|
||
added.append(f"{table.name}.{col.name}")
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
_log.warning(f"补列失败 {table.name}.{col.name}: {e}")
|
||
if added:
|
||
_log.info("schema 补列: " + ", ".join(added))
|
||
|
||
|
||
def _migrate_schema():
|
||
"""维护 schema_version 账本 + 执行数据回填类迁移。
|
||
|
||
建表/加列已由 create_all + _sync_columns 按模型自动完成,本函数不再写 DDL;
|
||
SCHEMA_MIGRATIONS 里带 SQL 的条目只允许是方言无关的数据操作。
|
||
"""
|
||
from core import db_config
|
||
try:
|
||
try:
|
||
current = int(db_config.meta_get("schema_version") or 0)
|
||
except Exception:
|
||
current = 0
|
||
for version, desc, backfill_sql in SCHEMA_MIGRATIONS:
|
||
if version <= current:
|
||
continue
|
||
if backfill_sql:
|
||
try:
|
||
db.session.execute(text(backfill_sql))
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
_log.error(f"迁移 v{version} 数据回填失败: {e}")
|
||
_log.info(f"schema 迁移到版本 {version}(结构由模型自动同步): {desc}")
|
||
if current < CURRENT_SCHEMA_VERSION:
|
||
db_config.meta_set("schema_version", str(CURRENT_SCHEMA_VERSION))
|
||
except Exception as e:
|
||
_log.error(f"schema 迁移失败(不阻塞启动): {e}")
|
||
|
||
|
||
def _ensure_unique_indexes():
|
||
"""建"空值不参与唯一约束"的设备名/指纹唯一索引(幂等,方言分叉)。
|
||
|
||
单独抽出来是因为索引不属于某个版本迁移:老库升级后也要补建。
|
||
历史数据若存在重复(名称/指纹撞车),建索引会失败——只告警不回滚、
|
||
不阻塞启动,由管理页提示用户改名(唯一约束从此刻起对新数据生效)。
|
||
"""
|
||
if db.engine.dialect.name == "mysql":
|
||
_ensure_unique_indexes_mysql()
|
||
return
|
||
for name, sql in _UNIQUE_INDEXES_SQLITE:
|
||
try:
|
||
db.session.execute(text(sql))
|
||
db.session.commit()
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||
|
||
|
||
def _ensure_unique_indexes_mysql():
|
||
"""MySQL 5.7 没有过滤索引,用「虚拟生成列 + 唯一索引」复刻部分索引语义。
|
||
|
||
生成列把空值映射成 NULL,而唯一索引允许多个 NULL —— 正好等于
|
||
「空值不参与唯一约束」。生成列不进 ORM 模型(进了 create_all 会尝试写入它
|
||
并报 Error 3105),所以只能在这里用 DDL 补。
|
||
"""
|
||
from sqlalchemy import inspect
|
||
coll = "utf8mb4_bin" # 与库默认一致:逐码点比较,等价 SQLite 的大小写敏感
|
||
for table, col, gen, typ in _MYSQL_UQ_COLUMNS:
|
||
try:
|
||
have = {c["name"] for c in inspect(db.engine).get_columns(table)}
|
||
if gen in have:
|
||
continue
|
||
db.session.execute(text(
|
||
"ALTER TABLE {t} ADD COLUMN {g} {ty} COLLATE {coll} "
|
||
"GENERATED ALWAYS AS (IF({c} IS NULL OR {c} = '', NULL, {c})) VIRTUAL"
|
||
.format(t=table, g=gen, ty=typ, coll=coll, c=col)))
|
||
db.session.commit()
|
||
_log.info(f"已建生成列 {table}.{gen}(部分唯一索引的 MySQL 替代)")
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
_log.warning(f"生成列 {table}.{gen} 创建失败: {e}")
|
||
|
||
for name, table, gen in _MYSQL_UNIQUE_INDEXES:
|
||
try:
|
||
have = {i["name"] for i in inspect(db.engine).get_indexes(table)}
|
||
if name in have:
|
||
continue
|
||
db.session.execute(
|
||
text("CREATE UNIQUE INDEX {} ON {}({})".format(name, table, gen)))
|
||
db.session.commit()
|
||
_log.info(f"已建唯一索引 {name}")
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
|
||
|
||
|
||
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 文件重命名为 <name>.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", "generic_steps"),
|
||
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 文件重命名为 <name>.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}")
|