feat(发布计划): 标题 + 正文(标题 || 正文 _手机号_日期_编号)+ 发布流程多一步填正文;账号名称全面下线

一、正文(video_plan.body,schema v10→v11)
- 标题 txt 一行里可带正文,用 `||` 分隔(标题/正文都常带下划线,不能再用下划线分列;
  只切第一处,正文里再出现 `||` 原样保留):`标题 || 正文 _手机号_日期_编号`
  正文可省(老文件照收);两种写法(标题在前 / 手机号在前)都能带正文
- 长度:标题 ≤100 字、正文 ≤1000 字,超了**拒收不截断**
- 「待发布」判据从"有没有标题"改成"**标题或正文有没有内容**"(`_sync_content_status` 一处定义,
  改标题/改正文/批量导入三处共用),只给正文的行也照样待发
- 导入配对:同标题补一条新正文**要收下**(不能当重复跳过);已有别的标题仍然拒收(不静默覆盖)

二、发布流程多一步
- 骨架 15 → **17 步**:⑪ 输入文字(计划的标题) → ⑫ 点击正文框 → ⑬ 输入文字(计划的正文) → ⑭ 发布 → ⑮ 标记
- 「输入文字」取值来源集中到 `RELEASE_TEXT_SOURCES`:release_title(_topics) / release_body(_topics) /
  release_title_body(标题+正文,只有一个输入框的抖音版本用);`build_caption(key=…)` 支持三种来源
- 任务编辑器与发布计划就地编辑器的下拉都补齐这 6 档;待抓的空选择器 5 → 6 个(多了正文框)

三、账号名称下线(用户要求:只要抖音号)
- 发布计划:卡片 / 明细表 / 搜索 / 导出 CSV 都不再有账号名称(CSV 改为
  发布日期/设备号/手机号/抖音号/编号/标题/正文/分享链接/发布时间)
- 账号台账:列表列 / 编辑弹窗 / 粘贴导入预览 / 设备维度弹窗 / 导入说明与示例都去掉;
  **粘贴导入忽略这一列**(不报错、不落库),库里的历史值保留(不删列)
- 设备端 Agent 身份页:平台不再下发名称(传空串,**不改 APK 也能生效**,避免老版本解析失败)

四、文档 / 测试
- DATA_MODEL:body 列 + schema 11;API:upload_titles(`||` 语义)、PUT {title,body}、CSV 列、骨架 17 步
- TASK_DEV:骨架 17 步、`input_text` 六个取值来源表、正文长度上限
- ARCHITECTURE:release.js 说明(正文、不显示账号名称)
- 测试(%TEMP% 被系统清理后重建):test_plan_body.py 33 项(解析/取内容/骨架/入库/导入配对)、
  smoke_app_body.py 10 项(整机装配:页面、PUT 改正文、时间线、CSV 表头)—— 全过
