Files
auto_control/core/models.py
T
butubb b98e6deac1 feat(发布计划): 视频发布计划(批量上传配对 → 时间线 → 推送到手机 → 发布任务 → 分享链接)
一、平台侧(账号 → 发布计划页)
- 新表 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
2026-09-28 15:59:09 +08:00

873 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}")