"""账号台账:一台设备上登录着哪些账号。 **它解决什么**(用户场景):一台手机登好几个抖音号、十几台手机四五十个号, 账号信息散在一张电子表格里 —— 换 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)}