一、平台侧(账号 → 发布计划页) - 新表 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
241 lines
13 KiB
Python
241 lines
13 KiB
Python
"""通知事件目录(**唯一真相**)。
|
||
|
||
约定:
|
||
- **key 点分层**:`模块.对象.动作`(如 `task.device.failed`),支持通配订阅
|
||
(`task.*`、`device.*`、`*`),匹配规则见 `match()`。
|
||
- **登记 ≠ 会发**:所有事件 `default` 一律 False —— 用户在「系统 → 通知」里
|
||
按 webhook 勾选才会推送。`recommend` 只用于界面高亮"建议开启",不改默认值。
|
||
- **聚合**:`agg_window` 秒内的同 `(hook, event, agg_key 取值)` 事件合并成一条
|
||
(保留前几个样本);`agg_window=0` = 不聚合,立即发(低频高危事件用它)。
|
||
- **fields**:该事件保证携带的上下文字段,供前端画字段表、也是模板占位符白名单。
|
||
|
||
新增事件:在 `EVENTS` 里加一条 + 在触发点调 `notifier.notify(key, **fields)`,
|
||
并同步 `doc/NOTIFY.md` 的事件表(见 doc/README.md 的文档同步红线)。
|
||
"""
|
||
from collections import namedtuple
|
||
|
||
# key, label, category, default, recommend, agg_window, agg_key, fields, desc
|
||
EventDef = namedtuple(
|
||
"EventDef",
|
||
"key label category default recommend agg_window agg_key fields desc")
|
||
|
||
|
||
def _e(key, label, category, fields, desc="", agg_window=30, agg_key=None,
|
||
recommend=False):
|
||
"""构造一条事件定义(default 恒为 False:登记不等于推送)。"""
|
||
return EventDef(key, label, category, False, recommend, agg_window,
|
||
agg_key, tuple(fields), desc)
|
||
|
||
|
||
# 聚合键:按"任务批次"合并(同一次任务的多台设备结果合成一条)
|
||
_BY_JOB = ("job_id",)
|
||
# 按"设备"合并(一台设备的重复抖动合成一条)
|
||
_BY_SERIAL = ("serial",)
|
||
|
||
EVENTS = [
|
||
# ---------------- 任务批次 ----------------
|
||
_e("task.batch.started", "任务批次开始", "任务批次",
|
||
["job_id", "job_name", "task_type", "device_count", "serials_preview"],
|
||
"一次任务开始铺开到 N 台设备", agg_window=0, recommend=True),
|
||
_e("task.batch.finished", "任务批次结束", "任务批次",
|
||
["job_id", "job_name", "total", "success", "failed", "stopped", "skipped",
|
||
"failed_devices", "duration_s"],
|
||
"整批跑完(成功/失败/停止台数 + 耗时;有失败时列出失败设备与型号)",
|
||
agg_window=0, recommend=True),
|
||
_e("task.batch.no_device", "任务无可用设备", "任务批次",
|
||
["job_id", "job_name", "target"],
|
||
"触发时一台可用设备都没有(任务空跑)", agg_window=0, recommend=True),
|
||
_e("task.batch.unknown_type", "任务类型不存在", "任务批次",
|
||
["job_id", "job_name", "task_type"],
|
||
"任务类型已从代码里删掉,任务永远不会执行", agg_window=0, recommend=True),
|
||
_e("task.cron.stopped", "定时停止任务", "任务批次",
|
||
["job_id", "job_name", "stopped_count", "serials"],
|
||
"cron_stop 到点,停掉了正在跑的设备", agg_window=0),
|
||
|
||
# ---------------- 任务 · 单设备(权威结论点) ----------------
|
||
_e("task.device.success", "设备任务成功", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "attempt", "duration_s"],
|
||
"某台设备上的任务最终成功。**只在目标设备只有 1 台时发送**——多设备批次看"
|
||
"「任务批次结束」就够了,13 台设备就是 13 条刷屏", agg_key=_BY_JOB),
|
||
|
||
_e("task.device.failed", "设备任务失败", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "attempts",
|
||
"cause", "msg"],
|
||
"某台设备上的任务最终失败(带真实原因与型号)", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.offline", "设备离线放弃", "任务·单设备",
|
||
["serial", "device_name", "model", "job_id", "job_name", "error"],
|
||
"设备离线,不重试直接失败", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.error", "单次尝试异常", "任务·单设备",
|
||
["serial", "device_name", "model", "job_name", "attempt", "error"],
|
||
"某次尝试抛异常(后面还会重试,噪音较大)", agg_key=_BY_JOB),
|
||
_e("task.device.retry", "设备任务重试", "任务·单设备",
|
||
["serial", "device_name", "job_name", "attempt", "next_attempt", "delay_s", "reason"],
|
||
"即将重试", agg_key=_BY_JOB),
|
||
_e("task.device.stopped", "设备任务被停止", "任务·单设备",
|
||
["serial", "device_name", "model", "job_name", "attempt", "phase"],
|
||
"用户手动停止 / cron 停止", agg_key=_BY_JOB),
|
||
_e("task.device.preempted", "设备被抢占", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id", "preempted_job_name"],
|
||
"本任务抢占了该设备上正在跑的其他任务", agg_key=_BY_JOB, recommend=True),
|
||
_e("task.device.preempt_timeout", "抢占超时跳过", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id"],
|
||
"等被抢占任务退出超时,本设备放弃执行", agg_window=0, recommend=True),
|
||
_e("task.device.released", "抢占结束归还", "任务·单设备",
|
||
["serial", "device_name", "job_name", "preempted_job_id", "returned", "reason"],
|
||
"抢占结束时重新拉起被抢占的任务", agg_key=_BY_JOB),
|
||
|
||
# ---------------- Worker 状态机(单次 attempt 级,默认关) ----------------
|
||
_e("worker.connected", "设备已连接", "Worker",
|
||
["serial", "model", "remote_adb_url"],
|
||
"worker 连上设备(单次尝试级,噪音大)"),
|
||
_e("worker.attempt.done", "单次执行完成", "Worker",
|
||
["serial", "task_type", "duration_s", "max_duration_hit"],
|
||
"单次 attempt 正常跑完(不代表任务最终成功)"),
|
||
_e("worker.attempt.error", "单次执行出错", "Worker",
|
||
["serial", "task_type", "error", "transient"],
|
||
"单次 attempt 抛异常"),
|
||
|
||
# ---------------- 业务(任务内容) ----------------
|
||
_e("task.patrol.hit", "任务巡检命中", "业务",
|
||
["title", "message", "patrol_name", "check_label", "detail", "action_label",
|
||
"job_id", "job_name", "serial", "device_name", "model"],
|
||
"任务里配置的「公共巡检」命中(熄屏/元素/前台…),已执行动作——"
|
||
"标题正文可在巡检项里自己写,支持 {device}/{job}/{time}/{app}/{screen}",
|
||
agg_window=0, recommend=True),
|
||
_e("task.notify.custom", "任务自定义通知", "业务",
|
||
["title", "message", "job_id", "job_name", "serial", "device_name", "model"],
|
||
"任务里的「发通知」步骤被触发——标题与正文由任务自己写,支持 "
|
||
"{device}/{serial}/{job}/{time}/{app}/{screen} 占位符", agg_window=0,
|
||
recommend=True),
|
||
_e("task.selector.invalid", "选择器连续失效", "业务",
|
||
["serial", "device_name", "selector", "miss_count"],
|
||
"某选择器连续 10 次未命中——任务可能显示成功但什么都没做", agg_window=0,
|
||
recommend=True),
|
||
_e("task.video.published", "视频已发布", "业务",
|
||
["serial", "device_name", "model", "phone", "nickname", "release_date", "seq",
|
||
"title", "share_url"],
|
||
"「发布视频」步骤把某个账号的视频发出去了(share_url 是作品分享链接,"
|
||
"没抓到链接时为空——界面上会标「缺链接」)", agg_window=0, recommend=True),
|
||
_e("task.video.failed", "视频发布失败/结果未知", "业务",
|
||
["serial", "device_name", "model", "phone", "nickname", "release_date", "seq",
|
||
"title", "msg"],
|
||
"「发布视频」步骤失败:推文件阶段失败=可重试;抖音那一步失败=**可能已经发出去了**,"
|
||
"要到计划页看状态并人工确认(平台不会自动重发)", agg_window=0,
|
||
agg_key=_BY_SERIAL, recommend=True),
|
||
|
||
# ---------------- 设备 ----------------
|
||
_e("device.online", "设备恢复在线", "设备",
|
||
["serials", "count"],
|
||
"原本断联的设备又能连上了", agg_window=0, recommend=True),
|
||
_e("device.offline", "设备断联", "设备",
|
||
["serials", "count", "devices"],
|
||
"设备池里的设备连不上了", agg_window=0, recommend=True),
|
||
_e("device.discovered", "发现新设备", "设备",
|
||
["serials", "count"],
|
||
"扫描到尚未在池内的设备(待认领)", agg_window=0),
|
||
_e("device.claimed", "设备自动认领", "设备",
|
||
["pairs", "count"],
|
||
"指纹匹配成功,自动把旧记录迁到新地址", agg_window=0),
|
||
_e("device.heartbeat_timeout", "心跳超时", "设备",
|
||
["serial", "device_name", "timeout_s", "task_job", "model"],
|
||
"设备卡死(长时间没心跳),任务可能已中断", agg_window=0, recommend=True),
|
||
_e("device.battery.low", "设备电量低", "设备",
|
||
["serial", "device_name", "model", "battery", "charging", "threshold"],
|
||
"设备电量掉到阈值以下(默认 20%,可在「工具 → 设备发现 → 电量监控」改)。"
|
||
"充电中的设备不报——插着充电器说明正在补电;"
|
||
"「插着却没充电」(劣质线/温控/满电停充)仍会报,那正是最该知道的情况",
|
||
agg_window=0, agg_key=_BY_SERIAL, recommend=True),
|
||
_e("device.battery.recovered", "设备电量已恢复", "设备",
|
||
["serial", "device_name", "model", "battery", "charging", "threshold"],
|
||
"低电量设备重新充上电,或电量回升到阈值以上(含恢复迟滞)", agg_window=0,
|
||
agg_key=_BY_SERIAL),
|
||
|
||
# ---------------- 应用安装 ----------------
|
||
_e("apk.install.started", "应用安装开始", "安装",
|
||
["apk_id", "apk_name", "package_name", "total"],
|
||
"开始往 N 台设备推装", agg_window=0),
|
||
_e("apk.install.finished", "应用安装完成", "安装",
|
||
["apk_name", "success", "failed", "skipped", "total", "failed_items"],
|
||
"批量安装结束(含失败台数与原因)", agg_window=0, recommend=True),
|
||
|
||
# ---------------- 系统 ----------------
|
||
_e("system.backup.exported", "备份已导出", "系统",
|
||
["filename", "size", "tables", "include_apk", "user"],
|
||
"有人导出了整库备份", agg_window=0, recommend=True),
|
||
_e("system.backup.imported", "备份导入已挂起", "系统",
|
||
["token_prefix", "env_label", "force"],
|
||
"上传了备份并确认导入(重启后生效)", agg_window=0),
|
||
_e("system.backup.restored", "备份已恢复", "系统",
|
||
["applied_rows", "schema_version"],
|
||
"启动时应用了待恢复的备份(所以这条是重启后才发)", agg_window=0,
|
||
recommend=True),
|
||
_e("system.backup.restore_failed", "备份恢复失败", "系统",
|
||
["error", "fail_dir"],
|
||
"恢复校验没过,已搁置(数据未被改动)", agg_window=0, recommend=True),
|
||
_e("service.started", "服务已启动", "系统",
|
||
["version", "env", "db_target", "device_count", "job_count"],
|
||
"平台进程起来了", agg_window=0, recommend=True),
|
||
_e("service.stopping", "服务正在停止", "系统",
|
||
["uptime_s"],
|
||
"平台进程收到退出信号", agg_window=0),
|
||
_e("user.login", "用户登录", "系统",
|
||
["username"],
|
||
"有人登录了后台", agg_window=0),
|
||
|
||
# ---------------- AI ----------------
|
||
_e("ai.audit.finished", "经验巡检完成", "AI",
|
||
["reviewed", "suggested", "summary"],
|
||
"每日经验库巡检跑完(或手动触发)", agg_window=0, recommend=True),
|
||
_e("ai.audit.failed", "经验巡检异常", "AI",
|
||
["error"],
|
||
"巡检过程中报错", agg_window=0),
|
||
_e("ai.audit.skipped", "经验巡检跳过", "AI",
|
||
["reason"],
|
||
"没配模型 Key 等原因跳过巡检", agg_window=0),
|
||
|
||
# ---------------- 其它 ----------------
|
||
_e("notify.test", "测试通知", "其它",
|
||
["hook_name", "operator"],
|
||
"「发送测试」按钮专用", agg_window=0),
|
||
]
|
||
|
||
BY_KEY = {e.key: e for e in EVENTS}
|
||
|
||
# 界面上的分组顺序
|
||
CATEGORIES = ("任务批次", "任务·单设备", "业务", "设备", "安装", "系统", "AI",
|
||
"Worker", "其它")
|
||
|
||
|
||
def get(key):
|
||
"""按 key 取事件定义;未知 key 返回 None(未知事件会被 notify 丢弃)。"""
|
||
return BY_KEY.get(key)
|
||
|
||
|
||
def match(pattern, key):
|
||
"""事件订阅匹配。
|
||
|
||
- `*` → 全部
|
||
- `task.*` → task 下所有(含 task.device.failed)
|
||
- `task.device.*` → 任意层级前缀匹配
|
||
- `task.device.failed` → 精确
|
||
"""
|
||
if not pattern:
|
||
return False
|
||
if pattern == "*" or pattern == key:
|
||
return True
|
||
if pattern.endswith(".*"):
|
||
return key.startswith(pattern[:-1]) # "task.*" → "task."
|
||
return False
|
||
|
||
|
||
def list_events():
|
||
"""给接口/前端用的事件目录(按 CATEGORIES 排序)。"""
|
||
order = {c: i for i, c in enumerate(CATEGORIES)}
|
||
rows = [{"key": e.key, "label": e.label, "category": e.category,
|
||
"default": e.default, "recommend": e.recommend,
|
||
"agg_window": e.agg_window, "agg_key": list(e.agg_key or ()),
|
||
"fields": list(e.fields), "desc": e.desc}
|
||
for e in EVENTS]
|
||
rows.sort(key=lambda r: (order.get(r["category"], 99), r["key"]))
|
||
return rows
|