diff --git a/README.md b/README.md index 957c544..fee48bd 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,12 @@ | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | | **电量监控** | 后台每分钟 `dumpsys battery`(只读)采一次电量,**大屏卡片**与**监控页设备列表**都显示(按档位着色 + ⚡ 充电中);低于阈值推 webhook 告警(**充电中不报、"插着却没充电"照报**,掉档/恢复各报一次,不刷屏)。电量只放内存不落库(见 [NOTIFY.md](doc/NOTIFY.md) §3.1) | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | -| **步骤编辑器** | 可视化拖拽编排 21 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 | +| **步骤编辑器** | 可视化拖拽编排 22 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 | | **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) | | **录制回放** | 「录制手势」步骤 = **纯录制**:**用手指在真机上划**(读 getevent 真触屏)或在网页画面上拖,把轨迹点列原样存下来,回放时**照原路径与时长重放**——不套滑动那套方向/幅度/拟人参数 | | **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 | | **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) | +| **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | @@ -157,7 +158,7 @@ auto_control/ ├── tasks/ # 任务定义层 │ ├── base.py # BaseTask + _TASK_TYPES + register_task │ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型) -│ └── task.py # STEP_TYPES(21 种步骤)+ Worker + 执行器 +│ └── task.py # STEP_TYPES(22 种步骤)+ Worker + 执行器 │ ├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033) │ ├── mcp_server.py # 工具定义 + 平台登录 + 门控 @@ -283,14 +284,14 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el `generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。 -**21 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): +**22 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): | 类别 | 步骤 | |------|------| | 屏幕 | `screen_on` 亮屏 · `screen_off` 息屏 · `keep_screen` 保持亮屏 | | 应用与输入 | `open_app` 打开 App · `stop_app` 结束 App · `input_text` 输入文字 · `clipboard` 剪贴板注入 · `key_event` 按键 | | 交互 | `click` 点击元素 · `click_xy` 点击坐标 · `long_click` 长按 · `swipe` 滑动 · `swipe_until` 滑动直到元素出现 · `wait_el` 等待元素 · `wait` 等待时长 | -| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素或 **OCR 识别**) | +| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素 / **OCR 识别** / 屏幕状态 / 前台App / **去重**)· `mark_done` 记为已做(配套去重) | 要点: @@ -405,7 +406,7 @@ read_log_text()`,`file` 参数走白名单,不接受任意路径。 | [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) | | [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) | | [doc/API.md](doc/API.md) | HTTP 接口全量说明 | -| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(21 种步骤、公共巡检、录制回放、选择器、自定义动作) | +| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(22 种步骤、公共巡检、录制回放、去重、选择器、自定义动作) | | [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 | | [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) | | [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) | diff --git a/core/dedup.py b/core/dedup.py new file mode 100644 index 0000000..0942d7d --- /dev/null +++ b/core/dedup.py @@ -0,0 +1,235 @@ +"""「已做过」账本:跨设备幂等(评论不重复)+ 进度可见。 + +**它解决什么**(用户场景):一台手机登录多个账号、多台手机跑同一个任务, +每天跑一次但不知道什么时候跑完,于是反复重跑 → 同一个号被做两次、有的号还没做。 +账本给出两个东西: + 1. `check()` —— "这个身份在这个任务里做过了吗"(跨设备,多台手机共享同一份判断) + 2. `list_marks()` —— "谁做过了、还差谁"(界面上看得见,就不用靠"一直重跑"来确认) + +**为什么必须靠唯一索引**:多台设备可能同时判断"没做过"。 +"先 SELECT 再 INSERT"有竞态——两台都会插进去。唯一索引 + `INSERT ... ON DUPLICATE KEY` +(MySQL)/ `INSERT OR IGNORE`(SQLite)的**受影响行数**才是原子的判据: +`1` = 你抢到了首次,`0` = 别人已经做过。 + +**身份值读不到时绝不去重**(`identity is None`):宁可漏拦一次, +也不能让"读不到"退化成"空身份"——那会让所有设备共用一个 key, +第一台记账后其余全部被误跳过。同理,身份值过长时也不截断(截断会让两个身份撞车)。 + +有效期策略(`kind`)由任务级参数 `dedup_reset` 决定,**只在一处配**: +步骤里各填一份的话,两处填不一致就会算出不用的 key、去重静默失效。 + +DB 访问由本模块自建 app context(照 `core/device_pool.py` 的 `_ctx()`), +调用方(任务线程 / Web 线程 / 每日清理)都不用关心。 +""" +import time +from datetime import datetime, timedelta + +from core.logger import get_logger +from core.models import db + +_log = get_logger("core.dedup") + +# 有效期策略 +KIND_DAY = "day" # 每天一次(默认;"每天跑一次"的场景) +KIND_HOURS = "hours" # 每 N 小时一次 +KIND_ALL = "all" # 只做一次(永久) +RESET_KINDS = (KIND_DAY, KIND_ALL, KIND_HOURS) +DEFAULT_HOURS = 6 +# 账本保留期:只影响 day/hours 桶(它们过期就没意义了); +# **kind='all' 的记录永不清理**——清了就等于"只做一次"失效 +KEEP_DAYS = 180 +# scope_key 上限(= DoneMark.scope_key 列宽)。超了不去重、只告警: +# 截断会让两个不同身份撞成同一个 key,进而误跳过 +MAX_KEY_LEN = 290 + +_app = None + + +def init_app(app): + """web_server 启动时调用:绑 app(后台线程访问 db 要自推 context)。""" + global _app + _app = app + + +def _ctx(): + if _app is None: + raise RuntimeError("dedup 未关联 Flask app(web_server 启动时调用 init_app)") + return _app.app_context() + + +# ================== 唯一键 ================== +def build_key(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS, now=None): + """算唯一键:`任务 | 身份 | 时间桶`。 + + 桶只影响"多久之后算新的一轮": + · day → `|d:2026-09-24`(本地日期,跟着自然日走) + · hours → `|h6:4939219`(epoch // (N*3600),改 N 就换了桶) + · all → `|all`(无桶,永远命中同一个 key) + """ + now = time.time() if now is None else now + base = f"{(job_id or '').strip()}|{(identity or '').strip()}" + if kind == KIND_ALL: + return base + "|all" + if kind == KIND_HOURS: + h = max(1, int(hours or DEFAULT_HOURS)) + return f"{base}|h{h}:{int(now // (h * 3600))}" + return base + "|d:" + time.strftime("%Y-%m-%d", time.localtime(now)) + + +def _key_or_none(job_id, identity, kind, hours): + """算 key 并做长度体检;超长返回 None(调用方按"不去重"处理,不截断)。""" + if not (identity or "").strip(): + return None + key = build_key(job_id, identity, kind, hours) + if len(key) > MAX_KEY_LEN: + _log.warning(f"去重键过长({len(key)} 字符)→ 本次不去重,避免截断后两个身份撞车: {key[:80]}…") + return None + return key + + +# ================== 查 / 记 ================== +def check(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS): + """这个身份在这个任务里做过了吗?(只读) + + 返回 True=做过 / False=没做过。identity 为空时返回 False(不去重,照常执行)。 + """ + key = _key_or_none(job_id, identity, kind, hours) + if key is None: + return False + try: + with _ctx(): + row = db.session.execute( + db.text("SELECT 1 FROM done_mark WHERE scope_key = :k LIMIT 1"), + {"k": key}).fetchone() + return row is not None + except Exception as e: + # 账本读失败不能拦住任务:当"没做过"放行(宁可重复,不可卡死) + _log.warning(f"去重检查失败(按未做过放行): {e}") + return False + + +def mark(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS, + serial="", device_name="", job_name=""): + """原子记账。返回 True=本次是首次(你抢到了)/ False=别人已经记过(或没记成)。 + + ⚠ **必须靠唯一索引**:不能先 check 再 insert(多台设备会同时通过)。 + """ + key = _key_or_none(job_id, identity, kind, hours) + if key is None: + return False + args = {"k": key, "kind": kind, "jid": job_id or "", "jname": job_name or "", + "serial": serial or "", "dname": device_name or "", + "ident": (identity or "").strip()[:200], + "ts": time.strftime("%Y-%m-%d %H:%M:%S")} + try: + with _ctx(): + is_mysql = db.engine.dialect.name == "mysql" + cols = ("scope_key, kind, job_id, job_name, serial, device_name, " + "identity, created_at") + vals = (":k, :kind, :jid, :jname, :serial, :dname, :ident, :ts") + if is_mysql: + # 冲突时做一次"无变化"的更新:受影响行数 1=插入、0=已存在(MySQL 语义) + sql = (f"INSERT INTO done_mark ({cols}) VALUES ({vals}) " + f"ON DUPLICATE KEY UPDATE scope_key = scope_key") + else: + sql = f"INSERT OR IGNORE INTO done_mark ({cols}) VALUES ({vals})" + r = db.session.execute(db.text(sql), args) + db.session.commit() + first = (r.rowcount or 0) > 0 + if first: + _log.info(f"[{serial}] 记账:任务『{job_name or job_id}』身份『{args['ident']}』" + f"({kind})") + return first + except Exception as e: + _log.warning(f"去重记账失败(本次不记,下次重跑会重试): {e}") + return False + + +# ================== 界面:看 / 清 ================== +def list_marks(job_id="", limit=200): + """去重记录(界面用):返回 (rows, stats)。 + + stats:`total` 本任务全部 · `today_devices` 今天已做的设备数 · + `today_identities` 今天已做的身份值数(== "今天做成了几个号")。 + """ + limit = max(1, min(int(limit or 200), 2000)) + today0 = time.strftime("%Y-%m-%d 00:00:00") + where, args = "", {} + if job_id: + where = "WHERE job_id = :jid" + args["jid"] = job_id + with _ctx(): + rows = db.session.execute(db.text( + f"SELECT id, scope_key, kind, job_id, job_name, serial, device_name, " + f"identity, created_at FROM done_mark {where} " + f"ORDER BY id DESC LIMIT :lim"), {**args, "lim": limit}).fetchall() + twhere = where + (" AND " if where else "WHERE ") + "created_at >= :t0" + stat = db.session.execute(db.text( + f"SELECT COUNT(*) AS total, " + f"COUNT(DISTINCT serial) AS devs, " + f"COUNT(DISTINCT identity) AS ids " + f"FROM done_mark {where}"), args).fetchone() + today = db.session.execute(db.text( + f"SELECT COUNT(DISTINCT serial) AS devs, " + f"COUNT(DISTINCT identity) AS ids " + f"FROM done_mark {twhere}"), {**args, "t0": today0}).fetchone() + out = [{"id": r[0], "scope_key": r[1], "kind": r[2], "job_id": r[3], "job_name": r[4], + "serial": r[5], "device_name": r[6], "identity": r[7], "created_at": r[8]} + for r in rows] + stats = {"total": stat[0] if stat else 0, + "devices": stat[1] if stat else 0, + "identities": stat[2] if stat else 0, + "today_devices": today[0] if today else 0, + "today_identities": today[1] if today else 0} + return out, stats + + +def delete_mark(mark_id): + """删一条记录(让某个号/某台设备能重跑)。返回是否删掉了。""" + try: + with _ctx(): + r = db.session.execute(db.text("DELETE FROM done_mark WHERE id = :i"), + {"i": int(mark_id)}) + db.session.commit() + return (r.rowcount or 0) > 0 + except Exception as e: + _log.warning(f"删除去重记录失败: {e}") + return False + + +def clear_job(job_id): + """清空某个任务的全部去重记录(整批重跑)。返回删掉的行数。""" + try: + with _ctx(): + r = db.session.execute(db.text("DELETE FROM done_mark WHERE job_id = :j"), + {"j": job_id or ""}) + db.session.commit() + n = r.rowcount or 0 + _log.info(f"清空去重记录:任务 {job_id} 共 {n} 条") + return n + except Exception as e: + _log.warning(f"清空去重记录失败: {e}") + return 0 + + +def purge_old(keep_days=KEEP_DAYS): + """清理过期的 day/hours 桶记录。**kind='all' 永不清理**(清了等于去重失效)。 + + 量级很小(设备数 × 天数),直接一条 DELETE 就行,不用像步骤明细那样分批。 + """ + cutoff = (datetime.now() - timedelta(days=max(1, int(keep_days))) + ).strftime("%Y-%m-%d %H:%M:%S") + try: + with _ctx(): + r = db.session.execute(db.text( + "DELETE FROM done_mark WHERE kind <> :all AND created_at < :cut"), + {"all": KIND_ALL, "cut": cutoff}) + db.session.commit() + n = r.rowcount or 0 + if n: + _log.info(f"去重记录清理:删除 {n} 条(保留 {keep_days} 天;" + f"kind={KIND_ALL} 的永不清理)") + return n + except Exception as e: + _log.warning(f"去重记录清理失败: {e}") + return 0 diff --git a/core/models.py b/core/models.py index bddeb68..63ce3e1 100644 --- a/core/models.py +++ b/core/models.py @@ -403,6 +403,39 @@ class TaskStepLog(db.Model): ) +class DoneMark(db.Model): + """「已做过」账本:跨设备幂等的标记(「任务 → 去重记录」页)。 + + 要解决的问题(用户场景):一台手机登录多个账号、多台手机跑同一个任务, + 任务被反复重跑(因为不知道什么时候跑完)→ 同一个号被做两次、有的号还没做。 + + **判据只有一条:`scope_key` 的唯一索引。** + 多台设备可能同时判断"没做过","先查后插"会两台都插进去; + 唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数** + 才是原子的(见 `core/dedup.py` 的 `mark()`)。 + + 写入方是任务步骤 `_exec_mark_done`(**成功之后才记账**):动作失败就不记账, + 下次重跑还会重试该设备——这是"失败不丢"的关键。 + + `kind` 是有效期策略(`day`/`hours`/`all`),清理时**只删 day/hours**: + `all` 代表"只做一次",删掉就等于去重失效。 + """ + __tablename__ = "done_mark" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + scope_key = db.Column(db.String(300), unique=True) # 幂等的全部依据(唯一索引) + kind = db.Column(db.String(12), default="day") # day / hours / all + job_id = db.Column(db.String(32), default="", index=True) + job_name = db.Column(db.String(120), default="") + serial = db.Column(db.String(120), default="") + device_name = db.Column(db.String(80), default="") + identity = db.Column(db.String(200), default="") # 身份值(如抖音号) + created_at = db.Column(db.String(20), default="", index=True) + + __table_args__ = ( + db.Index("ix_done_mark_job_ts", "job_id", "created_at"), + ) + + class AgentConversation(db.Model): """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" __tablename__ = "agent_conversation" @@ -427,6 +460,7 @@ SCHEMA_MIGRATIONS = [ (4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None), (5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None), (6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None), + (7, "去重账本:done_mark 表(跨设备幂等的「已做过」标记,唯一索引 scope_key)", None), ] # 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version) diff --git a/core/system_backup.py b/core/system_backup.py index 3ebbac8..ab99fa2 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -57,6 +57,7 @@ TABLE_LABELS = { "agent_experience": "经验库", "experience_audit": "经验巡检", "agent_action": "动作库", "device_install_log": "设备端安装记录", "task_step_log": "任务步骤明细", + "done_mark": "去重记录(已做过)", } _STAGE_TTL = 1800 # 导入暂存有效期(秒) diff --git a/core/task_draft.py b/core/task_draft.py index 1cdc408..7a25e50 100644 --- a/core/task_draft.py +++ b/core/task_draft.py @@ -148,8 +148,10 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None, errors.append(f"{here}.params.probability: 必须是 0~100 的数字" f"(当前 {prob!r})") - # 选择器类步骤 - if stype in NEED_SELECTOR: + # 选择器类步骤(例外:if_el 用「去重」条件时不需要 selector_value, + # 它的身份元素在 ident_value 里,按自己的规则校验) + if stype in NEED_SELECTOR and not (stype == "if_el" + and params.get("selector_type") == "dedup"): sel = params.get("selector_value") if not isinstance(sel, str) or not sel.strip(): errors.append(f"{here}.params.selector_value: 不能为空 —— " @@ -234,6 +236,17 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None, elif (params.get("selector_type") or "xpath") == "screen": warnings.append(f"{here}: 屏幕状态没有文本可比," "cmp_op/cmp_value 会被忽略") + # 去重条件(见 tasks/generic/task.py 的 selector_type="dedup"): + # 不用 selector_value,身份元素填在 ident_value + if (params.get("selector_type") or "") == "dedup": + iv = (params.get("ident_value") or "").strip() + if not iv: + warnings.append(f"{here}: 去重条件没填身份元素,将按「设备」当身份" + "(一号一机时没问题;一台机器多个号时要填账号那个元素)") + elif (params.get("ident_type") or "text") == "xpath" \ + and not (iv.startswith("//") or iv.startswith("(//")): + errors.append(f"{here}.params.ident_value: 身份元素的类型是 xpath," + f"取值应以 // 或 (// 开头(当前 {iv[:40]!r})") if stype == "swipe": _check_direction(params, here, errors) @@ -294,9 +307,9 @@ def _check_selector(params, here, stype, errors, warnings): stype_ok = IF_SELECTOR_TYPES if stype == "if_el" else SELECTOR_TYPES sel_type = params.get("selector_type") or "xpath" value = (params.get("selector_value") or "").strip() - if sel_type not in stype_ok: + if sel_type not in stype_ok and not (sel_type == "dedup" and stype == "if_el"): errors.append(f"{here}.params.selector_type: 不支持 {sel_type!r}" - f"(可用:{'、'.join(stype_ok)})") + f"(可用:{'、'.join(stype_ok + ('dedup',))})") return if len(value) > MAX_SELECTOR_LEN: errors.append(f"{here}.params.selector_value: 太长(>{MAX_SELECTOR_LEN} 字符)") diff --git a/doc/AI_CONSOLE.md b/doc/AI_CONSOLE.md index 5005238..b0bc4ca 100644 --- a/doc/AI_CONSOLE.md +++ b/doc/AI_CONSOLE.md @@ -184,7 +184,11 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage, 从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束: - **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效) -- 白名单步骤类型(21 种去掉 `click_xy`、`keep_screen`、`gesture`) +- 白名单步骤类型(**显式列举 16 种**,见 `web/agent_api.py` 的 `_ACTION_STEP_TYPES`): + `open_app` / `stop_app` / `screen_on` / `screen_off` / `key_event` / `swipe` / + `swipe_until` / `click` / `long_click` / `wait_el` / `input_text` / `clipboard` / + `wait` / `loop` / `group` / `if_el`——坐标类(`click_xy`)与流程标记类 + (`keep_screen` / `gesture` / `notify` / `stop_self` / `mark_done`)都不沉淀 - 每类型有必填参数校验(如 `click` 必须有选择器) - 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步 - 保存时服务端**再校验一次**,含坐标的提交直接 400 diff --git a/doc/AI_TASK_GEN.md b/doc/AI_TASK_GEN.md index 424239e..7fd6e46 100644 --- a/doc/AI_TASK_GEN.md +++ b/doc/AI_TASK_GEN.md @@ -9,7 +9,7 @@ 平台已有两套能力,但互不相通: - **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。 -- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…21 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 +- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…22 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。 diff --git a/doc/API.md b/doc/API.md index 21b989f..41773ea 100644 --- a/doc/API.md +++ b/doc/API.md @@ -134,6 +134,9 @@ | GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) | | GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) | | POST | `/api/steps/test` | D | 真机试执行单个步骤 | +| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) | +| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 | +| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 | ### 2.4 admin(`web/admin_api.py`) @@ -468,6 +471,17 @@ `steps` 的 schema 与 `generic_steps` 的 `params.steps` 完全一致,见 [TASK_DEV.md](TASK_DEV.md)。 +### 去重记录(「任务 → 去重记录」页) + +| 接口 | 请求 | 响应要点 | +|------|------|---------| +| `GET /api/done_marks` | `?job=<任务ID>&limit=200` | `{ok, marks:[{id,scope_key,kind,job_id,job_name,serial,device_name,identity,created_at}], stats:{total,devices,identities,today_devices,today_identities}}`。`job` 省略 = 全部任务 | +| `POST /api/done_marks/delete` | `{"id"}` | 删一条 → 该设备/身份下次会重新执行;不存在 → 404;缺 id → 400 | +| `POST /api/done_marks/clear` | `{"job"}` | 清空该任务的全部记录(整批重跑)→ `{ok,msg,deleted}`;缺 job → 400 | + +账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见 +[TASK_DEV.md](TASK_DEV.md) §4.6。 + --- ## 6. 设备池与自动发现 diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index 87c3718..7ebe2f7 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -26,7 +26,7 @@ ┌───────────────────────────▼──────────────────────────────────────────┐ │ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │ │ system_backup(备份) apk_manager(应用) device_discovery │ -│ device_battery(电量采集 + 低电量告警) │ +│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │ └───────────────────────────┬──────────────────────────────────────────┘ │ ┌───────────────────────────▼──────────────────────────────────────────┐ @@ -80,13 +80,13 @@ |------|------|---------| | **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` | | **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 | -| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) | +| **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`dedup.init_app`(**去重账本绑 app**,供任务线程/Web/清理自推 context);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) | ### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动 | 阶段 | 位置 | 做了什么 | |------|------|---------| | **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) | -| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning | +| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 app context;失败仅 warning | | **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + `device_battery.shutdown()` + 停 uiautodev | > ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。 @@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set ### 6.1 单页应用 -- 主页面 `templates/admin/monitor.html`:一个内联 `