发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 ix_video_plan_name_date(启动时补建) · 新步骤三个:读本机账号名字 / 切换账号(**按名字点控件**、切完回读校验、遇登录页停本设备)/ 循环本机待发的号(试过不回头、跑一轮没进展就换号、停止·超时·上限三个干净出口) · 发布骨架改 18 步:读名字 → 校验账号(cmp_source=account_target)→ 推送(**推送前重读昵称核对**) → 抖音流程 → 标记结果;push_release 的 account 默认 name · 账号管理整页下线:删 core/ledger.py(serial_of 迁到 core/device_pool.py)、6 个 /api/ledger*、 「账号」页与 ledger.js、设备池「账号」列、设备端账号块;发布计划升为一级页签; device_account 表与 42 行历史数据保留、仍在备份清单内(**停用不等于删数据**) · 人工入口「按名字找设备」:新增 core/account_probe.py(只读探测、跳过在跑任务的设备) + POST /api/video_plan/push_by_account,没设备登着的号逐条报出来; 推送时把"实际是谁发的"写进 posted_serial / posted_device_name · 通知:新增 task.account.switch_failed / task.account.need_login · schema v12(posted_*)/ v13(device.cur_account_name / cur_name_at) · 文档同步:TASK_DEV / API / DATA_MODEL / ARCHITECTURE / NOTIFY / DEVICE_AGENT / DEPLOY / DEVELOPMENT / README / .env.example(两个抖音控件选择器抓一次填 .env) · 新增 scripts/tests/:6 套用例 + run_all.py(临时库 + 假设备,不碰真实数据、不连真机)
147 lines
6.8 KiB
Python
147 lines
6.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""P6 测试:按名字找设备推送(`_push_by_name`)—— 临时库 + 假探测/假推送,不碰真机。"""
|
||
import io
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
|
||
TMP = tempfile.mkdtemp(prefix='ac_p6_')
|
||
os.environ['DATABASE_URL'] = 'sqlite:///' + TMP.replace(chr(92), '/') + '/ac.db'
|
||
os.environ['DATA_VIDEO_DIR'] = os.path.join(TMP, 'videos')
|
||
os.environ['DEPLOY_ENV'] = 'dev'
|
||
os.environ['DB_ALLOW_SQLITE_FALLBACK'] = '1'
|
||
|
||
# 仓库根 = scripts/tests/ 往上三级(不写死绝对路径,换机器也能跑)
|
||
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
sys.path.insert(0, _ROOT)
|
||
os.chdir(_ROOT)
|
||
|
||
from flask import Flask # noqa: E402
|
||
from core import db_config, models, device_pool # noqa: E402
|
||
from core import video_plan as vp # noqa: E402
|
||
from core import account_probe # noqa: E402
|
||
from tasks.generic import publish_flow as flow # noqa: E402
|
||
from web import video_plan_api as vapi # noqa: E402
|
||
|
||
DATE = '2026-09-12'
|
||
fails = []
|
||
|
||
|
||
def check(label, got, want):
|
||
ok = got == want
|
||
if not ok:
|
||
fails.append(label)
|
||
print((' ✅ ' if ok else ' ❌ ') + label + ('' if ok else '\n 得到 %r\n 期望 %r' % (got, want)))
|
||
|
||
|
||
app = Flask("t")
|
||
uri = db_config.build_db_uri()
|
||
app.config["SQLALCHEMY_DATABASE_URI"] = uri
|
||
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = db_config.engine_options(uri)
|
||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||
models.db.init_app(app)
|
||
with app.app_context():
|
||
models.db.create_all()
|
||
vp.init_app(app)
|
||
device_pool.init_app(app)
|
||
|
||
with app.app_context():
|
||
models.db.session.add(models.Device(serial='A:5555', name='A01', enabled=True,
|
||
created_at=vp._now()))
|
||
models.db.session.add(models.Device(serial='B:5555', name='A02', enabled=True,
|
||
created_at=vp._now()))
|
||
for i, (nm, s) in enumerate([('小王', 1), ('小王', 2), ('小李', 1)]):
|
||
models.db.session.add(models.VideoPlan(
|
||
id='p%d' % i, nickname=nm, phone='', device_name='', douyin_id='', serial='',
|
||
release_date=DATE, seq=s, seq_auto=False, title='t', body='',
|
||
video_file='x', video_name='x', status=vp.ST_READY,
|
||
created_at=vp._now(), updated_at=vp._now()))
|
||
models.db.session.commit()
|
||
|
||
pushed = []
|
||
lock = []
|
||
|
||
|
||
def fake_push(serial, plan, opts=None, log=None):
|
||
pushed.append((serial, plan['nickname'], plan['seq'], (opts or {}).get('device_name')))
|
||
return True, "ok"
|
||
|
||
|
||
print('=== 1. 名字 → 设备(两台设备各登一个)===')
|
||
probe = {'A:5555': {'serial': 'A:5555', 'name': '小王', 'error': ''},
|
||
'B:5555': {'serial': 'B:5555', 'name': '小李', 'error': ''}}
|
||
orig_probe, orig_push = account_probe.probe_online, flow.push_one
|
||
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: probe
|
||
flow.push_one = fake_push
|
||
try:
|
||
with app.app_context():
|
||
rows = vp.pushable_plans(DATE)
|
||
matched, unmatched = vapi._push_by_name(rows)
|
||
deadline = time.time() + 3
|
||
while len(pushed) < 3 and time.time() < deadline:
|
||
time.sleep(0.05)
|
||
check('两个名字都找到设备', sorted((m['name'], m['count']) for m in matched),
|
||
[('小李', 1), ('小王', 2)])
|
||
check('小王的设备是 A01', [m['device_name'] for m in matched if m['name'] == '小王'], ['A01'])
|
||
check('没有 unmatched', unmatched, [])
|
||
check('推了小王的 2 条 + 小李的 1 条', len(pushed), 3)
|
||
check('推给 A 的是小王的两条(按编号)',
|
||
sorted((s, q) for (ser, n, q, dn) in pushed if ser == 'A:5555'), [(1, 1), (1, 2)])
|
||
check('推给 B 的是小李', [(n, q) for (ser, n, q, dn) in pushed if ser == 'B:5555'],
|
||
[('小李', 1)])
|
||
check('顺手把设备名字传给了 push_one(写 posted_device_name 用)',
|
||
{dn for (ser, n, q, dn) in pushed}, {'A01', 'A02'})
|
||
with app.app_context():
|
||
cached = [(d.serial, d.cur_account_name)
|
||
for d in models.Device.query.order_by(models.Device.serial)]
|
||
check('探测结果缓存进了 device 表', cached,
|
||
[('A:5555', '小王'), ('B:5555', '小李')])
|
||
|
||
print('=== 2. 没人登着某个名字 → unmatched 里逐条说明 ===')
|
||
pushed.clear()
|
||
probe2 = {'A:5555': {'serial': 'A:5555', 'name': '小王', 'error': ''},
|
||
'B:5555': {'serial': 'B:5555', 'name': '别的号', 'error': ''}}
|
||
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: probe2
|
||
with app.app_context():
|
||
rows = vp.pushable_plans(DATE)
|
||
matched2, unmatched2 = vapi._push_by_name(rows)
|
||
deadline = time.time() + 3
|
||
while len(pushed) < 2 and time.time() < deadline:
|
||
time.sleep(0.05)
|
||
check('只推了小王那 2 条', len(pushed), 2)
|
||
check('小李进了 unmatched', [(u['name'], u['count']) for u in unmatched2], [('小李', 1)])
|
||
check('原因说清了「先切号」', ('没有在线设备登着' in unmatched2[0]['reason']
|
||
and '小李' in unmatched2[0]['reason']
|
||
and '先到设备上切到它' in unmatched2[0]['reason']), True)
|
||
check('原因里带上当前探测到的号(排查用,别把整个 dict 打出来)',
|
||
unmatched2[0]['reason'].endswith('(当前探测到:A:5555(小王)、B:5555(别的号))'), True)
|
||
if 'dict' in unmatched2[0]['reason'] or '{' in unmatched2[0]['reason']:
|
||
print(' 原因里混进了原始 dict:', unmatched2[0]['reason'])
|
||
|
||
print('=== 3. 探测失败(比如选择器没配)→ 也算没设备登着,不炸 ===')
|
||
pushed.clear()
|
||
account_probe.probe_online = lambda serials=None, skip_busy=True, workers=4: {
|
||
'A:5555': {'serial': 'A:5555', 'name': '', 'error': '还没抓昵称控件'},
|
||
'B:5555': {'serial': 'B:5555', 'name': '', 'error': '连不上设备'}}
|
||
with app.app_context():
|
||
rows = vp.pushable_plans(DATE)
|
||
matched3, unmatched3 = vapi._push_by_name(rows)
|
||
check('一条都没推', pushed, [])
|
||
check('两个名字都 unmatched', sorted(u['name'] for u in unmatched3), ['小李', '小王'])
|
||
with app.app_context():
|
||
keep = models.Device.query.get('A:5555').cur_account_name
|
||
check('失败的那台不清空旧缓存', keep, '小王')
|
||
finally:
|
||
account_probe.probe_online, flow.push_one = orig_probe, orig_push
|
||
|
||
print()
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
if fails:
|
||
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
|
||
sys.exit(1)
|
||
print('✅ 全部通过(临时库已清理)')
|