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:
@@ -209,6 +209,31 @@ def resolve_device(serial="", device_name=""):
|
||||
return name, rows, f"设备『{name}』登记 {len(rows)} 个号"
|
||||
|
||||
|
||||
def serial_of(device_name):
|
||||
"""设备名 → **当前**地址(拿不到返回空串)。
|
||||
|
||||
⚠ 为什么不能直接用台账/计划行里的 `serial` 快照:设备地址会变(换 IP、重连)。
|
||||
快照是"录入时"的值,久了就是错的 —— 要下发操作(发布视频等)时必须现查。
|
||||
优先设备池的**内存快照**(零 DB、已经是刷新过的),再退回 DB。
|
||||
"""
|
||||
name = (device_name or "").strip()
|
||||
if not name:
|
||||
return ""
|
||||
try:
|
||||
from core import device_pool
|
||||
for d in device_pool.list_devices():
|
||||
if (d.get("name") or "") == name:
|
||||
return d.get("serial") or ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with _ctx():
|
||||
dev = Device.query.filter(Device.name == name).first()
|
||||
return (dev.serial if dev else "") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def douyin_ids(scope, serial="", device_name="", group=""):
|
||||
"""按范围取抖音号(任务「条件判断」取号用)。返回 (去重后的纯号列表, 说明)。
|
||||
|
||||
|
||||
@@ -484,6 +484,93 @@ class DeviceAccount(db.Model):
|
||||
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"
|
||||
@@ -510,6 +597,9 @@ SCHEMA_MIGRATIONS = [
|
||||
(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)
|
||||
|
||||
@@ -111,6 +111,17 @@ EVENTS = [
|
||||
["serial", "device_name", "selector", "miss_count"],
|
||||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||||
recommend=True),
|
||||
_e("task.video.published", "视频已发布", "业务",
|
||||
["serial", "device_name", "model", "phone", "nickname", "release_date", "seq",
|
||||
"title", "share_url"],
|
||||
"「发布视频」步骤把某个账号的视频发出去了(share_url 是作品分享链接,"
|
||||
"没抓到链接时为空——界面上会标「缺链接」)", agg_window=0, recommend=True),
|
||||
_e("task.video.failed", "视频发布失败/结果未知", "业务",
|
||||
["serial", "device_name", "model", "phone", "nickname", "release_date", "seq",
|
||||
"title", "msg"],
|
||||
"「发布视频」步骤失败:推文件阶段失败=可重试;抖音那一步失败=**可能已经发出去了**,"
|
||||
"要到计划页看状态并人工确认(平台不会自动重发)", agg_window=0,
|
||||
agg_key=_BY_SERIAL, recommend=True),
|
||||
|
||||
# ---------------- 设备 ----------------
|
||||
_e("device.online", "设备恢复在线", "设备",
|
||||
|
||||
@@ -59,6 +59,7 @@ TABLE_LABELS = {
|
||||
"task_step_log": "任务步骤明细",
|
||||
"done_mark": "去重记录(已做过)",
|
||||
"device_account": "账号台账",
|
||||
"video_plan": "视频发布计划",
|
||||
}
|
||||
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
|
||||
|
||||
@@ -140,6 +141,46 @@ def _summary_info(con):
|
||||
return rows
|
||||
|
||||
|
||||
def _video_summary():
|
||||
"""视频素材目录的概况(写进 manifest;**不进备份包**,但要交代清楚)。
|
||||
|
||||
· `missing`:`video_plan` 引用了、但文件已经不在了的清单 —— 反向自检
|
||||
(与 `coverage_missing` 同一个思路:别让"看起来有其实没有"悄悄发生)
|
||||
"""
|
||||
try:
|
||||
from config import VIDEO_DIR
|
||||
except Exception:
|
||||
return {"included": False, "error": "config 读取失败"}
|
||||
count = 0
|
||||
size = 0
|
||||
try:
|
||||
for root, _dirs, files in os.walk(VIDEO_DIR):
|
||||
for fn in files:
|
||||
if fn.startswith(".tmp_"):
|
||||
continue
|
||||
try:
|
||||
size += os.path.getsize(os.path.join(root, fn))
|
||||
count += 1
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
missing = []
|
||||
try:
|
||||
from core.models import VideoPlan
|
||||
with db.app_context():
|
||||
for r in VideoPlan.query.filter(VideoPlan.video_file != "").all():
|
||||
p = os.path.join(VIDEO_DIR, r.video_file)
|
||||
if not os.path.exists(p):
|
||||
missing.append({"id": r.id, "file": r.video_file})
|
||||
except Exception:
|
||||
pass
|
||||
return {"included": False, "dir": "data/videos", "count": count, "bytes": size,
|
||||
"note": "视频素材不进备份包(体积)。恢复后需重新上传;"
|
||||
"计划、发布状态与分享链接在 video_plan 表里,已备份。",
|
||||
"missing": missing[:200], "missing_count": len(missing)}
|
||||
|
||||
|
||||
def _prune_old_exports(max_age=3600):
|
||||
"""清理过期的导出临时 zip(下载完成后的 call_on_close 在 Windows 上可能
|
||||
因文件锁删不掉,这里按时间兜底清理;保留近 1 小时的便于失败重试)。"""
|
||||
@@ -299,6 +340,9 @@ def create_export(include_apk=True):
|
||||
"include_apk": include_apk,
|
||||
"tables": _summary_info(con),
|
||||
"apks": apk_meta,
|
||||
# 视频发布计划的素材**不进备份包**(几十 GB 会把备份能力搞坏),
|
||||
# 但必须在 manifest 里交代清楚 —— 否则就是"以为备份了其实没有"(红线)。
|
||||
"videos": _video_summary(),
|
||||
}
|
||||
# 覆盖自检:登记在册的业务表若在快照里缺失(新增功能忘了登记 / 建表失败),
|
||||
# 显式告警并写进 manifest——避免"以为备份了其实没有"(备份覆盖红线)。
|
||||
|
||||
+1159
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user