一、平台侧(账号 → 发布计划页) - 新表 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
552 lines
23 KiB
Python
552 lines
23 KiB
Python
"""账号台账:一台设备上登录着哪些账号。
|
||
|
||
**它解决什么**(用户场景):一台手机登好几个抖音号、十几台手机四五十个号,
|
||
账号信息散在一张电子表格里 —— 换 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 serial_of(device_name):
|
||
"""设备名 → **当前**地址(拿不到返回空串)。
|
||
|
||
⚠ 为什么不能直接用台账/计划行里的 `serial` 快照:设备地址会变(换 IP、重连)。
|
||
快照是"录入时"的值,久了就是错的 —— 要下发操作(发布视频等)时必须现查。
|
||
优先设备池的**内存快照**(零 DB、已经是刷新过的),再退回 DB。
|
||
"""
|
||
name = (device_name or "").strip()
|
||
if not name:
|
||
return ""
|
||
try:
|
||
from core import device_pool
|
||
for d in device_pool.list_devices():
|
||
if (d.get("name") or "") == name:
|
||
return d.get("serial") or ""
|
||
except Exception:
|
||
pass
|
||
try:
|
||
with _ctx():
|
||
dev = Device.query.filter(Device.name == name).first()
|
||
return (dev.serial if dev else "") or ""
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
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)}
|