This commit is contained in:
2026-10-11 16:36:56 +08:00
parent a800d051ae
commit 6ee089e023
13 changed files with 196 additions and 95 deletions
+7 -1
View File
@@ -329,7 +329,9 @@ def agent_accounts(serial="", device_name="", limit=50, include_phone=False):
name, rows, note = resolve_device(serial=serial, device_name=device_name)
out = []
for r in rows[:max(1, int(limit or 50))]:
item = {"name": r.get("nickname") or "", "douyin_id": r.get("douyin_id") or "",
# name 恒空串:账号名称已停用(设备端 APK 仍会解析这个字段,
# 直接删 key 有解析失败的风险,所以留空串)
item = {"name": "", "douyin_id": r.get("douyin_id") or "",
"device_name": r.get("device_name") or "", "device_no": name,
"sim_in_device": bool(r.get("sim_in_device")),
"can_post_video": bool(r.get("can_post_video"))}
@@ -465,6 +467,10 @@ def parse_paste(text, delimiter="\t"):
row[f] = ""
for field, idx in mapped.items():
if field in FIELDS:
# 账号名称(nickname)**不再收录**:界面上已经去掉这一列,
# 老表格粘进来也不报错,只是这一列的值被忽略(库里的历史值不动)
if field == "nickname":
continue
row[field] = cells[idx].strip() if idx < len(cells) else ""
if not row["device_name"] and not row["douyin_id"]:
errors.append({"line": lineno, "reason": "设备号与抖音号都是空的,已跳过"})
+5 -2
View File
@@ -525,7 +525,8 @@ class VideoPlan(db.Model):
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="") # 文案
title = db.Column(db.Text, default="") # 标题(抖音发布页的「标题」框)
body = db.Column(db.Text, default="") # 正文/文案(发布页的文案框;标题 txt 里用 || 分隔)
video_file = db.Column(db.String(120), default="") # 平台落盘文件名(不含绝对路径)
video_name = db.Column(db.String(200), default="") # 原始上传名(排查用)
video_size = db.Column(db.Integer, default=0)
@@ -557,7 +558,8 @@ class VideoPlan(db.Model):
"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 "",
"title": self.title or "", "body": self.body 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),
@@ -600,6 +602,7 @@ SCHEMA_MIGRATIONS = [
(9, "视频发布计划:video_plan 表(账号×发布日期×编号 → 素材 + 标题 + 发布状态 + 分享链接)", None),
(10, "视频发布计划:video_plan 新增 push_verify(推送后相册校验:ok/no_index/nofile)"
"与 push_remote(手机上的绝对路径,删它/排查用)", None),
(11, "视频发布计划:video_plan 新增 body(正文)—— 标题 txt 一行里用 || 分隔「标题 || 正文」", None),
]
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
+71 -21
View File
@@ -60,6 +60,7 @@ STAGE_STATUS = {"push": ST_FAILED, "scan": ST_FAILED,
VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".avi", ".3gp", ".flv", ".wmv", ".m4v", ".ts", ".webm")
MAX_TITLE_LEN = 100 # 超过就拒收(**不静默截断**:截断会发出错文案,用户看不见)
MAX_BODY_LEN = 1000 # 正文上限(抖音文案框放得下 1000 字;同样不截断,超了拒收)
MAX_ATTEMPTS = 3 # 尝试上限,超了不再自动取(人工确认后重试可清零)
ZOMBIE_MINUTES = 30 # 占位行超过这么久没动静 → 按 stage 降级(否则崩溃一次就永远发不出去)
VIDEO_KEEP_DAYS = 7 # 平台素材:抓到分享链接后再留这么多天
@@ -164,6 +165,10 @@ def parse_video_name(filename):
def parse_title_line(line):
"""标题行 → (meta, error)。**两种写法都认**,标题里都可以带下划线。
**一行里可以有标题和正文**:`标题 || 正文 _手机号_日期_编号`(用 `||` 分隔,
因为标题/正文里常带下划线;只切第一处,正文里再出现 `||` 原样保留)。
没写 `||` 就是只有标题(老文件照收);` || 正文_…` 这种只有正文也行。
① 标题在前(推荐,右锚定):
A) 标题内容_手机号_日期_编号 B) 标题内容_手机号_日期
② 手机号在前(左锚定,跟视频文件名同序 —— "文件名去掉扩展名 + 标题"):
@@ -209,12 +214,19 @@ def parse_title_line(line):
if not d:
return None, f"日期 {date8} 不是合法日期(应为 20260912 这种 8 位)"
date = d
title = "_".join(title_parts).strip()
if not title:
return None, "缺标题内容(只有手机号/日期/编号,没有标题文字)"
raw = "_".join(title_parts).strip()
# 「标题 || 正文」:用 `||` 分隔(标题/正文里常带下划线,所以不能再用下划线分列)。
# 只切第一处:正文自己再出现 `||` 也原样保留。没写 `||` 就是只有标题(老文件照收)。
title, body = raw, ""
if "||" in raw:
title, body = (x.strip() for x in raw.split("||", 1))
if not title and not body:
return None, "缺标题/正文内容(只有手机号/日期/编号,没有文字)"
if len(title) > MAX_TITLE_LEN:
return None, f"标题太长({len(title)} 字 > {MAX_TITLE_LEN} 字上限),请改短或拆成两条"
return {"title": title, "phone": phone, "date": date,
if len(body) > MAX_BODY_LEN:
return None, f"正文太长({len(body)} 字 > {MAX_BODY_LEN} 字上限),请改短或拆成两条"
return {"title": title, "body": body, "phone": phone, "date": date,
"seq": seq, "seq_given": seq is not None}, ""
@@ -358,12 +370,21 @@ def pair_slots(videos, titles, accounts_by_phone, db_taken=None):
return pairs, rejected
def build_caption(plan, topics=""):
"""文案 = 标题 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。
def build_caption(plan, topics="", key="title"):
"""文案 = 内容 + 话题。**只在这一处定义拼接规则**(两处各配必然分叉)。
`key` 选内容来源:`title`(标题)/ `body`(正文)/ `title_body`(标题 + 换行 + 正文,
有的抖音版本只有一个输入框时用这个)。
`topics` 支持逗号/换行分隔,不带 `#` 的自动补上。
"""
title = (plan.get("title") or "").strip() if isinstance(plan, dict) else ""
plan = plan if isinstance(plan, dict) else {}
if key == "title_body":
title = chr(10).join(x for x in ((plan.get("title") or "").strip(),
(plan.get("body") or "").strip()) if x)
elif key == "body":
title = (plan.get("body") or "").strip()
else:
title = (plan.get("title") or "").strip()
tags = []
for chunk in re.split(r"[,,\n]", topics or ""):
c = chunk.strip().lstrip("#").strip()
@@ -483,8 +504,13 @@ def build_release_steps():
"label": "⑪ 填标题(自动取这一条计划的标题,会回读校验)",
"params": {"text_source": "release_title", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("publish", "⑫ 点击「发布」"),
dict(build_mark_step("⑬ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
clk("body", "⑫ 点击正文输入框(发布页的文案框)"),
{"id": sid("bodytxt"), "type": "input_text",
"label": "⑬ 填正文(自动取这一条计划的正文,会回读校验)",
"params": {"text_source": "release_body", "release_topics": "",
"clear_first": True, "mode": "random", "texts": "", "fixed_text": ""}},
clk("publish", "⑭ 点击「发布」"),
dict(build_mark_step("⑮ 标记发布结果(成功走这里;失败分支再放一个 result=failed)")),
]
return [
{"id": sid("light"), "type": "screen_on", "label": "⓪ 亮屏",
@@ -716,13 +742,16 @@ def import_titles(text, dry_run=False):
counts["rejected"] += 1
continue
item["seq"] = int(row.seq or 1)
if (row.title or "").strip() == m["title"]:
# 判"配过了"要**标题和正文都比**:同标题补一条正文是正常操作,不能当重复跳过
body_in = m.get("body") or ""
if ((row.title or "").strip() == m["title"]
and (not body_in or (row.body or "").strip() == body_in)):
item["action"] = "skip"
item["error"] = "这条标题已经配过了"
item["error"] = "这条标题/正文已经配过了"
out.append(item)
counts["skipped"] += 1
continue
if (row.title or "").strip():
if (row.title or "").strip() and (row.title or "").strip() != m["title"]:
# 已有别的标题 → 拒收(**不静默覆盖**:文案是发出去就改不了的东西;
# 要换就到计划页改标题,或先删掉这条标题)
item["action"] = "reject"
@@ -734,9 +763,11 @@ def import_titles(text, dry_run=False):
out.append(item)
counts["attached"] += 1
if not dry_run:
row.title = m["title"]
if row.status == ST_PENDING:
row.status = ST_READY
if m["title"]:
row.title = m["title"] # 只有正文的行别把已有标题清掉
if m.get("body"):
row.body = m["body"] # 只有标题的行别把已有正文清掉
_sync_content_status(row)
row.updated_at = _now()
if not dry_run:
db.session.commit()
@@ -747,20 +778,39 @@ def import_titles(text, dry_run=False):
return {"ok": True, "counts": counts, "rows": out, "dry_run": bool(dry_run), "msg": msg}
def set_title(plan_id, title):
"""人工改标题(改完 `pending → ready`)。"""
def _sync_content_status(row):
"""按"有没有内容(标题或正文)"在 `pending`(待内容)与 `ready`(待发布)之间切。
抽出来是因为改标题、改正文、批量导入三处都要用**同一个判据** ——
判据分叉就会出现"界面上有正文却显示待标题、推不出去"这种哑火。
"""
has = bool((row.title or "").strip()) or bool((row.body or "").strip())
if has and row.status == ST_PENDING:
row.status = ST_READY
elif not has and row.status == ST_READY:
row.status = ST_PENDING
def set_title(plan_id, title, body=None):
"""人工改标题 /(可选)正文,改完按"有没有内容"切 pending/ready。
`body=None` = **不动正文**(老调用方语义不变);传空串 = 清掉正文。
"""
title = (title or "").strip()
if len(title) > MAX_TITLE_LEN:
return None, f"标题太长(上限 {MAX_TITLE_LEN} 字)"
if body is not None:
body = (body or "").strip()
if len(body) > MAX_BODY_LEN:
return None, f"正文太长(上限 {MAX_BODY_LEN} 字)"
with _ctx():
row = VideoPlan.query.get(plan_id)
if not row:
return None, "计划不存在"
row.title = title
if title and row.status == ST_PENDING:
row.status = ST_READY
elif not title and row.status == ST_READY:
row.status = ST_PENDING
if body is not None:
row.body = body
_sync_content_status(row)
row.updated_at = _now()
db.session.commit()
return row.to_dict(), ""