一、平台侧(账号 → 发布计划页) - 新表 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
505 lines
23 KiB
Python
505 lines
23 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=&account_id=&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(),
|
||
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})
|
||
|
||
|
||
@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:
|
||
row, err = vp.set_title(pid, data.get("title") or "")
|
||
if err:
|
||
return jsonify({"ok": False, "error": err}), 400 if "太长" in err 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)
|
||
|
||
|
||
@bp.route("/api/video_plan/<pid>/push", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_vp_push(pid):
|
||
"""把这一条的素材**推到手机**(只推送,不在手机上发布)。
|
||
|
||
发布动作由你写在任务里(推送步骤后面接你自己的步骤),这里只是"人工先推一条"。
|
||
"""
|
||
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": "已开始推送到手机(推完状态会变成「已推送到手机」,"
|
||
"之后你在手机上发;发完点「标记已发布」)"})
|
||
|
||
|
||
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()
|
||
|
||
|
||
@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"推完状态会变成「已推送·相册可见/相册未见」,刷新看结果"})
|
||
|
||
|
||
@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(),
|
||
device=(request.args.get("device") or "").strip(),
|
||
phone=(request.args.get("phone") or "").strip())
|
||
if (request.args.get("format") or "") == "csv":
|
||
buf = io.StringIO()
|
||
w = csv.writer(buf)
|
||
w.writerow(["发布日期", "设备号", "手机号", "账号名称", "抖音号",
|
||
"编号", "标题", "分享链接", "发布时间"])
|
||
for r in rows:
|
||
w.writerow([r["release_date"], r["device_name"], r["phone"], r["nickname"],
|
||
r["douyin_id"], r["seq"], r["title"], 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)})
|