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
This commit is contained in:
2026-09-28 15:59:09 +08:00
parent 5c3dcba63e
commit b98e6deac1
26 changed files with 4011 additions and 51 deletions
+52
View File
@@ -35,6 +35,7 @@ app.config["SECRET_KEY"] = _web_secret
app.config["TEMPLATES_AUTO_RELOAD"] = True
# 数据库目标与连接参数统一由 core/db_config 装配(.env 的 DEPLOY_ENV / DB_* 决定)。
# 这里就把配置校验做完:连不上、环境与库名不匹配 → 直接拒绝启动,不要带着错配置跑起来。
from config import MAX_CONTENT_LENGTH as _MAX_UPLOAD
from core import db_config
try:
_DB_URI = db_config.build_db_uri()
@@ -45,11 +46,26 @@ except db_config.DBConfigError as _e:
app.config["SQLALCHEMY_DATABASE_URI"] = _DB_URI
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(_DB_URI)
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# 单次请求体上限(视频发布计划要传几百 MB 的视频素材)。
# 默认 2GiB:**必须大于现有 APK 上传的最大包(实测 336MB)**,否则会把「应用管理」的
# APK 上传一起卡死。原来是没设的(Werkzeug 默认无上限)—— 现在有了上限,
# 超限必须回 **JSON**(见下面 413 处理器),否则前端 JSON.parse 会炸成
# "解析响应失败",用户看不出是文件太大。
app.config["MAX_CONTENT_LENGTH"] = _MAX_UPLOAD
login_manager = LoginManager(app)
login_manager.login_view = "auth.login" # 蓝图化后路由前缀 auth
@app.errorhandler(413)
def _payload_too_large(e):
"""上传超限要说人话(默认返回 HTML,前端 JSON.parse 会炸成"解析响应失败")。"""
from flask import jsonify as _jsonify
mb = _MAX_UPLOAD // (1024 * 1024)
return _jsonify({"ok": False,
"error": f"文件太大:单次上传上限 {mb} MB(可在 .env 里调 MAX_UPLOAD_MB)"}), 413
def _consume_pending_restore():
"""消费「待生效的备份恢复」(若存在 data/restore_pending/users.db)。
@@ -107,6 +123,9 @@ dedup.init_app(app)
# 账号台账:绑 app 供任务线程(条件判断取号)/ Web / 设备端身份页自推 context
from core import ledger
ledger.init_app(app)
# 视频发布计划:绑 app 供任务线程(发布步骤)/ Web / 清理 job 自推 context
from core import video_plan
video_plan.init_app(app)
mgr = TaskManager(app=app)
# 设备换地址(认领/迁址)后同步分组与任务的引用:它们的"内存副本"在 TaskManager 里
# (调度用内存对象),device_pool 只改库不改内存不生效,所以在此注册回调打通
@@ -154,6 +173,35 @@ def _purge_done_mark():
_log.warning(f"去重记录清理失败(不影响主服务): {e}")
def _purge_video_plan():
"""发布计划:僵尸占位回收 + 过期行清理(每日 04:41)。
僵尸回收是**必须有的一步**:占位(pushing/publishing)后崩溃的话,没有它
这条计划永远停在"发布中",再也不会被取到 —— 表现成"这个号就是不发"。
"""
try:
from core.video_plan import reclaim_zombies, purge_plan
with app.app_context():
reclaim_zombies()
purge_plan()
except Exception as e:
_log.warning(f"发布计划清理失败(不影响主服务): {e}")
def _purge_video_files():
"""发布计划:平台素材文件清理(每日 04:47)。
**只删"已发布 + 已抓到分享链接 + 超过保留期"的**:没抓到链接的一律留着
(链接与素材至少留一个),未发布/失败/未知的更不删。
"""
try:
from core.video_plan import purge_files
with app.app_context():
purge_files()
except Exception as e:
_log.warning(f"发布素材清理失败(不影响主服务): {e}")
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try:
@@ -167,6 +215,10 @@ try:
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
# 去重记录清理:只清 day/hours 桶(all 永不清理,清了等于"只做一次"失效)
_audit_sched.add_job(_purge_done_mark, CronTrigger(hour=4, minute=23))
# 发布计划:僵尸占位回收 + 过期行清理(04:41)、孤儿素材清理(04:47)。
# 时间刻意与 04:13/04:23 错开 15 分钟以上:都是删数据的大操作,别挤同一分钟。
_audit_sched.add_job(_purge_video_plan, CronTrigger(hour=4, minute=41))
_audit_sched.add_job(_purge_video_files, CronTrigger(hour=4, minute=47))
_audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e: