Files
auto_control/core/ledger.py
T
butubb 7ce4ae9016 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 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
2026-09-24 16:12:58 +08:00

527 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""账号台账:一台设备上登录着哪些账号。
**它解决什么**(用户场景):一台手机登好几个抖音号、十几台手机四五十个号,
账号信息散在一张电子表格里 —— 换 IP、加号、想查"这台登的是哪几个号"都得翻表。
台账落进平台后有三个月牙:
1. web「账号」页 —— 列表 / 增删改 / **从表格粘贴导入**(`web/devices_api.py` 的 `/api/ledger*`)
2. 任务的「条件判断」取号 —— `if_el` 的 `cmp_source=device|all|group`
(比对运算符用「包含」时,纯号 `35377983067` 能匹配元素原文 `抖音号:35377983067`)
3. 手机端 Agent 的「身份大字页」 —— 平台推 `accounts_b64`(`doc/DEVICE_AGENT.md` §5.2)
⚠ **台账里的抖音号是纯号,绝不能拿去当去重身份**:
`done_mark.identity` 存的是**元素原文**、逐字算 key,格式不一致(`35377983067`
vs `抖音号:35377983067`)会让去重**静默失效**。取号只用于条件判断的比对值。
DB 访问自建 app context(照 `core/dedup.py`),调用方(任务线程 / Web 线程)不用关心。
"""
import re
import time
import uuid
from core.logger import get_logger
from core.models import db, Device, DeviceAccount, DeviceGroup
_log = get_logger("core.ledger")
# 可编辑字段(接口 / 导入 / 前端共用一份清单,避免某处漏字段)
FIELDS = ("device_name", "phone", "nickname", "douyin_id", "registered_at",
"sim_in_device", "can_post_video", "bio", "note")
# 这两列是布尔("空=否")
BOOL_FIELDS = ("sim_in_device", "can_post_video")
# 粘贴导入:表头列名 → 字段(允许乱序/缺列/多列;认不出的列忽略)
HEADER_ALIASES = {
"device_name": ("设备号", "设备", "设备名", "机器", "机型"),
"phone": ("手机号", "手机", "号码", "手机号码"),
"nickname": ("账号名称", "账号名", "昵称", "账号", "名称"),
"douyin_id": ("抖音号", "抖音", "抖音id", "douyin"),
"registered_at": ("注册时间", "注册日期", "注册"),
"sim_in_device": ("卡在机内", "卡在机", "卡在", "机内"),
"can_post_video": ("可发视频", "可发", "发视频"),
"bio": ("简介", "签名", "个人简介"),
"note": ("备注", "说明"),
}
# 没有表头时按这个顺序解析(= 用户表格的列顺序,界面上要写明)
DEFAULT_ORDER = FIELDS
_TRUE_WORDS = ("是", "有", "对", "在", "✔", "√", "✓", "1", "true", "y", "yes", "t")
_FALSE_WORDS = ("否", "无", "没有", "不在", "不", "0", "false", "n", "no", "f", "")
# 抖音号形态:纯数字。不拦(少数号可能带字母),只在导入结果里留痕 ——
# 候选值一旦是错的,任务会"正常跑完但永远不命中",最难查
_DOUYIN_RE = re.compile(r"^\d{6,20}$")
_app = None
def init_app(app):
"""web_server 启动时调用:绑 app(后台线程/任务线程访问 db 要自推 context)。"""
global _app
_app = app
def _ctx():
if _app is None:
raise RuntimeError("ledger 未关联 Flask app(web_server 启动时调用 init_app)")
return _app.app_context()
def _now():
return time.strftime("%Y-%m-%d %H:%M:%S")
def _norm_bool(v):
"""是/否 → (True/False, 是否认得)。认不出的按"否"处理,但**留痕**。"""
if isinstance(v, bool):
return v, True
s = str(v or "").strip().lower()
if s in _TRUE_WORDS:
return True, True
if s in _FALSE_WORDS:
return False, True
return False, False
def _clean(fields):
"""把接口来的字段清洗成可落库的值。只处理传进来的键。返回 (values, error)。"""
out = {}
for k in FIELDS:
if k not in (fields or {}):
continue
v = fields.get(k)
out[k] = _norm_bool(v)[0] if k in BOOL_FIELDS else str(v or "").strip()
if "device_name" in out and not out["device_name"]:
return {}, "设备号不能为空"
if "douyin_id" in out and not out["douyin_id"]:
return {}, "抖音号不能为空"
return out, ""
# ================== 查 ==================
def get_by_id(acc_id):
"""取一条(dict)或 None。
**跨边界一律返回 dict**:commit 之后 ORM 行的属性会过期,出了 app context
再读属性会抛 DetachedInstanceError(接口层 `row.to_dict()` 必炸)。
"""
with _ctx():
row = DeviceAccount.query.get(acc_id)
return row.to_dict() if row else None
def find_by_douyin(douyin_id, exclude_id=""):
"""按抖音号找已存在的那条(唯一性判据;空号不参与)。返回 (id, 设备号) 或 None。
只返回两个纯值,同样是为了不让 ORM 行溜出 context。
"""
d = str(douyin_id or "").strip()
if not d:
return None
with _ctx():
q = DeviceAccount.query.filter(DeviceAccount.douyin_id == d)
if exclude_id:
q = q.filter(DeviceAccount.id != exclude_id)
row = q.first()
return (row.id, row.device_name or "") if row else None
def list_accounts(device_name="", q="", limit=500):
"""列表 + 统计(「账号」页用)。返回 (rows, stats)。"""
with _ctx():
query = DeviceAccount.query
if device_name:
query = query.filter(DeviceAccount.device_name == device_name)
if q:
like = f"%{q.strip()}%"
query = query.filter(db.or_(DeviceAccount.douyin_id.like(like),
DeviceAccount.phone.like(like),
DeviceAccount.nickname.like(like)))
rows = (query.order_by(DeviceAccount.device_name, DeviceAccount.id)
.limit(max(1, min(int(limit or 500), 2000))).all())
all_rows = DeviceAccount.query.all()
stats = {
"total": len(all_rows),
"devices": len({r.device_name for r in all_rows if r.device_name}),
"can_post_video": len([r for r in all_rows if r.can_post_video]),
"shown": len(rows),
}
return [r.to_dict() for r in rows], stats
def device_names():
"""台账里出现过的设备号(筛选下拉用)。"""
try:
with _ctx():
return sorted({r.device_name for r in DeviceAccount.query.all() if r.device_name})
except Exception:
return []
def for_device(serial="", device_name=""):
"""这台设备的账号。serial 优先,再按设备名(换 IP / 改名都找得到)。
`serial` 是**录入时的快照**:设备换 IP 后台账里那条还是旧地址,
所以两边都查一遍并按抖音号去重。
"""
with _ctx():
rows, seen = [], set()
if serial:
for r in DeviceAccount.query.filter(DeviceAccount.serial == serial).all():
rows.append(r)
seen.add(r.douyin_id)
name = device_name
if not name and serial:
dev = Device.query.get(serial)
name = (dev.name if dev else "") or ""
if name:
for r in DeviceAccount.query.filter(DeviceAccount.device_name == name).all():
if r.douyin_id not in seen:
rows.append(r)
seen.add(r.douyin_id)
return [r.to_dict() for r in rows]
def resolve_device(serial="", device_name=""):
"""serial/设备名 → (设备号, 账号行, 说明)。
"本机是哪台设备"这条链只在这一个地方定义,任务侧和设备端都走它 ——
两套规则必然分叉(那会变成"任务取到 3 个号、页面显示 0 个号"这种鬼故事)。
"""
name = (device_name or "").strip()
if not name and serial:
with _ctx():
dev = Device.query.get(serial)
name = (dev.name if dev else "") or ""
rows = for_device(serial=serial, device_name=name)
# 兜底:台账里有人把"设备号"直接填成地址(手填习惯),按 serial 再认一次
if not rows and serial:
with _ctx():
hit = DeviceAccount.query.filter(DeviceAccount.device_name == serial).all()
if hit:
name = serial
rows = [r.to_dict() for r in hit]
if not rows:
note = (f"设备名『{name}』在台账里没有账号" if name
else f"设备 {serial or '(无地址)'} 认不出设备名(设备池里没登记或还没刷新)")
if serial and name:
note += f",地址 {serial} 也没对上台账"
return name, [], note
return name, rows, f"设备『{name}』登记 {len(rows)} 个号"
def douyin_ids(scope, serial="", device_name="", group=""):
"""按范围取抖音号(任务「条件判断」取号用)。返回 (去重后的纯号列表, 说明)。
说明是给日志/步骤明细看的**人话解释** —— 取不到号时它是唯一线索
(取不到候选值的后果是"这一步永远不命中",但任务本身会正常跑完)。
scope: `device`=本机台账 / `all`=全部 / `group`=某设备分组 / 其他=不取
"""
scope = (scope or "").strip()
if scope in ("", "manual", "none"):
return [], ""
try:
with _ctx():
if scope == "device":
_, rows, note = resolve_device(serial=serial, device_name=device_name)
return _uniq([r.get("douyin_id") for r in rows]), note
if scope == "all":
ids = _uniq([r.douyin_id for r in DeviceAccount.query.all()])
return ids, f"全部台账共 {len(ids)} 个号"
if scope == "group":
names = _device_names_of_group(group)
if not names:
return [], f"设备分组『{group}』没有设备(或分组不存在)"
rows = DeviceAccount.query.filter(
DeviceAccount.device_name.in_(list(names))).all()
ids = _uniq([r.douyin_id for r in rows])
return ids, f"设备分组『{group}』({len(names)} 台设备)共 {len(ids)} 个号"
return [], f"未知的取号范围 {scope!r}"
except Exception as e:
# 取号失败绝不拦住任务:返回空,由调用方回落到手填值
_log.warning(f"台账取号失败(回落手填值): {e}")
return [], f"台账取号失败: {e}"
def _uniq(ids):
out, seen = [], set()
for i in ids or []:
s = str(i or "").strip()
if s and s not in seen:
seen.add(s)
out.append(s)
return out
def _device_names_of_group(group_name):
"""设备分组 → 设备名集合(分组里存的是 serial,这里换成台账里的设备号)。"""
g = DeviceGroup.query.filter(DeviceGroup.name == (group_name or "").strip()).first()
if not g:
return set()
serials = g.get_serials() or []
if not serials:
return set()
devs = Device.query.filter(Device.serial.in_(list(serials))).all()
return {d.name for d in devs if d.name}
def counts_by_device():
"""{设备名: 号数}(设备池显示"这台几个号"用,一次拿全避免 N 次请求)。"""
try:
with _ctx():
out = {}
for r in DeviceAccount.query.all():
if r.device_name:
out[r.device_name] = out.get(r.device_name, 0) + 1
return out
except Exception as e:
_log.warning(f"台账统计失败: {e}")
return {}
def by_device():
"""{设备名: {count, accounts[]}}(设备维度查看弹窗用)。"""
groups = {}
try:
with _ctx():
for r in DeviceAccount.query.order_by(DeviceAccount.douyin_id).all():
g = groups.setdefault(r.device_name or "", {"count": 0, "accounts": []})
g["count"] += 1
g["accounts"].append(r.to_dict())
except Exception as e:
_log.warning(f"台账按设备分组失败: {e}")
return groups
def agent_accounts(serial="", device_name="", limit=50, include_phone=False):
"""设备端 Agent 的账号块(身份大字页用)。
· **默认不带手机号**:大字页是放在机器旁的公开屏幕,手机号不该默认上屏
· 超量**按整条丢**(`truncated=True`),绝不按字节切 —— 切了就是坏 JSON,
设备端只能整块丢掉
· 任何异常都返回空块(不是抛):台账坏了不能把"显示身份"这个动作弄坏
"""
try:
name, rows, note = resolve_device(serial=serial, device_name=device_name)
out = []
for r in rows[:max(1, int(limit or 50))]:
item = {"name": r.get("nickname") or "", "douyin_id": r.get("douyin_id") or "",
"device_name": r.get("device_name") or "", "device_no": name,
"sim_in_device": bool(r.get("sim_in_device")),
"can_post_video": bool(r.get("can_post_video"))}
if include_phone:
item["phone"] = r.get("phone") or ""
out.append(item)
return {"v": 1, "total": len(rows), "shown": len(out),
"truncated": len(out) < len(rows), "accounts": out, "note": note}
except Exception as e:
_log.warning(f"设备端账号块生成失败: {e}")
return {"v": 1, "total": 0, "shown": 0, "truncated": False, "accounts": [],
"note": f"读取失败: {e}"}
# ================== 增 / 改 / 删 ==================
def add(fields):
"""新增一条。返回 (dict, error)。"""
values, err = _clean(fields)
if err:
return None, err
if not values.get("device_name"):
return None, "设备号不能为空"
if not values.get("douyin_id"):
return None, "抖音号不能为空"
# try 必须在 `with _ctx()` 里面:否则 rollback 自己会在 context 外抛
# RuntimeError,把真正的错误盖掉(这条踩过一次,别挪出去)
with _ctx():
try:
if find_by_douyin(values["douyin_id"]):
return None, f"抖音号 {values['douyin_id']} 已在台账里(一个号只能有一条)"
row = DeviceAccount(id=uuid.uuid4().hex[:8], created_at=_now(), updated_at=_now())
for k in FIELDS:
# 没传的布尔列必须是 False,不能是 ""(Boolean 列收到空串会直接报
# TypeError: Not a boolean value,整条插不进去)
setattr(row, k, values.get(k, False if k in BOOL_FIELDS else ""))
_fill_serial(row)
db.session.add(row)
db.session.commit()
_log.info(f"账号台账新增:{row.device_name} / {row.nickname} / {row.douyin_id}")
return row.to_dict(), "" # 出 context 前转成纯值
except Exception as e:
db.session.rollback()
return None, f"保存失败: {e}"
def update(acc_id, fields):
"""改一条(只改传进来的键)。返回 (dict, error)。"""
with _ctx():
try:
row = DeviceAccount.query.get(acc_id)
if not row:
return None, "记录不存在"
values, err = _clean(fields)
if err:
return None, err
new_id = values.get("douyin_id", row.douyin_id)
dup = find_by_douyin(new_id, exclude_id=acc_id)
if dup:
return None, f"抖音号 {new_id} 已被 {dup[1]} 占用(一个号只能有一条)"
for k, v in values.items():
setattr(row, k, v)
row.updated_at = _now()
_fill_serial(row)
db.session.commit()
return row.to_dict(), ""
except Exception as e:
db.session.rollback()
return None, f"保存失败: {e}"
def delete(acc_id):
"""删一条(删掉之后任务就取不到这个号了)。"""
with _ctx():
try:
row = DeviceAccount.query.get(acc_id)
if not row:
return False
db.session.delete(row)
db.session.commit()
_log.info(f"账号台账删除:{row.device_name} / {row.nickname} / {row.douyin_id}")
return True
except Exception as e:
db.session.rollback()
_log.warning(f"账号台账删除失败: {e}")
return False
def _fill_serial(row):
"""按设备名补 serial 快照(台账只填设备号时,自动关联设备池那条)。"""
if row.serial:
return
if not row.device_name:
return
dev = Device.query.filter(Device.name == row.device_name).first()
if dev:
row.serial = dev.serial
# ================== 粘贴导入 ==================
def parse_paste(text, delimiter="\t"):
"""把粘贴的表格文本解析成台账行。返回 (rows, errors)。
rows 里每行是 `{"_line": 3, "device_name": "...", ..., "_warns": [...]}`
(`_line` = 粘贴文本里的行号,界面把错误指回原行;`_warns` 是"能导但要注意"的提示)。
errors 是 `[{"line": 3, "reason": "..."}]`(这些行会被跳过)。
分隔符默认 Tab(Excel 直接粘贴就是 Tab)。**不做"多个空格当分隔"**——
账号名里本来就有双空格(实测 `有牛奶面包 你吃吗?`),按空格切会切坏。
带表头时按列名映射(顺序随意、缺列留空);不带表头则按 DEFAULT_ORDER 的固定顺序。
"""
if delimiter not in ("\t", "|", ","):
delimiter = "\t"
text = (text or "").replace("\r\n", "\n").replace("\r", "\n")
lines = [(i + 1, ln) for i, ln in enumerate(text.split("\n")) if ln.strip()]
if not lines:
return [], [{"line": 0, "reason": "没有可解析的内容"}]
first_cells = [c.strip() for c in lines[0][1].split(delimiter)]
mapped = _map_header(first_cells)
if mapped:
body = lines[1:]
else:
mapped = {name: i for i, name in enumerate(DEFAULT_ORDER)}
body = lines
if not body:
return [], [{"line": lines[0][0], "reason": "只有表头,没有数据行"}]
rows, errors = [], []
for lineno, line in body:
cells = line.split(delimiter)
row = {"_line": lineno, "_warns": []}
for f in FIELDS: # 先把所有字段填上,缺列留空
row[f] = ""
for field, idx in mapped.items():
if field in FIELDS:
row[field] = cells[idx].strip() if idx < len(cells) else ""
if not row["device_name"] and not row["douyin_id"]:
errors.append({"line": lineno, "reason": "设备号与抖音号都是空的,已跳过"})
continue
if not row["device_name"]:
errors.append({"line": lineno, "reason": "缺设备号,已跳过"})
continue
if not row["douyin_id"]:
errors.append({"line": lineno, "reason": "缺抖音号,已跳过"})
continue
for f in BOOL_FIELDS:
raw = row[f]
val, known = _norm_bool(raw)
row[f] = val
if not known:
row["_warns"].append(f"『{raw}』认不出,按「否」处理")
if not _DOUYIN_RE.match(row["douyin_id"]):
row["_warns"].append(f"抖音号『{row['douyin_id']}』不是纯数字 —— "
f"任务比对时可能匹配不上")
rows.append(row)
return rows, errors
def _map_header(cells):
"""列名 → 列下标(认不出"设备号+抖音号"时返回 {},按固定顺序解析)。"""
mapping = {}
for idx, cell in enumerate(cells):
c = re.sub(r"\s+", "", cell or "")
if not c:
continue
for field, aliases in HEADER_ALIASES.items():
if field in mapping:
continue
if any(c == a or c.startswith(a) for a in aliases):
mapping[field] = idx
break
if "device_name" in mapping and "douyin_id" in mapping:
return mapping
return {}
def import_rows(rows, mode="skip", dry_run=False):
"""把解析好的行落库(或只预览)。返回逐行结果 + 汇总。
mode=`skip`(默认):抖音号已在台账里 → 跳过(不动现有那条)
mode=`overwrite`:抖音号已在台账里 → 用粘贴的这行**整行覆盖**它
dry_run=True:只判重、不写库(界面上"先预览后导入")
"""
mode = mode if mode in ("skip", "overwrite") else "skip"
counts = {"total": 0, "added": 0, "updated": 0, "skipped": 0, "failed": 0}
out_rows = []
for row in rows or []:
line = row.get("_line", 0)
fields = {k: v for k, v in row.items() if k not in ("_line", "_warns")}
counts["total"] += 1
existing = find_by_douyin(fields.get("douyin_id"))
item = {"line": line, "device_name": fields.get("device_name", ""),
"douyin_id": fields.get("douyin_id", ""),
"nickname": fields.get("nickname", ""),
"warns": list(row.get("_warns") or []), "error": ""}
if existing:
if mode == "overwrite":
item["action"] = "update"
if not dry_run:
_, err = update(existing[0], fields)
if err:
item["action"], item["error"] = "fail", err
counts["updated" if item["action"] == "update" else "failed"] += 1
else:
item["action"] = "skip"
item["error"] = f"已存在({existing[1]}),跳过"
counts["skipped"] += 1
else:
item["action"] = "add"
if not dry_run:
_, err = add(fields)
if err:
item["action"], item["error"] = "fail", err
counts["added" if item["action"] == "add" else "failed"] += 1
out_rows.append(item)
if not dry_run:
_log.info(f"账号台账导入:新增 {counts['added']}、覆盖 {counts['updated']}、"
f"跳过 {counts['skipped']}、失败 {counts['failed']}")
return {"counts": counts, "rows": out_rows, "mode": mode, "dry_run": bool(dry_run)}