feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent

一、账号台账(新表 device_account,schema v8)
- core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、
  按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览
- 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 +
  粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕)
- 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细
- 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账,
  与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志
  (否则"永远走 else 分支"而任务照样显示成功,最难查)
- 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号;
  超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1
- 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/
  TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark

⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素
  (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。

二、剪贴板注入通道(修:平台还在调早期的独立 APK)
- 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→
  旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject)
- 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写,
  原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
This commit is contained in:
2026-09-24 16:12:58 +08:00
parent e82fd64695
commit 7ce4ae9016
24 changed files with 1455 additions and 62 deletions
+47 -2
View File
@@ -41,7 +41,7 @@ from core.device_worker import BaseWorker, _update_status
from core.u2_helper import (ensure_app_running, wait_for_app_home, random_sleep,
screen_is_on, current_package)
from core.logger import get_logger
from core import notifier, step_log, humanize, patrol, gesture, dedup
from core import notifier, step_log, humanize, patrol, gesture, dedup, ledger
_log = get_logger("task.generic")
@@ -113,7 +113,7 @@ STEP_TYPES = [
"params": {"children": []}},
{"type": "if_el", "label": "条件判断", "icon": "❓",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
"cmp_op": "", "cmp_value": "",
"cmp_op": "", "cmp_value": "", "cmp_source": "", "cmp_group": "",
"ident_type": "text", "ident_value": "",
"then": [], "else": []}},
{"type": "mark_done", "label": "记为已做", "icon": "✅",
@@ -683,6 +683,12 @@ class GenericStepsWorker(BaseWorker):
timeout = float(params.get("timeout", 3))
cmp_op = str(params.get("cmp_op") or "").strip()
cmp_val = str(params.get("cmp_value") or "")
# 候选值可以来自「账号台账」(cmp_source)——这里只把 cmp_val 这个**字符串**
# 换掉来路,下面 762-780 的比对、候选计数、日志文案全自动跟着对
# (_cmp_hit 保持纯函数,不掺 DB)
src_note = ""
if cmp_op:
cmp_val, src_note = self._resolve_cmp_value(params, cmp_val)
# 去重条件不用 selector_value(身份元素在 ident_value 里),所以别按"缺选择器"拦
if not sel_val and sel_type != "dedup":
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
@@ -783,6 +789,11 @@ class GenericStepsWorker(BaseWorker):
# 界面上那块是隐藏的,旧值会留在 params 里,那种残留每轮都刷警告没意义
_log.warning(f"[{self.serial}] {sel_type} 没有文本可比,已忽略文本比对设置")
# 候选值来源(台账/手填)也要进日志与步骤明细:取 0 个号会让这一步
# **永远走 else 分支**,而任务照样"跑完了"——只有这里能看出原因
if src_note:
detail = f"{detail};{src_note}" if detail else src_note
if dynamic:
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
else:
@@ -797,6 +808,40 @@ class GenericStepsWorker(BaseWorker):
self._exec_steps(d, branch, depth + 1)
return found
def _resolve_cmp_value(self, params, manual_raw):
"""「条件判断」的候选值 = 手填值 + 台账取号(合并,OR 语义)。
返回 (候选值文本, 给日志/步骤明细的来源说明)。
· **合并**而不是二选一:台账取空时手填值仍然生效(台账没维护好不会把任务
直接打哑);"台账里的号 + 临时补一个"也是自然需求 —— split_cmp_values
与 `_cmp_hit` 本来就是多值 OR 语义。
· 取 0 个号时**必须留痕**:候选为空 ⇒ 这一步永远走 else 分支,而任务本身
"跑完了、显示成功",不写日志就只能靠人猜。
· ⚠ 这里产出的只是**比对用的文本**,绝不参与去重 identity:
身份是元素原文逐字算 key,台账里是纯号,格式不同会让去重静默失效
(见 core/ledger.py 顶部的警告)。
"""
source = str(params.get("cmp_source") or "").strip()
manual = split_cmp_values(manual_raw)
if source not in ("device", "all", "group"):
return manual_raw, "" # 手填(默认):行为与以前完全一致
ids, note = ledger.douyin_ids(
source, serial=self.serial,
device_name=(self.ctx or {}).get("device_name", ""),
group=str(params.get("cmp_group") or "").strip())
merged = list(ids) + [v for v in manual if v not in ids]
if not ids:
_log.warning(
f"[{self.serial}] 条件判断取号为空(来源 {source}):{note};手填候选 "
f"{len(manual)} 个 → "
+ ("仅用手填值" if manual else "候选为空,这一步将永远不命中(走未找到分支)"))
src_txt = (f"候选来自台账({source}) {len(ids)} 个号" if ids
else f"台账取号为空:{note}")
if manual:
src_txt += f",另加手填 {len(manual)} 个"
return "\n".join(merged), src_txt
def _dedup_policy(self):
"""任务级去重有效期(`dedup_reset` / `dedup_hours`)→ (kind, hours)。