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:
+132
-85
@@ -1,4 +1,4 @@
|
||||
"""视频发布计划 API(「账号 → 发布计划」页)。
|
||||
"""视频发布计划 API(「发布计划」页)。
|
||||
|
||||
数据在 `video_plan` 表、服务层 `core/video_plan.py`;任务侧怎么自动发布见
|
||||
`tasks/generic/task.py` 的 `_exec_publish_video`(本文件的「立即发布」调的是同一段逻辑)。
|
||||
@@ -24,7 +24,7 @@ bp = Blueprint("video_plan", __name__)
|
||||
def api_vp_upload_video():
|
||||
"""上传单个视频(前端**逐文件**发,才能给每个文件一条结果与进度)。
|
||||
|
||||
文件名规则 `手机号_日期_编号`(编号可省略);解析出的手机号必须**恰好**命中台账一个账号。
|
||||
文件名规则 `名字_日期_编号`(编号可省略,名字 = 抖音昵称,不查台账)。
|
||||
"""
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
@@ -42,8 +42,8 @@ def api_vp_upload_video():
|
||||
def api_vp_upload_titles():
|
||||
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
|
||||
|
||||
每行 `标题内容_手机号_日期_编号` 或 `手机号_日期_编号_标题`(都认;编号可省略、标题里可以有下划线);
|
||||
`#` 开头的行**只有认不出手机号/日期时才当注释**(`#话题` 开头的标题照收)。
|
||||
每行 `标题 || 正文_名字_日期_编号`(`||` 后面是正文、可省略;编号可省略、标题里可以有下划线);
|
||||
`#` 开头的行**只有认不出名字/日期时才当注释**(`#话题` 开头的标题照收)。
|
||||
"""
|
||||
if request.files.get("file"):
|
||||
raw = request.files["file"].stream.read()
|
||||
@@ -65,7 +65,7 @@ def api_vp_upload_titles():
|
||||
@bp.route("/api/video_plan/timeline")
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_vp_timeline():
|
||||
"""时间线:?range=today|week|all &from=&to=&status=&account_id=&q=&limit="""
|
||||
"""时间线:?range=today|week|all &from=&to=&status=&q=&limit="""
|
||||
rng = (request.args.get("range") or "").strip()
|
||||
today = vp._today()
|
||||
date_from = (request.args.get("from") or "").strip()
|
||||
@@ -78,7 +78,6 @@ def api_vp_timeline():
|
||||
date_to = _t.strftime("%Y-%m-%d", _t.localtime(_t.time() + 7 * 86400))
|
||||
data = vp.timeline(date_from, date_to,
|
||||
status=(request.args.get("status") or "").strip(),
|
||||
account_id=(request.args.get("account_id") or "").strip(),
|
||||
q=(request.args.get("q") or "").strip(),
|
||||
limit=int(request.args.get("limit") or 500))
|
||||
return jsonify({"ok": True, **data})
|
||||
@@ -105,11 +104,13 @@ def api_vp_one(pid):
|
||||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||||
return jsonify({"ok": True, "msg": "已删除(素材文件一并删除)"})
|
||||
data = request.json or {}
|
||||
if "title" in data or "body" in data:
|
||||
row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body"))
|
||||
if "title" in data or "body" in data or "nickname" in data:
|
||||
row, err = vp.set_title(pid, data.get("title") or "", body=data.get("body"),
|
||||
nickname=data.get("nickname") if "nickname" in data else None)
|
||||
if err:
|
||||
return jsonify({"ok": False, "error": err}), 400 if "太长" in err else 404
|
||||
return jsonify({"ok": True, "msg": "标题/正文已改", "plan": row})
|
||||
bad = "太长" in err or "名字不对" in err
|
||||
return jsonify({"ok": False, "error": err}), 400 if bad else 404
|
||||
return jsonify({"ok": True, "msg": "标题/正文/名字已改", "plan": row})
|
||||
return jsonify({"ok": False, "error": "没有要改的字段"}), 400
|
||||
|
||||
|
||||
@@ -128,94 +129,142 @@ def api_vp_video(pid):
|
||||
return _send(path, conditional=True)
|
||||
|
||||
|
||||
def _push_by_name(rows, serials=None):
|
||||
"""**按名字找设备**:把一批计划推到"现在登着这个名字的那台设备"上。
|
||||
|
||||
① 计划按 `nickname` 分组;
|
||||
② **实时探测**在线设备当前登的号(`core.account_probe`,跳过正在跑任务的设备);
|
||||
③ 名字 → 设备;找到就**每台设备一个线程、设备内串行**推(同一个手机同时推两个文件
|
||||
会互相干扰:两条 adb push 抢带宽 + 两条 `content call scan_file` 抢相册索引);
|
||||
④ 没有在线设备登着某个名字 → 进 `unmatched`,**如实报出来**(人要先去那台设备上切号)。
|
||||
|
||||
返回 `(matched, unmatched)`,推送本身在后台线程里跑(接口立刻返回)。
|
||||
"""
|
||||
from core import account_probe, device_pool
|
||||
from tasks.generic.publish_flow import push_one
|
||||
|
||||
by_name = {}
|
||||
for row in rows:
|
||||
by_name.setdefault(row.get("nickname") or "", []).append(row)
|
||||
|
||||
dev_names = {}
|
||||
try:
|
||||
for d in device_pool.list_devices():
|
||||
dev_names[d.get("serial") or ""] = d.get("name") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
probes = account_probe.probe_online(serials)
|
||||
account_probe.cache_names(probes) # 顺手存进 device 表(人工入口的设备建议用)
|
||||
name2serial = {}
|
||||
for serial, r in probes.items():
|
||||
nm = (r or {}).get("name") or ""
|
||||
if nm and nm not in name2serial:
|
||||
name2serial[nm] = serial # 同名多机:取第一台(下面的 dup 里说明)
|
||||
|
||||
by_serial, matched, unmatched = {}, [], []
|
||||
for name, plans in by_name.items():
|
||||
serial = name2serial.get(name)
|
||||
if not serial:
|
||||
who = "、".join(f"{s}({(v or {}).get('name')})"
|
||||
for s, v in sorted(probes.items()) if (v or {}).get("name")) \
|
||||
or "没有在线设备"
|
||||
unmatched.append({"name": name, "count": len(plans),
|
||||
"reason": f"没有在线设备登着『{name}』——先到设备上切到它"
|
||||
f"(当前探测到:{who})"})
|
||||
continue
|
||||
by_serial.setdefault(serial, []).extend(plans)
|
||||
matched.append({"name": name, "count": len(plans), "serial": serial,
|
||||
"device_name": dev_names.get(serial, "")})
|
||||
|
||||
def _push_device(serial, plans, dname):
|
||||
for row in plans:
|
||||
try:
|
||||
push_one(serial, row, {"device_name": dname})
|
||||
except Exception as e:
|
||||
_log.warning(f"按名字推送 {row.get('id')} 异常: {e}")
|
||||
|
||||
if rows:
|
||||
import threading
|
||||
for serial, plans in by_serial.items():
|
||||
threading.Thread(target=_push_device,
|
||||
args=(serial, plans, dev_names.get(serial, "")),
|
||||
daemon=True).start()
|
||||
return matched, unmatched
|
||||
|
||||
|
||||
@bp.route("/api/video_plan/<pid>/push", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_vp_push(pid):
|
||||
"""把这一条的素材**推到手机**(只推送,不在手机上发布)。
|
||||
|
||||
发布动作由你写在任务里(推送步骤后面接你自己的步骤),这里只是"人工先推一条"。
|
||||
计划不绑设备了,所以这里**按名字找设备**:实时探测哪台在线设备现在登着这条计划的
|
||||
名字,推给它;没有设备登着 → 400 说清"先去切号"。
|
||||
"""
|
||||
import threading
|
||||
row = vp.get_plan(pid)
|
||||
if not row:
|
||||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||||
# 地址现查(设备名 → 当前地址):**不能用计划行里的快照** —— 设备换 IP 后快照就是错的,
|
||||
# 而且台账是粘贴导入的老记录可能压根没存上快照(实测踩过:设备池里明明有 A01,
|
||||
# 却因为快照为空点不动按钮)。快照只作为兜底。
|
||||
from core import ledger
|
||||
serial = ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")
|
||||
if not serial:
|
||||
return jsonify({"ok": False,
|
||||
"error": f"找不到设备『{row.get('device_name') or '?'}』的地址 ——"
|
||||
f"确认「工具 → 设备池」里有这台设备且已连接"}), 400
|
||||
if row.get("device_name") and not row.get("serial"):
|
||||
vp.set_serial(pid, serial) # 顺手把空快照补上(自愈,免得下次又踩)
|
||||
if row["status"] in (vp.ST_PUSHING, vp.ST_PUBLISHING):
|
||||
return jsonify({"ok": False, "error": "这条已经推到手机上了,别重复推"}), 409
|
||||
if row["status"] == vp.ST_UNKNOWN:
|
||||
return jsonify({"ok": False,
|
||||
"error": "这条上次结果未知(可能已经发出去了)—— 先去抖音确认,"
|
||||
"再用「已发出 / 未发出」裁决"}), 409
|
||||
from tasks.generic.publish_flow import run_push
|
||||
t = threading.Thread(target=run_push, args=(serial, pid), daemon=True)
|
||||
t.start()
|
||||
_log.info(f"推送素材到手机 video_plan {pid}({row['phone']} {row['release_date']})")
|
||||
return jsonify({"ok": True,
|
||||
"msg": "已开始推送到手机(推完状态会变成「已推送到手机」,"
|
||||
"之后你在手机上发;发完点「标记已发布」)"})
|
||||
matched, unmatched = _push_by_name([row])
|
||||
if unmatched:
|
||||
return jsonify({"ok": False, "error": unmatched[0]["reason"]}), 400
|
||||
m = matched[0]
|
||||
_log.info(f"推送素材到手机 video_plan {pid}({row.get('nickname')} "
|
||||
f"{row['release_date']})→ {m['device_name'] or m['serial']}")
|
||||
return jsonify({"ok": True, "device_name": m["device_name"],
|
||||
"msg": f"已开始推送到『{m['device_name'] or m['serial']}』"
|
||||
f"(推完状态变「已推送到手机」,之后你在手机上发;"
|
||||
f"发完点「标记已发布」)"})
|
||||
|
||||
|
||||
def _serial_for_plan(row):
|
||||
"""这条计划要推给哪台设备:**设备名现查**(设备换 IP 后快照是错的),快照兜底。"""
|
||||
from core import ledger
|
||||
return (ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")).strip()
|
||||
def _push_plans_by_account():
|
||||
"""`push_by_account` / `push_all` 两个路由的公共实现(避免"调用另一个视图")。"""
|
||||
data = request.json or {}
|
||||
date = (data.get("date") or "").strip() or vp._today()
|
||||
rows = vp.pushable_plans(date, retry_failed=bool(data.get("retry_failed", True)))
|
||||
if not rows:
|
||||
return jsonify({"ok": True, "count": 0, "devices": 0, "matched": [],
|
||||
"unmatched": [],
|
||||
"msg": f"{date} 没有待推送的计划(有标题的才推)"})
|
||||
matched, unmatched = _push_by_name(rows, data.get("serials") or None)
|
||||
if not matched:
|
||||
return jsonify({"ok": False, "count": 0, "devices": 0, "matched": [],
|
||||
"unmatched": unmatched,
|
||||
"error": "没有在线设备登着这些号:" +
|
||||
"、".join(f"{u['name']}({u['count']}条)" for u in unmatched) +
|
||||
" —— 先到设备上切号,或到「工具 → 设备池」确认设备在线"}), 400
|
||||
_log.info(f"一键推送(按名字){date}:{len(rows)} 条 → {len(matched)} 个名字"
|
||||
f"(没设备登着的 {len(unmatched)} 个名字已跳过)")
|
||||
return jsonify({"ok": True, "count": sum(m["count"] for m in matched),
|
||||
"devices": len({m["serial"] for m in matched}),
|
||||
"matched": matched, "unmatched": unmatched,
|
||||
"msg": f"已开始推送 {sum(m['count'] for m in matched)} 条"
|
||||
f"({len(matched)} 个号)"
|
||||
+ (f";{len(unmatched)} 个名字没有在线设备登着,见明细"
|
||||
if unmatched else "")})
|
||||
|
||||
|
||||
@bp.route("/api/video_plan/push_by_account", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_vp_push_by_account():
|
||||
"""**按名字找设备**的批量推送(「一键推送」):某天所有待推的计划,谁登着谁推。
|
||||
|
||||
立刻返回受理条数(推送在后台跑)——进度与结果看时间线的状态徽章 /
|
||||
「已推送·相册可见」标记。**没设备登着的名字**在 `unmatched` 里逐条说明。
|
||||
"""
|
||||
return _push_plans_by_account()
|
||||
|
||||
|
||||
@bp.route("/api/video_plan/push_all", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_vp_push_all():
|
||||
"""**一键推送**:把某天(默认今天)所有「待发布/失败」的计划推到各自的手机上。
|
||||
|
||||
并发口径:**每台设备一个线程、设备内串行**(同一个手机同时推两个文件会互相干扰:
|
||||
两条 adb push 抢带宽 + 两条 `content call scan_file` 抢索引),设备之间并行。
|
||||
|
||||
立刻返回受理条数(推送是后台跑的)——进度与结果看时间线的状态徽章/
|
||||
「已推送·相册可见」标记。
|
||||
"""
|
||||
import threading
|
||||
from core import ledger
|
||||
data = request.json or {}
|
||||
date = (data.get("date") or "").strip() or vp._today()
|
||||
rows = vp.pushable_plans(date, retry_failed=bool(data.get("retry_failed", True)))
|
||||
if not rows:
|
||||
return jsonify({"ok": True, "count": 0, "devices": 0,
|
||||
"msg": f"{date} 没有待推送的计划(有标题的才推)"})
|
||||
by_dev, no_dev = {}, []
|
||||
for row in rows:
|
||||
serial = (ledger.serial_of(row.get("device_name") or "") or (row.get("serial") or "")).strip()
|
||||
(by_dev.setdefault(serial, []) if serial else no_dev).append(row)
|
||||
by_dev.pop("", None) # 兜底:空 key 不该出现
|
||||
if not by_dev:
|
||||
return jsonify({"ok": False, "error": f"{len(rows)} 条计划都没绑上设备地址 ——"
|
||||
f"到「工具 → 设备池」确认设备在线"}), 400
|
||||
|
||||
from tasks.generic.publish_flow import push_one
|
||||
|
||||
def _push_device(serial, plans):
|
||||
for row in plans:
|
||||
try:
|
||||
push_one(serial, row)
|
||||
except Exception as e:
|
||||
_log.warning(f"一键推送 {row.get('id')} 异常: {e}")
|
||||
|
||||
for serial, plans in by_dev.items():
|
||||
threading.Thread(target=_push_device, args=(serial, plans), daemon=True).start()
|
||||
_log.info(f"一键推送 {date}:{len(rows)} 条 → {len(by_dev)} 台设备"
|
||||
f"(没绑设备的 {len(no_dev)} 条已跳过)")
|
||||
return jsonify({"ok": True, "count": len(rows), "devices": len(by_dev),
|
||||
"no_device": len(no_dev),
|
||||
"msg": f"已开始推送 {len(rows)} 条({len(by_dev)} 台设备,每台串行)——"
|
||||
f"推完状态会变成「已推送·相册可见/相册未见」,刷新看结果"})
|
||||
"""老入口(前端旧版本还可能在调):语义与 `push_by_account` 一致 —— 都按名字找设备。"""
|
||||
return _push_plans_by_account()
|
||||
|
||||
|
||||
@bp.route("/api/video_plan/<pid>/mark", methods=["POST"])
|
||||
@@ -419,14 +468,14 @@ def api_vp_task_adopt(job_id):
|
||||
return jsonify({"ok": False, "error": f"保存失败: {str(e)[:120]}"}), 500
|
||||
_log.info(f"给任务 {job.name}({job_id}) 插上平台发布步骤")
|
||||
return jsonify({"ok": True, "msg": "已插入「推送发布视频」(最前)与「标记发布结果」(最后)——"
|
||||
"你原来的步骤没动;到「账号 → 发布计划」里核对一下顺序",
|
||||
"你原来的步骤没动;到「发布计划」里核对一下顺序",
|
||||
"task": _job_brief(job, context.mgr)})
|
||||
|
||||
|
||||
@bp.route("/api/video_plan/tasks/<job_id>", methods=["GET", "PUT"])
|
||||
@perm_required(PERM_TASKS)
|
||||
def api_vp_task_one(job_id):
|
||||
"""就地编辑发布任务(「账号 → 发布计划」页里,不用跳「任务」页)。
|
||||
"""就地编辑发布任务(「发布计划」页里,不用跳「任务」页)。
|
||||
|
||||
只放开这一页用得上的字段:`name` / `time`(HH:MM) / `target` / `steps` / `enabled`。
|
||||
**没传的字段原样保留**(深合并:`params` 里还有去重有效期、抢占等通用参数,
|
||||
@@ -488,17 +537,15 @@ def api_vp_links():
|
||||
"""已发布作品的分享链接(界面展示 / `format=csv` 导出,给后续铺评论用)。"""
|
||||
rows = vp.links(date_from=(request.args.get("from") or "").strip(),
|
||||
date_to=(request.args.get("to") or "").strip(),
|
||||
device=(request.args.get("device") or "").strip(),
|
||||
phone=(request.args.get("phone") or "").strip())
|
||||
name=(request.args.get("name") or "").strip())
|
||||
if (request.args.get("format") or "") == "csv":
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf)
|
||||
w.writerow(["发布日期", "设备号", "手机号", "抖音号",
|
||||
"编号", "标题", "正文", "分享链接", "发布时间"])
|
||||
# 按**名字**导出(手机号/抖音号随台账停用一起下线;由谁发的看 posted_device_name)
|
||||
w.writerow(["发布日期", "名字", "编号", "标题", "正文", "分享链接", "发布时间"])
|
||||
for r in rows:
|
||||
w.writerow([r["release_date"], r["device_name"], r["phone"],
|
||||
r["douyin_id"], r["seq"], r["title"], r.get("body", ""),
|
||||
r["share_url"], r["published_at"]])
|
||||
w.writerow([r["release_date"], r["nickname"], r["seq"], r["title"],
|
||||
r.get("body", ""), r["share_url"], r["published_at"]])
|
||||
data = ("" + buf.getvalue()).encode("utf-8") # BOM:Excel 打开中文不乱码
|
||||
return send_file(io.BytesIO(data), mimetype="text/csv", as_attachment=True,
|
||||
download_name=f"video_links_{vp._today()}.csv")
|
||||
|
||||
Reference in New Issue
Block a user