feat(发布计划): 改按名字发(文件名 名字_日期_编号)+ 账号管理整页下线 + 自动切号一轮发完

发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行
`标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块
「一轮把本机待发的号跑完」自动切号,一直发到发完。

· 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因),
  删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建)
· 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/
  循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口)
· 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**)
  → 抖音流程 → 标记结果;push_release 的 account 默认 name
· 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、
  「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签;
  device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**)
· 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备)
  + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来;
  推送时把"实际是谁发的"写进 posted_serial / posted_device_name
· 通知:新增 task.account.switch_failed / task.account.need_login
· schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at)
· 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY /
  DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env)
· 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
This commit is contained in:
2026-10-12 09:02:40 +08:00
parent 75bee21ffb
commit beda0f3779
37 changed files with 2615 additions and 1747 deletions
+59 -11
View File
@@ -250,6 +250,10 @@ class Device(db.Model):
enabled = db.Column(db.Boolean, default=True) # 是否参与调度
note = db.Column(db.Text, default="") # 备注
created_at = db.Column(db.String(20), default="") # 添加时间
# 上次探测到的**抖音当前登录账号名字**(「发布计划 → 一键推送(按名字找设备)」用)。
# 只是**缓存**:自动任务永远现场读屏幕,不读这两列(缓存旧了也不影响发布正确性)。
cur_account_name = db.Column(db.String(80), default="")
cur_name_at = db.Column(db.String(20), default="") # 上面那个名字是什么时候读的
# 设备指纹(ro.serialno):识别"同一台物理设备"的稳定标识。
# 网络设备(serial=IP:5555)换 IP 后靠它认领回原记录,名称/分组/任务引用都不丢。
fingerprint = db.Column(db.String(120), default="")
@@ -258,7 +262,9 @@ class Device(db.Model):
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 ""}
"fingerprint": self.fingerprint or "",
"cur_account_name": self.cur_account_name or "",
"cur_name_at": self.cur_name_at or ""}
def __repr__(self):
return f"<Device {self.serial}>"
@@ -437,7 +443,11 @@ class DoneMark(db.Model):
class DeviceAccount(db.Model):
"""账号台账:一台设备上登录着哪些账号(「账号」页)。
"""账号台账(**2026-10-12 起已停用**:页面/接口/任务依赖全部下线,表和数据保留)。
保留模型 = 保留表和 42 行历史数据、并继续留在备份清单(`SUMMARY_TABLES` 按模型派生)——
停用不等于删数据(删不可逆)。新功能**不要再读写这张表**。
历史用途:一台设备上登录着哪些账号(「账号」页维护)。
要解决的问题(用户场景):一台手机登好几个抖音号、十几台手机四五十个号,
账号信息散在一张电子表格里 —— 换 IP、加号、想查"这台登的是哪几个号"都要翻表。
@@ -451,7 +461,7 @@ class DeviceAccount(db.Model):
格式不一致会让去重**静默失效**(见 `core/dedup.py`)。
`device_name` 是设备号(平台设备名,如 A01);`serial` 是**录入时的地址快照**,
设备换 IP 或改名后,台账靠任一侧都能找回来(见 `core/ledger.for_device`)。
设备换 IP 或改名后,台账靠任一侧都能找回来(账号管理已停用,这列仅留作历史数据)。
同一抖音号不允许两条 —— 唯一性由服务层保证(号可能为空,DB 层要做
"部分唯一索引"三处方言适配,人工维护的几十条不值当)。
"""
@@ -506,7 +516,7 @@ class VideoPlan(db.Model):
⚠ **`failed` 与 `unknown` 必须分开**:把"不知道自己发没发"混成"知道自己没发",
就是重复发布的来源。`stage` 记录失败发生在哪一步,是这两者互相转换的唯一依据。
**唯一性**:`(phone, release_date, seq)` 由服务层(`core/video_plan.py`)保证,
**唯一性**:`(nickname, release_date, seq)` 由服务层(`core/video_plan.py`)保证,
**不加 DB 唯一索引** —— `seq` 从 1 起、没有"空值"可言,做部分唯一索引要写三处方言适配
(见 §唯一索引那段注释与 `_ensure_unique_indexes`),收益不匹配;违反的代价只是
低频人工上传产生的重复行,可见、可删。
@@ -516,12 +526,12 @@ class VideoPlan(db.Model):
"""
__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="") # 地址快照
account_id = db.Column(db.String(32), default="", index=True) # 老台账账号 id(已停用,保留列)
phone = db.Column(db.String(32), default="", index=True) # 老台账手机号快照(已停用,保留列)
device_name = db.Column(db.String(80), default="", index=True) # 设备号快照(**已不写入**;实际由谁发的看 posted_device_name)
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) # 编号是自动分配出来的(界面要提示)
@@ -540,6 +550,8 @@ class VideoPlan(db.Model):
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="") # 推到手机上的绝对路径(删它/排查用)
posted_serial = db.Column(db.String(120), default="") # **实际是哪台设备发的**(地址快照)
posted_device_name = db.Column(db.String(80), 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)
@@ -550,6 +562,8 @@ class VideoPlan(db.Model):
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"),
# 主查询路径(按名字取待发计划/查重)——老库靠 _ensure_plain_indexes 补建
db.Index("ix_video_plan_name_date", "nickname", "release_date"),
)
def to_dict(self):
@@ -566,6 +580,8 @@ class VideoPlan(db.Model):
"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 "",
"posted_serial": self.posted_serial or "",
"posted_device_name": self.posted_device_name or "",
"last_error": self.last_error or "", "note": self.note or "",
"created_at": self.created_at or "", "updated_at": self.updated_at or ""}
@@ -603,6 +619,10 @@ SCHEMA_MIGRATIONS = [
(10, "视频发布计划:video_plan 新增 push_verify(推送后相册校验:ok/no_index/nofile)"
"与 push_remote(手机上的绝对路径,删它/排查用)", None),
(11, "视频发布计划:video_plan 新增 body(正文)—— 标题 txt 一行里用 || 分隔「标题 || 正文」", None),
(12, "发布计划按名字发:计划行以 nickname 为识别键,新增 posted_serial/posted_device_name"
"(**实际是哪台设备发的**);账号台账(device_account)停用但表与数据保留", None),
(13, "按名字找设备:device 表新增 cur_account_name/cur_name_at"
"(上次探测到的抖音当前账号名字,仅供人工入口做设备建议)", None),
]
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
@@ -633,13 +653,20 @@ _MYSQL_UNIQUE_INDEXES = (
("ux_device_fingerprint", "device", "fingerprint_uq"),
)
# 普通索引:模型里已经声明了(`__table_args__`),但那对**已有表**不生效 ——
# `create_all` 只建缺的表,不补已有表的新索引。这里按名字查了再建,老库升级也能有。
_PLAIN_INDEXES = (
("ix_video_plan_name_date", "video_plan", "nickname, release_date"),
)
def init_db(app):
"""在 Flask app context 里初始化数据库 + 创建默认管理员。
web_server 启动时调用。自动迁移旧 groups.json/jobs.json 到数据库。
顺序:建表(create_all)→ 补列(_sync_columns)→ 版本账本 → 唯一索引 → 管理员/旧 JSON。
顺序:建表(create_all)→ 补列(_sync_columns)→ 版本账本 → 唯一索引 → 普通索引
→ 管理员/旧 JSON。
建表与补列都以模型定义为准,SQLite / MySQL 两种方言自动匹配,不再手写 DDL。
"""
db.init_app(app)
@@ -648,6 +675,7 @@ def init_db(app):
_sync_columns()
_migrate_schema()
_ensure_unique_indexes()
_ensure_plain_indexes()
_ensure_default_admin()
_migrate_old_json()
@@ -780,6 +808,26 @@ def _ensure_unique_indexes_mysql():
_log.warning(f"唯一索引 {name} 创建失败(历史数据可能有重复): {e}")
def _ensure_plain_indexes():
"""补建模型里声明、但已有表上还没有的普通索引(幂等)。
SQLite / MySQL 都能跑同一条 `CREATE INDEX`(MySQL 5.7 没有 `IF NOT EXISTS`,
所以先 inspect 查名字再建,不靠方言特有能力)。
"""
from sqlalchemy import inspect
for name, table, cols in _PLAIN_INDEXES:
try:
have = {i["name"] for i in inspect(db.engine).get_indexes(table)}
if name in have:
continue
db.session.execute(text("CREATE INDEX {} ON {}({})".format(name, table, cols)))
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():