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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user