发布计划不再绑设备、不再依赖账号台账:上传文件名 `名字_日期_编号`(标题行 `标题 || 正文_名字_日期_编号`),运行时读本机当前抖音昵称取计划;靠循环块 「一轮把本机待发的号跑完」自动切号,一直发到发完。 · 解析/配对换键:右锚定认名字(不许含下划线、不许纯数字 —— 不合法直接拒收并说清原因), 删掉三处"手机号必须命中台账"的校验,加索引 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(临时库 + 假设备,不碰真实数据、不连真机)
178 lines
6.9 KiB
Python
178 lines
6.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""P5 测试:`foreach_account`(一轮把本机待发的号跑完)—— 临时库,不碰真实数据。"""
|
||
import io
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import tempfile
|
||
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||
|
||
TMP = tempfile.mkdtemp(prefix='ac_p5_')
|
||
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 # noqa: E402
|
||
from core import video_plan as vp # noqa: E402
|
||
from tasks.generic import task as gt # 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)
|
||
|
||
|
||
def seed():
|
||
"""小王 2 条、小李 1 条、小张 1 条(未来)。"""
|
||
with app.app_context():
|
||
for i, (nm, d, s) in enumerate([('小王', DATE, 1), ('小王', DATE, 2),
|
||
('小李', DATE, 1), ('小张', '2027-01-01', 1)]):
|
||
models.db.session.add(models.VideoPlan(
|
||
id='p%d' % i, nickname=nm, phone='', device_name='', douyin_id='',
|
||
serial='', release_date=d, seq=s, seq_auto=False, title='t' + str(s),
|
||
body='', video_file='x', video_name='x', status=vp.ST_READY,
|
||
created_at=vp._now(), updated_at=vp._now()))
|
||
models.db.session.commit()
|
||
|
||
|
||
def status_of():
|
||
with app.app_context():
|
||
out = {}
|
||
for r in models.VideoPlan.query.all():
|
||
out.setdefault(r.nickname, []).append(r.status)
|
||
return {k: sorted(v) for k, v in out.items()}
|
||
|
||
|
||
print('=== 1. 一轮把本机待发的号跑完(模拟"推+发成功")===')
|
||
seed()
|
||
w = gt.GenericStepsWorker("FAKE01", {}, ctx={"job_id": "t1", "device_name": "A01"})
|
||
order = []
|
||
|
||
|
||
def fake_exec_ok(d, steps, depth=0):
|
||
"""模拟一段正常的循环体:把当前 target_name 的下一条待发计划标成 done。"""
|
||
name = w._vars.get('target_name')
|
||
order.append(name)
|
||
with app.app_context():
|
||
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
|
||
.order_by(models.VideoPlan.seq).first())
|
||
if row:
|
||
row.status = vp.ST_DONE
|
||
models.db.session.commit()
|
||
|
||
|
||
w._exec_steps = fake_exec_ok
|
||
w._exec_foreach_account(None, {"children": [{"type": "x"}],
|
||
"date_source": "fixed", "fixed_date": DATE})
|
||
# 同日期同编号时按名字**码点**定序:李 U+674E < 王 U+738B → 小李先跑
|
||
check('两个号都跑过(小李 1 条 → 小王 2 条)', order, ['小李', '小王', '小王'])
|
||
check('小王两条都发了', status_of()['小王'], ['done', 'done'])
|
||
check('小李发了', status_of()['小李'], ['done'])
|
||
check('未来的小张没动', status_of()['小张'], ['ready'])
|
||
check('跑完把 target_name 清掉了', w._vars.get('target_name'), None)
|
||
|
||
print('=== 2. 本机没登这个号 → 不卡死,直接换下一个 ===')
|
||
with app.app_context():
|
||
models.VideoPlan.query.delete()
|
||
models.db.session.commit()
|
||
seed()
|
||
w2 = gt.GenericStepsWorker("FAKE02", {}, ctx={"job_id": "t2"})
|
||
seen = []
|
||
|
||
|
||
def fake_exec_only_xiaowang(d, steps, depth=0):
|
||
"""只有小王的号在这台机器上:切到别人时什么都不做(模拟切号失败/守卫挡下)。"""
|
||
name = w2._vars.get('target_name')
|
||
seen.append(name)
|
||
if name != '小王':
|
||
return
|
||
with app.app_context():
|
||
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
|
||
.order_by(models.VideoPlan.seq).first())
|
||
if row:
|
||
row.status = vp.ST_DONE
|
||
models.db.session.commit()
|
||
|
||
|
||
w2._exec_steps = fake_exec_only_xiaowang
|
||
w2._exec_foreach_account(None, {"children": [{"type": "x"}],
|
||
"date_source": "fixed", "fixed_date": DATE})
|
||
check('小王发完(2 条)', status_of()['小王'], ['done', 'done'])
|
||
check('小李只被试了 1 次就跳过(没空转)', seen.count('小李'), 1)
|
||
check('试过的名字不回头', seen, ['小李', '小王', '小王'])
|
||
check('小李还是待发', status_of()['小李'], ['ready'])
|
||
|
||
print('=== 3. 下一轮会接着发小李(上一轮没发完的还在队列里)===')
|
||
w3 = gt.GenericStepsWorker("FAKE03", {}, ctx={"job_id": "t3"})
|
||
|
||
|
||
def fake_exec_all(d, steps, depth=0):
|
||
name = w3._vars.get('target_name')
|
||
with app.app_context():
|
||
row = (models.VideoPlan.query.filter_by(nickname=name, status=vp.ST_READY)
|
||
.order_by(models.VideoPlan.seq).first())
|
||
if row:
|
||
row.status = vp.ST_DONE
|
||
models.db.session.commit()
|
||
|
||
|
||
w3._exec_steps = fake_exec_all
|
||
w3._exec_foreach_account(None, {"children": [{"type": "x"}],
|
||
"date_source": "fixed", "fixed_date": DATE})
|
||
check('小李补上了', status_of()['小李'], ['done'])
|
||
|
||
print('=== 4. 没有待发的号 → 干净收工 ===')
|
||
w4 = gt.GenericStepsWorker("FAKE04", {}, ctx={"job_id": "t4"})
|
||
w4._exec_steps = lambda d, s, depth=0: None
|
||
w4._exec_foreach_account(None, {"children": [{"type": "x"}],
|
||
"date_source": "fixed", "fixed_date": DATE})
|
||
check('一条都没跑(不会死循环)', w4._vars.get('target_name'), None)
|
||
|
||
print('=== 5. max_accounts 上限 ===')
|
||
with app.app_context():
|
||
models.VideoPlan.query.delete()
|
||
models.db.session.commit()
|
||
seed()
|
||
w5 = gt.GenericStepsWorker("FAKE05", {}, ctx={"job_id": "t5"})
|
||
ran = []
|
||
w5._exec_steps = lambda d, s, depth=0: ran.append(w5._vars.get('target_name'))
|
||
w5._exec_foreach_account(None, {"children": [{"type": "x"}], "max_accounts": 1,
|
||
"date_source": "fixed", "fixed_date": DATE})
|
||
check('只跑 1 个号', ran, ['小李'])
|
||
|
||
print('=== 6. 子步骤为空 → 不报错、直接结束 ===')
|
||
w6 = gt.GenericStepsWorker("FAKE06", {})
|
||
w6._exec_foreach_account(None, {"children": []})
|
||
check('空 children 直接返回', w6._vars.get('target_name'), None)
|
||
|
||
print()
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
if fails:
|
||
print('❌ 失败 %d 项:%s' % (len(fails), ' / '.join(fails)))
|
||
sys.exit(1)
|
||
print('✅ 全部通过(临时库已清理)')
|