feat: sync runtime multi-user web and login updates

This commit is contained in:
Rixuan Shao
2026-08-17 23:16:12 +08:00
parent 846ce88daf
commit ee441ff5a1
23 changed files with 1879 additions and 198 deletions
+3
View File
@@ -15,3 +15,6 @@ DouYinSparkFlow/core/protocol_sender_debug.mjs
DouYinSparkFlow/**/__pycache__/ DouYinSparkFlow/**/__pycache__/
DouYinSparkFlow/**/*.pyc DouYinSparkFlow/**/*.pyc
DouYinSparkFlow/config.json DouYinSparkFlow/config.json
.migration-backups/
DouYinSparkFlow/webui_users.json
+3
View File
@@ -7,3 +7,6 @@ logs/
usersData.json usersData.json
webui_settings.json webui_settings.json
config.json config.json
webui_users.json
.migration-backups/
+16 -4
View File
@@ -141,7 +141,21 @@ async def _wait_for_first_friend_or_empty(page):
return False return False
async def _wait_for_chat_or_login(page, timeout_seconds=30):
deadline = asyncio.get_running_loop().time() + timeout_seconds
while asyncio.get_running_loop().time() < deadline:
await _ensure_logged_in(page)
try:
if await page.locator("#sub-app").count() > 0:
return
except Exception:
pass
await asyncio.sleep(0.5)
raise RuntimeError("chat page did not load within timeout")
async def collect_friend_names(page): async def collect_friend_names(page):
await _wait_for_chat_or_login(page)
await _click_friends_tab(page) await _click_friends_tab(page)
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -217,12 +231,10 @@ async def fetch_account_friends(account):
context.set_default_timeout(120000) context.set_default_timeout(120000)
page = await context.new_page() page = await context.new_page()
await page.goto("https://creator.douyin.com/", wait_until="domcontentloaded", timeout=60000)
await context.add_cookies(cookies) await context.add_cookies(cookies)
await page.goto(CHAT_PAGE_URL, wait_until="domcontentloaded", timeout=60000) await page.goto(CHAT_PAGE_URL, wait_until="commit", timeout=30000)
await asyncio.sleep(2) await asyncio.sleep(1)
await _ensure_logged_in(page)
friends = await collect_friend_names(page) friends = await collect_friend_names(page)
return friends return friends
except RuntimeError: except RuntimeError:
+10
View File
@@ -1625,6 +1625,13 @@ def _manual_run_unsent_only():
return _is_manual_run() and os.getenv("SPARKFLOW_MANUAL_UNSENT_ONLY") == "1" return _is_manual_run() and os.getenv("SPARKFLOW_MANUAL_UNSENT_ONLY") == "1"
def _requested_account_refs():
raw = os.getenv("SPARKFLOW_ACCOUNT_REFS")
if raw is None:
return None
return {item.strip() for item in raw.split(",") if item.strip()}
def _unsent_retry_max_attempts(): def _unsent_retry_max_attempts():
raw_value = str(os.getenv("SPARKFLOW_UNSENT_RETRY_MAX_ATTEMPTS") or "3").strip() raw_value = str(os.getenv("SPARKFLOW_UNSENT_RETRY_MAX_ATTEMPTS") or "3").strip()
try: try:
@@ -2719,6 +2726,9 @@ async def _do_user_task_locked(browser, user, send_strategy, profile_config, fri
async def runTasks(): async def runTasks():
active_config = get_config(force_reload=True) active_config = get_config(force_reload=True)
all_user_data = get_userData(force_reload=True) all_user_data = get_userData(force_reload=True)
requested_refs = _requested_account_refs()
if requested_refs is not None:
all_user_data = [user for user in all_user_data if user.get("account_ref") in requested_refs]
active_user_data = [user for user in all_user_data if user.get("enabled", True)] active_user_data = [user for user in all_user_data if user.get("enabled", True)]
disabled_user_data = [user for user in all_user_data if not user.get("enabled", True)] disabled_user_data = [user for user in all_user_data if not user.get("enabled", True)]
+70 -17
View File
@@ -44,6 +44,7 @@ GENERIC_WWW_NAMES = {
class LoginDesktopManager: class LoginDesktopManager:
def __init__(self): def __init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._page_operation_lock = asyncio.Lock()
self.playwright = None self.playwright = None
self.context = None self.context = None
self.page = None self.page = None
@@ -242,13 +243,14 @@ class LoginDesktopManager:
if page: if page:
current_url = page.url current_url = page.url
try: if not self._page_operation_lock.locked():
result = await collect_login_result(page, self.context, timeout_ms=1000) try:
logged_in = True result = await collect_login_result(page, self.context, timeout_ms=1000)
username = result["username"] logged_in = True
unique_id = result["unique_id"] username = result["username"]
except Exception: unique_id = result["unique_id"]
pass except Exception:
pass
payload = { payload = {
"running": True, "running": True,
@@ -263,30 +265,73 @@ class LoginDesktopManager:
return dict(payload) return dict(payload)
async def open_login(self): async def open_login(self):
await self.refresh_login_qr() self.mark_activity()
try:
await asyncio.wait_for(self._page_operation_lock.acquire(), timeout=5)
except asyncio.TimeoutError as exc:
raise RuntimeError("login page is busy; retry shortly") from exc
try:
page = await self._get_active_page()
if page.url.startswith(REMOTE_LOGIN_URL):
return {"ok": True, "url": page.url}
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
try:
await page.goto(refresh_url, wait_until="commit", timeout=30000)
except Exception:
await self.stop(clear_profile=False)
await self.start()
page = await self._get_active_page()
await page.goto(refresh_url, wait_until="commit", timeout=30000)
return {"ok": True, "url": page.url}
finally:
self._page_operation_lock.release()
async def refresh_login_qr(self): async def refresh_login_qr(self):
self.mark_activity() self.mark_activity()
try:
await asyncio.wait_for(self._page_operation_lock.acquire(), timeout=5)
except asyncio.TimeoutError as exc:
raise RuntimeError("login page is busy; retry shortly") from exc
try:
return await self._refresh_login_qr_locked()
finally:
self._page_operation_lock.release()
async def _refresh_login_qr_locked(self):
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}" refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
try: try:
page = await self._get_active_page() page = await self._get_active_page()
await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000) await page.goto(refresh_url, wait_until="commit", timeout=30000)
except Exception: except Exception:
await self.reset() await self.reset()
page = await self._get_active_page() page = await self._get_active_page()
await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000) await page.goto(refresh_url, wait_until="commit", timeout=30000)
deadline = asyncio.get_running_loop().time() + 45 deadline = asyncio.get_running_loop().time() + 45
logged_in = False logged_in = False
qr_ready = False qr_ready = False
while asyncio.get_running_loop().time() < deadline: while asyncio.get_running_loop().time() < deadline:
qr = page.locator('img[class*="qrcode"]').first for selector in (
try: 'img[class*="qrcode"]',
if await qr.count() and await qr.is_visible(): 'img[src^="data:image/png;base64"]',
qr_ready = True ):
candidates = page.locator(selector)
for index in range(await candidates.count()):
qr = candidates.nth(index)
try:
if not await qr.is_visible():
continue
box = await qr.bounding_box()
if not box or box["width"] < 120 or box["height"] < 120:
continue
ratio = box["width"] / max(1, box["height"])
if 0.8 <= ratio <= 1.25:
qr_ready = True
break
except Exception:
pass
if qr_ready:
break break
except Exception:
pass
if "/creator-micro/" in page.url: if "/creator-micro/" in page.url:
logged_in = True logged_in = True
@@ -452,6 +497,12 @@ async def reset():
return {"ok": True} return {"ok": True}
@app.post("/close")
async def close():
await manager.stop(clear_profile=True)
return {"ok": True}
@app.post("/refresh-qr") @app.post("/refresh-qr")
async def refresh_qr(): async def refresh_qr():
return await manager.refresh_login_qr() return await manager.refresh_login_qr()
@@ -472,6 +523,8 @@ async def export():
@app.get("/qr") @app.get("/qr")
async def login_qr(): async def login_qr():
if manager._page_operation_lock.locked():
raise HTTPException(status_code=503, detail="login page is busy; retry shortly")
page = await manager._get_active_page() page = await manager._get_active_page()
expired = await page.locator('[class*="qrcode_expired"]').count() expired = await page.locator('[class*="qrcode_expired"]').count()
if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible(): if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible():
@@ -499,7 +552,7 @@ async def login_qr():
) )
except Exception: except Exception:
continue continue
raise HTTPException(status_code=404, detail="login QR code is not ready") raise HTTPException(status_code=202, detail="login QR code is still starting", headers={"Retry-After": "2"})
@app.get("/debug/screenshot") @app.get("/debug/screenshot")
+3 -1
View File
@@ -1,5 +1,7 @@
fastapi==0.115.6 fastapi==0.117.1
itsdangerous==2.2.0 itsdangerous==2.2.0
Jinja2==3.1.6 Jinja2==3.1.6
python-multipart==0.0.20 python-multipart==0.0.20
uvicorn==0.34.0 uvicorn==0.34.0
starlette==0.46.2
tzdata==2025.2
+3 -1
View File
@@ -1,7 +1,7 @@
certifi==2025.11.12 certifi==2025.11.12
charset-normalizer==3.4.4 charset-normalizer==3.4.4
colorama==0.4.6 colorama==0.4.6
fastapi==0.115.6 fastapi==0.117.1
greenlet==3.2.4 greenlet==3.2.4
httpx==0.28.1 httpx==0.28.1
idna==3.11 idna==3.11
@@ -21,3 +21,5 @@ typing_extensions==4.15.0
urllib3==2.5.0 urllib3==2.5.0
uvicorn==0.34.0 uvicorn==0.34.0
websockets==15.0.1 websockets==15.0.1
starlette==0.46.2
tzdata==2025.2
@@ -0,0 +1,121 @@
"""Idempotently initialize SparkFlow Web users and account ownership.
Usage (passwords can also be supplied through environment variables):
python scripts/migrate_web_users.py --zxb-password '...' --zcf-password '...'
"""
from __future__ import annotations
import argparse
import os
import shutil
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from utils.config import get_userData, save_userData
from webui.auth import hash_password
from webui.users import get_web_users, save_web_users
BINDINGS = {
"zxb": "头像是本人",
"zcf": "你成功捕捉一只野生妖孽",
}
def parse_args():
parser = argparse.ArgumentParser(description="Initialize SparkFlow Web users and account ownership")
parser.add_argument("--zxb-password", default=os.getenv("SPARKFLOW_ZXB_PASSWORD", ""))
parser.add_argument("--zcf-password", default=os.getenv("SPARKFLOW_ZCF_PASSWORD", ""))
parser.add_argument("--backup-dir", default="")
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
def backup_json_files(directory: Path):
directory.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
target = directory / stamp
target.mkdir(parents=True, exist_ok=True)
for relative in ("usersData.json", "webui_settings.json", "webui_users.json"):
source = ROOT / relative
if source.exists():
shutil.copy2(source, target / source.name)
return target
def main():
args = parse_args()
passwords = {"zxb": args.zxb_password, "zcf": args.zcf_password}
missing = [username for username, password in passwords.items() if not password]
if missing:
raise SystemExit("Missing password for: " + ", ".join(missing))
accounts = get_userData(force_reload=True)
changed = False
for account in accounts:
if not str(account.get("account_ref", "")).strip():
import uuid
account["account_ref"] = f"acc-{uuid.uuid4().hex}"
changed = True
matched = {}
for web_username, douyin_username in BINDINGS.items():
candidates = [account for account in accounts if str(account.get("username", "")).strip() == douyin_username]
if len(candidates) != 1:
raise SystemExit(
f"Expected exactly one Douyin account named {douyin_username!r} for {web_username}; found {len(candidates)}"
)
matched[web_username] = candidates[0]
users = get_web_users()
existing = {str(user.get("username", "")).casefold(): user for user in users}
assigned_by_other = {
ref: user["username"]
for user in users
for ref in user.get("account_refs", [])
if str(user.get("username", "")).casefold() not in BINDINGS
}
for web_username, account in matched.items():
ref = account["account_ref"]
owner = assigned_by_other.get(ref)
if owner and owner.casefold() != web_username.casefold():
raise SystemExit(f"Account {account['username']!r} is already assigned to {owner}")
item = existing.get(web_username.casefold())
if item is None:
item = {
"username": web_username,
"role": "user",
"password_hash": hash_password(passwords[web_username]),
"enabled": True,
"account_refs": [ref],
}
users.append(item)
else:
refs = list(dict.fromkeys(item.get("account_refs", [])))
if ref not in refs:
refs.append(ref)
item["account_refs"] = [ref]
item["enabled"] = True
changed = True
if args.dry_run:
print(f"accounts={len(accounts)} users={len(users)} matched=zxb,zcf changed={changed}")
return 0
backup_dir = Path(args.backup_dir) if args.backup_dir else ROOT / ".migration-backups"
backup_path = backup_json_files(backup_dir)
if changed:
save_userData(accounts)
save_web_users(users)
print(f"migration complete: users={len(users)} backup={backup_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -44,6 +44,11 @@ class ConfigContractTests(unittest.TestCase):
) )
self.assertEqual(0, result.returncode, result.stderr) self.assertEqual(0, result.returncode, result.stderr)
def test_default_schedule_timezone_resolves_without_fallback(self):
with patch.dict(os.environ, {"SPARKFLOW_TIMEZONE": ""}, clear=False):
schedule_timezone = tasks._schedule_timezone()
self.assertEqual("Asia/Shanghai", getattr(schedule_timezone, "key", None))
def test_profile_root_environment_override_wins(self): def test_profile_root_environment_override_wins(self):
with patch.dict(os.environ, {"SPARKFLOW_BROWSER_PROFILE_ROOT": "/tmp/sparkflow-profiles"}): with patch.dict(os.environ, {"SPARKFLOW_BROWSER_PROFILE_ROOT": "/tmp/sparkflow-profiles"}):
normalized = tasks._normalize_persistent_profile_config(config_module.DEFAULT_CONFIG) normalized = tasks._normalize_persistent_profile_config(config_module.DEFAULT_CONFIG)
+86
View File
@@ -0,0 +1,86 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from webui import login_lock, users
from webui.auth import hash_password
class MultiUserTests(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.users_path = Path(self.temp_dir.name) / "webui_users.json"
self.lock_path = Path(self.temp_dir.name) / "login-workspace.lock.json"
self.accounts = [
{"account_ref": "acc-1", "username": "头像是本人", "unique_id": "111", "targets": [], "enabled": True},
{"account_ref": "acc-2", "username": "你成功捕捉一只野生妖孽", "unique_id": "222", "targets": [], "enabled": True},
{"account_ref": "acc-3", "username": "管理员账号", "unique_id": "333", "targets": [], "enabled": True},
]
self.user_file_patch = patch.object(users, "USERS_FILE", self.users_path)
self.user_file_patch.start()
self.ensure_patch = patch.object(users, "get_userData", return_value=self.accounts)
self.ensure_patch.start()
self.save_accounts_patch = patch.object(users, "save_userData")
self.save_accounts_patch.start()
self.addCleanup(self.ensure_patch.stop)
self.addCleanup(self.save_accounts_patch.stop)
self.addCleanup(self.user_file_patch.stop)
self.addCleanup(self.temp_dir.cleanup)
def test_user_creation_auth_and_unique_assignment(self):
a, changed = users.ensure_account_refs(self.accounts)
self.assertFalse(changed)
ref = a[0]["account_ref"]
created = users.create_web_user("zxb", "zxb123456", account_refs=[ref])
self.assertEqual([ref], created["account_refs"])
identity = users.authenticate("zxb", "zxb123456")
self.assertEqual("user", identity["role"])
self.assertEqual([ref], identity["account_refs"])
self.assertIsNone(users.authenticate("zxb", "wrong"))
with self.assertRaises(users.UserStoreError):
users.create_web_user("zcf", "zcf123456", account_refs=[ref])
def test_visible_accounts_and_admin_reassignment(self):
accounts, _ = users.ensure_account_refs(self.accounts)
first_ref = accounts[0]["account_ref"]
second_ref = accounts[1]["account_ref"]
users.create_web_user("zxb", "secret", account_refs=[first_ref])
principal = {"role": "user", "account_refs": [first_ref]}
self.assertEqual([first_ref], [a["account_ref"] for a in users.get_visible_accounts(principal, accounts)])
users.update_web_user("zxb", account_refs=[second_ref])
self.assertEqual([second_ref], users.find_web_user("zxb")["account_refs"])
self.assertTrue(users.delete_web_user("zxb"))
self.assertEqual([], users.get_web_users())
def test_fifo_queue_promotes_after_active_release(self):
with patch.object(login_lock, "LOCK_PATH", self.lock_path):
first = login_lock.request_workspace(username="zxb", session_id="s1", account_ref="a1", mode="relogin")
second = login_lock.request_workspace(username="zcf", session_id="s2", account_ref="", mode="add")
self.assertEqual("active", first["state"])
self.assertEqual("queued", second["state"])
self.assertEqual("add", second["request"]["mode"])
self.assertEqual(1, second["position"])
self.assertEqual("queued", login_lock.workspace_status(username="zcf", session_id="s2")["state"])
released = login_lock.begin_release(username="zxb", session_id="s1", ticket=first["request"]["ticket"], account_ref="a1")
self.assertIsNotNone(released)
promoted = login_lock.finish_transition()
self.assertEqual("zcf", promoted["username"])
self.assertEqual("active", login_lock.workspace_status(username="zcf", session_id="s2")["state"])
def test_login_workspace_is_serialized_and_expires(self):
with patch.object(login_lock, "LOCK_PATH", self.lock_path), patch.object(login_lock, "LOCK_TTL_SECONDS", 1):
ok, lock = login_lock.acquire(username="zxb", session_id="s1", account_ref="a1")
self.assertTrue(ok)
self.assertTrue(login_lock.owns(lock, username="zxb", session_id="s1", account_ref="a1"))
blocked, current = login_lock.acquire(username="zcf", session_id="s2", account_ref="a2")
self.assertFalse(blocked)
self.assertEqual("zxb", current["username"])
self.assertTrue(login_lock.refresh(username="zxb", session_id="s1", account_ref="a1"))
self.assertTrue(login_lock.release(username="zxb", session_id="s1"))
self.assertIsNone(login_lock.get_lock())
if __name__ == "__main__":
unittest.main()
+34 -2
View File
@@ -11,10 +11,23 @@ from fastapi.testclient import TestClient
from core import tasks from core import tasks
from webui import app as app_module from webui import app as app_module
from webui import login_lock
from webui import ops from webui import ops
class WebUiSafetyTests(unittest.TestCase): class WebUiSafetyTests(unittest.TestCase):
def setUp(self):
try:
login_lock.LOCK_PATH.unlink()
except FileNotFoundError:
pass
def tearDown(self):
try:
login_lock.LOCK_PATH.unlink()
except FileNotFoundError:
pass
def test_windows_invalid_pid_probe_is_treated_as_dead(self): def test_windows_invalid_pid_probe_is_treated_as_dead(self):
error = OSError(errno.EINVAL, "invalid pid") error = OSError(errno.EINVAL, "invalid pid")
error.winerror = 87 error.winerror = 87
@@ -23,6 +36,19 @@ class WebUiSafetyTests(unittest.TestCase):
with patch.object(tasks.os, "kill", side_effect=error): with patch.object(tasks.os, "kill", side_effect=error):
self.assertFalse(tasks._pid_is_alive(999999)) self.assertFalse(tasks._pid_is_alive(999999))
def test_missing_optional_runtime_tools_do_not_log_warnings(self):
with (
patch.object(ops.subprocess, "run", side_effect=FileNotFoundError("missing")),
patch.object(ops.logger, "warning") as warning,
patch.object(ops.logger, "debug") as debug,
):
result = ops.run_command(["docker", "ps"])
self.assertEqual(1, result.returncode)
ops.read_crontab()
warning.assert_not_called()
self.assertGreaterEqual(debug.call_count, 2)
def test_stale_lock_inspection_does_not_delete_file(self): def test_stale_lock_inspection_does_not_delete_file(self):
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir) root = Path(temp_dir)
@@ -183,6 +209,8 @@ class WebUiSafetyTests(unittest.TestCase):
with ( with (
patch.object(app_module, "current_user", return_value="admin"), patch.object(app_module, "current_user", return_value="admin"),
patch.object(app_module, "get_login_lock", return_value={"username": "admin", "session_id": ""}),
patch.object(app_module, "owns_login_lock", return_value=True),
patch.object( patch.object(
app_module, app_module,
"fetch_login_desktop_asset", "fetch_login_desktop_asset",
@@ -205,6 +233,8 @@ class WebUiSafetyTests(unittest.TestCase):
upstream.read.return_value = b"fake-png" upstream.read.return_value = b"fake-png"
with ( with (
patch.object(app_module, "current_user", return_value="admin"), patch.object(app_module, "current_user", return_value="admin"),
patch.object(app_module, "get_login_lock", return_value={"username": "admin", "session_id": ""}),
patch.object(app_module, "owns_login_lock", return_value=True),
patch.object(app_module.urllib.request, "urlopen", return_value=upstream), patch.object(app_module.urllib.request, "urlopen", return_value=upstream),
): ):
response = client.get("/login-desktop/qr") response = client.get("/login-desktop/qr")
@@ -225,8 +255,10 @@ class WebUiSafetyTests(unittest.TestCase):
block_start = script.index('document.querySelectorAll(".login-desktop-open")') block_start = script.index('document.querySelectorAll(".login-desktop-open")')
block_end = script.index('document.querySelectorAll(".login-desktop-save")', block_start) block_end = script.index('document.querySelectorAll(".login-desktop-save")', block_start)
block = script[block_start:block_end] block = script[block_start:block_end]
self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")')) self.assertLess(block.index('window.open("about:blank"'), block.index('postForm("/login-desktop/open"'))
self.assertIn("refreshLoginQr(1800)", block) self.assertIn("refreshLoginQr(500)", block)
self.assertIn('data.state === "queued"', block)
self.assertIn("renderWorkspace(data.workspace)", block)
self.assertIn("retries - 1", script) self.assertIn("retries - 1", script)
self.assertIn('/login-desktop/qr/refresh', script) self.assertIn('/login-desktop/qr/refresh', script)
+19 -2
View File
@@ -3,6 +3,8 @@ import logging
import os import os
import secrets import secrets
import sys import sys
import tempfile
import uuid
from copy import deepcopy from copy import deepcopy
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
@@ -176,7 +178,20 @@ def _load_json_file(path, defaults=None):
def _save_json_file(path, data): def _save_json_file(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
finally:
try:
os.unlink(temp_name)
except FileNotFoundError:
pass
def get_config(force_reload=False): def get_config(force_reload=False):
@@ -230,6 +245,7 @@ def upsert_user_account(unique_id, username, cookies, targets, extra=None):
unique_id = normalize_unique_id(unique_id) unique_id = normalize_unique_id(unique_id)
accounts = get_userData(force_reload=True) accounts = get_userData(force_reload=True)
payload = { payload = {
"account_ref": f"acc-{uuid.uuid4().hex}",
"unique_id": unique_id, "unique_id": unique_id,
"username": username, "username": username,
"cookies": cookies, "cookies": cookies,
@@ -240,11 +256,12 @@ def upsert_user_account(unique_id, username, cookies, targets, extra=None):
for account in accounts: for account in accounts:
if normalize_unique_id(account.get("unique_id")) == unique_id: if normalize_unique_id(account.get("unique_id")) == unique_id:
payload["account_ref"] = account.get("account_ref") or payload["account_ref"]
if "enabled" not in payload: if "enabled" not in payload:
payload["enabled"] = account.get("enabled", True) payload["enabled"] = account.get("enabled", True)
account.update(payload) account.update(payload)
save_userData(accounts) save_userData(accounts)
return payload return account
if "enabled" not in payload: if "enabled" not in payload:
payload["enabled"] = True payload["enabled"] = True
+536 -73
View File
@@ -36,6 +36,7 @@ from webui.auth import (
clear_session, clear_session,
csrf_token, csrf_token,
current_user, current_user,
current_principal,
is_bootstrapped, is_bootstrapped,
is_https_request, is_https_request,
issue_session, issue_session,
@@ -43,6 +44,33 @@ from webui.auth import (
validate_csrf, validate_csrf,
verify_password, verify_password,
) )
from webui.users import (
UserStoreError,
account_by_ref,
account_by_unique_id,
all_assigned_refs,
can_access_account,
create_web_user,
delete_web_user,
ensure_account_refs,
get_visible_accounts,
get_web_users,
remove_account_refs_from_users,
update_web_user,
)
from webui.login_lock import (
begin_expiration as begin_login_expiration,
begin_force_reset as begin_login_force_reset,
begin_release as begin_login_release,
cancel_request as cancel_login_request,
finish_transition as finish_login_transition,
get_lock as get_login_lock,
get_workspace_state,
heartbeat as heartbeat_login,
owns as owns_login_lock,
request_workspace,
workspace_status,
)
from webui.ops import ( from webui.ops import (
TASK_ALREADY_RUNNING, TASK_ALREADY_RUNNING,
get_overview_snapshot, get_overview_snapshot,
@@ -283,23 +311,42 @@ async def _run_websocket_relays(*coroutines):
await asyncio.gather(*tasks, return_exceptions=True) await asyncio.gather(*tasks, return_exceptions=True)
def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "", display_name: str = "") -> tuple[dict, str]: def _dedupe_account_records(accounts: list[dict], *, unique_id: str, keep_ref: str) -> set[str]:
normalized = normalize_unique_id(unique_id)
removed_refs = set()
remaining = []
for account in accounts:
if normalize_unique_id(account.get("unique_id")) == normalized and str(account.get("account_ref", "")) != str(keep_ref):
ref = str(account.get("account_ref", "")).strip()
if ref:
removed_refs.add(ref)
continue
remaining.append(account)
if len(remaining) != len(accounts):
accounts[:] = remaining
if removed_refs:
remove_account_refs_from_users(removed_refs)
return removed_refs
def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "", relogin_account_ref: str = "", display_name: str = "") -> tuple[dict, str]:
unique_id = normalize_unique_id(login_result.get("unique_id")) unique_id = normalize_unique_id(login_result.get("unique_id"))
username = str(display_name or login_result.get("username") or "").strip() username = str(display_name or login_result.get("username") or "").strip()
cookies = list(login_result.get("cookies") or []) cookies = list(login_result.get("cookies") or [])
if not unique_id or not username or not cookies: if not unique_id or not username or not cookies:
raise RuntimeError("Exported login result is incomplete") raise RuntimeError("Exported login result is incomplete")
accounts = get_userData(force_reload=True) accounts, _ = ensure_account_refs(get_userData(force_reload=True))
if relogin_unique_id: if relogin_account_ref or relogin_unique_id:
target = find_account(accounts, relogin_unique_id) target = account_by_ref(accounts, relogin_account_ref) if relogin_account_ref else find_account(accounts, relogin_unique_id)
if not target: if not target:
raise RuntimeError("Target account not found for relogin") raise RuntimeError("Target account not found for relogin")
target["unique_id"] = unique_id target["unique_id"] = unique_id
target["username"] = username target["username"] = username
target["cookies"] = cookies target["cookies"] = cookies
target.setdefault("enabled", True) target.setdefault("enabled", True)
_dedupe_account_records(accounts, unique_id=unique_id, keep_ref=target.get("account_ref", ""))
save_userData(accounts) save_userData(accounts)
return target, "updated" return target, "updated"
@@ -308,10 +355,14 @@ def save_exported_login_result(login_result: dict, *, relogin_unique_id: str = "
existing["username"] = username existing["username"] = username
existing["cookies"] = cookies existing["cookies"] = cookies
existing.setdefault("enabled", True) existing.setdefault("enabled", True)
_dedupe_account_records(accounts, unique_id=unique_id, keep_ref=existing.get("account_ref", ""))
save_userData(accounts) save_userData(accounts)
return existing, "updated" return existing, "updated"
account = upsert_user_account(unique_id, username, cookies, []) account = upsert_user_account(unique_id, username, cookies, [])
accounts, _ = ensure_account_refs(get_userData(force_reload=True))
_dedupe_account_records(accounts, unique_id=unique_id, keep_ref=account.get("account_ref", ""))
save_userData(accounts)
return account, "created" return account, "created"
@@ -336,11 +387,17 @@ def create_app():
@asynccontextmanager @asynccontextmanager
async def lifespan(_app): async def lifespan(_app):
# Add stable ownership identifiers without changing existing account data.
ensure_account_refs()
result = sync_daily_schedule_from_config() result = sync_daily_schedule_from_config()
if result.returncode != 0: if result.returncode != 0:
logger.warning("Failed to synchronize the configured daily schedule: %s", result.stderr) logger.warning("Failed to synchronize the configured daily schedule: %s", result.stderr)
yield watchdog = asyncio.create_task(login_workspace_watchdog())
try:
yield
finally:
watchdog.cancel()
await asyncio.gather(watchdog, return_exceptions=True)
secure_cookie = str(os.getenv("SPARKFLOW_SESSION_COOKIE_SECURE") or "").strip().lower() in { secure_cookie = str(os.getenv("SPARKFLOW_SESSION_COOKIE_SECURE") or "").strip().lower() in {
"1", "1",
"true", "true",
@@ -375,6 +432,8 @@ def create_app():
"current_user": current_user(request), "current_user": current_user(request),
"csrf_token": csrf_token(request) if current_user(request) else "", "csrf_token": csrf_token(request) if current_user(request) else "",
"is_https": is_https_request(request), "is_https": is_https_request(request),
"principal": current_principal(request),
"is_admin": bool(current_principal(request) and current_principal(request).get("role") == "admin"),
"app_settings": public_app_settings(), "app_settings": public_app_settings(),
"login_desktop_public_url": login_desktop_public_url(request), "login_desktop_public_url": login_desktop_public_url(request),
} }
@@ -390,11 +449,64 @@ def create_app():
def redirect(path="/", status_code=303): def redirect(path="/", status_code=303):
return RedirectResponse(url=path, status_code=status_code) return RedirectResponse(url=path, status_code=status_code)
def principal(request):
resolved = current_principal(request)
if resolved:
return resolved
# Keep compatibility with older tests/signed sessions that only expose
# the legacy ``user`` value.
legacy_user = current_user(request)
admin_username = str(get_app_settings().get("admin_username", "admin")).strip() or "admin"
if legacy_user and str(legacy_user).casefold() == admin_username.casefold():
return {"username": admin_username, "role": "admin", "account_refs": [], "session_id": "", "enabled": True}
return None
def require_user(request): def require_user(request):
if not current_user(request): if not principal(request):
return redirect("/login") return redirect("/login")
return None return None
def require_admin(request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
if principal(request).get("role") != "admin":
return PlainTextResponse("Forbidden", status_code=403)
return None
def account_for_request(request, unique_id):
accounts, _ = ensure_account_refs(get_userData(force_reload=True))
account = account_by_unique_id(accounts, unique_id)
if not account:
return accounts, None, PlainTextResponse("Account not found", status_code=404)
if not can_access_account(principal(request), account):
return accounts, None, PlainTextResponse("Forbidden", status_code=403)
return accounts, account, None
def principal_account_refs(request):
current = principal(request)
if not current or current.get("role") == "admin":
return None
return list(current.get("account_refs", []))
def scoped_ops_snapshot(request):
refs = principal_account_refs(request)
snapshot = get_ops_snapshot(account_refs=refs)
if refs is not None:
# Do not place host/container state or global log tails into a
# normal user's rendered context.
snapshot["containers"] = []
snapshot["task_containers"] = []
snapshot["crontab"] = ""
snapshot["log_tail"] = []
snapshot["compose_root"] = ""
snapshot["compose_file"] = ""
snapshot["image_present"] = False
return snapshot
def scoped_overview_snapshot(request):
return get_overview_snapshot(account_refs=principal_account_refs(request))
def flash(request, message, level="info"): def flash(request, message, level="info"):
request.session["flash"] = {"message": message, "level": level} request.session["flash"] = {"message": message, "level": level}
@@ -403,7 +515,7 @@ def create_app():
@app.get("/debug-artifacts/{artifact_path:path}") @app.get("/debug-artifacts/{artifact_path:path}")
async def debug_artifact(request: Request, artifact_path: str): async def debug_artifact(request: Request, artifact_path: str):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
root = DEBUG_ARTIFACTS_DIR.resolve() root = DEBUG_ARTIFACTS_DIR.resolve()
@@ -452,12 +564,19 @@ def create_app():
form = await request.form() form = await request.form()
username = str(form.get("username", "")).strip() username = str(form.get("username", "")).strip()
password = str(form.get("password", "")) password = str(form.get("password", ""))
settings = get_app_settings(force_reload=True) from webui.users import authenticate
if username != settings["admin_username"] or not verify_password(password, settings["admin_password_hash"]):
identity = authenticate(username, password)
if not identity:
flash(request, "Invalid username or password.", "error") flash(request, "Invalid username or password.", "error")
return redirect("/login") return redirect("/login")
issue_session(request, username) issue_session(
request,
identity["username"],
role=identity["role"],
account_refs=identity.get("account_refs", []),
)
flash(request, "Signed in successfully.", "success") flash(request, "Signed in successfully.", "success")
return redirect("/") return redirect("/")
@@ -475,24 +594,116 @@ def create_app():
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
return JSONResponse( return JSONResponse(
get_overview_snapshot(), scoped_overview_snapshot(request),
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
@app.post("/account/password")
async def change_own_password(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
current = principal(request)
if current.get("role") == "admin":
flash(request, "请在系统设置中修改管理员密码。", "info")
return redirect("/")
password = str(form.get("new_password", ""))
confirm = str(form.get("confirm_password", ""))
if not password or password != confirm:
flash(request, "两次密码输入不一致。", "error")
return redirect("/")
try:
update_web_user(current["username"], password=password)
flash(request, "密码已修改,请重新登录。", "success")
clear_session(request)
return redirect("/login")
except UserStoreError as exc:
flash(request, str(exc), "error")
return redirect("/")
@app.post("/admin/users/create")
async def create_admin_user(request: Request):
maybe_redirect = require_admin(request)
if maybe_redirect:
return maybe_redirect
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
refs = [value for value in form.getlist("account_refs")] if hasattr(form, "getlist") else []
try:
create_web_user(
str(form.get("username", "")),
str(form.get("password", "")),
enabled=str(form.get("enabled", "")) == "on",
account_refs=refs,
)
flash(request, "普通用户已创建。", "success")
except UserStoreError as exc:
flash(request, str(exc), "error")
return redirect("/#user-management")
@app.post("/admin/users/{username}/update")
async def update_admin_user(request: Request, username: str):
maybe_redirect = require_admin(request)
if maybe_redirect:
return maybe_redirect
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
refs = [value for value in form.getlist("account_refs")] if hasattr(form, "getlist") else []
try:
update_web_user(
username,
new_username=str(form.get("new_username", "")).strip() or None,
password=str(form.get("password", "")) or None,
enabled=str(form.get("enabled", "")) == "on",
account_refs=refs,
)
flash(request, "普通用户已更新。", "success")
except UserStoreError as exc:
flash(request, str(exc), "error")
return redirect("/#user-management")
@app.post("/admin/users/{username}/delete")
async def delete_admin_user(request: Request, username: str):
maybe_redirect = require_admin(request)
if maybe_redirect:
return maybe_redirect
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403)
try:
if delete_web_user(username):
flash(request, "普通用户已删除,抖音账号数据未删除。", "success")
else:
flash(request, "普通用户不存在。", "error")
except UserStoreError as exc:
flash(request, str(exc), "error")
return redirect("/#user-management")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request): async def dashboard(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
current = principal(request)
accounts = get_visible_accounts(current, get_userData(force_reload=True))
return render_template( return render_template(
request, request,
"dashboard.html", "dashboard.html",
{ {
"flash": pop_flash(request), "flash": pop_flash(request),
"accounts": get_userData(force_reload=True), "accounts": accounts,
"runtime_config": get_config(force_reload=True), "runtime_config": get_config(force_reload=True) if current.get("role") == "admin" else {},
"ops": get_ops_snapshot(), "ops": scoped_ops_snapshot(request),
"principal": current,
"is_admin": current.get("role") == "admin",
"web_users": get_web_users() if current.get("role") == "admin" else [],
"all_accounts": get_userData(force_reload=True) if current.get("role") == "admin" else [],
}, },
) )
@@ -507,7 +718,7 @@ def create_app():
"send_console.html", "send_console.html",
{ {
"flash": pop_flash(request), "flash": pop_flash(request),
"ops": get_ops_snapshot(), "ops": scoped_ops_snapshot(request),
}, },
) )
@@ -524,8 +735,9 @@ def create_app():
username = str(form.get("username", "")).strip() username = str(form.get("username", "")).strip()
targets = extract_targets_from_form(form) targets = extract_targets_from_form(form)
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
account = find_account(accounts, unique_id) if access_error:
return access_error
if account: if account:
account["username"] = username or account.get("username", "") account["username"] = username or account.get("username", "")
account["targets"] = targets account["targets"] = targets
@@ -547,11 +759,9 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
account = find_account(accounts, unique_id) if access_error:
if not account: return access_error
flash(request, "Account not found.", "error")
return redirect("/")
account["enabled"] = not is_account_enabled(account) account["enabled"] = not is_account_enabled(account)
save_userData(accounts) save_userData(accounts)
@@ -572,10 +782,9 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"error": "Invalid CSRF token"}, status_code=403) return JSONResponse({"error": "Invalid CSRF token"}, status_code=403)
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
account = find_account(accounts, unique_id) if access_error:
if not account: return JSONResponse({"error": "Forbidden" if access_error.status_code == 403 else "Account not found."}, status_code=access_error.status_code)
return JSONResponse({"error": "Account not found."}, status_code=404)
try: try:
friends = await fetch_account_friends(account) friends = await fetch_account_friends(account)
@@ -594,7 +803,7 @@ def create_app():
@app.post("/accounts/{unique_id}/delete") @app.post("/accounts/{unique_id}/delete")
async def delete_account(request: Request, unique_id: str): async def delete_account(request: Request, unique_id: str):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -602,7 +811,9 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
if access_error:
return access_error
updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)] updated_accounts = [item for item in accounts if normalize_unique_id(item.get("unique_id")) != normalize_unique_id(unique_id)]
if len(updated_accounts) != len(accounts): if len(updated_accounts) != len(accounts):
save_userData(updated_accounts) save_userData(updated_accounts)
@@ -626,11 +837,9 @@ def create_app():
flash(request, "Target is required for retry.", "error") flash(request, "Target is required for retry.", "error")
return redirect("/ops/send-console") return redirect("/ops/send-console")
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
account = find_account(accounts, unique_id) if access_error:
if not account: return access_error
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
lock_status = task_run_lock_status() lock_status = task_run_lock_status()
if lock_status.get("running"): if lock_status.get("running"):
@@ -682,11 +891,9 @@ def create_app():
flash(request, "Target is required.", "error") flash(request, "Target is required.", "error")
return redirect("/ops/send-console") return redirect("/ops/send-console")
accounts = get_userData(force_reload=True) accounts, account, access_error = account_for_request(request, unique_id)
account = find_account(accounts, unique_id) if access_error:
if not account: return access_error
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
changed = mark_target_unconfirmed(account, target_name) changed = mark_target_unconfirmed(account, target_name)
if changed: if changed:
@@ -698,7 +905,7 @@ def create_app():
@app.post("/ops/reset-today-unconfirmed") @app.post("/ops/reset-today-unconfirmed")
async def reset_today_unconfirmed(request: Request): async def reset_today_unconfirmed(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -727,7 +934,7 @@ def create_app():
@app.post("/config") @app.post("/config")
async def save_runtime_config(request: Request): async def save_runtime_config(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -793,7 +1000,7 @@ def create_app():
@app.post("/settings") @app.post("/settings")
async def save_panel_settings(request: Request): async def save_panel_settings(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -832,7 +1039,8 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_task_now(force_all=True) refs = principal_account_refs(request)
pid = run_task_now(force_all=refs is None, account_refs=refs)
if pid == TASK_ALREADY_RUNNING: if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning") flash(request, "已有发送任务正在运行,本次补发全部对象没有启动。请等当前任务结束后再试。", "warning")
elif pid == -1: elif pid == -1:
@@ -851,7 +1059,8 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_failed_retry_now() refs = principal_account_refs(request)
pid = run_failed_retry_now(account_refs=refs)
if pid == TASK_ALREADY_RUNNING: if pid == TASK_ALREADY_RUNNING:
flash(request, "已有发送任务正在运行,本次补发未成功目标没有启动。请等当前任务结束后再试。", "warning") flash(request, "已有发送任务正在运行,本次补发未成功目标没有启动。请等当前任务结束后再试。", "warning")
elif pid == -1: elif pid == -1:
@@ -870,7 +1079,8 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return Response("Invalid CSRF token", status_code=403) return Response("Invalid CSRF token", status_code=403)
pid = run_unsent_retry_now() refs = principal_account_refs(request)
pid = run_unsent_retry_now(account_refs=refs)
if pid == TASK_ALREADY_RUNNING: if pid == TASK_ALREADY_RUNNING:
flash(request, "A send task is already running; unsent retry was not started.", "warning") flash(request, "A send task is already running; unsent retry was not started.", "warning")
elif pid == -1: elif pid == -1:
@@ -881,7 +1091,7 @@ def create_app():
@app.post("/ops/proxy/refresh") @app.post("/ops/proxy/refresh")
async def proxy_refresh(request: Request): async def proxy_refresh(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -895,7 +1105,7 @@ def create_app():
@app.post("/ops/proxy/restart") @app.post("/ops/proxy/restart")
async def proxy_restart(request: Request): async def proxy_restart(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -909,7 +1119,7 @@ def create_app():
@app.post("/ops/schedule") @app.post("/ops/schedule")
async def save_schedule(request: Request): async def save_schedule(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
@@ -927,7 +1137,7 @@ def create_app():
@app.get("/ops/logs", response_class=HTMLResponse) @app.get("/ops/logs", response_class=HTMLResponse)
async def logs_page(request: Request): async def logs_page(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_admin(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
return render_template( return render_template(
@@ -939,11 +1149,108 @@ def create_app():
}, },
) )
login_transition_lock = asyncio.Lock()
def _workspace_payload(request):
current = principal(request)
state = get_workspace_state()
mine = workspace_status(
username=current.get("username", "") if current else "",
session_id=current.get("session_id", "") if current else "",
)
active = state.get("active") or {}
is_owner = bool(current and owns_login_lock(
active,
username=current.get("username", ""),
session_id=current.get("session_id", ""),
))
return {
"state": mine.get("state", "closed"),
"position": mine.get("position", 0),
"ticket": mine.get("ticket", ""),
"remaining_seconds": mine.get("remaining_seconds", 0),
"queue_length": len(state.get("queue") or []),
"active": is_owner,
"active_username": active.get("username", "") if current and current.get("role") == "admin" else (current.get("username", "") if is_owner and current else ""),
}
async def _reset_and_promote(*, force=False, clear_queue=False):
"""Reset the shared browser profile, then activate the next queue item."""
async with login_transition_lock:
state = get_workspace_state()
if force:
transition = begin_login_force_reset(clear_queue=clear_queue)
else:
transition = begin_login_expiration()
state_after = get_workspace_state()
needs_reset = bool(transition or state_after.get("phase") == "resetting")
if not needs_reset:
return True, None
try:
try:
call_login_desktop("/close", method="POST", payload={}, timeout=60)
except RuntimeError:
# Older login-desktop images do not have /close; reset is
# still safe because it clears the temporary login profile.
call_login_desktop("/reset", method="POST", payload={}, timeout=120)
except RuntimeError as exc:
logger.error("Failed to reset login workspace: %s", exc)
return False, None
promoted = finish_login_transition()
if promoted:
try:
call_login_desktop("/open-login", method="POST", payload={}, timeout=90)
except RuntimeError as exc:
logger.error("Failed to open login workspace for queued user: %s", exc)
return False, promoted
return True, promoted
async def _expire_login_workspace():
return await _reset_and_promote()
async def login_workspace_watchdog():
"""Reap abandoned leases even when no browser request arrives."""
while True:
await asyncio.sleep(10)
try:
await _expire_login_workspace()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("login workspace watchdog failed")
def login_lock_owner(request):
current = principal(request)
active = get_login_lock()
if not current or not active:
return current, active, False
return current, active, owns_login_lock(
active,
username=current["username"],
session_id=current.get("session_id", ""),
)
def login_lock_required(request, *, api=False):
current, active, allowed = login_lock_owner(request)
if allowed:
return None
if api:
return JSONResponse({"ok": False, "error": "登录工作区当前未由本会话占用", "workspace": _workspace_payload(request)}, status_code=423)
return HTMLResponse(
"""<!doctype html><html lang='zh-CN'><meta charset='utf-8'><title>等待登录工作区</title>
<body style='font-family:sans-serif;padding:32px'><h2>登录工作区尚未分配</h2>
<p>请返回账号管理,点击对应抖音账号的“重新登录”。如果前面有其他用户,页面会自动排队等待。</p></body></html>""",
status_code=423,
)
@app.get("/login-desktop/proxy") @app.get("/login-desktop/proxy")
async def login_desktop_proxy_root(request: Request): async def login_desktop_proxy_root(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
lock_error = login_lock_required(request)
if lock_error:
return lock_error
return RedirectResponse(login_desktop_public_url(request), status_code=307) return RedirectResponse(login_desktop_public_url(request), status_code=307)
@app.get("/login-desktop/proxy/{asset_path:path}") @app.get("/login-desktop/proxy/{asset_path:path}")
@@ -951,6 +1258,9 @@ def create_app():
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
lock_error = login_lock_required(request)
if lock_error:
return lock_error
try: try:
status, headers, content = await asyncio.to_thread( status, headers, content = await asyncio.to_thread(
fetch_login_desktop_asset, fetch_login_desktop_asset,
@@ -963,9 +1273,18 @@ def create_app():
@app.websocket("/login-desktop/proxy/websockify") @app.websocket("/login-desktop/proxy/websockify")
async def login_desktop_proxy_websocket(websocket: WebSocket): async def login_desktop_proxy_websocket(websocket: WebSocket):
if not current_user(websocket): current = current_principal(websocket)
active = get_login_lock()
if not current:
await websocket.close(code=4401) await websocket.close(code=4401)
return return
if not owns_login_lock(
active,
username=current.get("username", ""),
session_id=current.get("session_id", ""),
):
await websocket.close(code=4423)
return
requested_protocols = [ requested_protocols = [
item.strip() item.strip()
@@ -1000,10 +1319,7 @@ def create_app():
else: else:
await websocket.send_text(message) await websocket.send_text(message)
await _run_websocket_relays( await _run_websocket_relays(client_to_upstream(), upstream_to_client())
client_to_upstream(),
upstream_to_client(),
)
except (ConnectionClosed, WebSocketDisconnect): except (ConnectionClosed, WebSocketDisconnect):
pass pass
except Exception as exc: except Exception as exc:
@@ -1016,20 +1332,42 @@ def create_app():
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return maybe_redirect return maybe_redirect
lock_error = login_lock_required(request)
if lock_error:
return lock_error
url = f"{login_desktop_api_url()}/qr" url = f"{login_desktop_api_url()}/qr"
try: try:
upstream_request = urllib.request.Request(url, method="GET") upstream_request = urllib.request.Request(url, method="GET")
content = await asyncio.to_thread( def read_qr_response():
lambda: urllib.request.urlopen(upstream_request, timeout=20).read() upstream = urllib.request.urlopen(upstream_request, timeout=20)
) try:
return Response( raw_headers = getattr(upstream, "headers", {})
content=content, try:
media_type="image/png", headers = dict(raw_headers)
headers={"Cache-Control": "no-store, max-age=0"}, except (TypeError, ValueError):
) headers = {}
return getattr(upstream, "status", 200), headers, upstream.read()
finally:
close = getattr(upstream, "close", None)
if close:
close()
upstream_status, upstream_headers, content = await asyncio.to_thread(read_qr_response)
if upstream_status == 202:
retry_after = upstream_headers.get("Retry-After", "2")
return JSONResponse(
{"ok": False, "state": "starting", "retry_after": int(retry_after or 2)},
status_code=202,
headers={"Retry-After": str(retry_after), "Cache-Control": "no-store"},
)
return Response(content=content, media_type="image/png", headers={"Cache-Control": "no-store, max-age=0"})
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
status = exc.code if exc.code in {404, 409} else 502 if exc.code in {404, 409, 202}:
return PlainTextResponse("login QR code is not ready", status_code=status) return JSONResponse(
{"ok": False, "state": "starting", "retry_after": 2},
status_code=202,
headers={"Retry-After": "2", "Cache-Control": "no-store"},
)
return PlainTextResponse("login QR service is unavailable", status_code=502)
except (urllib.error.URLError, TimeoutError): except (urllib.error.URLError, TimeoutError):
return PlainTextResponse("login QR service is unavailable", status_code=502) return PlainTextResponse("login QR service is unavailable", status_code=502)
@@ -1038,12 +1376,20 @@ def create_app():
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401) return JSONResponse({"redirect": "/login"}, status_code=401)
lock_error = login_lock_required(request, api=True)
if lock_error:
return lock_error
form = await request.form() form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403) return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
heartbeat_login(
username=principal(request)["username"],
session_id=principal(request).get("session_id", ""),
ticket=str(form.get("ticket", "")),
)
try: try:
payload = call_login_desktop("/refresh-qr", method="POST", payload={}, timeout=90) payload = call_login_desktop("/refresh-qr", method="POST", payload={}, timeout=90)
return JSONResponse({"ok": True, "result": payload}) return JSONResponse({"ok": True, "result": payload, "workspace": _workspace_payload(request)})
except RuntimeError as exc: except RuntimeError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503) return JSONResponse({"ok": False, "error": str(exc)}, status_code=503)
@@ -1052,12 +1398,41 @@ def create_app():
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
if maybe_redirect: if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401) return JSONResponse({"redirect": "/login"}, status_code=401)
await _expire_login_workspace()
try: try:
payload = call_login_desktop("/status") payload = call_login_desktop("/status")
payload["public_url"] = login_desktop_public_url(request) payload["public_url"] = login_desktop_public_url(request)
payload["workspace"] = _workspace_payload(request)
return JSONResponse(payload) return JSONResponse(payload)
except RuntimeError as exc: except RuntimeError as exc:
return JSONResponse({"ok": False, "error": str(exc), "public_url": login_desktop_public_url(request)}, status_code=503) return JSONResponse({"ok": False, "error": str(exc), "public_url": login_desktop_public_url(request), "workspace": _workspace_payload(request)}, status_code=503)
@app.get("/login-desktop/workspace-status")
async def login_desktop_workspace_status(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401)
await _expire_login_workspace()
return JSONResponse({"ok": True, "workspace": _workspace_payload(request)}, headers={"Cache-Control": "no-store"})
@app.post("/login-desktop/heartbeat")
async def login_desktop_heartbeat(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401)
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
await _expire_login_workspace()
current = principal(request)
ok = heartbeat_login(
username=current["username"],
session_id=current.get("session_id", ""),
ticket=str(form.get("ticket", "")),
)
if not ok:
return JSONResponse({"ok": False, "error": "登录工作区已释放,请重新申请"}, status_code=423)
return JSONResponse({"ok": True, "workspace": _workspace_payload(request)})
@app.post("/login-desktop/open") @app.post("/login-desktop/open")
async def login_desktop_open(request: Request): async def login_desktop_open(request: Request):
@@ -1067,12 +1442,64 @@ def create_app():
form = await request.form() form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403) return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
await _expire_login_workspace()
current = principal(request)
relogin_unique_id = str(form.get("relogin_unique_id", "")).strip()
requested_mode = str(form.get("mode", "")).strip().lower()
mode = requested_mode if requested_mode in {"add", "relogin"} else ("relogin" if relogin_unique_id else "add")
account_ref = ""
if relogin_unique_id:
_, account, access_error = account_for_request(request, relogin_unique_id)
if access_error:
return JSONResponse({"ok": False, "error": "无权操作该账号"}, status_code=access_error.status_code)
account_ref = account.get("account_ref", "")
mode = "relogin"
elif mode != "add":
return JSONResponse({"ok": False, "error": "重新登录已有账号时必须选择账号"}, status_code=400)
result = request_workspace(
username=current["username"],
session_id=current.get("session_id", ""),
account_ref=account_ref,
mode=mode,
)
if result["state"] == "full":
return JSONResponse({"ok": False, "error": "登录排队人数已满,请稍后重试"}, status_code=429)
if result["state"] == "queued":
return JSONResponse({"ok": True, "state": "queued", "workspace": _workspace_payload(request)}, status_code=202)
try: try:
call_login_desktop("/open-login", method="POST", payload={}, timeout=90) call_login_desktop("/open-login", method="POST", payload={}, timeout=90)
return JSONResponse({"ok": True, "public_url": login_desktop_public_url(request)}) return JSONResponse({"ok": True, "state": "active", "public_url": login_desktop_public_url(request), "workspace": _workspace_payload(request)})
except RuntimeError as exc: except RuntimeError as exc:
begin_login_release(username=current["username"], session_id=current.get("session_id", ""), ticket=result["request"].get("ticket", ""), account_ref=account_ref)
await _reset_and_promote()
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503) return JSONResponse({"ok": False, "error": str(exc)}, status_code=503)
@app.post("/login-desktop/close")
async def login_desktop_close(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401)
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
current = principal(request)
if current.get("role") == "admin":
await _reset_and_promote(force=True)
return JSONResponse({"ok": True, "workspace": _workspace_payload(request)})
active = get_login_lock()
if owns_login_lock(active, username=current["username"], session_id=current.get("session_id", "")):
begin_login_release(
username=current["username"],
session_id=current.get("session_id", ""),
ticket=active.get("ticket", ""),
account_ref=active.get("account_ref", ""),
)
await _reset_and_promote()
else:
cancel_login_request(username=current["username"], session_id=current.get("session_id", ""))
return JSONResponse({"ok": True, "workspace": _workspace_payload(request)})
@app.post("/login-desktop/reset") @app.post("/login-desktop/reset")
async def login_desktop_reset(request: Request): async def login_desktop_reset(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
@@ -1081,11 +1508,17 @@ def create_app():
form = await request.form() form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403) return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
try: current = principal(request)
payload = call_login_desktop("/reset", method="POST", payload={}, timeout=120) if current.get("role") == "admin":
return JSONResponse({"ok": True, "result": payload}) await _reset_and_promote(force=True, clear_queue=str(form.get("clear_queue", "")) == "1")
except RuntimeError as exc: return JSONResponse({"ok": True, "workspace": _workspace_payload(request)})
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503) active = get_login_lock()
if not owns_login_lock(active, username=current["username"], session_id=current.get("session_id", "")):
kind, _ = cancel_login_request(username=current["username"], session_id=current.get("session_id", ""))
return JSONResponse({"ok": kind == "queued", "workspace": _workspace_payload(request)})
begin_login_release(username=current["username"], session_id=current.get("session_id", ""), ticket=active.get("ticket", ""), account_ref=active.get("account_ref", ""))
await _reset_and_promote()
return JSONResponse({"ok": True, "workspace": _workspace_payload(request)})
@app.post("/login-desktop/save") @app.post("/login-desktop/save")
async def login_desktop_save(request: Request): async def login_desktop_save(request: Request):
@@ -1096,25 +1529,55 @@ def create_app():
if not validate_csrf(request, str(form.get("csrf_token", ""))): if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403) return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
current = principal(request)
active = get_login_lock()
if not owns_login_lock(active, username=current["username"], session_id=current.get("session_id", "")):
return JSONResponse({"ok": False, "error": "登录工作区已释放,请重新申请"}, status_code=423)
relogin_unique_id = str(form.get("relogin_unique_id", "")).strip() relogin_unique_id = str(form.get("relogin_unique_id", "")).strip()
display_name = str(form.get("display_name", "")).strip() display_name = str(form.get("display_name", "")).strip()
operation = str(active.get("mode", "relogin"))
relogin_account_ref = str(active.get("account_ref", ""))
if relogin_account_ref:
account = account_by_ref(get_userData(force_reload=True), relogin_account_ref)
if not account or not can_access_account(current, account):
return JSONResponse({"ok": False, "error": "无权操作该账号"}, status_code=403)
relogin_unique_id = account.get("unique_id", "")
operation = "relogin"
elif operation != "add" and current.get("role") != "admin":
return JSONResponse({"ok": False, "error": "普通用户必须选择自己的抖音账号"}, status_code=400)
try: try:
payload = call_login_desktop("/export", method="POST", payload={}, timeout=30) payload = call_login_desktop("/export", method="POST", payload={}, timeout=30)
if not payload.get("ok"): if not payload.get("ok"):
raise RuntimeError("login-desktop export did not return ok") raise RuntimeError("login-desktop export did not return ok")
exported = payload.get("result", {}) or {}
existing = account_by_unique_id(get_userData(force_reload=True), exported.get("unique_id"))
if existing and str(existing.get("account_ref", "")) != relogin_account_ref and not can_access_account(current, existing):
raise RuntimeError("这个抖音账号已经绑定给其他用户,不能覆盖")
if operation == "add" and current.get("role") == "user" and existing:
relogin_account_ref = existing.get("account_ref", "")
relogin_unique_id = existing.get("unique_id", "")
operation = "relogin"
account, action = save_exported_login_result( account, action = save_exported_login_result(
payload.get("result", {}), exported,
relogin_unique_id=relogin_unique_id, relogin_unique_id=relogin_unique_id,
relogin_account_ref=relogin_account_ref,
display_name=display_name, display_name=display_name,
) )
if operation == "add" and current.get("role") == "user":
refs = list(dict.fromkeys(list(current.get("account_refs", [])) + [account.get("account_ref", "")]))
update_web_user(current["username"], account_refs=refs)
begin_login_release(username=current["username"], session_id=current.get("session_id", ""), ticket=active.get("ticket", ""), account_ref=active.get("account_ref", ""))
await _reset_and_promote()
return JSONResponse({ return JSONResponse({
"ok": True, "ok": True,
"action": action, "action": action,
"account": { "account": {
"account_ref": account.get("account_ref"),
"unique_id": account.get("unique_id"), "unique_id": account.get("unique_id"),
"username": account.get("username"), "username": account.get("username"),
"enabled": account.get("enabled", True), "enabled": account.get("enabled", True),
}, },
"workspace": _workspace_payload(request),
}) })
except RuntimeError as exc: except RuntimeError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400) return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
+46 -3
View File
@@ -14,8 +14,10 @@ def hash_password(password, salt=None):
def verify_password(password, stored_hash): def verify_password(password, stored_hash):
if not stored_hash or "$" not in stored_hash: if not stored_hash or "$" not in stored_hash:
return False return False
try:
algorithm, salt, digest = stored_hash.split("$", 2) algorithm, salt, digest = stored_hash.split("$", 2)
except ValueError:
return False
if algorithm != "pbkdf2_sha256": if algorithm != "pbkdf2_sha256":
return False return False
candidate = hash_password(password, salt=salt) candidate = hash_password(password, salt=salt)
@@ -39,9 +41,12 @@ def update_admin_password(password):
return save_app_settings(settings) return save_app_settings(settings)
def issue_session(request, username): def issue_session(request, username, *, role="admin", account_refs=None):
request.session.clear() request.session.clear()
request.session["user"] = username request.session["user"] = username
request.session["role"] = role
request.session["account_refs"] = list(account_refs or [])
request.session["session_id"] = secrets.token_urlsafe(24)
request.session["csrf_token"] = secrets.token_urlsafe(24) request.session["csrf_token"] = secrets.token_urlsafe(24)
@@ -53,6 +58,44 @@ def current_user(request):
return request.session.get("user") return request.session.get("user")
def current_principal(request):
"""Resolve the current session to live role and account assignments."""
username = current_user(request)
if not username:
return None
session_id = request.session.get("session_id", "")
role = request.session.get("role")
admin_username = str(get_app_settings().get("admin_username", "admin")).strip() or "admin"
if role == "admin" or (role is None and username.casefold() == admin_username.casefold()):
return {
"username": admin_username,
"role": "admin",
"account_refs": [],
"session_id": session_id,
"enabled": True,
}
try:
from webui.users import find_web_user
user = find_web_user(username)
except Exception:
user = None
if user and user.get("enabled", True):
return {
"username": user["username"],
"role": "user",
"account_refs": list(user.get("account_refs", [])),
"session_id": session_id,
"enabled": True,
}
return None
def is_admin(request):
principal = current_principal(request)
return bool(principal and principal.get("role") == "admin")
def csrf_token(request): def csrf_token(request):
token = request.session.get("csrf_token") token = request.session.get("csrf_token")
if not token: if not token:
+346
View File
@@ -0,0 +1,346 @@
"""Shared login-desktop workspace lease and FIFO queue."""
from __future__ import annotations
import json
import os
import tempfile
import threading
import time
import uuid
from copy import deepcopy
from pathlib import Path
from utils.config import repo_root
def _default_lock_path() -> Path:
candidates = [
Path("/opt/douyin-sparkflow/state"),
repo_root().parent / "state",
repo_root() / "state",
]
for candidate in candidates:
if candidate.exists() or candidate.parent.exists():
return candidate / "login-workspace.lock.json"
return candidates[0] / "login-workspace.lock.json"
LOCK_PATH = _default_lock_path()
LOCK_TTL_SECONDS = 180
QUEUE_MAX_SIZE = 32
_MUTEX = threading.Lock()
def _empty_state() -> dict:
return {"version": 2, "phase": "idle", "active": None, "queue": []}
def _read_raw() -> dict | None:
try:
data = json.loads(LOCK_PATH.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else None
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def _normalize_state(data: dict | None) -> dict:
if not data:
return _empty_state()
if "active" in data or "queue" in data:
state = _empty_state()
state.update({key: data.get(key) for key in ("version", "phase", "active", "queue")})
state["version"] = 2
state["phase"] = str(state.get("phase") or ("active" if state.get("active") else "idle"))
state["queue"] = [item for item in (state.get("queue") or []) if isinstance(item, dict)]
return state
# Backward compatibility with the original single-lock file.
legacy = dict(data)
now = time.time()
legacy.setdefault("ticket", "legacy-" + uuid.uuid4().hex)
legacy.setdefault("requested_at", now)
legacy.setdefault("started_at", legacy.get("acquired_at", now))
legacy.setdefault("last_heartbeat_at", legacy.get("acquired_at", now))
return {"version": 2, "phase": "active", "active": legacy, "queue": []}
def _write_state(state: dict) -> None:
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=".login-workspace.", suffix=".tmp", dir=str(LOCK_PATH.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(state, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, LOCK_PATH)
finally:
try:
os.unlink(temp_name)
except FileNotFoundError:
pass
def _delete_state() -> None:
try:
LOCK_PATH.unlink()
except FileNotFoundError:
pass
def _now() -> float:
return time.time()
def _expired(active: dict | None, now: float | None = None) -> bool:
if not active:
return False
now = _now() if now is None else now
try:
return now - float(active.get("last_heartbeat_at", active.get("acquired_at", 0))) > LOCK_TTL_SECONDS
except (TypeError, ValueError):
return True
def _public_item(item: dict | None) -> dict | None:
if not item:
return None
return {
"ticket": item.get("ticket", ""),
"username": item.get("username", ""),
"account_ref": item.get("account_ref", ""),
"requested_at": item.get("requested_at", 0),
"started_at": item.get("started_at", 0),
"last_heartbeat_at": item.get("last_heartbeat_at", 0),
}
def get_workspace_state() -> dict:
with _MUTEX:
return deepcopy(_normalize_state(_read_raw()))
def get_lock() -> dict | None:
"""Compatibility helper: return the current active lease only."""
return get_workspace_state().get("active")
def owns(lock: dict | None, *, username: str, session_id: str, account_ref: str | None = None, ticket: str | None = None) -> bool:
if not lock:
return False
if str(lock.get("username")) != str(username) or str(lock.get("session_id")) != str(session_id):
return False
if account_ref is not None and str(lock.get("account_ref", "")) != str(account_ref):
return False
if ticket is not None and str(lock.get("ticket", "")) != str(ticket):
return False
return True
def _new_item(username: str, session_id: str, account_ref: str, now: float, mode: str = "relogin") -> dict:
return {
"ticket": "ticket-" + uuid.uuid4().hex,
"mode": mode if mode in {"relogin", "add"} else "relogin",
"username": username,
"session_id": session_id,
"account_ref": account_ref,
"requested_at": now,
"started_at": now,
"last_heartbeat_at": now,
}
def find_request(state: dict, *, username: str, session_id: str) -> tuple[str, dict | None, int]:
active = state.get("active")
if active and str(active.get("username")) == str(username) and str(active.get("session_id")) == str(session_id):
return "active", active, 0
for index, item in enumerate(state.get("queue") or [], start=1):
if str(item.get("username")) == str(username) and str(item.get("session_id")) == str(session_id):
return "queued", item, index
return "none", None, -1
def request_workspace(*, username: str, session_id: str, account_ref: str = "", mode: str = "relogin") -> dict:
with _MUTEX:
state = _normalize_state(_read_raw())
now = _now()
if state.get("phase") == "resetting":
queue = state.setdefault("queue", [])
kind, existing, position = find_request(state, username=username, session_id=session_id)
if kind == "queued":
return {"state": "queued", "position": position, "request": deepcopy(existing), "workspace": state}
if len(queue) >= QUEUE_MAX_SIZE:
return {"state": "full", "position": -1, "request": None, "workspace": state}
item = _new_item(username, session_id, account_ref, now, mode=mode)
item["started_at"] = 0
item["last_heartbeat_at"] = 0
queue.append(item)
_write_state(state)
return {"state": "queued", "position": len(queue), "request": deepcopy(item), "workspace": state}
elif state.get("active") and not _expired(state["active"], now):
kind, existing, position = find_request(state, username=username, session_id=session_id)
if kind == "active":
existing["last_heartbeat_at"] = now
_write_state(state)
return {"state": "active", "position": 0, "request": deepcopy(existing), "workspace": state}
if kind == "queued":
return {"state": "queued", "position": position, "request": deepcopy(existing), "workspace": state}
queue = state.setdefault("queue", [])
if len(queue) >= QUEUE_MAX_SIZE:
return {"state": "full", "position": -1, "request": None, "workspace": state}
item = _new_item(username, session_id, account_ref, now, mode=mode)
item["started_at"] = 0
item["last_heartbeat_at"] = 0
queue.append(item)
_write_state(state)
return {"state": "queued", "position": len(queue), "request": deepcopy(item), "workspace": state}
else:
item = _new_item(username, session_id, account_ref, now, mode=mode)
state = {"version": 2, "phase": "active", "active": item, "queue": state.get("queue", [])}
_write_state(state)
return {"state": "active", "position": 0, "request": deepcopy(item), "workspace": state}
def heartbeat(*, username: str, session_id: str, ticket: str = "", account_ref: str = "") -> bool:
with _MUTEX:
state = _normalize_state(_read_raw())
active = state.get("active")
if not owns(active, username=username, session_id=session_id, account_ref=account_ref or None, ticket=ticket or None):
return False
active["last_heartbeat_at"] = _now()
_write_state(state)
return True
def begin_expiration() -> dict | None:
with _MUTEX:
state = _normalize_state(_read_raw())
if state.get("phase") != "active" or not _expired(state.get("active")):
return None
old = deepcopy(state.get("active"))
state["phase"] = "resetting"
state["active"] = None
state["transition_reason"] = "heartbeat_timeout"
state["transition_at"] = _now()
_write_state(state)
return old
def begin_force_reset(*, clear_queue: bool = False) -> dict | None:
with _MUTEX:
state = _normalize_state(_read_raw())
active = deepcopy(state.get("active"))
if not active and not state.get("queue"):
return None
state["phase"] = "resetting"
state["active"] = None
if clear_queue:
state["queue"] = []
state["transition_reason"] = "admin_reset"
state["transition_at"] = _now()
_write_state(state)
return active
def begin_release(*, username: str, session_id: str, ticket: str = "", account_ref: str = "") -> dict | None:
with _MUTEX:
state = _normalize_state(_read_raw())
active = state.get("active")
if not owns(active, username=username, session_id=session_id, account_ref=account_ref or None, ticket=ticket or None):
return None
old = deepcopy(active)
state["phase"] = "resetting"
state["active"] = None
state["transition_reason"] = "released"
state["transition_at"] = _now()
_write_state(state)
return old
def finish_transition() -> dict | None:
with _MUTEX:
state = _normalize_state(_read_raw())
queue = state.get("queue") or []
if queue:
item = queue.pop(0)
now = _now()
item["started_at"] = now
item["last_heartbeat_at"] = now
state["phase"] = "active"
state["active"] = item
state["queue"] = queue
state.pop("transition_reason", None)
state.pop("transition_at", None)
_write_state(state)
return deepcopy(item)
_delete_state()
return None
def cancel_request(*, username: str, session_id: str, ticket: str = "") -> tuple[str, dict | None]:
with _MUTEX:
state = _normalize_state(_read_raw())
active = state.get("active")
if owns(active, username=username, session_id=session_id, ticket=ticket or None):
state["phase"] = "resetting"
state["active"] = None
state["transition_reason"] = "cancelled"
state["transition_at"] = _now()
_write_state(state)
return "active", deepcopy(active)
queue = state.get("queue") or []
for index, item in enumerate(queue):
if str(item.get("username")) == str(username) and str(item.get("session_id")) == str(session_id) and (not ticket or str(item.get("ticket")) == str(ticket)):
removed = queue.pop(index)
state["queue"] = queue
if queue or state.get("active"):
_write_state(state)
else:
_delete_state()
return "queued", deepcopy(removed)
return "none", None
def workspace_status(*, username: str, session_id: str) -> dict:
state = get_workspace_state()
kind, item, position = find_request(state, username=username, session_id=session_id)
if kind == "active":
remaining = max(0, int(LOCK_TTL_SECONDS - (_now() - float(item.get("last_heartbeat_at", _now())))))
return {"state": "active", "position": 0, "ticket": item.get("ticket", ""), "remaining_seconds": remaining, "workspace": state}
if kind == "queued":
return {"state": "queued", "position": position, "ticket": item.get("ticket", ""), "remaining_seconds": 0, "workspace": state}
if state.get("phase") == "resetting":
return {"state": "resetting", "position": 0, "ticket": "", "remaining_seconds": 0, "workspace": state}
return {"state": "closed", "position": 0, "ticket": "", "remaining_seconds": 0, "workspace": state}
# Backward-compatible single-lease helpers used by existing tests and callers.
def acquire(*, username: str, session_id: str, account_ref: str = "", force: bool = False) -> tuple[bool, dict | None]:
with _MUTEX:
state = _normalize_state(_read_raw())
if force:
state = _empty_state()
active = state.get("active")
if active and not owns(active, username=username, session_id=session_id, account_ref=account_ref):
return False, active
result = request_workspace(username=username, session_id=session_id, account_ref=account_ref)
return result["state"] == "active", result.get("request")
def refresh(*, username: str, session_id: str, account_ref: str = "") -> bool:
return heartbeat(username=username, session_id=session_id, account_ref=account_ref)
def release(*, username: str | None = None, session_id: str | None = None, force: bool = False) -> bool:
with _MUTEX:
state = _normalize_state(_read_raw())
active = state.get("active")
if not active:
return False
if not force:
if username is not None and str(active.get("username")) != str(username):
return False
if session_id is not None and str(active.get("session_id")) != str(session_id):
return False
_delete_state()
return True
+30 -12
View File
@@ -259,7 +259,11 @@ def run_command(args, cwd=None, timeout=120, check=False):
timeout=timeout, timeout=timeout,
) )
except FileNotFoundError: except FileNotFoundError:
logger.warning("Command not found: %s", args[0] if args else args) # Docker and cron are optional integration points when the UI is run
# directly on a developer workstation (especially on Windows). A
# status probe must not turn their absence into a warning on every
# dashboard refresh.
logger.debug("Optional command not found: %s", args[0] if args else args)
return _empty_result() return _empty_result()
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("Command timed out: %s", args) logger.warning("Command timed out: %s", args)
@@ -359,7 +363,7 @@ def get_task_container_rows():
return [] return []
def run_task_now(*, unsent_only=False, failed_only=False, force_all=False): def run_task_now(*, unsent_only=False, failed_only=False, force_all=False, account_refs=None):
try: try:
lock_status = task_run_lock_status() lock_status = task_run_lock_status()
if lock_status.get("running"): if lock_status.get("running"):
@@ -376,6 +380,8 @@ def run_task_now(*, unsent_only=False, failed_only=False, force_all=False):
"SPARKFLOW_MANUAL_RUN": "1", "SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1", "PYTHONUNBUFFERED": "1",
} }
if account_refs is not None:
run_env["SPARKFLOW_ACCOUNT_REFS"] = ",".join(sorted({str(ref).strip() for ref in account_refs if str(ref).strip()}))
if force_all: if force_all:
run_env["SPARKFLOW_MANUAL_FORCE_ALL"] = "1" run_env["SPARKFLOW_MANUAL_FORCE_ALL"] = "1"
elif failed_only: elif failed_only:
@@ -402,12 +408,12 @@ def run_task_now(*, unsent_only=False, failed_only=False, force_all=False):
return -1 return -1
def run_failed_retry_now(): def run_failed_retry_now(*, account_refs=None):
return run_task_now(failed_only=True) return run_task_now(failed_only=True, account_refs=account_refs)
def run_unsent_retry_now(): def run_unsent_retry_now(*, account_refs=None):
return run_task_now(unsent_only=True) return run_task_now(unsent_only=True, account_refs=account_refs)
def refresh_proxy(): def refresh_proxy():
@@ -447,6 +453,12 @@ def read_crontab():
if result.returncode != 0: if result.returncode != 0:
return "" return ""
return result.stdout return result.stdout
except FileNotFoundError:
# Native Windows installs do not provide ``crontab``. The caller can
# treat an unavailable scheduler as an empty schedule and still serve
# the rest of the dashboard.
logger.debug("Optional command not found: crontab")
return ""
except Exception as exc: except Exception as exc:
logger.warning("read_crontab failed: %s", exc) logger.warning("read_crontab failed: %s", exc)
return "" return ""
@@ -927,8 +939,13 @@ def _orphan_records(account, configured_targets):
return orphan_history, orphan_failure return orphan_history, orphan_failure
def get_send_console_snapshot(): def get_send_console_snapshot(account_refs=None):
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)] allowed_refs = None if account_refs is None else {str(ref).strip() for ref in account_refs}
accounts = [
account
for account in get_userData(force_reload=True)
if account.get("enabled", True) and (allowed_refs is None or account.get("account_ref") in allowed_refs)
]
send_window = _normalize_send_window() send_window = _normalize_send_window()
now = datetime.now(_schedule_timezone()) now = datetime.now(_schedule_timezone())
@@ -1049,6 +1066,7 @@ def get_send_console_snapshot():
account_rows.append( account_rows.append(
{ {
"account_ref": str(account.get("account_ref") or ""),
"unique_id": str(account.get("unique_id") or ""), "unique_id": str(account.get("unique_id") or ""),
"username": account.get("username") or "", "username": account.get("username") or "",
"total_targets": len(configured_targets), "total_targets": len(configured_targets),
@@ -1100,8 +1118,8 @@ def get_send_console_snapshot():
} }
def get_overview_snapshot(): def get_overview_snapshot(account_refs=None):
send_console = get_send_console_snapshot() send_console = get_send_console_snapshot(account_refs=account_refs)
summary = dict(send_console["summary"]) summary = dict(send_console["summary"])
accounts = [] accounts = []
for row in send_console["accounts"]: for row in send_console["accounts"]:
@@ -1154,13 +1172,13 @@ def _check_image_present():
return False return False
def get_ops_snapshot(): def get_ops_snapshot(account_refs=None):
"""Collect operational metrics for the dashboard. """Collect operational metrics for the dashboard.
Every external call is individually guarded so the dashboard always Every external call is individually guarded so the dashboard always
renders, even when Docker or crontab are not available. renders, even when Docker or crontab are not available.
""" """
send_console = get_send_console_snapshot() send_console = get_send_console_snapshot(account_refs=account_refs)
return { return {
"compose_root": str(compose_root()), "compose_root": str(compose_root()),
"compose_file": str(compose_file_path() or ""), "compose_file": str(compose_file_path() or ""),
+20
View File
@@ -988,6 +988,26 @@ textarea {
accent-color: var(--primary); accent-color: var(--primary);
} }
.account-assignment-picker {
border: 0;
padding: 0;
margin: 0;
}
.account-assignment-picker legend {
padding: 0;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.account-assignment-picker .check-row {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--surface-alt);
}
.button-row, .button-row,
.button-grid, .button-grid,
.form-actions { .form-actions {
+124 -71
View File
@@ -339,58 +339,82 @@
const section = document.getElementById("interactive-login-section"); const section = document.getElementById("interactive-login-section");
const csrfToken = root.dataset.csrfToken || ""; const csrfToken = root.dataset.csrfToken || "";
const publicUrl = root.dataset.publicUrl || ""; const publicUrl = root.dataset.publicUrl || "";
const runtimeState = document.getElementById( const runtimeState = document.getElementById("login-desktop-runtime-state");
"login-desktop-runtime-state",
);
const statusText = document.getElementById("login-desktop-status-text"); const statusText = document.getElementById("login-desktop-status-text");
const frame = document.querySelector("[data-login-frame]"); const frame = document.querySelector("[data-login-frame]");
const qrImage = document.querySelector("[data-login-qr]"); const qrImage = document.querySelector("[data-login-qr]");
const qrStatus = document.querySelector("[data-login-qr-status]"); const qrStatus = document.querySelector("[data-login-qr-status]");
let timer = null; let timer = null;
let heartbeatTimer = null;
let countdownTimer = null;
let qrRefreshTimer = null; let qrRefreshTimer = null;
let workspace = { state: "closed", active: false, position: 0, ticket: "" };
const setStatus = (text, tone = "") => { const setStatus = (text, tone = "") => {
if (statusText) statusText.textContent = text; if (statusText) statusText.textContent = text;
if (runtimeState) { if (runtimeState) {
runtimeState.className = `pill${tone ? ` ${tone}` : ""}`; runtimeState.className = `pill${tone ? ` ${tone}` : ""}`;
runtimeState.textContent = runtimeState.textContent = tone === "success" ? "使用中" : tone === "danger" ? "异常" : tone === "warning" ? "排队中" : "已关闭";
tone === "success" ? "已登录" : tone === "danger" ? "异常" : "待登录";
} }
}; };
const postForm = async (url, payload = {}) => { const postForm = async (url, payload = {}) => {
const formData = new FormData(); const formData = new FormData();
formData.set("csrf_token", csrfToken); formData.set("csrf_token", csrfToken);
Object.entries(payload).forEach(([key, value]) => { Object.entries(payload).forEach(([key, value]) => formData.set(key, String(value ?? "")));
formData.set(key, String(value ?? "")); const response = await fetch(url, { method: "POST", body: formData, credentials: "same-origin" });
});
const response = await fetch(url, {
method: "POST",
body: formData,
credentials: "same-origin",
});
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) { if (!response.ok || data.ok === false) throw new Error(data.error || `请求失败:${response.status}`);
throw new Error(data.error || `请求失败:${response.status}`);
}
return data; return data;
}; };
const loadFrame = () => { const loadFrame = (force = false) => {
if (frame && frame.dataset.loaded !== "1" && frame.dataset.src) { if (frame && (force || frame.dataset.loaded !== "1") && frame.dataset.src) {
frame.src = frame.dataset.src; frame.src = frame.dataset.src;
frame.dataset.loaded = "1"; frame.dataset.loaded = "1";
} }
}; };
const refreshLoginQr = async (delay = 0, retries = 8) => { const closeFrame = () => {
if (!qrImage) return; if (!frame) return;
frame.removeAttribute("src");
frame.dataset.loaded = "0";
};
const renderWorkspace = (next) => {
workspace = next || { state: "closed", active: false, position: 0, ticket: "" };
if (workspace.state === "queued") {
setStatus(`登录工作区排队中,前面还有 ${Math.max(0, Number(workspace.position || 1) - 1)} 人。`, "warning");
if (qrStatus) qrStatus.textContent = "排队成功,轮到你后会自动打开登录二维码。";
return;
}
if (workspace.state === "resetting") {
setStatus("正在清理上一位用户的登录环境,请稍候。", "warning");
return;
}
if (workspace.state === "active" && workspace.active) {
const remaining = Math.max(0, Number(workspace.remaining_seconds || 0));
const tone = remaining > 0 && remaining <= 60 ? "warning" : "success";
setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, tone);
return;
}
setStatus("登录工作区当前关闭。请从账号卡片点击“重新登录”。");
};
const refreshLoginQr = async (delay = 0, retries = 40) => {
if (!qrImage || workspace.state !== "active" || !workspace.active) return;
window.clearTimeout(qrRefreshTimer); window.clearTimeout(qrRefreshTimer);
qrRefreshTimer = window.setTimeout(async () => { qrRefreshTimer = window.setTimeout(async () => {
if (qrStatus) qrStatus.textContent = "正在读取登录二维码..."; if (qrStatus) qrStatus.textContent = "正在读取登录二维码...";
const url = `/login-desktop/qr?t=${Date.now()}`;
try { try {
const response = await fetch(url, { credentials: "same-origin", cache: "no-store" }); const response = await fetch(`/login-desktop/qr?t=${Date.now()}`, { credentials: "same-origin", cache: "no-store" });
if (response.status === 202) {
if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "浏览器正在生成二维码,继续等待...";
refreshLoginQr(1400, retries - 1);
}
return;
}
if (!response.ok) throw new Error(String(response.status)); if (!response.ok) throw new Error(String(response.status));
const blob = await response.blob(); const blob = await response.blob();
const previous = qrImage.dataset.objectUrl || ""; const previous = qrImage.dataset.objectUrl || "";
@@ -401,62 +425,73 @@
if (previous) URL.revokeObjectURL(previous); if (previous) URL.revokeObjectURL(previous);
if (qrStatus) qrStatus.textContent = "二维码已加载。如果过期,点击刷新。"; if (qrStatus) qrStatus.textContent = "二维码已加载。如果过期,点击刷新。";
} catch { } catch {
if (retries > 1) { if (retries > 1 && workspace.state === "active") {
if (qrStatus) qrStatus.textContent = "登录页正在加载,继续等待二维码..."; if (qrStatus) qrStatus.textContent = "登录页正在加载,继续等待二维码...";
refreshLoginQr(1400, retries - 1); refreshLoginQr(1400, retries - 1);
} else if (qrStatus) { } else if (qrStatus) {
qrStatus.textContent = "二维码还未准备好,请点击刷新重试。"; qrStatus.textContent = "二维码还未准备好,请确认自己已经获得登录工作区。";
} }
} }
}, delay); }, delay);
}; };
const pollStatus = async () => { const pollStatus = async () => {
if (document.visibilityState !== "visible" || (section && !section.open)) { if (document.visibilityState !== "visible") return;
return;
}
try { try {
const response = await fetch("/login-desktop/status", { const statusUrl = workspace.state === "active" ? "/login-desktop/status" : "/login-desktop/workspace-status";
credentials: "same-origin", const response = await fetch(statusUrl, { credentials: "same-origin", cache: "no-store" });
cache: "no-store",
});
const data = await response.json(); const data = await response.json();
if (!response.ok || data.ok === false) { if (!response.ok || data.ok === false) {
setStatus( setStatus(data.error || "登录工作区不可用,请检查 login-desktop 服务。", "danger");
data.error || "登录工作区不可用,请检查 login-desktop 服务。",
"danger",
);
return; return;
} }
if (data.logged_in) { renderWorkspace(data.workspace);
setStatus(`当前浏览器已登录:${data.username}`, "success"); if (workspace.state === "active" && workspace.active) {
loadFrame();
if (data.logged_in) setStatus(`当前浏览器已登录:${data.username},请保存登录态。`, "success");
} else { } else {
setStatus("当前浏览器尚未登录,可打开工作区开始人工登录。"); closeFrame();
} }
} catch (error) { } catch (error) {
setStatus(`状态检查失败:${error.message}`, "danger"); setStatus(`状态检查失败:${error.message}`, "danger");
} }
}; };
const heartbeat = async () => {
if (workspace.state !== "active" || !workspace.active || !workspace.ticket) return;
try {
const data = await postForm("/login-desktop/heartbeat", { ticket: workspace.ticket });
renderWorkspace(data.workspace);
} catch (error) {
workspace = { state: "closed", active: false, position: 0, ticket: "" };
closeFrame();
setStatus(`登录工作区已释放:${error.message}`, "danger");
}
};
document.querySelectorAll(".login-desktop-open").forEach((button) => { document.querySelectorAll(".login-desktop-open").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
// Mobile browsers block window.open after an awaited request. Open the if (section) section.open = true;
// authenticated same-origin workspace while the click gesture is active. const popup = publicUrl ? window.open("about:blank", "_blank", "noopener") : null;
const popup = publicUrl
? window.open(publicUrl, "_blank", "noopener")
: null;
try { try {
await postForm("/login-desktop/open"); const reloginUniqueId = button.dataset.reloginUniqueId || "";
loadFrame(); const mode = button.dataset.loginMode || (reloginUniqueId ? "relogin" : "add");
refreshLoginQr(1800); const data = await postForm("/login-desktop/open", {
if (!popup && frame) { mode,
frame.scrollIntoView({ behavior: "smooth", block: "start" }); ...(reloginUniqueId ? { relogin_unique_id: reloginUniqueId } : {}),
setStatus("弹窗被浏览器拦截,已在当前页面加载登录工作区。"); });
} else { renderWorkspace(data.workspace);
setStatus("请在登录工作区完成登录,然后返回此页保存登录态。"); if (data.state === "queued") {
if (popup && !popup.closed) popup.close();
return;
} }
if (popup && !popup.closed) popup.location.href = publicUrl;
loadFrame(true);
refreshLoginQr(500);
if (!popup && frame) frame.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (error) { } catch (error) {
setStatus(`打开登录工作区失败:${error.message}`, "danger"); if (popup && !popup.closed) popup.close();
setStatus(`申请登录工作区失败:${error.message}`, "danger");
} }
}); });
}); });
@@ -464,10 +499,9 @@
document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => { document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
button.disabled = true; button.disabled = true;
if (qrStatus) qrStatus.textContent = "正在让抖音重新生成二维码...";
try { try {
await postForm("/login-desktop/qr/refresh"); await postForm("/login-desktop/qr/refresh", { ticket: workspace.ticket });
refreshLoginQr(900); refreshLoginQr(500);
} catch (error) { } catch (error) {
if (qrStatus) qrStatus.textContent = `刷新二维码失败:${error.message}`; if (qrStatus) qrStatus.textContent = `刷新二维码失败:${error.message}`;
} finally { } finally {
@@ -479,10 +513,10 @@
document.querySelectorAll(".login-desktop-save").forEach((button) => { document.querySelectorAll(".login-desktop-save").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
try { try {
const data = await postForm("/login-desktop/save", { const data = await postForm("/login-desktop/save", { relogin_unique_id: button.dataset.reloginUniqueId || "" });
relogin_unique_id: button.dataset.reloginUniqueId || "", renderWorkspace(data.workspace);
});
setStatus(`已保存登录账号:${data.account?.username || ""}`, "success"); setStatus(`已保存登录账号:${data.account?.username || ""}`, "success");
closeFrame();
window.setTimeout(() => window.location.reload(), 800); window.setTimeout(() => window.location.reload(), 800);
} catch (error) { } catch (error) {
setStatus(`保存登录账号失败:${error.message}`, "danger"); setStatus(`保存登录账号失败:${error.message}`, "danger");
@@ -490,14 +524,28 @@
}); });
}); });
document.querySelectorAll(".login-desktop-close").forEach((button) => {
button.addEventListener("click", async () => {
try {
const data = await postForm("/login-desktop/close");
renderWorkspace(data.workspace);
closeFrame();
if (qrImage) qrImage.hidden = true;
} catch (error) {
setStatus(`关闭登录界面失败:${error.message}`, "danger");
}
});
});
document.querySelectorAll(".login-desktop-reset").forEach((button) => { document.querySelectorAll(".login-desktop-reset").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
try { try {
await postForm("/login-desktop/reset"); const data = await postForm("/login-desktop/reset");
setStatus("登录工作区已重置,正在重新初始化。"); renderWorkspace(data.workspace);
await pollStatus(); closeFrame();
if (qrImage) qrImage.hidden = true;
} catch (error) { } catch (error) {
setStatus(`重置登录工作区失败:${error.message}`, "danger"); setStatus(`结束登录流程失败:${error.message}`, "danger");
} }
}); });
}); });
@@ -513,16 +561,21 @@
}); });
}); });
if (section) { if (section) section.addEventListener("toggle", () => { if (section.open) pollStatus(); });
section.addEventListener("toggle", () => { pollStatus();
if (section.open) {
loadFrame();
pollStatus();
}
});
}
timer = window.setInterval(pollStatus, 5000); timer = window.setInterval(pollStatus, 5000);
window.addEventListener("pagehide", () => window.clearInterval(timer)); heartbeatTimer = window.setInterval(heartbeat, 5000);
countdownTimer = window.setInterval(() => {
if (workspace.state !== "active" || !workspace.active) return;
workspace.remaining_seconds = Math.max(0, Number(workspace.remaining_seconds || 0) - 1);
const remaining = workspace.remaining_seconds;
setStatus(`登录工作区已分配给当前会话,剩余 ${remaining} 秒。完成扫码后请保存登录态。`, remaining <= 60 ? "warning" : "success");
}, 1000);
window.addEventListener("pagehide", () => {
window.clearInterval(timer);
window.clearInterval(heartbeatTimer);
window.clearInterval(countdownTimer);
});
})(); })();
(() => { (() => {
+11 -3
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<title>{% block title %}续火花{% endblock %}</title> <title>{% block title %}续火花{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css?v=20260710"> <link rel="stylesheet" href="/static/app.css?v=20260817">
</head> </head>
<body data-page="{% block page_key %}dashboard{% endblock %}"> <body data-page="{% block page_key %}dashboard{% endblock %}">
<header class="mobile-topbar"> <header class="mobile-topbar">
@@ -49,9 +49,11 @@
<a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console"> <a class="nav-item {% if current_nav|trim == 'send_console' %}active{% endif %}" href="/ops/send-console">
<i data-lucide="send"></i><span>发送控制台</span> <i data-lucide="send"></i><span>发送控制台</span>
</a> </a>
{% if is_admin %}
<a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs"> <a class="nav-item {% if current_nav|trim == 'logs' %}active{% endif %}" href="/ops/logs">
<i data-lucide="scroll-text"></i><span>发送记录</span> <i data-lucide="scroll-text"></i><span>发送记录</span>
</a> </a>
{% endif %}
</div> </div>
</section> </section>
@@ -64,15 +66,21 @@
<a class="nav-item" href="/#interactive-login-section"> <a class="nav-item" href="/#interactive-login-section">
<i data-lucide="scan-line"></i><span>登录抖音账号</span> <i data-lucide="scan-line"></i><span>登录抖音账号</span>
</a> </a>
{% if is_admin %}
<a class="nav-item" href="/#config-panel"> <a class="nav-item" href="/#config-panel">
<i data-lucide="sliders-horizontal"></i><span>运行配置</span> <i data-lucide="sliders-horizontal"></i><span>运行配置</span>
</a> </a>
{% endif %}
{% if is_admin %}
<a class="nav-item" href="/#ops-panel"> <a class="nav-item" href="/#ops-panel">
<i data-lucide="wrench"></i><span>运维操作</span> <i data-lucide="wrench"></i><span>运维操作</span>
</a> </a>
{% endif %}
{% if is_admin %}
<a class="nav-item" href="/#settings-panel"> <a class="nav-item" href="/#settings-panel">
<i data-lucide="settings"></i><span>系统设置</span> <i data-lucide="settings"></i><span>系统设置</span>
</a> </a>
{% endif %}
</div> </div>
</section> </section>
</nav> </nav>
@@ -82,7 +90,7 @@
<span class="user-avatar">{{ (current_user or "A")[:1] }}</span> <span class="user-avatar">{{ (current_user or "A")[:1] }}</span>
<span class="user-copy"> <span class="user-copy">
<strong>{{ current_user or "未登录" }}</strong> <strong>{{ current_user or "未登录" }}</strong>
<small>管理员</small> <small>{% if is_admin %}管理员{% else %}普通用户{% endif %}</small>
</span> </span>
</div> </div>
<form method="post" action="/logout"> <form method="post" action="/logout">
@@ -131,7 +139,7 @@
</dialog> </dialog>
<script defer src="/static/lucide.min.js?v=20260710"></script> <script defer src="/static/lucide.min.js?v=20260710"></script>
<script defer src="/static/app.js?v=20260710"></script> <script defer src="/static/app.js?v=20260817"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+112 -7
View File
@@ -226,21 +226,33 @@
</div> </div>
</div> </div>
<div class="button-row"> <div class="button-row">
<button class="button button-primary login-desktop-open" type="button"> <button class="button button-primary login-desktop-open" type="button" data-login-mode="add">
<i data-lucide="external-link"></i><span>打开登录工作区</span> <i data-lucide="user-plus"></i><span>添加新的抖音账号</span>
</button> </button>
<button class="button button-soft login-desktop-save" type="button" data-relogin-unique-id=""> <button class="button button-soft login-desktop-save" type="button" data-relogin-unique-id="" data-login-mode="add">
<i data-lucide="save"></i><span>保存当前登录</span> <i data-lucide="save"></i><span>保存新账号登录</span>
</button> </button>
<button
class="button button-danger login-desktop-close"
type="button"
data-confirm-title="关闭登录界面"
data-confirm="当前未保存的登录过程会被关闭,登录界面会恢复初始状态。确认继续吗?"
data-confirm-accept="确认关闭"
>
<i data-lucide="x-circle"></i><span>关闭登录界面</span>
</button>
{% if is_admin %}
<button <button
class="button button-danger login-desktop-reset" class="button button-danger login-desktop-reset"
type="button" type="button"
data-confirm-title="重置登录工作区" data-confirm-title="强制重置登录工作区"
data-confirm="登录浏览器会被重置,当前未保存的登录过程将丢失。确认继续吗?" data-confirm="会清理当前登录环境并切换队列中的下一位用户。确认继续吗?"
data-confirm-accept="确认重置" data-confirm-accept="确认重置"
> >
<i data-lucide="rotate-ccw"></i><span>重置工作区</span> <i data-lucide="rotate-ccw"></i><span>强制重置工作区</span>
</button> </button>
{% endif %}
<p class="muted compact login-workspace-hint">已有抖音账号请从对应账号卡片点击“重新登录”;“添加新的抖音账号”会将新账号自动绑定到当前用户。</p>
<button class="button button-quiet" type="button" data-copy-login-url> <button class="button button-quiet" type="button" data-copy-login-url>
<i data-lucide="copy"></i><span>复制地址</span> <i data-lucide="copy"></i><span>复制地址</span>
</button> </button>
@@ -258,6 +270,76 @@
</div> </div>
</details> </details>
{% if is_admin %}
<section class="dashboard-section" id="user-management">
<div class="section-header">
<div>
<h2 class="section-title">用户管理</h2>
<p class="muted compact">创建普通用户并分配可管理的抖音账号。管理员始终可以查看全部账号。</p>
</div>
<span class="pill">{{ web_users|length }} 个普通用户</span>
</div>
<details class="collapsible-section" open>
<summary><span class="summary-copy"><i data-lucide="user-plus"></i><span><strong>创建用户</strong><small>账号绑定可后续调整</small></span></span><i class="summary-chevron" data-lucide="chevron-down"></i></summary>
<div class="collapsible-body">
<form method="post" action="/admin/users/create" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-grid">
<label><span>用户名</span><input type="text" name="username" pattern="[A-Za-z0-9_.-]+" required></label>
<label><span>初始密码</span><input type="password" name="password" autocomplete="new-password" required></label>
</div>
<label class="check-row"><input type="checkbox" name="enabled" checked><span>允许登录</span></label>
<fieldset class="stack-form account-assignment-picker">
<legend>分配账号</legend>
<p class="muted compact">可多选;如果暂时不分配账号,保持全部未选即可。</p>
{% for account in all_accounts %}
<label class="check-row">
<input type="checkbox" name="account_refs" value="{{ account.account_ref }}">
<span>{{ account.username }}{{ account.unique_id }}</span>
</label>
{% else %}
<p class="muted compact">当前没有可分配的抖音账号。</p>
{% endfor %}
</fieldset>
<button class="button button-primary" type="submit"><i data-lucide="user-plus"></i><span>创建普通用户</span></button>
</form>
</div>
</details>
{% for web_user in web_users %}
<details class="collapsible-section">
<summary><span class="summary-copy"><span class="account-avatar">{{ (web_user.username or "U")[:1] }}</span><span><strong>{{ web_user.username }}</strong><small>{{ web_user.account_refs|length }} 个绑定账号</small></span></span><span class="status-chip {% if web_user.enabled %}success{% else %}warning{% endif %}">{% if web_user.enabled %}可登录{% else %}已停用{% endif %}</span><i class="summary-chevron" data-lucide="chevron-down"></i></summary>
<div class="collapsible-body">
<form method="post" action="/admin/users/{{ web_user.username }}/update" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-grid">
<label><span>用户名</span><input type="text" name="new_username" value="{{ web_user.username }}" pattern="[A-Za-z0-9_.-]+" required></label>
<label><span>重置密码(留空不修改)</span><input type="password" name="password" autocomplete="new-password"></label>
</div>
<label class="check-row"><input type="checkbox" name="enabled" {% if web_user.enabled %}checked{% endif %}><span>允许登录</span></label>
<fieldset class="stack-form account-assignment-picker">
<legend>绑定账号</legend>
<p class="muted compact">可多选;全部未选表示解除该用户的所有抖音账号绑定。</p>
{% for account in all_accounts %}
<label class="check-row">
<input type="checkbox" name="account_refs" value="{{ account.account_ref }}" {% if account.account_ref in web_user.account_refs %}checked{% endif %}>
<span>{{ account.username }}{{ account.unique_id }}</span>
</label>
{% else %}
<p class="muted compact">当前没有可分配的抖音账号。</p>
{% endfor %}
</fieldset>
<div class="form-actions"><button class="button button-primary" type="submit"><i data-lucide="save"></i><span>保存用户</span></button></div>
</form>
<form method="post" action="/admin/users/{{ web_user.username }}/delete" data-confirm-title="删除 Web 用户" data-confirm="只删除 Web 用户并解除账号绑定,不删除抖音账号数据。确认继续吗?" data-confirm-accept="确认删除">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="button button-danger" type="submit"><i data-lucide="trash-2"></i><span>删除用户</span></button>
</form>
</div>
</details>
{% endfor %}
</section>
{% endif %}
<section class="dashboard-section" id="account-management"> <section class="dashboard-section" id="account-management">
<div class="section-header"> <div class="section-header">
<div> <div>
@@ -355,6 +437,7 @@
<span>{% if account.enabled|default(true) %}停用自动发送{% else %}启用自动发送{% endif %}</span> <span>{% if account.enabled|default(true) %}停用自动发送{% else %}启用自动发送{% endif %}</span>
</button> </button>
</form> </form>
{% if is_admin %}
<form <form
method="post" method="post"
action="/accounts/{{ account.unique_id }}/delete" action="/accounts/{{ account.unique_id }}/delete"
@@ -367,6 +450,7 @@
<i data-lucide="trash-2"></i><span>删除账号</span> <i data-lucide="trash-2"></i><span>删除账号</span>
</button> </button>
</form> </form>
{% endif %}
</div> </div>
</div> </div>
</details> </details>
@@ -377,6 +461,7 @@
</section> </section>
<section class="advanced-grid"> <section class="advanced-grid">
{% if is_admin %}
<details class="collapsible-section" id="config-panel"> <details class="collapsible-section" id="config-panel">
<summary> <summary>
<span class="summary-copy"> <span class="summary-copy">
@@ -494,6 +579,26 @@
</form> </form>
</div> </div>
</details> </details>
{% endif %}
{% if not is_admin %}
<details class="collapsible-section" id="password-panel">
<summary>
<span class="summary-copy"><i data-lucide="key-round"></i><span><strong>修改登录密码</strong><small>修改当前 Web 用户密码</small></span></span>
<i class="summary-chevron" data-lucide="chevron-down"></i>
</summary>
<div class="collapsible-body">
<form method="post" action="/account/password" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-grid">
<label><span>新密码</span><input type="password" name="new_password" autocomplete="new-password" required></label>
<label><span>确认新密码</span><input type="password" name="confirm_password" autocomplete="new-password" required></label>
</div>
<button class="button button-primary" type="submit"><i data-lucide="save"></i><span>保存密码</span></button>
</form>
</div>
</details>
{% endif %}
</section> </section>
</div> </div>
{% endblock %} {% endblock %}
+278
View File
@@ -0,0 +1,278 @@
"""Persistent Web UI users and stable account ownership helpers."""
from __future__ import annotations
import json
import os
import re
import tempfile
import uuid
from copy import deepcopy
from pathlib import Path
from utils.config import get_app_settings, get_userData, normalize_unique_id, save_userData
from webui.auth import hash_password, verify_password
USERS_FILE = Path(__file__).resolve().parents[1] / "webui_users.json"
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
class UserStoreError(ValueError):
"""Raised when a Web UI user operation is invalid."""
def normalize_username(username: str) -> str:
return str(username or "").strip().casefold()
def _atomic_write_json(path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_name, path)
finally:
try:
os.unlink(temp_name)
except FileNotFoundError:
pass
def _load_raw() -> dict:
if not USERS_FILE.exists():
return {"users": []}
text = USERS_FILE.read_text(encoding="utf-8")
if not text.strip():
return {"users": []}
data = json.loads(text)
if not isinstance(data, dict) or not isinstance(data.get("users", []), list):
raise UserStoreError("webui_users.json must contain a users list")
return data
def get_web_users(force_reload: bool = False) -> list[dict]:
del force_reload
users = []
for raw in _load_raw().get("users", []):
if not isinstance(raw, dict):
continue
username = str(raw.get("username", "")).strip()
if not username:
continue
users.append(
{
"username": username,
"role": "user",
"password_hash": str(raw.get("password_hash", "")),
"enabled": bool(raw.get("enabled", True)),
"account_refs": list(dict.fromkeys(
str(ref).strip() for ref in (raw.get("account_refs") or []) if str(ref).strip()
)),
}
)
return users
def save_web_users(users: list[dict]) -> list[dict]:
normalized = []
seen = set()
assigned = set()
for raw in users:
username = str(raw.get("username", "")).strip()
key = normalize_username(username)
if not username or key == normalize_username("admin") or key in seen:
raise UserStoreError("invalid or duplicate Web user")
if not USERNAME_RE.fullmatch(username):
raise UserStoreError("Web username may contain only letters, digits, _, ., and -")
password_hash = str(raw.get("password_hash", ""))
if not password_hash:
raise UserStoreError(f"password hash missing for {username}")
refs = list(dict.fromkeys(
str(ref).strip() for ref in (raw.get("account_refs") or []) if str(ref).strip()
))
if assigned.intersection(refs):
raise UserStoreError("one or more accounts are already assigned")
assigned.update(refs)
seen.add(key)
normalized.append(
{
"username": username,
"role": "user",
"password_hash": password_hash,
"enabled": bool(raw.get("enabled", True)),
"account_refs": refs,
}
)
_atomic_write_json(USERS_FILE, {"users": normalized})
try:
os.chmod(USERS_FILE, 0o600)
except OSError:
pass
return deepcopy(normalized)
def find_web_user(username: str) -> dict | None:
key = normalize_username(username)
return next((user for user in get_web_users() if normalize_username(user["username"]) == key), None)
def authenticate(username: str, password: str) -> dict | None:
settings = get_app_settings(force_reload=True)
admin_username = str(settings.get("admin_username", "admin")).strip() or "admin"
if normalize_username(username) == normalize_username(admin_username):
if verify_password(password, settings.get("admin_password_hash", "")):
return {"username": admin_username, "role": "admin", "account_refs": [], "enabled": True}
return None
user = find_web_user(username)
if not user or not user.get("enabled") or not verify_password(password, user.get("password_hash", "")):
return None
return {
"username": user["username"],
"role": "user",
"account_refs": list(user.get("account_refs", [])),
"enabled": True,
}
def ensure_account_refs(accounts: list[dict] | None = None) -> tuple[list[dict], bool]:
accounts = deepcopy(accounts if accounts is not None else get_userData(force_reload=True))
changed = False
for account in accounts:
if not str(account.get("account_ref", "")).strip():
account["account_ref"] = f"acc-{uuid.uuid4().hex}"
changed = True
if changed:
save_userData(accounts)
return accounts, changed
def account_by_ref(accounts: list[dict], account_ref: str) -> dict | None:
target = str(account_ref or "").strip()
return next((account for account in accounts if str(account.get("account_ref", "")).strip() == target), None)
def account_by_unique_id(accounts: list[dict], unique_id: str) -> dict | None:
target = normalize_unique_id(unique_id)
return next((account for account in accounts if normalize_unique_id(account.get("unique_id")) == target), None)
def get_visible_accounts(principal: dict | None, accounts: list[dict] | None = None) -> list[dict]:
accounts, _ = ensure_account_refs(accounts)
if principal and principal.get("role") == "admin":
return accounts
allowed = set(principal.get("account_refs", [])) if principal else set()
return [account for account in accounts if account.get("account_ref") in allowed]
def can_access_account(principal: dict | None, account: dict | None) -> bool:
if not principal or not account:
return False
return principal.get("role") == "admin" or account.get("account_ref") in set(principal.get("account_refs", []))
def all_assigned_refs(exclude_username: str | None = None) -> set[str]:
excluded = normalize_username(exclude_username) if exclude_username else None
refs = set()
for user in get_web_users():
if excluded and normalize_username(user["username"]) == excluded:
continue
refs.update(user.get("account_refs", []))
return refs
def _validate_refs(refs: list[str] | None, accounts: list[dict] | None = None) -> list[str]:
accounts, _ = ensure_account_refs(accounts)
valid = {str(account.get("account_ref")) for account in accounts}
result = list(dict.fromkeys(str(ref).strip() for ref in (refs or []) if str(ref).strip()))
unknown = [ref for ref in result if ref not in valid]
if unknown:
raise UserStoreError("one or more account assignments are invalid")
return result
def create_web_user(username: str, password: str, *, enabled: bool = True, account_refs: list[str] | None = None) -> dict:
username = str(username or "").strip()
if not USERNAME_RE.fullmatch(username) or normalize_username(username) == normalize_username("admin"):
raise UserStoreError("invalid Web username")
if not password:
raise UserStoreError("password is required")
if find_web_user(username):
raise UserStoreError("Web username already exists")
refs = _validate_refs(account_refs)
if all_assigned_refs().intersection(refs):
raise UserStoreError("one or more accounts are already assigned")
item = {
"username": username,
"role": "user",
"password_hash": hash_password(password),
"enabled": bool(enabled),
"account_refs": refs,
}
save_web_users(get_web_users() + [item])
return deepcopy(item)
def update_web_user(
username: str,
*,
new_username: str | None = None,
password: str | None = None,
enabled: bool | None = None,
account_refs: list[str] | None = None,
) -> dict:
users = get_web_users()
target = next((user for user in users if normalize_username(user["username"]) == normalize_username(username)), None)
if target is None:
raise UserStoreError("Web user not found")
original_username = target["username"]
if new_username is not None:
new_username = str(new_username).strip()
if not USERNAME_RE.fullmatch(new_username) or normalize_username(new_username) == normalize_username("admin"):
raise UserStoreError("invalid Web username")
if normalize_username(new_username) != normalize_username(target["username"]) and find_web_user(new_username):
raise UserStoreError("Web username already exists")
target["username"] = new_username
if password:
target["password_hash"] = hash_password(password)
if enabled is not None:
target["enabled"] = bool(enabled)
if account_refs is not None:
refs = _validate_refs(account_refs)
if all_assigned_refs(original_username).intersection(refs):
raise UserStoreError("one or more accounts are already assigned")
target["account_refs"] = refs
save_web_users(users)
return deepcopy(target)
def remove_account_refs_from_users(account_refs: list[str] | set[str]) -> int:
refs = {str(ref).strip() for ref in account_refs if str(ref).strip()}
if not refs:
return 0
users = get_web_users()
changed = 0
for user in users:
original = list(user.get("account_refs", []))
filtered = [ref for ref in original if ref not in refs]
if filtered != original:
user["account_refs"] = filtered
changed += 1
if changed:
save_web_users(users)
return changed
def delete_web_user(username: str) -> bool:
key = normalize_username(username)
users = get_web_users()
remaining = [user for user in users if normalize_username(user["username"]) != key]
if len(remaining) == len(users):
return False
save_web_users(remaining)
return True
+2 -2
View File
@@ -8,7 +8,7 @@
[![GitHub stars](https://img.shields.io/github/stars/halfwaystudent/douyin-sparkflow?style=social)](https://github.com/halfwaystudent/douyin-sparkflow) [![GitHub stars](https://img.shields.io/github/stars/halfwaystudent/douyin-sparkflow?style=social)](https://github.com/halfwaystudent/douyin-sparkflow)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/)
[![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Discussion-blue)](https://linux.do) [![LINUX DO](https://img.shields.io/badge/LINUX%20DO-Discussion-blue)](https://linux.do)
[功能特性](#-功能特性) • [快速开始](#-快速开始) • [使用文档](#-使用文档) • [部署指南](#-部署指南) • [社区讨论](https://linux.do) [功能特性](#-功能特性) • [快速开始](#-快速开始) • [使用文档](#-使用文档) • [部署指南](#-部署指南) • [社区讨论](https://linux.do)
@@ -69,7 +69,7 @@
### 前置要求 ### 前置要求
- Python 3.8 或更高版本 - Python 3.9 或更高版本
- Docker 和 Docker Compose(用于容器部署) - Docker 和 Docker Compose(用于容器部署)
- 稳定的网络连接 - 稳定的网络连接
+1
View File
@@ -66,6 +66,7 @@ services:
image: douyin-sparkflow:local image: douyin-sparkflow:local
container_name: login-desktop container_name: login-desktop
restart: unless-stopped restart: unless-stopped
init: true
depends_on: depends_on:
- proxy - proxy
environment: environment: