Files
douyin-pc/src/account_store.py
T

987 lines
41 KiB
Python

"""SQLite task truth; each connection belongs to its calling thread."""
import json
import logging
import sqlite3
import time
import uuid
from contextlib import contextmanager
from pathlib import Path
from account_log import UI_LIMIT, visible
from follow_user import validate_uid
def validate_rule(rule):
rule = dict(rule)
rule["require_follow"] = False
rule.setdefault("cooldown", 14400)
rule.setdefault("work_mode", "all")
rule.setdefault("work_ids", [])
kinds = rule.get("kinds", [])
if not isinstance(kinds, list) or any(
k not in ("digg", "follow", "comment", "general_notice") for k in kinds
):
raise ValueError("请选择有效通知类型")
for flag in ("enabled", "follow", "dm", "require_follow"):
if type(rule.get(flag)) is not bool:
raise ValueError("规则开关必须为布尔值")
if not isinstance(rule.get("text"), str) or len(rule["text"]) > 1000:
raise ValueError("私信正文最多 1000 字")
if rule["enabled"] and (not kinds or not (rule["follow"] or rule["dm"])):
raise ValueError("启用规则必须选择通知类型及动作")
if rule["dm"] and not rule["text"].strip():
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 秒")
if rule["work_mode"] not in ("all", "selected"):
raise ValueError("作品监控模式无效")
if (
not isinstance(rule["work_ids"], list)
or len(rule["work_ids"]) > 50000
or any(
not isinstance(item, str) or not item.isascii() or not item.isdecimal()
for item in rule["work_ids"]
)
):
raise ValueError("作品 ID 列表无效")
rule["work_ids"] = list(dict.fromkeys(rule["work_ids"]))
return rule
DEFAULT_RULE = {
"enabled": False,
"kinds": ["follow"],
"follow": False,
"dm": False,
"require_follow": False,
"text": "",
"interval": 30,
"cooldown": 14400,
"work_mode": "all",
"work_ids": [],
}
def decode(value):
try:
return json.loads(value)
except (TypeError, ValueError) as exc:
raise ValueError("持久化数据损坏,请从备份恢复") from exc
def notice_work_id(notice):
values = [notice]
values.extend(
notice.get(key)
for key in ("comment", "digg", "favorite", "collect", "general_notice")
)
for value in values:
if not isinstance(value, dict):
continue
for candidate in (value, value.get("aweme"), value.get("item")):
if not isinstance(candidate, dict):
continue
ident = candidate.get("aweme_id") or candidate.get("awemeId")
if isinstance(ident, str) and ident.isascii() and ident.isdecimal():
return ident
if type(ident) is int and ident > 0:
return str(ident)
return ""
def notice_kind(notice):
if notice.get("comment"):
return "comment"
if notice.get("follow"):
return "follow"
if notice.get("digg"):
return "digg"
return "general_notice"
class Store:
def __init__(self, root, audit=None):
self.audit = audit
self._audit_pending = []
self._dispatch_wait = {}
self._dependency_wait = {}
self.root = Path(root).resolve()
self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
self.db = sqlite3.connect(
self.root / "accounts.sqlite3", isolation_level=None, timeout=10
)
self.db.row_factory = sqlite3.Row
self.db.execute("PRAGMA foreign_keys=ON")
self.db.execute("PRAGMA journal_mode=WAL")
self.db.executescript("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('main','worker')),
name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id),
avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '',
rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0,
profile TEXT NOT NULL DEFAULT '{}',
CHECK(role='worker' OR owner IS NULL));
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS inbox (
source TEXT REFERENCES accounts(id), nid TEXT, state TEXT NOT NULL DEFAULT 'pending',
retry_count INTEGER NOT NULL DEFAULT 0, next_retry_at REAL NOT NULL DEFAULT 0,
PRIMARY KEY(source,nid));
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY, source TEXT NOT NULL REFERENCES accounts(id), nid TEXT NOT NULL,
created REAL NOT NULL, targets TEXT NOT NULL, rule TEXT NOT NULL, state TEXT NOT NULL,
origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history')),
UNIQUE(source,nid));
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY, event INTEGER NOT NULL REFERENCES events(id),
source TEXT NOT NULL REFERENCES accounts(id), worker TEXT NOT NULL REFERENCES accounts(id),
target TEXT NOT NULL, action TEXT NOT NULL CHECK(action IN ('follow','dm')),
params TEXT NOT NULL, dependency INTEGER REFERENCES tasks(id),
priority INTEGER NOT NULL DEFAULT 100,
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 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 {
r["name"] for r in self.db.execute("PRAGMA table_info(accounts)")
}:
self.db.execute(
"ALTER TABLE accounts ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0"
)
columns = {r["name"] for r in self.db.execute("PRAGMA table_info(inbox)")}
event_columns = {
r["name"] for r in self.db.execute("PRAGMA table_info(events)")
}
task_columns = {r["name"] for r in self.db.execute("PRAGMA table_info(tasks)")}
with self.transaction():
if "retry_count" not in columns:
self.db.execute(
"ALTER TABLE inbox ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0"
)
if "next_retry_at" not in columns:
self.db.execute(
"ALTER TABLE inbox ADD COLUMN next_retry_at REAL NOT NULL DEFAULT 0"
)
if "origin" not in event_columns:
self.db.execute(
"ALTER TABLE events ADD COLUMN origin TEXT NOT NULL DEFAULT 'live' CHECK(origin IN ('live','history'))"
)
if "priority" not in task_columns:
self.db.execute(
"ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 100"
)
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')
BEGIN SELECT RAISE(ABORT,'owner must be main'); END;
CREATE TRIGGER IF NOT EXISTS owner_update BEFORE UPDATE OF owner ON accounts
WHEN NEW.owner IS NOT NULL AND NOT EXISTS(SELECT 1 FROM accounts WHERE id=NEW.owner AND role='main')
BEGIN SELECT RAISE(ABORT,'owner must be main'); END;
""")
def migrate_accounts(self):
columns = {r["name"]: r for r in self.db.execute("PRAGMA table_info(accounts)")}
if not columns["uid"]["notnull"]:
return
backup = sqlite3.connect(
self.root / ("before-onboarding-" + uuid.uuid4().hex + ".sqlite3")
)
try:
self.db.backup(backup)
finally:
backup.close()
self.db.execute("PRAGMA foreign_keys=OFF")
try:
with self.transaction():
self.db.execute("""CREATE TABLE accounts_new (
id TEXT PRIMARY KEY, role TEXT NOT NULL CHECK(role IN ('main','worker')),
name TEXT NOT NULL, uid TEXT UNIQUE, owner TEXT REFERENCES accounts(id),
avatar TEXT NOT NULL DEFAULT '', nickname TEXT NOT NULL DEFAULT '',
rule TEXT NOT NULL, rr INTEGER NOT NULL DEFAULT 0,
profile TEXT NOT NULL DEFAULT '{}', CHECK(role='worker' OR owner IS NULL))""")
self.db.execute("""INSERT INTO accounts_new(id,role,name,uid,owner,avatar,nickname,rule,rr)
SELECT id,role,name,uid,owner,avatar,nickname,rule,rr FROM accounts ORDER BY rowid""")
self.db.execute("DROP TABLE accounts")
self.db.execute("ALTER TABLE accounts_new RENAME TO accounts")
if self.db.execute("PRAGMA foreign_key_check").fetchone():
raise RuntimeError("账号升级校验失败,已回滚;原数据库备份已保留")
finally:
self.db.execute("PRAGMA foreign_keys=ON")
def log(self, stage, message, level=logging.INFO, **fields):
if self.audit is None:
return
labels = {
"source": "大号",
"worker": "小号",
"account": "账号",
"owner": "归属",
"event": "事件",
"task": "任务",
"nid": "通知标识",
"target": "目标",
"action": "动作",
}
values = {}
for key, value in fields.items():
if key in ("source", "worker", "account", "owner") and value:
row = self.db.execute(
"SELECT name,deleted FROM accounts WHERE id=?", (value,)
).fetchone()
value = (
(row["name"] + ("(已删除)" if row["deleted"] else ""))
if row
else "不存在的账号"
)
elif key == "action":
value = {"follow": "关注", "dm": "私信"}.get(value, value)
values[labels.get(key, key)] = value
entry = (stage, message, level, values)
if self.db.in_transaction:
self._audit_pending.append(entry)
else:
self.audit.record(stage, message, level, **values)
@contextmanager
def transaction(self):
self.db.execute("BEGIN IMMEDIATE")
start = len(self._audit_pending)
try:
yield
self.db.execute("COMMIT")
except BaseException:
self.db.execute("ROLLBACK")
del self._audit_pending[start:]
self._dispatch_wait.clear()
self._dependency_wait.clear()
raise
entries = self._audit_pending[start:]
del self._audit_pending[start:]
if self.audit is not None:
for stage, message, level, fields in entries:
self.audit.record(stage, message, level, **fields)
def close(self):
self.db.close()
def setting(self, key, default=None):
row = self.db.execute(
"SELECT value FROM settings WHERE key=?", (key,)
).fetchone()
return decode(row[0]) if row else default
def set_setting(self, key, value):
self.db.execute(
"INSERT OR REPLACE INTO settings VALUES (?,?)", (key, json.dumps(value))
)
def accounts(self, include_deleted=False):
return [
dict(r)
for r in self.db.execute(
"SELECT * FROM accounts WHERE deleted=0 OR ? ORDER BY rowid",
(include_deleted,),
)
]
def account(self, ident):
row = self.db.execute(
"SELECT * FROM accounts WHERE id=? AND deleted=0", (ident,)
).fetchone()
if row is None:
raise ValueError("账号不存在")
return dict(row)
def add(self, role, name, uid=None, owner=None):
if (
role not in ("main", "worker")
or not isinstance(name, str)
or not name.strip()
or len(name) > 80
):
raise ValueError("账号角色或名称无效")
if uid is not None:
validate_uid(uid)
ident = uuid.uuid4().hex
self.db.execute(
"INSERT INTO accounts(id,role,name,uid,owner,rule) VALUES (?,?,?,?,?,?)",
(ident, role, name.strip(), uid, owner, json.dumps(DEFAULT_RULE)),
)
self.log(
"账号添加",
"已创建独立账号,等待手动登录;业务默认不启动",
account=ident,
owner=owner,
)
return ident
def bind_profile(self, ident, profile):
uid = validate_uid(profile.get("uid"))
saved = visible(profile)
if not isinstance(saved, dict):
raise ValueError("账号资料格式无效")
saved["uid"] = uid
with self.transaction():
account = self.account(ident)
if account["uid"] is not None and account["uid"] != uid:
raise ValueError(
"当前登录账号与已绑定账号不同,请登录原账号;不会覆盖原 UID"
)
if self.db.execute(
"SELECT 1 FROM accounts WHERE uid=? AND id<>?", (uid, ident)
).fetchone():
raise ValueError("该抖音账号已添加,请在浏览器中切换到另一个账号")
if account["uid"] is None:
self.log(
"身份绑定",
"首次身份核验通过,绑定已锁定;业务资料按原文输出,认证凭据保持隐藏",
account=ident,
UID=uid,
平台资料=saved,
)
self.db.execute(
"UPDATE accounts SET uid=?,nickname=?,avatar=?,profile=? WHERE id=?",
(
uid,
profile.get("nickname") or "",
profile.get("avatar") or profile.get("avatar_url") or "",
json.dumps(saved, ensure_ascii=False),
ident,
),
)
def set_rule(self, ident, rule):
if self.account(ident)["role"] != "main":
raise ValueError("只有大号可配置规则")
rule = validate_rule(rule)
self.db.execute(
"UPDATE accounts SET rule=? WHERE id=?", (json.dumps(rule), ident)
)
self.log(
"规则更新",
"已保存;已入队任务保留原快照,业务字段按原文写日志",
source=ident,
启用=rule["enabled"],
通知类型=",".join(rule["kinds"]),
关注=rule["follow"],
私信=rule["dm"],
双动作="关注和私信并行" if rule["follow"] and rule["dm"] else "单动作",
动作间隔秒=rule["interval"],
同组UID冷却秒=rule["cooldown"],
作品监控模式=rule["work_mode"],
作品ID=rule["work_ids"],
私信正文=rule["text"],
)
def set_work_filter(self, ident, mode, ids):
rule = decode(self.account(ident)["rule"])
rule["work_mode"] = mode
rule["work_ids"] = ids
self.set_rule(ident, rule)
def move(self, worker, owner):
with self.transaction():
account = self.account(worker)
if account["role"] != "worker":
raise ValueError("只有小号可切换归属")
if owner is not None and self.account(owner)["role"] != "main":
raise ValueError("归属必须为大号")
if account["owner"] == owner:
return
if self.db.execute(
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (worker,)
).fetchone():
raise ValueError("正在执行任务,请等待结束后切换")
self.cancel_pending(worker, "归属变更,取消旧组待执行任务")
self.db.execute("UPDATE accounts SET owner=? WHERE id=?", (owner, worker))
self.log(
"归属变更",
"已变更归属;旧任务不改派、不重发",
worker=worker,
owner=owner or "",
)
def cancel_pending(self, worker, reason):
rows = self.db.execute(
"UPDATE tasks SET status='cancelled',result=?,updated=? WHERE worker=? AND status='pending' RETURNING id,event,source,target,action",
(reason, time.time(), worker),
).fetchall()
for row in rows:
self.log(
"任务取消",
reason,
worker=worker,
task=row["id"],
event=row["event"],
source=row["source"],
target=row["target"],
action=row["action"],
)
def delete_worker(self, ident):
"""Call only after the engine has safely stopped/closed/erased this profile."""
with self.transaction():
account = self.account(ident)
if account["role"] != "worker":
raise ValueError("此操作只支持删除小号")
if self.db.execute(
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (ident,)
).fetchone():
raise ValueError("正在执行任务,不能删除小号")
self.cancel_pending(ident, "小号已删除,取消待执行任务,不改派、不重发")
self.db.execute(
"UPDATE accounts SET deleted=1,uid=NULL,owner=NULL,nickname='',avatar='',profile='{}',rule=?,rr=0 WHERE id=?",
(json.dumps(DEFAULT_RULE), ident),
)
self.db.execute(
"DELETE FROM settings WHERE key IN (?,?)",
("fingerprint_browser:" + ident, "browser:" + ident),
)
self.log(
"小号删除",
"浏览器与登录目录已清除;账号已移除,历史任务和日志保留,允许重新添加原账号",
worker=ident,
)
def record_push(self, source, ids):
if self.account(source)["role"] != "main":
raise ValueError("通知来源必须为大号")
added = 0
with self.transaction():
for nid in ids:
validate_uid(nid)
cur = self.db.execute(
"INSERT OR IGNORE INTO inbox(source,nid) VALUES (?,?)",
(source, nid),
)
added += cur.rowcount
self.log(
"通知接收" if cur.rowcount else "通知去重",
"推送 ID 已持久化,等待详情"
if cur.rowcount
else "重复推送已忽略,不重复执行且不重置重试时间",
source=source,
nid=nid,
)
return added
def due_details(self, source):
return [
r["nid"]
for r in self.db.execute(
"SELECT nid FROM inbox WHERE source=? AND state='pending' AND next_retry_at<=? ORDER BY next_retry_at,rowid LIMIT 20",
(source, time.time()),
)
]
def defer_details(self, source, missing):
# ponytail: retain unavailable IDs indefinitely; add explicit discard/retention only if volume requires it.
now = time.time()
with self.transaction():
for nid in missing:
row = self.db.execute(
"SELECT retry_count FROM inbox WHERE source=? AND nid=? AND state='pending'",
(source, nid),
).fetchone()
if row is not None:
delay = min(30 * 2 ** min(row["retry_count"], 4), 300)
self.db.execute(
"UPDATE inbox SET retry_count=retry_count+1,next_retry_at=? WHERE source=? AND nid=? AND state='pending'",
(now + delay, source, nid),
)
self.log(
"详情延期",
"本次未返回该通知,保留 ID,不阻塞新通知",
logging.WARNING,
source=source,
nid=nid,
重试次数=row["retry_count"] + 1,
等待秒=delay,
下次允许重试=time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(now + delay)
),
)
def pending_details_count(self, source):
return self.db.execute(
"SELECT COUNT(*) FROM inbox WHERE source=? AND state='pending'", (source,)
).fetchone()[0]
def ingest(self, source, notice, origin="live"):
if origin not in ("live", "history"):
raise ValueError("事件来源无效")
account = self.account(source)
if account["role"] != "main" or str(notice.get("user_id")) != account["uid"]:
raise ValueError("通知所属身份不符")
nid = validate_uid(notice.get("nid_str") or str(notice.get("nid", "")))
kind = notice_kind(notice)
detail = (
notice.get(kind) or notice.get("favorite") or notice.get("collect") or {}
)
users = detail.get("from_user") or []
if isinstance(users, dict):
users = [users]
if not users and (detail.get("comment") or {}).get("user"):
users = [detail["comment"]["user"]]
targets = sorted({validate_uid(str(u.get("uid", ""))) for u in users})
rule = validate_rule(decode(account["rule"]))
work_id = notice_work_id(notice)
work_allowed = (
kind == "follow"
or rule["work_mode"] == "all"
or work_id in rule["work_ids"]
)
enabled = (
rule["enabled"] and kind in rule["kinds"] and bool(targets) and work_allowed
)
created = notice.get("create_time") or notice.get("createTime") or time.time()
if type(created) not in (int, float) or created <= 0:
created = time.time()
if created > 10_000_000_000:
created /= 1000
with self.transaction():
cur = self.db.execute(
"INSERT OR IGNORE INTO events(source,nid,created,targets,rule,state,origin) VALUES (?,?,?,?,?,?,?)",
(
source,
nid,
created,
json.dumps(targets),
json.dumps(rule),
"waiting" if enabled else "ignored",
origin,
),
)
event = self.db.execute(
"SELECT id,origin FROM events WHERE source=? AND nid=?", (source, nid)
).fetchone()
if not cur.rowcount and origin == "live" and event["origin"] == "history":
self.db.execute(
"UPDATE events SET origin='live' WHERE id=?", (event["id"],)
)
promoted = self.db.execute(
"UPDATE tasks SET priority=100 WHERE event=? AND status='pending'",
(event["id"],),
).rowcount
self.log(
"新消息优先",
"该通知此前从历史列表进入;已提升未执行任务优先级",
source=source,
event=event["id"],
nid=nid,
提升任务数=promoted,
)
if cur.rowcount:
reason = (
"规则匹配,等待组内分配"
if enabled
else "规则未启用,本通知不生成任务"
if not rule["enabled"]
else "通知类型未勾选,本通知不生成任务"
if kind not in rule["kinds"]
else "未提取到有效通知来源用户,不生成任务"
if not targets
else "通知不属于当前选择的作品,不生成任务"
)
self.log(
"详情入库",
"详情已取得且所属身份一致",
source=source,
nid=nid,
event=event["id"],
来源="实时通知" if origin == "live" else "历史列表",
通知类型={
"digg": "点赞",
"comment": "评论",
"follow": "关注",
"general_notice": "其他作品互动",
}[kind],
作品ID=work_id,
目标数量=len(targets),
业务数据=notice,
)
self.log(
"规则匹配" if enabled else "通知跳过",
reason,
source=source,
event=event["id"],
)
elif not (origin == "live" and event["origin"] == "history"):
self.log(
"详情去重",
"已有该通知,不重复生成事件或任务",
source=source,
event=event["id"],
nid=nid,
)
self.db.execute(
"UPDATE inbox SET state='done' WHERE source=? AND nid=?", (source, nid)
)
self._dispatch(source)
return bool(cur.rowcount)
def dispatch(self, source):
with self.transaction():
self._dispatch(source)
def _dispatch(self, source):
main = self.account(source)
events = self.db.execute(
"SELECT * FROM events WHERE source=? AND state='waiting' ORDER BY CASE origin WHEN 'live' THEN 0 ELSE 1 END,id",
(source,),
).fetchall()
if not events:
self._dispatch_wait.pop(source, None)
return
workers = list(
self.db.execute(
"SELECT id,uid FROM accounts WHERE owner=? AND role='worker' AND uid IS NOT NULL AND deleted=0 ORDER BY rowid",
(source,),
)
)
reason = (
"大号尚未绑定身份"
if main["uid"] is None
else "大号规则已关闭,保留待分配通知"
if not decode(main["rule"])["enabled"]
else "没有已绑定且归属本组的小号,保留待分配通知"
if not workers
else ""
)
if reason:
if self._dispatch_wait.get(source) != (reason, len(events)):
self.log("分配等待", reason, source=source, 等待通知数=len(events))
self._dispatch_wait[source] = (reason, len(events))
return
self._dispatch_wait.pop(source, None)
rr = main["rr"]
for event in events:
worker = workers[rr % len(workers)]
rr += 1
rule = decode(event["rule"])
created = skipped = 0
self.log(
"通知分配",
"按组内轮询选定一个小号",
source=source,
worker=worker["id"],
event=event["id"],
)
for target in decode(event["targets"]):
if target in (worker["uid"], main["uid"]):
skipped += 1
self.log(
"任务跳过",
"触发者就是执行小号自身,禁止关注自己或私信自己"
if target == worker["uid"]
else "目标是来源大号,安全规则禁止操作",
source=source,
worker=worker["id"],
event=event["id"],
target=target,
)
continue
active = self.db.execute(
"""SELECT t.id,t.event,t.status,e.origin FROM tasks t JOIN events e ON e.id=t.event
WHERE t.source=? AND t.target=? AND t.status IN ('pending','running','unknown') ORDER BY t.id""",
(source, target),
).fetchall()
if event["origin"] == "live":
history = [
row
for row in active
if row["status"] == "pending" and row["origin"] == "history"
]
for row in history:
self.db.execute(
"UPDATE tasks SET status='cancelled',result='新消息优先,取消未执行历史任务',updated=? WHERE id=? AND status='pending'",
(time.time(), row["id"]),
)
self.log(
"新消息优先",
"同 UID 新消息到达,取消尚未执行的历史任务,不重复打扰",
source=source,
event=event["id"],
task=row["id"],
target=target,
)
active = [row for row in active if row not in history]
if active:
skipped += 1
self.log(
"冷却跳过",
"同组同 UID 已有待执行、执行中或待核对任务,不重复排队",
source=source,
event=event["id"],
target=target,
现有状态=active[-1]["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,priority,updated) VALUES (?,?,?,?,?,?,NULL,?,?)",
(
event["id"],
source,
worker["id"],
target,
action,
json.dumps(rule),
100 if event["origin"] == "live" else 0,
now,
),
)
created += 1
self.log(
"任务入队",
"已创建任务,等待执行;同一通知的关注和私信将并行",
source=source,
worker=worker["id"],
event=event["id"],
task=cur.lastrowid,
target=target,
action=action,
)
self.log(
"分配完成",
"分配流程已完成;不代表动作已执行",
source=source,
worker=worker["id"],
event=event["id"],
生成任务数=created,
跳过目标数=skipped,
)
self.db.execute(
"UPDATE events SET state='dispatched' WHERE id=?", (event["id"],)
)
self.db.execute("UPDATE accounts SET rr=? WHERE id=?", (rr, source))
def _claim(self, worker, batch):
with self.transaction():
account = self.account(worker)
if (
account["role"] != "worker"
or account["owner"] is None
or account["uid"] is None
):
return []
owner = self.account(account["owner"])
if owner["uid"] is None or not decode(owner["rule"])["enabled"]:
return []
if self.db.execute(
"SELECT 1 FROM tasks WHERE worker=? AND status='running'", (worker,)
).fetchone():
return []
cancelled = self.db.execute(
"""UPDATE tasks SET status='cancelled',result='前置关注未成功',updated=?
WHERE worker=? AND status='pending' AND dependency IN
(SELECT id FROM tasks WHERE status IN ('failed','cancelled')) RETURNING id,event,action""",
(time.time(), worker),
).fetchall()
for row in cancelled:
self.log(
"任务取消",
"前置关注未成功,取消旧版依赖步骤,不重发",
worker=worker,
source=owner["id"],
task=row["id"],
event=row["event"],
action=row["action"],
)
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 priority DESC,id LIMIT 1""",
(worker, account["owner"]),
).fetchone()
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"]),
).fetchone()
key = tuple(blocked) if blocked else None
if key and self._dependency_wait.get(worker) != key:
self.log(
"依赖等待",
"旧版前置关注尚未确认成功;待核对不会自动重发",
worker=worker,
source=owner["id"],
task=blocked["id"],
前置任务=blocked["dependency"],
前置状态=blocked["status"],
)
self._dependency_wait[worker] = key
return []
self._dependency_wait.pop(worker, None)
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=?",
[(now, task["id"]) for task in tasks],
)
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"):
raise ValueError("无效执行结果")
task = self.db.execute(
"UPDATE tasks SET status=?,result=?,updated=? WHERE id=? AND status='running' RETURNING id,event,source,worker,target,action",
(status, json.dumps(result, ensure_ascii=False), time.time(), task_id),
).fetchone()
if task:
reason = {
"succeeded": "动作成功,结果已落库",
"failed": "动作明确失败,不自动重发",
"unknown": "结果不确定,必须人工核对;不会自动重发,依赖步骤继续等待",
}[status]
code = (
result.get("code", result.get("status_code"))
if isinstance(result, dict)
else None
)
self.log(
"任务结果",
reason,
logging.INFO if status == "succeeded" else logging.WARNING,
task=task_id,
event=task["event"],
source=task["source"],
worker=task["worker"],
target=task["target"],
action=task["action"],
耗时毫秒=elapsed_ms,
返回码=code,
)
def recover(self):
rows = self.db.execute(
"UPDATE tasks SET status='unknown',result='程序中断,先人工核对,不自动重发',updated=? WHERE status='running' RETURNING id,event,source,worker,action",
(time.time(),),
).fetchall()
for row in rows:
self.log(
"中断恢复",
"上次执行中断,已改为待核对,不自动重发",
logging.WARNING,
task=row["id"],
event=row["event"],
source=row["source"],
worker=row["worker"],
action=row["action"],
)
def resolve(self, task_id, succeeded):
task = self.db.execute(
"UPDATE tasks SET status=?,result='用户已人工核对确认',updated=? WHERE id=? AND status='unknown' RETURNING worker,source,event",
("succeeded" if succeeded else "failed", time.time(), task_id),
).fetchone()
if task:
self.log(
"人工核对",
"用户确认成功,依赖步骤可继续"
if succeeded
else "用户确认未成功,不重发",
task=task_id,
event=task["event"],
source=task["source"],
worker=task["worker"],
)
else:
self.log(
"人工核对",
"没有可确认的待核对任务,未修改记录",
logging.WARNING,
task=task_id,
)
def tasks(self):
return [
dict(r)
for r in self.db.execute(
"SELECT * FROM tasks ORDER BY id DESC LIMIT ?", (UI_LIMIT,)
)
]