fix(web): 审核回归 — 5 项修复 (渠道写入丢失/斗鱼WS生命周期/归属校验/恢复订阅/画像key稳定)
B1 routers/huya.py: 批量 Web 登录 login_channel/status 回填在 helper 内部 commit 之后赋值且未再提交 → 丢失; 补 db.commit() 并统一 status=active B2 routers/login.py + services/login_service.py: 斗鱼登录 WS 断线即 pop 批次, 刷新后无法重连订阅 → 对齐虎牙模式 (BatchRegistry.mark_finished, 仅结束后清理) B3 routers/login.py: WS 增加批次归属校验 (非 view_all 仅可订阅自建批次) S1 LoginTasksPage: effectiveBatchId 从任务列表推导活跃批次, 刷新后停止按钮/ 日志订阅自动恢复 S4 services/huya_service.py: username 仅在为空时回填, 防止 udb_passport 覆盖 登录名导致一号一设备绑定 key 漂移 验证: 77 后端单测 OK; tsc + vite build OK
This commit is contained in:
@@ -932,7 +932,11 @@ def password_login_selected_accounts(
|
||||
tag=account.tag or "",
|
||||
username_hint=username,
|
||||
)
|
||||
# save_huya_login_cookie_to_account 内部已 commit,渠道/状态回填需再次提交,
|
||||
# 否则 get_db 关闭会话时丢弃(GUI 登录渠道列会显示"仅导入")。
|
||||
saved.status = "active"
|
||||
saved.login_channel = "web"
|
||||
db.commit()
|
||||
success_count += 1
|
||||
results.append({
|
||||
"line": account.id,
|
||||
|
||||
@@ -185,6 +185,10 @@ def stop_batch(
|
||||
):
|
||||
batch = batch_registry.get(batch_id)
|
||||
if batch:
|
||||
if batch.get("finished"):
|
||||
# 已结束但 WS 尚未清理(或从未有 WS 订阅):回收注册表条目。
|
||||
batch_registry.pop(batch_id)
|
||||
raise HTTPException(status_code=404, detail="批次已结束")
|
||||
batch["runner"].stop()
|
||||
return {"message": "已发送停止信号", "success": True}
|
||||
raise HTTPException(status_code=404, detail="批次不存在或已结束")
|
||||
@@ -199,6 +203,21 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||
await websocket.close(code=1008, reason="未授权")
|
||||
return
|
||||
|
||||
# 归属校验:非 view_all 只能订阅自己创建的批次(与虎牙批次一致)。
|
||||
if not user_has_permission(user, "login:view_all"):
|
||||
check_db = SessionLocal()
|
||||
try:
|
||||
owned = (
|
||||
check_db.query(LoginTask.id)
|
||||
.filter(LoginTask.batch_id == batch_id, LoginTask.created_by == user.id)
|
||||
.first()
|
||||
)
|
||||
finally:
|
||||
check_db.close()
|
||||
if not owned:
|
||||
await websocket.close(code=1008, reason="无权访问该任务批次")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
|
||||
# 从已注册的批次中获取 log_queue(由 create_batch 创建)
|
||||
@@ -224,4 +243,7 @@ async def ws_login_logs(websocket: WebSocket, batch_id: str):
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
batch_registry.pop(batch_id)
|
||||
# 只在批次真正结束后清理注册表,客户端断线/刷新页面后重连仍可继续订阅。
|
||||
latest = batch_registry.get(batch_id)
|
||||
if latest and latest.get("finished"):
|
||||
batch_registry.pop(batch_id)
|
||||
|
||||
@@ -229,7 +229,10 @@ def _upsert_huya_account(db: Session, parsed: dict, tag: str = "", status: str |
|
||||
else:
|
||||
account.uid = parsed["uid"] or account.uid
|
||||
account.yyuid = parsed["yyuid"] or account.yyuid
|
||||
account.username = parsed["username"] or account.username
|
||||
# 已存在账号保留原有 username(登录名/设备画像 key),避免重复导入或登录
|
||||
# 前后 udb_passport 差异导致一号一设备绑定断裂。
|
||||
if not account.username:
|
||||
account.username = parsed["username"] or ""
|
||||
account.cookie = parsed["cookie"]
|
||||
account.game_phone = parsed["game_phone"] or account.game_phone
|
||||
if tag:
|
||||
@@ -254,7 +257,11 @@ def save_huya_login_cookie_to_account(
|
||||
|
||||
account.uid = parsed["uid"] or account.uid
|
||||
account.yyuid = parsed["yyuid"] or account.yyuid
|
||||
account.username = parsed["username"] or account.username
|
||||
# 保留已有 username(登录名): 设备画像/指纹状态目录按登录用户名分账号隔离,
|
||||
# 若被 cookie 里的 udb_passport 覆盖, 下一次登录会拿到不同的 key → 生成全新设备,
|
||||
# 一号一设备绑定断裂且绑定页出现两条记录。仅在无用户名时回填。
|
||||
if not account.username:
|
||||
account.username = parsed["username"] or ""
|
||||
account.cookie = parsed["cookie"]
|
||||
account.game_phone = parsed["game_phone"] or account.game_phone
|
||||
if tag:
|
||||
|
||||
@@ -467,6 +467,8 @@ class LoginBatchRunner:
|
||||
self._push_log("info", f"批量{action_name}任务 {batch_id} 完成")
|
||||
self._push_log("result", "")
|
||||
finally:
|
||||
# 标记批次结束:WS 端据此决定何时清理注册表(断线重连可继续订阅日志)。
|
||||
batch_registry.mark_finished(batch_id)
|
||||
# 确保 DB Session 被关闭,避免连接泄漏
|
||||
self.db.close()
|
||||
|
||||
@@ -485,11 +487,24 @@ class BatchRegistry:
|
||||
"loop": loop,
|
||||
"runner": runner,
|
||||
"owner_id": owner_id,
|
||||
"finished": False,
|
||||
"finished_at": None,
|
||||
}
|
||||
|
||||
def get(self, batch_id: str):
|
||||
return self._batches.get(batch_id)
|
||||
|
||||
def mark_finished(self, batch_id: str):
|
||||
"""标记批次已结束(幂等;不在本注册表的批次为无操作)。
|
||||
|
||||
与虎牙批次一致:WS 端只在 finished 后 pop,客户端断线重连仍可订阅
|
||||
到运行中批次的实时日志。
|
||||
"""
|
||||
batch = self._batches.get(batch_id)
|
||||
if batch:
|
||||
batch["finished"] = True
|
||||
batch["finished_at"] = time.time()
|
||||
|
||||
def pop(self, batch_id: str):
|
||||
return self._batches.pop(batch_id, None)
|
||||
|
||||
|
||||
@@ -213,6 +213,27 @@ export default function LoginTasksPage() {
|
||||
[tasks],
|
||||
);
|
||||
|
||||
// 刷新/切页后 batchId 内存态丢失:从任务列表推导仍在运行的批次,
|
||||
// 保证停止按钮与日志订阅在页面恢复后依然可用。
|
||||
const activeBatchIdFromTasks = useMemo(
|
||||
() => tasks.find((task) => ['pending', 'running'].includes(task.status))?.batch_id || null,
|
||||
[tasks],
|
||||
);
|
||||
const effectiveBatchId = batchId || activeBatchIdFromTasks;
|
||||
|
||||
// 活跃批次存在但未订阅时自动重连实时日志(配合后端 WS 仅在批次结束后清理注册表)。
|
||||
const wsSubscribedBatchRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!effectiveBatchId || wsConnected) return;
|
||||
if (wsSubscribedBatchRef.current === effectiveBatchId) return;
|
||||
wsSubscribedBatchRef.current = effectiveBatchId;
|
||||
connectLogs(`/api/login/ws/login/${effectiveBatchId}`, {
|
||||
onClose: () => { setBatchId(null); setStarting(false); },
|
||||
onResult: () => { setBatchId(null); setStarting(false); },
|
||||
onError: () => { setBatchId(null); setStarting(false); },
|
||||
});
|
||||
}, [effectiveBatchId, wsConnected, connectLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTags();
|
||||
void loadTasks();
|
||||
@@ -249,6 +270,7 @@ export default function LoginTasksPage() {
|
||||
mode,
|
||||
});
|
||||
setBatchId(result.batch_id);
|
||||
wsSubscribedBatchRef.current = result.batch_id;
|
||||
message.success(`已创建${mode === 'check' ? '检测' : '登录'}任务,共 ${result.count} 个账号`);
|
||||
|
||||
connectLogs(`/api/login/ws/login/${result.batch_id}`, {
|
||||
@@ -287,9 +309,9 @@ export default function LoginTasksPage() {
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (batchId) {
|
||||
if (effectiveBatchId) {
|
||||
try {
|
||||
await loginApi.stop(batchId);
|
||||
await loginApi.stop(effectiveBatchId);
|
||||
message.success('已发送停止信号');
|
||||
} catch (e: unknown) {
|
||||
message.error(getErrorMessage(e));
|
||||
@@ -522,11 +544,11 @@ export default function LoginTasksPage() {
|
||||
>
|
||||
开始登录
|
||||
</Button>
|
||||
{batchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop} size="small">
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{effectiveBatchId && (
|
||||
<Button danger icon={<StopOutlined />} onClick={handleStop} size="small">
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -539,7 +561,7 @@ export default function LoginTasksPage() {
|
||||
<span>成功 <b style={{ color: token.colorSuccess }}>{successCount}</b></span>
|
||||
<span>失败 <b style={{ color: token.colorError }}>{failedCount}</b></span>
|
||||
{checkedCount > 0 && <span>检测 <b>{checkedCount}</b></span>}
|
||||
{batchId && <span>批次: <b>{batchId}</b></span>}
|
||||
{effectiveBatchId && <span>批次: <b>{effectiveBatchId}</b></span>}
|
||||
<div style={{ flex: 1 }} />
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 个任务?`} onConfirm={handleDeleteSelected} okText="删除" cancelText="取消">
|
||||
@@ -658,7 +680,7 @@ export default function LoginTasksPage() {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="登录整体重试次数"
|
||||
tooltip="单个账号最多重试多少轮(每轮换新代理)。0 表示无限重试直到成功或超时。"
|
||||
tooltip="单个账号最多重试多少轮(每轮换新代理)。0 表示使用后端默认上限 20 轮。"
|
||||
>
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
|
||||
Reference in New Issue
Block a user