发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 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(临时库 + 假设备,不碰真实数据、不连真机)
553 lines
25 KiB
Python
553 lines
25 KiB
Python
"""视频发布计划 API(「发布计划」页)。
|
||
|
||
数据在 `video_plan` 表、服务层 `core/video_plan.py`;任务侧怎么自动发布见
|
||
`tasks/generic/task.py` 的 `_exec_publish_video`(本文件的「立即发布」调的是同一段逻辑)。
|
||
|
||
红线(与 `core/video_plan.py` 顶部一致):发布前必须原子占位;失败要分
|
||
"推文件阶段(可重试)"与"抖音阶段(结果未知、绝不自动重试)"两类。
|
||
"""
|
||
import csv
|
||
import io
|
||
|
||
from flask import Blueprint, jsonify, request, send_file
|
||
|
||
from core import video_plan as vp
|
||
from core.logger import get_logger
|
||
from web.auth import perm_required, PERM_DEVICES, PERM_TASKS
|
||
|
||
_log = get_logger("web")
|
||
bp = Blueprint("video_plan", __name__)
|
||
|
||
|
||
@bp.route("/api/video_plan/upload_video", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_upload_video():
|
||
"""上传单个视频(前端**逐文件**发,才能给每个文件一条结果与进度)。
|
||
|
||
文件名规则 `名字_日期_编号`(编号可省略,名字 = 抖音昵称,不查台账)。
|
||
"""
|
||
f = request.files.get("file")
|
||
if not f or not f.filename:
|
||
return jsonify({"ok": False, "error": "没收到文件"}), 400
|
||
replace = str(request.form.get("replace") or "") in ("1", "true", "yes")
|
||
order = request.form.get("order")
|
||
result = vp.upload_video(f, order=order, replace=replace)
|
||
if not result.get("ok"):
|
||
return jsonify(result), 400
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/video_plan/upload_titles", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_upload_titles():
|
||
"""上传标题:`{text, dry_run}`;也接受 multipart 的 txt 文件(字段名 file)。
|
||
|
||
每行 `标题 || 正文_名字_日期_编号`(`||` 后面是正文、可省略;编号可省略、标题里可以有下划线);
|
||
`#` 开头的行**只有认不出名字/日期时才当注释**(`#话题` 开头的标题照收)。
|
||
"""
|
||
if request.files.get("file"):
|
||
raw = request.files["file"].stream.read()
|
||
try:
|
||
text = raw.decode("utf-8")
|
||
except UnicodeDecodeError:
|
||
text = raw.decode("gbk", errors="replace")
|
||
dry = str(request.form.get("dry_run") or "") in ("1", "true", "yes")
|
||
else:
|
||
data = request.json or {}
|
||
text = data.get("text") or ""
|
||
dry = bool(data.get("dry_run"))
|
||
if not text.strip():
|
||
return jsonify({"ok": False, "error": "内容是空的"}), 400
|
||
result = vp.import_titles(text, dry_run=dry)
|
||
return jsonify(result)
|
||
|
||
|
||
@bp.route("/api/video_plan/timeline")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_timeline():
|
||
"""时间线:?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()
|
||
date_to = (request.args.get("to") or "").strip()
|
||
if rng == "today" and not date_from:
|
||
date_from = date_to = today
|
||
elif rng == "week" and not date_from:
|
||
import time as _t
|
||
date_from = today
|
||
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(),
|
||
q=(request.args.get("q") or "").strip(),
|
||
limit=int(request.args.get("limit") or 500))
|
||
return jsonify({"ok": True, **data})
|
||
|
||
|
||
@bp.route("/api/video_plan/stats")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_stats():
|
||
"""顶部统计(今天要发/已发/待标题/过期未发 + 素材占用与磁盘余量)。"""
|
||
return jsonify({"ok": True, "stats": vp.stats(), "disk": vp.disk_info(),
|
||
"today": vp._today()})
|
||
|
||
|
||
@bp.route("/api/video_plan/<pid>", methods=["GET", "PUT", "DELETE"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_one(pid):
|
||
if request.method == "GET":
|
||
row = vp.get_plan(pid)
|
||
if not row:
|
||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||
return jsonify({"ok": True, "plan": row})
|
||
if request.method == "DELETE":
|
||
if not vp.delete_plan(pid):
|
||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||
return jsonify({"ok": True, "msg": "已删除(素材文件一并删除)"})
|
||
data = request.json or {}
|
||
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:
|
||
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
|
||
|
||
|
||
@bp.route("/api/video_plan/<pid>/video")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_video(pid):
|
||
"""预览平台上的素材(文件被清理后返回 410)。"""
|
||
import os
|
||
from flask import send_file as _send
|
||
row = vp.get_plan(pid)
|
||
if not row:
|
||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||
path = vp._video_path(row.get("video_file"))
|
||
if not path or not os.path.exists(path):
|
||
return jsonify({"ok": False, "error": "素材文件已清理"}), 410
|
||
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 说清"先去切号"。
|
||
"""
|
||
row = vp.get_plan(pid)
|
||
if not row:
|
||
return jsonify({"ok": False, "error": "计划不存在"}), 404
|
||
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
|
||
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 _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():
|
||
"""老入口(前端旧版本还可能在调):语义与 `push_by_account` 一致 —— 都按名字找设备。"""
|
||
return _push_plans_by_account()
|
||
|
||
|
||
@bp.route("/api/video_plan/<pid>/mark", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_mark(pid):
|
||
"""人工标记结果:`{status: done|failed|unknown, why}`(推送到手机之后用)。"""
|
||
data = request.json or {}
|
||
st = (data.get("status") or "").strip()
|
||
if st not in (vp.ST_DONE, vp.ST_FAILED, vp.ST_UNKNOWN):
|
||
return jsonify({"ok": False, "error": "status 只能是 done / failed / unknown"}), 400
|
||
why = (data.get("why") or "").strip()
|
||
row, err = vp.set_status(pid, st, note=why)
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
_log.info(f"人工标记 video_plan {pid} → {st}")
|
||
return jsonify({"ok": True, "msg": "已标记", "plan": row})
|
||
|
||
|
||
@bp.route("/api/video_plan/<pid>/<action>", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_action(pid, action):
|
||
"""单条操作:skip 跳过 / retry 重试(failed→ready)/ resolve 裁决 unknown。"""
|
||
data = request.json or {}
|
||
if action == "skip":
|
||
row, err = vp.set_status(pid, vp.ST_SKIPPED, note=data.get("reason") or "")
|
||
msg = "已跳过"
|
||
elif action == "retry":
|
||
row, err = vp.set_status(pid, vp.ST_READY, note="人工重试")
|
||
msg = "已放回待发布(尝试次数已清零)"
|
||
elif action == "resolve":
|
||
row, err = vp.resolve_unknown(pid, bool(data.get("published")),
|
||
note=data.get("note") or "")
|
||
msg = "已记为「已发出」" if data.get("published") else "已记为「未发出」(可重试)"
|
||
else:
|
||
return jsonify({"ok": False, "error": f"未知操作 {action}"}), 400
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
return jsonify({"ok": True, "msg": msg, "plan": row})
|
||
|
||
|
||
def _norm_target(target):
|
||
"""规范化任务目标。**分组键必须是 `group_name`** —— `TaskJob.resolve_serials()`
|
||
只认这个键,写成 `group` 会静默解析出 0 台设备(任务"跑完了"但谁也没跑)。"""
|
||
t = dict(target or {})
|
||
mode = (t.get("mode") or "all").strip()
|
||
out = {"mode": mode}
|
||
if mode == "group":
|
||
out["group_name"] = (t.get("group_name") or t.get("group") or "").strip()
|
||
elif mode == "serial":
|
||
out["serial"] = (t.get("serial") or "").strip()
|
||
return out
|
||
|
||
|
||
def _target_error(target):
|
||
if target["mode"] not in ("all", "group", "serial"):
|
||
return "目标只能是 全部/分组/单设备"
|
||
if target["mode"] == "group" and not target.get("group_name"):
|
||
return "选了「设备分组」但没指定分组名"
|
||
if target["mode"] == "serial" and not target.get("serial"):
|
||
return "选了「单台设备」但没指定设备"
|
||
return ""
|
||
|
||
|
||
def _parse_hhmm(text):
|
||
try:
|
||
h, m = [int(x) for x in str(text).split(":")]
|
||
assert 0 <= h < 24 and 0 <= m < 60
|
||
return h, m, ""
|
||
except Exception:
|
||
return 0, 0, "时间格式应为 HH:MM"
|
||
|
||
|
||
def _count_steps(steps):
|
||
"""步骤总数(**含嵌套**:条件判断的 then/else、循环块里的)—— 界面上给人看的数字,
|
||
只数顶层会让人以为任务很短(骨架顶层 4 步、实际 14 步)。"""
|
||
n = 0
|
||
for s in steps or []:
|
||
if not isinstance(s, dict):
|
||
continue
|
||
n += 1
|
||
p = s.get("params") or {}
|
||
for k in ("then", "else", "children"):
|
||
n += _count_steps(p.get(k) or [])
|
||
return n
|
||
|
||
|
||
def _job_brief(j, mgr):
|
||
"""任务摘要(列表用)+ 步骤(就地编辑用)。"""
|
||
steps = (j.params or {}).get("steps") or []
|
||
try:
|
||
nxt = mgr.next_run_of(j)
|
||
nxt = nxt.strftime("%Y-%m-%d %H:%M") if nxt else ""
|
||
except Exception:
|
||
nxt = ""
|
||
sch = j.schedule or {}
|
||
return {"id": j.id, "name": j.name, "enabled": bool(j.enabled),
|
||
"mode": sch.get("mode", "once"), "cron": sch.get("cron", ""),
|
||
"next_run": nxt, "target": j.target, "steps": steps,
|
||
"step_count": _count_steps(steps),
|
||
"retry": j.retry or {},
|
||
"schedule": sch}
|
||
|
||
|
||
@bp.route("/api/video_plan/tasks")
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_tasks():
|
||
"""**可编辑的任务列表** —— 「发布计划」页顶部那一块。
|
||
|
||
返回**全部通用步骤任务**,每条带 `is_release`(含「推送/标记发布结果」= 发布任务):
|
||
- 只列发布任务的话,手写的抖音发布流程(自己点相册/输入框/发布,没有平台的
|
||
`push_release` 步骤)就会**一条都不显示** —— 人会以为"这页没有能改的地方"
|
||
- 界面上按 `is_release` 分成两组:上面「发布任务」,下面「其它任务」也能就地改步骤
|
||
|
||
带上完整 `steps`:这一页要**就地编辑**这些步骤(抓选择器),不用再跳「任务」页。
|
||
"""
|
||
from web import context
|
||
release, other = [], []
|
||
try:
|
||
jobs = list((context.mgr.jobs or {}).values())
|
||
except Exception:
|
||
jobs = []
|
||
for j in jobs:
|
||
# 只有通用步骤任务有步骤可编(其它任务类型的 params 是另一套)
|
||
if getattr(j, "task_type", "generic_steps") != "generic_steps":
|
||
continue
|
||
b = _job_brief(j, context.mgr)
|
||
b["is_release"] = vp.is_release_task((j.params or {}).get("steps") or [])
|
||
b["has_push"] = vp.has_push_step((j.params or {}).get("steps") or [])
|
||
(release if b["is_release"] else other).append(b)
|
||
release.sort(key=lambda x: x["name"])
|
||
other.sort(key=lambda x: x["name"])
|
||
return jsonify({"ok": True, "tasks": release + other,
|
||
"release_count": len(release), "other_count": len(other)})
|
||
|
||
|
||
@bp.route("/api/video_plan/tasks", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_vp_task_create():
|
||
"""一键新建「标准发布任务」:账号校验 → 推送 → 抖音点击(占位待你抓)→ 填标题 → 标记。
|
||
|
||
请求:{"name": "发布视频", "target": {"mode":"all"|"group"|"serial", …},
|
||
"time": "10:00", "enabled": true}
|
||
**走 TaskManager 建**(内存 + 库一起更新)—— 直接写库的话调度器不认。
|
||
"""
|
||
from web import context
|
||
data = request.json or {}
|
||
name = (data.get("name") or "发布视频").strip()[:120]
|
||
target = _norm_target(data.get("target"))
|
||
err = _target_error(target)
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
h, m, err = _parse_hhmm((data.get("time") or "10:00").strip())
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
schedule = {"mode": "cron", "cron": f"{m} {h} * * *", "stop_cron": "",
|
||
"window": {"start": "", "end": ""}}
|
||
params = {"steps": vp.build_release_steps(),
|
||
"dedup_reset": "day", "dedup_hours": 6,
|
||
"preempt": False, "skip_offline": True, "max_duration": 0}
|
||
try:
|
||
job = context.mgr.add_job(name=name, task_type="generic_steps", target=target,
|
||
params=params, schedule=schedule,
|
||
retry={"max_attempts": 1, "delay": 60},
|
||
enabled=bool(data.get("enabled", True)))
|
||
except Exception as e:
|
||
_log.warning(f"新建发布任务失败: {e}")
|
||
return jsonify({"ok": False, "error": f"创建失败: {str(e)[:120]}"}), 500
|
||
_log.info(f"新建发布任务 {job.name}({job.id}),每天 "
|
||
f"{h:02d}:{m:02d},目标 {target}")
|
||
return jsonify({"ok": True, "msg": f"已创建「{name}」(每天 {h:02d}:{m:02d} 跑)——"
|
||
f"就在下面这一块把抖音那几步的选择器抓一下就能用了",
|
||
"job_id": job.id, "task": _job_brief(job, context.mgr)})
|
||
|
||
|
||
@bp.route("/api/video_plan/tasks/<job_id>/adopt", methods=["POST"])
|
||
@perm_required(PERM_TASKS)
|
||
def api_vp_task_adopt(job_id):
|
||
"""给**已有任务**插上平台那两步(`push_release` 放最前、`mark_release` 放最后)。
|
||
|
||
手写的抖音发布流程常缺这两步:不插的话计划页的视频永远不会被推到手机、
|
||
发布结果也不会回写状态。**中间你自己的步骤一步不动**(位置理由见
|
||
`core.video_plan.insert_release_steps`)。
|
||
"""
|
||
from web import context
|
||
job = (context.mgr.jobs or {}).get(job_id)
|
||
if not job:
|
||
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||
if getattr(job, "task_type", "generic_steps") != "generic_steps":
|
||
return jsonify({"ok": False, "error": "只有「通用步骤」任务能插平台发布步骤"}), 400
|
||
steps = (job.params or {}).get("steps") or []
|
||
if not steps:
|
||
return jsonify({"ok": False, "error": "这个任务没有步骤,先去「任务」页把抖音流程画好"}), 400
|
||
if vp.has_push_step(steps) and vp.has_mark_step(steps):
|
||
return jsonify({"ok": False, "error": "这个任务已经有平台发布步骤了"}), 409
|
||
params = dict(job.params or {})
|
||
params["steps"] = vp.insert_release_steps(steps)
|
||
try:
|
||
context.mgr.update_job(job_id, params=params)
|
||
except Exception as e:
|
||
_log.warning(f"给任务 {job_id} 插平台发布步骤失败: {e}")
|
||
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` 里还有去重有效期、抢占等通用参数,
|
||
整块覆盖会把它们抹掉)。底层仍是 `context.mgr.update_job()` —— 内存 + 库一起改,
|
||
否则调度器不认(直接写库的坑见 `core/task_manager` 的 `_save_jobs`)。
|
||
"""
|
||
from web import context
|
||
job = (context.mgr.jobs or {}).get(job_id)
|
||
if not job:
|
||
return jsonify({"ok": False, "error": "任务不存在"}), 404
|
||
if request.method == "GET":
|
||
return jsonify({"ok": True, "task": _job_brief(job, context.mgr)})
|
||
data = request.json or {}
|
||
fields = {}
|
||
if "name" in data:
|
||
nm = (data.get("name") or "").strip()[:120]
|
||
if not nm:
|
||
return jsonify({"ok": False, "error": "任务名不能为空"}), 400
|
||
fields["name"] = nm
|
||
if "target" in data:
|
||
target = _norm_target(data.get("target"))
|
||
err = _target_error(target)
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
fields["target"] = target
|
||
if "enabled" in data:
|
||
fields["enabled"] = bool(data.get("enabled"))
|
||
if "time" in data:
|
||
h, m, err = _parse_hhmm((data.get("time") or "").strip())
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400
|
||
sch = dict(job.schedule or {})
|
||
sch["mode"] = "cron"
|
||
sch["cron"] = f"{m} {h} * * *"
|
||
sch.setdefault("stop_cron", "")
|
||
sch.setdefault("window", {"start": "", "end": ""})
|
||
fields["schedule"] = sch
|
||
if "steps" in data:
|
||
steps = data.get("steps")
|
||
if not isinstance(steps, list):
|
||
return jsonify({"ok": False, "error": "steps 必须是数组"}), 400
|
||
params = dict(job.params or {})
|
||
params["steps"] = steps
|
||
fields["params"] = params
|
||
if not fields:
|
||
return jsonify({"ok": False, "error": "没有要改的字段"}), 400
|
||
try:
|
||
context.mgr.update_job(job_id, **fields)
|
||
except Exception as e:
|
||
_log.warning(f"改发布任务 {job_id} 失败: {e}")
|
||
return jsonify({"ok": False, "error": f"保存失败: {str(e)[:120]}"}), 500
|
||
_log.info(f"改发布任务 {job.name}({job_id}):{'/'.join(fields)}")
|
||
return jsonify({"ok": True, "msg": "已保存", "task": _job_brief(job, context.mgr)})
|
||
|
||
|
||
@bp.route("/api/video_plan/links")
|
||
@perm_required(PERM_DEVICES)
|
||
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(),
|
||
name=(request.args.get("name") or "").strip())
|
||
if (request.args.get("format") or "") == "csv":
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf)
|
||
# 按**名字**导出(手机号/抖音号随台账停用一起下线;由谁发的看 posted_device_name)
|
||
w.writerow(["发布日期", "名字", "编号", "标题", "正文", "分享链接", "发布时间"])
|
||
for r in rows:
|
||
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")
|
||
return jsonify({"ok": True, "links": rows, "count": len(rows)})
|