feat: add group cooldown and parallel actions

This commit is contained in:
2026-09-07 00:03:02 +08:00
parent fee39517c9
commit dafa335bb5
9 changed files with 511 additions and 114 deletions
+18 -6
View File
@@ -1,7 +1,7 @@
抖音账号助手 0.1.5 — Windows 10/11 x64
抖音账号助手 0.1.6 — Windows 10/11 x64
【安装】
只需运行 DouyinAccounts-0.1.5-Windows-x64-Setup.exe。
只需运行 DouyinAccounts-0.1.6-Windows-x64-Setup.exe。
无需安装 Python、Qt、Patchright、Node.js 或另行下载浏览器。
安装程序不会自动启动业务;没有开机自启、自动登录或默认私信。
默认使用随包的 fingerprint-chromiumadryfish 项目)和 Patchright 驱动,不再回退到系统 Chrome。
@@ -16,14 +16,16 @@
账号已添加或登录身份与原绑定不一致时会提示,不会覆盖已有绑定。登录成功也不会自动启动业务。
若关闭了浏览器,可点击“登录 / 刷新信息”继续;已绑定账号点击此按钮可刷新资料和统计。
只保留一个抖音首页或自己的页面;多个匹配标签会拒绝执行,防止选错。
3. 选择大号,配置规则。默认关闭;可配置通知类别、关注、私信、正文和顺序
勾选大号后点击启动规则未开启不会产生或执行业务任务。
3. 选择大号,配置规则。默认关闭;可配置通知类别、关注、私信、正文、不同任务批次间隔和同组 UID 冷却
冷却默认 240 分钟(4 小时),设为 0 可关闭;勾选大号后点击启动规则未开启不会产生或执行业务任务。
4. 每条匹配通知只给一个关联小号,按组内轮询,不跨组操作。
一条聚合通知可能包含多个来源用户,每个有效来源用户各执行配置动作。
5. 小号不可用时等待原小号,不自动改派;大号不可用时整个组暂停。
6. 停止只暂停业务、等待在途请求保存,浏览器保留。
关闭浏览器为独立操作,先停止账号组;退出软件也不会自动退出 Chrome。
7. “待核对”表示执行结果不确定,不会重发。请到平台人工核对,再确认成功或失败。
同一通知同时开启关注和私信时,两个动作并行开始、结果互不依赖,不等待默认 30 秒批次间隔。
批次间隔仍用于同一小号的不同通知批次;旧版“必须关注成功后再私信”配置升级后自动关闭。
8. “正在监听通知(N 条详情待重试,不阻塞新通知)”表示部分通知暂未返回详情。
这些 ID 会保留,按 30、60、120、240、300 秒间隔重试,之后最多每 5 分钟一次;不拖停整组,不影响正常新通知。
身份不符或真正的接口错误仍会暂停;不能根据空详情推断通知一定已删除。
@@ -35,10 +37,19 @@
此操作不删除账号定义、任务和历史,也不影响其他账号。
更换 Chrome 可执行路径会使用不同登录目录,不复制 Cookie。
【同组 UID 冷却与双动作并行(0.1.6 新增)】
冷却按大号组共享:该组任一小号开始操作某 UID 后,组内其他小号在设定时长内也不会再次操作该 UID;不同大号组互不影响。
冷却从任务实际领取、即将请求平台时开始并持久化,程序重启不重置。只排队后因停止、切换归属或删除小号而取消的任务不会启动冷却。
冷却期间,上一次结果无论成功、明确失败还是待核对,都不会因新通知再次创建任务;待核对记录在人工处理前还会继续阻止重复任务,优先保证不重复打扰。
在任务实际开始前,如果同组同 UID 已存在待执行、执行中或待核对任务,新通知也会直接跳过,避免短时间多条通知重复排队。
修改冷却时长会用于之后收到的通知;现有冷却按新通知当时的规则计算剩余时间。日志记录冷却开始、剩余秒数、已有任务跳过和所属本地大号名称,不记录完整 UID。
同一通知同时启用关注和私信时,两项组成一个并行批次,同时调用平台接口;其中一项失败或待核对不会取消另一项。两项各自保存结果。
默认 30 秒“不同任务批次间隔”只作用于前后两个通知批次,不插入到同一并行批次的关注和私信之间。
【详细运行记录(0.1.5 新增)】
“运行记录”显示最近 2000 条,重新打开程序也会加载最近的日志。可关闭自动滚动,或打开日志文件夹。
完整记录按电脑本地日期写入 %LOCALAPPDATA%\DouyinAccounts\logs\YYYY-MM-DD.logUTF-8 编码,不因 UI 上限而截断,也不自动删除旧日期文件。
记录推送接收与去重、详情请求/重试、规则不匹配、无执行小号、自操作保护、轮询分配、任务入队/执行/结果、依赖等待、人工核对、账号操作和异常。
记录推送接收与去重、详情请求/重试、规则不匹配、无执行小号、自操作保护、UID 冷却、并行批次、轮询分配、任务入队/执行/结果、旧任务依赖等待、人工核对、账号操作和异常。
例如“触发者就是执行小号自身”会明确显示跳过原因;“分配完成”不代表已经关注或发送私信。
监听无新通知时每分钟写一次心跳,重复等待状态不反复刷屏。任务列表也仅展示最近 2000 条,完整历史仍在数据库中。
日志不保存 Cookie、签名、私信正文、原始请求/响应;通知标识使用稳定摘要,目标 UID 只显示尾号,账号用本地名称标识。
@@ -59,7 +70,8 @@
卸载/覆盖升级默认保留账号数据。从 0.1.0 升级旧数据库时会先备份再迁移,保留原 UID、归属、任务和历史。
升级 0.1.2 前请先停止业务并关闭旧 Chrome。新内核使用独立登录目录,需要自行重新登录原账号。
旧目录及旧浏览器配置保留,但新版本不自动沿用旧 Chrome 的路径配置;如需自定义请重新设置指纹浏览器路径。
0.1.3 及更新版本首次启动旧库时会增加通知重试字段;0.1.5 增加删除标记字段,旧账号默认均保留,旧通知 ID、任务和登录目录不变。
0.1.3 及更新版本首次启动旧库时会增加通知重试字段;0.1.5 增加删除标记字段;0.1.6 增加组内 UID 冷却表和规则字段。旧账号、通知 ID、任务和登录目录不变。
0.1.6 将旧规则的“必须关注成功后再私信”自动关闭;旧版已排队且带前置依赖的任务仍按原快照安全处理,不重写历史。
0.1.4 修复了新点赞、评论推送后无法取得详情的问题:复用网页自身请求层,自动补齐当前运行时参数,保持原始 ID 精度。
本次已经实际验证“新增点赞和评论 → 两条推送 → 两条完整详情”,未执行自动关注或私信。
备份前先停止业务、退出软件并关闭所有账号浏览器,备份整个数据目录。
+2 -2
View File
@@ -168,7 +168,7 @@ def main():
break
source_hashes = {p.as_posix(): sha256(p) for p in sorted(Path("src").glob("*.py"))}
manifest = {
"app_version": "0.1.5",
"app_version": "0.1.6",
"python": sys.version,
"chrome": browser,
"dependencies": versions,
@@ -208,7 +208,7 @@ def main():
"onedir 和离线冒烟已完成。安装 Inno Setup 6 后传 --iscc PATH 以生成最终单文件安装包。"
)
subprocess.run([str(iscc), str(ROOT / "installer.iss")], check=True)
installer = ROOT / "release/DouyinAccounts-0.1.5-Windows-x64-Setup.exe"
installer = ROOT / "release/DouyinAccounts-0.1.6-Windows-x64-Setup.exe"
(installer.parent / "SHA256SUMS.txt").write_text(
sha256(installer) + " " + installer.name + "\n", encoding="ascii"
)
@@ -0,0 +1,216 @@
# 0.1.6:大号组共享 UID 冷却与关注/私信并行
## 需求与已确认口径
用户要求:
1. 同一个 UID 可配置冷却时长,默认 4 小时,期间不重复打扰。
2. 同一通知同时配置关注和私信时,两项并行执行,不等待默认 30 秒动作间隔。
3. 开始本轮修改前先推送已有版本。
已先将完整 0.1.5 快照提交并推送至 `origin/main`
```text
fee3951 feat: add Windows multi-account assistant
```
通过交互确认的业务口径:
- 冷却按**大号组共享**:一个大号组内所有执行小号共用;不同大号组互不影响。
- 同时开启关注和私信时**强制并行**。旧“私信前必须关注成功”不再控制新任务;两个结果分别保存,互不取消。
## 冷却生命周期
### 为什么不在通知入库时立即开始
通知可能刚入队就因用户停止业务、切换小号归属或删除小号而取消。如果通知入库即开始 4 小时冷却,会在没有实际请求平台时消耗冷却期。
因此冷却从任务被执行小号**实际领取、即将发起平台请求**时开始。领取批次和写入冷却记录处于同一个 SQLite 事务中;进程重启后仍然有效。
### 冷却开始前的重复保护
同一大号组、同一目标 UID 如果已经存在以下任一状态的任务,新通知不再创建重复任务:
- `pending`:已排队;
- `running`:正在执行;
- `unknown`:结果不确定,必须人工核对。
这样可以关闭“第一条还没执行,短时间又来了多条点赞/评论”的重复排队窗口。只排队后被取消且从未领取的任务不会写入冷却表。
`unknown` 比普通冷却更保守:即使配置冷却时间已经过去,只要结果仍未人工核对,仍不自动创建同 UID 的新任务。这延续了项目原有的“结果不确定绝不自动重发”约束。
### 冷却期行为
任务领取后,不论最终状态为:
- 成功;
- 明确失败;
- 结果不确定;
该大号组在配置时长内都不会因为新通知再次创建同 UID 的任务。日志会写出“冷却开始”或“冷却跳过”、本地大号名称及剩余秒数,但不写完整 UID。
默认值:
```text
14400 秒 = 240 分钟 = 4 小时
```
规则 UI 使用分钟配置:
- 范围 0525600 分钟;
- 0 表示关闭;
- 内部继续使用整数秒存储;
- 数据层允许 031536000 秒。
新通知使用其入库时的规则快照判断冷却时长。如果用户修改规则,之后收到的通知使用新时长;历史事件和任务不被重写。
### 持久化结构
新增表:
```sql
CREATE TABLE cooldowns (
source TEXT NOT NULL REFERENCES accounts(id),
target TEXT NOT NULL,
last_at REAL NOT NULL,
event INTEGER NOT NULL REFERENCES events(id),
PRIMARY KEY(source, target)
);
```
主键 `(source, target)` 使冷却天然按大号组和目标 UID 共享。执行小号不在主键中,因此组内轮询切换到另一个小号不会绕过冷却。另一个大号组使用不同 `source`,不会互相影响。
表只保留每组每 UID 最近一次实际领取时间,采用覆盖更新,不为同一组合无限追加历史行。详细历史仍由每日运行日志和任务表保存。
## 关注和私信并行
### 任务创建
同一事件、同一目标、同一执行小号同时开启关注和私信时,仍创建两个独立任务,但两者:
- `dependency=NULL`
- 保存同一规则快照;
- 属于同一事件和目标;
- 不再创建“私信依赖关注任务”的关系。
UI 删除了“必须关注成功后才发送私信”复选框。兼容字段 `require_follow` 暂时保留在 JSON 中,但规则验证、保存和旧库升级都会强制归一化为 `false`,避免旧界面配置继续产生新依赖。
升级不会重写历史任务。0.1.5 之前已经入队且带 dependency 的旧任务,领取逻辑仍保留兼容处理:按旧快照等待或取消,不将旧私信突然并行发送。
### 批次领取
新增 `Store.claim_batch(worker)`
1. 使用 `BEGIN IMMEDIATE` 串行化领取事务;
2. 拒绝账号未绑定、归属不符、规则关闭或已有 running 任务;
3. 选出最早可执行任务;
4. 若同一 worker/source/event/target 下还有无依赖的 pending 兄弟任务,一并改为 running;
5. 在同一事务写入共享冷却时间;
6. 返回一个或两个任务。
旧数据库中的 `worker_single_running` 部分唯一索引会删除,因为合法并行批次需要同一小号同时有两个 running 任务。重复领取仍由 `BEGIN IMMEDIATE`、running 检查和状态更新保证;另一个 Store 连接在前一个事务提交后只能看到 running,不会再次领取。
保留 `Store.claim()` 供单任务测试和兼容调用;产品 Worker 使用 `claim_batch()`
### 同时执行和独立结果
`Engine.perform_batch()` 使用:
```python
await asyncio.gather(..., return_exceptions=True)
```
关注和私信协程同时启动。单项抛出异常时:
- 该项记为 `unknown / INTERRUPTED_CHECK_MANUALLY`
- 另一项不会被取消,继续完成;
- 两项分别调用 `Store.finish()` 保存状态和耗时;
- unknown 仍需用户人工核对,不自动重发。
默认 30 秒设置改名为“不同任务批次间隔”。Worker 只在领取下一个通知批次前检查它;同一批次内部不插入 sleep,也不等待关注结果。
停止业务仍等待在途并行批次完成,再退出循环和保留浏览器,避免关闭过程中把两项静默丢失。程序意外中断时,启动恢复会把仍为 running 的两项都改为 unknown。
## 可观察日志
0.1.5 的按日日志新增:
- `[冷却开始]`:任务已实际领取、组共享冷却开始、冷却秒数;
- `[冷却跳过]`:仍在冷却期及剩余秒数;
- `[冷却跳过]`:已有 pending/running/unknown 时不重复排队;
- `[并行批次]`:同一通知的关注和私信同时开始;
- 两条独立 `[任务开始]` 和两条独立 `[任务结果]`
日志继续不保存 Cookie、签名、私信正文、原始响应、完整通知 ID 或完整目标 UID。UI 仍只展示最近 2000 条,完整日志按本地日期保留在:
```text
%LOCALAPPDATA%\DouyinAccounts\logs\YYYY-MM-DD.log
```
## 数据库升级
0.1.6 启动时:
1. `CREATE TABLE IF NOT EXISTS cooldowns`
2. `DROP INDEX IF EXISTS worker_single_running`
3. 所有账号规则缺少 `cooldown` 时补为 14400
4.`require_follow` 统一改为 false。
旧账号、UID 绑定、归属、通知、任务、日志和浏览器目录均保留。旧任务的 params 快照不批量改写。
## 测试
### Python 回归
Linux 和 Windows 完整 pytest 均:
```text
64 passed
```
新增或调整的核心验证:
- 缺少 cooldown 的旧规则升级为 14400 秒;
- 旧 require_follow 升级为 false
- cooldowns 表自动创建;
- bool 等非法类型被拒绝,0 可关闭冷却;
- 通知入队但未领取时不写冷却;
- 同 UID 已有 pending 任务时,新通知不重复创建;
- 领取关注/私信批次后只写一条组共享冷却;
- 同组第二个小号不能绕过冷却;
- 不同大号组对同 UID 互不影响;
- SQLite 重开后冷却仍有效;
- 4 小时到期后新通知可再次创建任务;
- cooldown=0 时前一批完成后新事件可再次创建;
- 双动作批次同时变成 running;
- 一个动作 failed、另一个 unknown 时结果独立,不生成自动重试;
- 程序中断后两个 running 任务均恢复为 unknown
- Worker 测试使用闸门:关注和私信必须都进入协程后才放行,证明不是串行;
- 点击停止时,在途并行批次完成后才返回;关注和私信各调用一次且均落库成功。
### Windows 浏览器与打包
- Windows pytest64 项通过。
- fingerprint-chromium/Patchright 离线矩阵全部通过:端口/目录隔离、固定指纹种子、SDK 详情请求、64 位通知 ID、登录回填、重连、错误身份拒绝、小号删除和兄弟账号隔离等均保持。
- 矩阵 `real_write_actions=0`,未使用真实账号执行关注或私信。
- 打包后 GUI smokeQt、核心模块、SQLite、Patchright 和内置浏览器路径均通过;临时账号数 0、真实写动作 0。
- 6 个相关 Python 文件 LSP error 检查为 0。
- 本地 23 个 Python 源文件 SHA256 与 Windows `build-manifest.json` 全部一致。
本次没有使用真实账号验证并行写操作,避免产生未授权关注或私信。并发调度通过可控制的异步闸门测试验证,关注与私信各自的 SDK 适配继续由原有独立测试覆盖。
## 发布
```text
C:\Users\rogee\Desktop\抖音账号助手-0.1.6\
DouyinAccounts-0.1.6-Windows-x64-Setup.exe
SHA256SUMS.txt
使用说明.txt
```
- 安装包大小:222,651,315 字节。
- SHA256`6ab893fc2c7de8dbad4dcca43339eb2a2919d340f7b3ef6fe6b4a84cf2c038bc`
- 桌面副本已重新计算校验和。
- 浏览器与依赖版本不变:fingerprint-chromium 148.0.7778.215、Patchright 1.62.3、Python 3.12.10。
未代用户安装或启动真实业务。覆盖安装后,建议先打开大号规则确认“同组同 UID 冷却”为 240 分钟,再手动启动账号组。旧版已排队任务仍按原历史快照处理;新通知使用并行与冷却规则。
+2 -2
View File
@@ -2,14 +2,14 @@
[Setup]
AppId={{C9C3EE3B-3666-4E41-A52B-BB309838F701}
AppName=抖音账号助手
AppVersion=0.1.5
AppVersion=0.1.6
DefaultDirName={localappdata}\Programs\DouyinAccounts
DefaultGroupName=抖音账号助手
PrivilegesRequired=lowest
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
OutputDir=release
OutputBaseFilename=DouyinAccounts-0.1.5-Windows-x64-Setup
OutputBaseFilename=DouyinAccounts-0.1.6-Windows-x64-Setup
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
+21 -16
View File
@@ -311,6 +311,17 @@ class Engine:
return "failed", {"code": result["status_code"]}
return "unknown", {"code": "SDK_RESULT_UNCONFIRMED"}
async def perform_batch(self, session, tasks):
results = await asyncio.gather(
*(self.perform(session, task) for task in tasks), return_exceptions=True
)
return [
("unknown", {"code": "INTERRUPTED_CHECK_MANUALLY"})
if isinstance(result, BaseException)
else result
for result in results
]
async def worker_loop(self, ident):
session = None
while ident in self.desired:
@@ -349,23 +360,17 @@ class Engine:
self.state(ident, f"等待动作间隔({interval} 秒)")
await self.pause(ident, 1)
continue
task = self.store.claim(ident)
if task:
self.state(ident, "正在执行任务")
started = time.monotonic()
try:
status, result = await self.perform(session, task)
except Exception:
status, result = (
"unknown",
{"code": "INTERRUPTED_CHECK_MANUALLY"},
)
self.store.finish(
task["id"],
status,
result,
round((time.monotonic() - started) * 1000),
tasks = self.store.claim_batch(ident)
if tasks:
self.state(
ident,
"正在并行执行关注和私信" if len(tasks) > 1 else "正在执行任务",
)
started = time.monotonic()
results = await self.perform_batch(session, tasks)
elapsed = round((time.monotonic() - started) * 1000)
for task, (status, result) in zip(tasks, results, strict=True):
self.store.finish(task["id"], status, result, elapsed)
self.last_write[ident] = time.monotonic()
else:
self.state(ident, "等待组内任务")
+117 -36
View File
@@ -13,6 +13,9 @@ from follow_user import validate_uid
def validate_rule(rule):
rule = dict(rule)
rule["require_follow"] = False
rule.setdefault("cooldown", 14400)
kinds = rule.get("kinds", [])
if not isinstance(kinds, list) or any(
k not in ("digg", "follow", "comment", "general_notice") for k in kinds
@@ -27,10 +30,10 @@ def validate_rule(rule):
raise ValueError("启用规则必须选择通知类型及动作")
if rule["dm"] and not rule["text"].strip():
raise ValueError("私信正文不能为空")
if rule["require_follow"] and not (rule["follow"] and rule["dm"]):
raise ValueError("先关注成功再私信必须同时启用两个动作")
if type(rule.get("interval")) is not int or not 5 <= rule["interval"] <= 86400:
raise ValueError("执行间隔需为 5..86400 秒")
if type(rule["cooldown"]) is not int or not 0 <= rule["cooldown"] <= 31536000:
raise ValueError("同 UID 冷却需为 0..31536000 秒")
return rule
@@ -42,6 +45,7 @@ DEFAULT_RULE = {
"require_follow": False,
"text": "",
"interval": 30,
"cooldown": 14400,
}
@@ -91,7 +95,11 @@ class Store:
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','running','succeeded','failed','unknown','cancelled')),
result TEXT NOT NULL DEFAULT '', updated REAL NOT NULL,
UNIQUE(event,worker,target,action));
CREATE UNIQUE INDEX IF NOT EXISTS worker_single_running ON tasks(worker) WHERE status='running';
CREATE TABLE IF NOT EXISTS cooldowns (
source TEXT NOT NULL REFERENCES accounts(id), target TEXT NOT NULL,
last_at REAL NOT NULL, event INTEGER NOT NULL REFERENCES events(id),
PRIMARY KEY(source,target));
DROP INDEX IF EXISTS worker_single_running;
""")
self.migrate_accounts()
if "deleted" not in {
@@ -110,6 +118,9 @@ class Store:
self.db.execute(
"ALTER TABLE inbox ADD COLUMN next_retry_at REAL NOT NULL DEFAULT 0"
)
for row in self.db.execute("SELECT id,rule FROM accounts").fetchall():
rule = validate_rule(decode(row["rule"]))
self.db.execute("UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), row["id"]))
self.db.executescript("""
CREATE TRIGGER IF NOT EXISTS owner_insert BEFORE INSERT ON accounts
WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main')
@@ -304,7 +315,7 @@ class Store:
def set_rule(self, ident, rule):
if self.account(ident)["role"] != "main":
raise ValueError("只有大号可配置规则")
validate_rule(rule)
rule = validate_rule(rule)
self.db.execute(
"UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), ident)
)
@@ -316,8 +327,9 @@ class Store:
通知类型=",".join(rule["kinds"]),
关注=rule["follow"],
私信=rule["dm"],
先关注=rule["require_follow"],
双动作="关注和私信并行" if rule["follow"] and rule["dm"] else "单动作",
动作间隔秒=rule["interval"],
同组UID冷却秒=rule["cooldown"],
)
def move(self, worker, owner):
@@ -587,37 +599,64 @@ class Store:
target=target,
)
continue
dependency = None
active = self.db.execute(
"SELECT status FROM tasks WHERE source=? AND target=? AND status IN ('pending','running','unknown') ORDER BY id DESC LIMIT 1",
(source, target),
).fetchone()
if active:
skipped += 1
self.log(
"冷却跳过",
"同组同 UID 已有待执行、执行中或待核对任务,不重复排队",
source=source,
event=event["id"],
target=target,
现有状态=active["status"],
)
continue
cooldown = rule.get("cooldown", 14400)
previous = self.db.execute(
"SELECT last_at FROM cooldowns WHERE source=? AND target=?",
(source, target),
).fetchone()
now = time.time()
if cooldown and previous and previous["last_at"] + cooldown > now:
skipped += 1
self.log(
"冷却跳过",
"同组同 UID 仍在冷却期,本通知不创建任务",
source=source,
event=event["id"],
target=target,
剩余秒=max(1, round(previous["last_at"] + cooldown - now)),
)
continue
for action in ("follow", "dm"):
if not rule[action]:
continue
cur = self.db.execute(
"INSERT INTO tasks(event,source,worker,target,action,params,dependency,updated) VALUES (?,?,?,?,?,?,?,?)",
"INSERT INTO tasks(event,source,worker,target,action,params,dependency,updated) VALUES (?,?,?,?,?,?,NULL,?)",
(
event["id"],
source,
worker["id"],
target,
action,
event["rule"],
dependency if rule["require_follow"] else None,
time.time(),
json.dumps(rule),
now,
),
)
created += 1
self.log(
"任务入队",
"已创建任务,等待执行",
"已创建任务,等待执行;同一通知的关注和私信将并行",
source=source,
worker=worker["id"],
event=event["id"],
task=cur.lastrowid,
target=target,
action=action,
前置任务=dependency if rule["require_follow"] else None,
)
if action == "follow":
dependency = cur.lastrowid
self.log(
"分配完成",
"分配流程已完成;不代表动作已执行",
@@ -632,7 +671,7 @@ class Store:
)
self.db.execute("UPDATE accounts SET rr=? WHERE id=?", (rr, source))
def claim(self, worker):
def _claim(self, worker, batch):
with self.transaction():
account = self.account(worker)
if (
@@ -640,14 +679,14 @@ class Store:
or account["owner"] is None
or account["uid"] is None
):
return None
return []
owner = self.account(account["owner"])
if owner["uid"] is None or not decode(owner["rule"])["enabled"]:
return None
return []
if self.db.execute(
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (worker,)
).fetchone():
return None
return []
cancelled = self.db.execute(
"""UPDATE tasks SET status='cancelled',result='前置关注未成功',updated=?
WHERE worker=? AND status='pending' AND dependency IN
@@ -657,20 +696,20 @@ class Store:
for row in cancelled:
self.log(
"任务取消",
"前置关注未成功,取消依赖步骤,不重发",
"前置关注未成功,取消旧版依赖步骤,不重发",
worker=worker,
source=owner["id"],
task=row["id"],
event=row["event"],
action=row["action"],
)
task = self.db.execute(
first = self.db.execute(
"""SELECT t.* FROM tasks t WHERE worker=? AND source=? AND status='pending'
AND (dependency IS NULL OR EXISTS(SELECT 1 FROM tasks d WHERE d.id=t.dependency AND d.status='succeeded'))
ORDER BY id LIMIT 1""",
(worker, account["owner"]),
).fetchone()
if task is None:
if first is None:
blocked = self.db.execute(
"SELECT t.id,t.dependency,d.status FROM tasks t JOIN tasks d ON d.id=t.dependency WHERE t.worker=? AND t.source=? AND t.status='pending' ORDER BY t.id LIMIT 1",
(worker, account["owner"]),
@@ -679,7 +718,7 @@ class Store:
if key and self._dependency_wait.get(worker) != key:
self.log(
"依赖等待",
"前置关注尚未确认成功;待核对不会自动重发",
"旧版前置关注尚未确认成功;待核对不会自动重发",
worker=worker,
source=owner["id"],
task=blocked["id"],
@@ -687,23 +726,65 @@ class Store:
前置状态=blocked["status"],
)
self._dependency_wait[worker] = key
return None
return []
self._dependency_wait.pop(worker, None)
self.db.execute(
tasks = [first]
if batch and first["dependency"] is None:
tasks = self.db.execute(
"""SELECT * FROM tasks WHERE worker=? AND source=? AND event=? AND target=?
AND status='pending' AND dependency IS NULL ORDER BY id""",
(worker, first["source"], first["event"], first["target"]),
).fetchall()
now = time.time()
self.db.executemany(
"UPDATE tasks SET status='running',updated=? WHERE id=?",
(time.time(), task["id"]),
[(now, task["id"]) for task in tasks],
)
self.log(
"任务开始",
"已领取任务,即将发起动作;中断后需人工核对,不自动重发",
source=task["source"],
worker=worker,
event=task["event"],
task=task["id"],
target=task["target"],
action=task["action"],
)
return dict(task)
cooldown = decode(first["params"]).get("cooldown", 14400)
if cooldown:
self.db.execute(
"""INSERT INTO cooldowns(source,target,last_at,event) VALUES (?,?,?,?)
ON CONFLICT(source,target) DO UPDATE SET last_at=excluded.last_at,event=excluded.event""",
(first["source"], first["target"], now, first["event"]),
)
self.log(
"冷却开始",
"任务已实际领取;从现在起组内所有小号共享该 UID 冷却",
source=first["source"],
worker=worker,
event=first["event"],
target=first["target"],
冷却秒=cooldown,
)
if len(tasks) > 1:
self.log(
"并行批次",
"同一通知的关注和私信同时开始,互不依赖且不等待动作间隔",
source=first["source"],
worker=worker,
event=first["event"],
target=first["target"],
任务数=len(tasks),
)
for task in tasks:
self.log(
"任务开始",
"已领取任务,即将发起动作;中断后需人工核对,不自动重发",
source=task["source"],
worker=worker,
event=task["event"],
task=task["id"],
target=task["target"],
action=task["action"],
)
return [dict(task) for task in tasks]
def claim(self, worker):
tasks = self._claim(worker, False)
return tasks[0] if tasks else None
def claim_batch(self, worker):
return self._claim(worker, True)
def finish(self, task_id, status, result, elapsed_ms=None):
if status not in ("succeeded", "failed", "unknown"):
+11 -5
View File
@@ -421,12 +421,11 @@ class Window(QMainWindow):
return
rule = decode(account["rule"])
win, form, buttons = dialog("自动操作规则 — " + account["name"], self)
enabled, follow, dm, require = [QCheckBox() for _ in range(4)]
enabled, follow, dm = [QCheckBox() for _ in range(3)]
for widget, key, label in [
(enabled, "enabled", "启用自动操作"),
(follow, "follow", "执行关注"),
(dm, "dm", "发送私信"),
(require, "require_follow", "必须关注成功后才发送私信"),
]:
widget.setChecked(rule[key])
form.addRow(label, widget)
@@ -447,9 +446,15 @@ class Window(QMainWindow):
interval = QSpinBox()
interval.setRange(5, 86400)
interval.setValue(rule["interval"])
form.addRow("小号动作间隔(秒)", interval)
form.addRow("不同任务批次间隔(秒)", interval)
cooldown = QSpinBox()
cooldown.setRange(0, 525600)
cooldown.setSpecialValueText("关闭")
cooldown.setSuffix(" 分钟")
cooldown.setValue(rule.get("cooldown", 14400) // 60)
form.addRow("同组同 UID 冷却", cooldown)
warning = QLabel(
"每条通知只分配给一个小号,组内轮询。已排队任务保留原规则快照;关闭规则会暂停执行。\n启用后只处理新通知,结果不确定不会重发;请确保行为获得授权并符合平台规则"
"每条通知只分配给一个小号,组内轮询。同一 UID 的冷却由整个大号组共享,默认 240 分钟;冷却从任务实际开始时计算。\n关注和私信同时开启时会并行执行、结果互不依赖,不等待批次间隔。已排队任务保留原规则快照;结果不确定不会重发"
)
warning.setWordWrap(True)
form.addRow(warning)
@@ -459,10 +464,11 @@ class Window(QMainWindow):
"enabled": enabled.isChecked(),
"follow": follow.isChecked(),
"dm": dm.isChecked(),
"require_follow": require.isChecked(),
"require_follow": False,
"kinds": [k for k, v in kinds.items() if v.isChecked()],
"text": text.toPlainText(),
"interval": interval.value(),
"cooldown": cooldown.value() * 60,
}
try:
validate_rule(value)
+114 -35
View File
@@ -29,7 +29,7 @@ def rule(**overrides):
"enabled": True,
"follow": True,
"dm": True,
"require_follow": True,
"require_follow": False,
"text": "offline-only-test",
**overrides,
}
@@ -61,6 +61,11 @@ class StoreTest(unittest.TestCase):
validate_rule(rule(text=""))
with self.assertRaises(ValueError):
validate_rule(rule(interval=True))
with self.assertRaises(ValueError):
validate_rule(rule(cooldown=True))
normalized = validate_rule({k: v for k, v in rule(require_follow=True).items() if k != "cooldown"})
self.assertFalse(normalized["require_follow"])
self.assertEqual(normalized["cooldown"], 14400)
self.assertFalse(json.loads(self.store.account(self.other)["rule"])["enabled"])
with self.assertRaises(ValueError):
self.store.add("worker", "坏UID", "01")
@@ -134,23 +139,68 @@ class StoreTest(unittest.TestCase):
with self.assertRaises(sqlite3.IntegrityError):
self.store.add("worker", "不能归属小号", owner=self.worker)
def test_dedup_round_robin_and_dependency(self):
def test_dedup_round_robin_and_parallel_batch(self):
second = self.store.add("worker", "第二小号", "202", self.main)
for nid in ("111", "111", "112", "113"):
self.store.ingest(self.main, notice("101", nid))
for nid, target in (("111", "901"), ("111", "901"), ("112", "902"), ("113", "903")):
self.store.ingest(self.main, notice("101", nid, target))
tasks = sorted(self.store.tasks(), key=lambda t: t["id"])
self.assertEqual(len(tasks), 6)
self.assertEqual(
[tasks[i]["worker"] for i in (0, 2, 4)], [self.worker, second, self.worker]
)
first = self.store.claim(self.worker)
assert first is not None
self.assertEqual(first["action"], "follow")
batch = self.store.claim_batch(self.worker)
self.assertEqual({task["action"] for task in batch}, {"follow", "dm"})
self.assertEqual({task["status"] for task in self.store.tasks() if task["id"] in {t["id"] for t in batch}}, {"running"})
self.assertIsNone(self.store.claim(self.worker))
self.store.finish(first["id"], "succeeded", {})
second_task = self.store.claim(self.worker)
assert second_task is not None
self.assertEqual(second_task["action"], "dm")
self.store.finish(batch[0]["id"], "failed", {})
self.store.finish(batch[1]["id"], "unknown", {})
next_batch = self.store.claim_batch(self.worker)
self.assertEqual({task["target"] for task in next_batch}, {"903"})
def test_group_cooldown_persists_is_shared_and_other_groups_are_independent(self):
second = self.store.add("worker", "第二小号", "202", self.main)
other_worker = self.store.add("worker", "其他组小号", "203", self.other)
self.store.set_rule(self.other, rule())
with patch("account_store.time.time", return_value=100):
self.store.ingest(self.main, notice("101", "111", "999"))
self.assertEqual(
self.store.db.execute("SELECT count(*) FROM cooldowns").fetchone()[0], 0
)
# A second notification before execution cannot duplicate the queued bundle.
self.store.ingest(self.main, notice("101", "112", "999"))
self.assertEqual(len(self.store.tasks()), 2)
batch = self.store.claim_batch(self.worker)
self.assertEqual(len(batch), 2)
self.assertEqual(
self.store.db.execute("SELECT last_at FROM cooldowns WHERE source=? AND target='999'", (self.main,)).fetchone()[0], 100
)
for task in batch:
self.store.finish(task["id"], "succeeded", {})
# The same UID in a different main-account group is independent.
self.store.ingest(self.other, notice("102", "211", "999"))
self.assertEqual(len([t for t in self.store.tasks() if t["worker"] == other_worker]), 2)
self.store.close()
self.store = Store(self.temp.name)
with patch("account_store.time.time", return_value=200):
self.store.ingest(self.main, notice("101", "113", "999"))
self.assertEqual(len([t for t in self.store.tasks() if t["source"] == self.main]), 2)
with patch("account_store.time.time", return_value=14501):
self.store.ingest(self.main, notice("101", "114", "999"))
new_tasks = [t for t in self.store.tasks() if t["source"] == self.main]
self.assertEqual(len(new_tasks), 4)
self.assertEqual({t["worker"] for t in new_tasks[:2]}, {second})
def test_zero_cooldown_allows_later_event_after_first_bundle_finishes(self):
self.store.set_rule(self.main, rule(cooldown=0))
self.store.ingest(self.main, notice("101", "111", "999"))
batch = self.store.claim_batch(self.worker)
for task in batch:
self.store.finish(task["id"], "succeeded", {})
self.store.ingest(self.main, notice("101", "112", "999"))
self.assertEqual(len(self.store.tasks()), 4)
self.assertEqual(
self.store.db.execute("SELECT count(*) FROM cooldowns").fetchone()[0], 0
)
def test_move_cancels_only_pending_and_is_atomic(self):
self.store.ingest(self.main, notice("101"))
@@ -169,28 +219,27 @@ class StoreTest(unittest.TestCase):
self.store.move(self.worker, None)
self.assertIsNone(self.store.account(self.worker)["owner"])
def test_restart_running_becomes_unknown_never_resends(self):
def test_restart_running_batch_becomes_unknown_never_resends(self):
self.store.ingest(self.main, notice("101"))
first = self.store.claim(self.worker)
assert first is not None
batch = self.store.claim_batch(self.worker)
self.assertEqual(len(batch), 2)
self.store.close()
self.store = Store(self.temp.name)
self.store.recover()
self.assertIsNone(self.store.claim(self.worker))
self.store.resolve(first["id"], True)
second_task = self.store.claim(self.worker)
assert second_task is not None
self.assertEqual(second_task["action"], "dm")
self.assertEqual({t["status"] for t in self.store.tasks()}, {"unknown"})
self.assertEqual(self.store.claim_batch(self.worker), [])
for task in batch:
self.store.resolve(task["id"], True)
self.assertEqual(self.store.claim_batch(self.worker), [])
def test_unknown_failed_and_no_auto_retry(self):
def test_parallel_results_are_independent_and_never_resend(self):
self.store.ingest(self.main, notice("101"))
first = self.store.claim(self.worker)
assert first is not None
self.store.finish(first["id"], "failed", {})
self.assertIsNone(self.store.claim(self.worker))
self.assertEqual(
{t["status"] for t in self.store.tasks()}, {"failed", "cancelled"}
)
batch = self.store.claim_batch(self.worker)
self.assertEqual(len(batch), 2)
self.store.finish(batch[0]["id"], "failed", {})
self.store.finish(batch[1]["id"], "unknown", {})
self.assertEqual(self.store.claim_batch(self.worker), [])
self.assertEqual({t["status"] for t in self.store.tasks()}, {"failed", "unknown"})
def test_disabled_and_cross_group_identity(self):
with self.assertRaises(ValueError):
@@ -236,6 +285,21 @@ class StoreTest(unittest.TestCase):
).fetchone()
self.assertEqual(tuple(row), ("pending", 20, 400))
def test_rule_migration_adds_default_cooldown_and_disables_old_dependency(self):
legacy = rule(require_follow=True)
legacy.pop("cooldown")
self.store.db.execute(
"UPDATE accounts SET rule=? WHERE id=?", (json.dumps(legacy), self.main)
)
self.store.close()
self.store = Store(self.temp.name)
migrated = json.loads(self.store.account(self.main)["rule"])
self.assertEqual(migrated["cooldown"], 14400)
self.assertFalse(migrated["require_follow"])
self.assertIsNotNone(
self.store.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cooldowns'").fetchone()
)
def test_legacy_inbox_migration_preserves_pending_ids(self):
self.store.record_push(self.main, ["111"])
self.store.db.executescript("""
@@ -663,26 +727,41 @@ class EngineTest(unittest.IsolatedAsyncioTestCase):
worker.page.is_closed = lambda: False
self.engine.sessions[self.main] = main
self.engine.open = AsyncMock(return_value=worker)
entered = asyncio.Event()
both_entered = asyncio.Event()
release = asyncio.Event()
entered = 0
async def action(*args):
entered.set()
async def action(kind, *args):
nonlocal entered
entered += 1
if entered == 2:
both_entered.set()
await release.wait()
return {"status": "succeeded"}
return {"status": "succeeded"} if kind == "follow" else {
"success": True,
"message": {"client_id": "client", "server_id": "server"},
}
worker.follow.side_effect = action
async def follow_action(*args, **kwargs):
return await action("follow", *args)
async def dm_action(*args, **kwargs):
return await action("dm", *args)
worker.follow.side_effect = follow_action
worker.im.side_effect = dm_action
runner = asyncio.create_task(self.engine.worker_loop(self.worker))
self.engine.runners[self.worker] = runner
await asyncio.wait_for(entered.wait(), 2)
await asyncio.wait_for(both_entered.wait(), 2)
stopping = asyncio.create_task(self.engine.stop())
await asyncio.sleep(0.02)
self.assertFalse(stopping.done())
release.set()
await asyncio.wait_for(stopping, 2)
tasks = self.engine.store.tasks()
self.assertEqual({t["status"] for t in tasks}, {"succeeded", "pending"})
worker.im.assert_not_called()
self.assertEqual({t["status"] for t in tasks}, {"succeeded"})
self.assertEqual(worker.follow.await_count, 1)
self.assertEqual(worker.im.await_count, 1)
if __name__ == "__main__":
+10 -12
View File
@@ -127,17 +127,14 @@ def test_business_stages_skips_dependencies_and_rollback():
assert "1234567890123456789" not in all_logs(audit)
store.record_push(main, ["2"])
store.ingest(main, notification("2"))
task = store.claim(worker)
assert task is not None
store.finish(task["id"], "unknown", {"code": "UNCONFIRMED"}, 45)
assert store.claim(worker) is None
before = audit.sequence
assert store.claim(worker) is None
assert audit.sequence == before, "相同依赖等待不应反复刷日志"
store.resolve(task["id"], True)
task = store.claim(worker)
assert task is not None and task["action"] == "dm"
store.finish(task["id"], "succeeded", {"status_code": 0}, 80)
batch = store.claim_batch(worker)
assert {task["action"] for task in batch} == {"follow", "dm"}
follow = next(task for task in batch if task["action"] == "follow")
dm = next(task for task in batch if task["action"] == "dm")
store.finish(follow["id"], "unknown", {"code": "UNCONFIRMED"}, 45)
store.finish(dm["id"], "succeeded", {"status_code": 0}, 80)
assert store.claim_batch(worker) == []
store.resolve(follow["id"], True)
text = all_logs(audit)
for stage in (
"通知接收",
@@ -150,7 +147,8 @@ def test_business_stages_skips_dependencies_and_rollback():
"任务入队",
"任务开始",
"任务结果",
"依赖等待",
"冷却开始",
"并行批次",
"人工核对",
):
assert f"[{stage}]" in text