Sync remote sparkflow changes and add send console

This commit is contained in:
Rixuan Shao
2026-05-17 22:48:30 +08:00
parent de4f3aeb74
commit 9e3cc85215
7 changed files with 967 additions and 83 deletions
+85 -3
View File
@@ -1,7 +1,7 @@
import json
import logging
import traceback
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pathlib import Path
import urllib.error
import urllib.request
@@ -16,6 +16,7 @@ from starlette.middleware.sessions import SessionMiddleware
logger = logging.getLogger(__name__)
from core.friends import fetch_account_friends
from core.tasks import run_browser_tasks
from utils.config import (
get_app_settings,
get_config,
@@ -96,6 +97,31 @@ def coerce_int(value, default, minimum=0):
return max(minimum, int(default))
def _schedule_timezone():
return timezone(timedelta(hours=8), name="Asia/Shanghai")
def _parse_sent_at(raw_value):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=_schedule_timezone())
return parsed.astimezone(_schedule_timezone())
def _target_sent_today(account, target_name):
entry = dict(account.get("message_history") or {}).get(target_name) or {}
sent_at = _parse_sent_at(entry.get("sentAt"))
return bool(sent_at and sent_at.date() == datetime.now(_schedule_timezone()).date())
def login_desktop_api_url():
settings = get_app_settings(force_reload=True)
return str(settings.get("login_desktop_api_url") or "http://127.0.0.1:18090").rstrip("/")
@@ -278,6 +304,21 @@ def create_app():
},
)
@app.get("/ops/send-console", response_class=HTMLResponse)
async def send_console_page(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return maybe_redirect
return render_template(
request,
"send_console.html",
{
"flash": pop_flash(request),
"ops": get_ops_snapshot(),
},
)
@app.post("/accounts/{unique_id}/update")
async def update_account(request: Request, unique_id: str):
maybe_redirect = require_user(request)
@@ -378,6 +419,47 @@ def create_app():
flash(request, "Account not found.", "error")
return redirect("/")
@app.post("/accounts/{unique_id}/retry-target")
async def retry_account_target(request: Request, unique_id: str):
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)
target_name = str(form.get("target", "")).strip()
if not target_name:
flash(request, "Target is required for retry.", "error")
return redirect("/ops/send-console")
accounts = get_userData(force_reload=True)
account = find_account(accounts, unique_id)
if not account:
flash(request, "Account not found.", "error")
return redirect("/ops/send-console")
account_copy = dict(account)
account_copy["targets"] = [target_name]
config = get_config(force_reload=True)
config["taskCount"] = 1
try:
await run_browser_tasks(config, [account_copy])
except Exception as exc:
flash(request, f"Retry failed for {account.get('username', 'Account')} / {target_name}: {exc}", "error")
return redirect("/ops/send-console")
updated_account = find_account(get_userData(force_reload=True), unique_id) or {}
if _target_sent_today(updated_account, target_name):
flash(request, f"Retried {account.get('username', 'Account')} / {target_name} successfully.", "success")
else:
failure_entry = dict(updated_account.get("failure_queue") or {}).get(target_name) or {}
reason = str(failure_entry.get("reason") or "Retry did not confirm a successful send.")
flash(request, f"Retry did not succeed for {account.get('username', 'Account')} / {target_name}: {reason}", "error")
return redirect("/ops/send-console")
@app.post("/config")
async def save_runtime_config(request: Request):
maybe_redirect = require_user(request)
@@ -493,9 +575,9 @@ def create_app():
pid = run_task_now()
if pid == -1:
flash(request, "Task launch failed. Check console logs for Missing Docker or protected log_file path.", "error")
flash(request, "Failed to start failed-target retry run. Check server logs for details.", "error")
else:
flash(request, f"Triggered a background task run (pid {pid}).", "success")
flash(request, f"Triggered a failed-target retry run in the background (pid {pid}).", "success")
return redirect("/")
@app.post("/ops/proxy/refresh")
+172 -1
View File
@@ -1,13 +1,16 @@
import json
import hashlib
import logging
import os
import re
import shlex
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
from utils.config import get_app_settings, get_config, repo_root, save_config
from utils.config import get_app_settings, get_config, get_userData, normalize_unique_id, repo_root, save_config
logger = logging.getLogger(__name__)
@@ -193,6 +196,7 @@ def run_task_now():
cwd=cwd,
env={
"SPARKFLOW_MANUAL_RUN": "1",
"SPARKFLOW_MANUAL_FAILED_ONLY": "1",
"PYTHONUNBUFFERED": "1",
},
)
@@ -375,6 +379,172 @@ def current_daily_schedule():
return ""
def _schedule_timezone():
timezone_name = (
str(os.getenv("SPARKFLOW_TIMEZONE") or "").strip()
or str(os.getenv("TZ") or "").strip()
or "Asia/Shanghai"
)
try:
return ZoneInfo(timezone_name)
except Exception:
if timezone_name == "Asia/Shanghai":
return timezone(timedelta(hours=8), name="Asia/Shanghai")
return datetime.now().astimezone().tzinfo
def _normalize_send_window():
raw = dict(get_config(force_reload=True).get("dailySendWindow") or {})
return {
"enabled": bool(raw.get("enabled", False)),
"startHour": int(raw.get("startHour", 10)),
"endHour": int(raw.get("endHour", 18)),
"scheduleIntervalMinutes": max(1, int(raw.get("scheduleIntervalMinutes", 10))),
}
def _parse_sent_at(raw_value, local_tz):
if not raw_value:
return None
raw = str(raw_value).strip()
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=local_tz)
return parsed.astimezone(local_tz)
def _account_identity(user):
return str(user.get("unique_id") or user.get("username") or "unknown").strip()
def _scheduled_send_time(user, target_name, send_window, now):
window_minutes = max(1, (send_window["endHour"] - send_window["startHour"]) * 60)
start_of_window = now.replace(
hour=send_window["startHour"],
minute=0,
second=0,
microsecond=0,
)
seed = f"{now.date().isoformat()}|{_account_identity(user)}|{target_name}"
digest = hashlib.sha256(seed.encode("utf-8")).digest()
offset_minutes = int.from_bytes(digest[:8], "big") % window_minutes
return start_of_window + timedelta(minutes=offset_minutes)
def _build_target_status(account, target_name, now, send_window):
history = dict(account.get("message_history") or {})
failure_queue = dict(account.get("failure_queue") or {})
history_entry = history.get(target_name) or {}
sent_at = _parse_sent_at(history_entry.get("sentAt"), now.tzinfo)
if sent_at and sent_at.date() == now.date():
return {
"target": target_name,
"status": "sent",
"message": str(history_entry.get("message") or ""),
"sentAt": sent_at.isoformat(timespec="seconds"),
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": "",
}
failure_entry = failure_queue.get(target_name) or {}
last_attempt_at = _parse_sent_at(failure_entry.get("lastAttemptAt"), now.tzinfo)
if last_attempt_at and last_attempt_at.date() == now.date():
return {
"target": target_name,
"status": "failed",
"message": str(failure_entry.get("message") or ""),
"sentAt": "",
"lastAttemptAt": last_attempt_at.isoformat(timespec="seconds"),
"category": str(failure_entry.get("category") or ""),
"reason": str(failure_entry.get("reason") or ""),
"attemptCount": int(failure_entry.get("attemptCount") or 0),
"scheduledAt": "",
}
scheduled_at = None
if send_window.get("enabled"):
scheduled_at = _scheduled_send_time(account, target_name, send_window, now)
if scheduled_at > now:
return {
"target": target_name,
"status": "pending",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds"),
}
return {
"target": target_name,
"status": "unprocessed",
"message": "",
"sentAt": "",
"lastAttemptAt": "",
"category": "",
"reason": "",
"attemptCount": 0,
"scheduledAt": scheduled_at.isoformat(timespec="seconds") if scheduled_at else "",
}
def get_send_console_snapshot():
accounts = [account for account in get_userData(force_reload=True) if account.get("enabled", True)]
send_window = _normalize_send_window()
now = datetime.now(_schedule_timezone())
summary = {
"enabled_accounts": len(accounts),
"today_sent_targets": 0,
"today_failed_targets": 0,
"today_pending_targets": 0,
"today_unprocessed_targets": 0,
}
account_rows = []
for account in accounts:
statuses = [_build_target_status(account, target_name, now, send_window) for target_name in account.get("targets") or []]
sent_targets = [item for item in statuses if item["status"] == "sent"]
failed_targets = [item for item in statuses if item["status"] == "failed"]
pending_targets = [item for item in statuses if item["status"] == "pending"]
unprocessed_targets = [item for item in statuses if item["status"] == "unprocessed"]
summary["today_sent_targets"] += len(sent_targets)
summary["today_failed_targets"] += len(failed_targets)
summary["today_pending_targets"] += len(pending_targets)
summary["today_unprocessed_targets"] += len(unprocessed_targets)
account_rows.append(
{
"unique_id": str(account.get("unique_id") or ""),
"username": account.get("username") or "",
"sent_targets": sent_targets,
"failed_targets": failed_targets,
"pending_targets": pending_targets,
"unprocessed_targets": unprocessed_targets,
"last_failure_reason": failed_targets[0]["reason"] if failed_targets else "",
"failure_queue": dict(account.get("failure_queue") or {}),
}
)
return {
"now": now.isoformat(timespec="seconds"),
"summary": summary,
"accounts": account_rows,
}
def _check_image_present():
"""Return True if the douyin-sparkflow:local image exists."""
try:
@@ -400,6 +570,7 @@ def get_ops_snapshot():
"compose_file": str(compose_file_path() or ""),
"containers": get_container_status(),
"task_containers": get_task_container_rows(),
"send_console": get_send_console_snapshot(),
"daily_schedule": current_daily_schedule(),
"crontab": read_crontab(),
"log_tail": read_log_tail(120),
@@ -141,6 +141,74 @@
</div>
</section>
<section class="panel" id="send-console-summary">
<div class="section-title-row">
<div>
<h2>发送控制台摘要</h2>
<p class="muted compact">展示今天的成功、失败、待发送与待补发状态。失败目标可在详情页单独重试。</p>
</div>
<a class="link-button" href="/ops/send-console">打开发送控制台</a>
</div>
<div class="stats-grid" style="grid-template-columns: repeat(4, minmax(0, 1fr));">
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">启用账号</span>
<strong class="stat-value">{{ ops.send_console.summary.enabled_accounts }}</strong>
</div>
<div class="stat-icon blue">A</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">今日成功目标</span>
<strong class="stat-value">{{ ops.send_console.summary.today_sent_targets }}</strong>
</div>
<div class="stat-icon green">OK</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">失败待补发</span>
<strong class="stat-value">{{ ops.send_console.summary.today_failed_targets }}</strong>
</div>
<div class="stat-icon soft">!</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">待发送 / 未处理</span>
<strong class="stat-value">{{ ops.send_console.summary.today_pending_targets + ops.send_console.summary.today_unprocessed_targets }}</strong>
</div>
<div class="stat-icon soft">...</div>
</article>
</div>
<div class="table-shell" style="margin-top: 16px;">
<table>
<thead>
<tr>
<th>账号</th>
<th>今日成功</th>
<th>失败待补发</th>
<th>待发送</th>
<th>未处理</th>
<th>最后失败原因</th>
</tr>
</thead>
<tbody>
{% for row in ops.send_console.accounts %}
<tr>
<td><strong>{{ row.username }}</strong><br><span class="muted">{{ row.unique_id }}</span></td>
<td>{{ row.sent_targets|length }}</td>
<td>{{ row.failed_targets|length }}</td>
<td>{{ row.pending_targets|length }}</td>
<td>{{ row.unprocessed_targets|length }}</td>
<td>{{ row.last_failure_reason or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="6">暂无发送状态摘要。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel" id="account-management">
<div class="section-title-row">
<div>
@@ -437,6 +505,21 @@
{% block scripts %}
<script>
(() => {
const runNowForm = document.querySelector('form[action="/ops/run-now"]');
if (!runNowForm) return;
const button = runNowForm.querySelector('button[type="submit"]');
if (button) {
button.textContent = "补发全部失败项";
}
if (!runNowForm.nextElementSibling || !runNowForm.nextElementSibling.classList.contains("failed-only-note")) {
const note = document.createElement("p");
note.className = "muted compact failed-only-note";
note.textContent = "“补发全部失败项”只处理今天 failure_queue 中的目标,不会全量重发全部账号。";
runNowForm.insertAdjacentElement("afterend", note);
}
})();
(() => {
const root = document.getElementById("login-desktop-controls");
if (!root) return;
@@ -0,0 +1,185 @@
{% extends "base.html" %}
{% block title %}发送控制台{% endblock %}
{% block page_title %}发送控制台{% endblock %}
{% block content %}
{% set send_console = ops.send_console %}
<div class="stack">
<section class="panel">
<div class="section-title-row">
<div>
<h2>今日发送总览</h2>
<p class="muted compact">当前时间:{{ send_console.now }}。本页按账号展示今天已成功、失败待补发、待发送和未处理目标。</p>
</div>
<div class="button-row two" style="width: 320px;">
<a class="ghost-button" href="/">返回首页</a>
<form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">补发全部失败项</button>
</form>
</div>
</div>
<div class="stats-grid">
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">启用账号</span>
<strong class="stat-value">{{ send_console.summary.enabled_accounts }}</strong>
</div>
<div class="stat-icon blue">A</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">今日成功目标</span>
<strong class="stat-value">{{ send_console.summary.today_sent_targets }}</strong>
</div>
<div class="stat-icon green">OK</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">失败待补发</span>
<strong class="stat-value">{{ send_console.summary.today_failed_targets }}</strong>
</div>
<div class="stat-icon soft">!</div>
</article>
<article class="stat-card">
<div class="stat-meta">
<span class="stat-label">待发送 / 未处理</span>
<strong class="stat-value">{{ send_console.summary.today_pending_targets + send_console.summary.today_unprocessed_targets }}</strong>
</div>
<div class="stat-icon soft">...</div>
</article>
</div>
</section>
{% for account in send_console.accounts %}
<section class="panel">
<div class="section-title-row">
<div>
<h2>{{ account.username }}</h2>
<p class="muted compact">unique_id: {{ account.unique_id }}</p>
</div>
<div class="status-line">
<span class="pill soft">成功 {{ account.sent_targets|length }}</span>
<span class="pill {% if account.failed_targets %}danger{% else %}soft{% endif %}">失败 {{ account.failed_targets|length }}</span>
<span class="pill">待发送 {{ account.pending_targets|length }}</span>
<span class="pill warning">未处理 {{ account.unprocessed_targets|length }}</span>
</div>
</div>
<div class="layout-grid" style="grid-template-columns: repeat(2, minmax(0, 1fr));">
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
<h3>今日已成功</h3>
<div class="table-shell" style="margin-top: 12px;">
<table>
<thead>
<tr>
<th>目标</th>
<th>消息</th>
<th>sentAt</th>
</tr>
</thead>
<tbody>
{% for item in account.sent_targets %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.message or "-" }}</td>
<td>{{ item.sentAt or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="3">暂无今日成功目标。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
<h3>失败待补发</h3>
<div class="table-shell" style="margin-top: 12px;">
<table>
<thead>
<tr>
<th>目标</th>
<th>失败分类</th>
<th>失败原因</th>
<th>次数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{% for item in account.failed_targets %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.category or "-" }}</td>
<td>{{ item.reason or "-" }}</td>
<td>{{ item.attemptCount or 0 }}</td>
<td>
<form method="post" action="/accounts/{{ account.unique_id }}/retry-target">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="target" value="{{ item.target }}">
<button type="submit" class="ghost-button">重试此目标</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="5">暂无失败待补发目标。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
<h3>今日待发送</h3>
<div class="table-shell" style="margin-top: 12px;">
<table>
<thead>
<tr>
<th>目标</th>
<th>scheduledAt</th>
</tr>
</thead>
<tbody>
{% for item in account.pending_targets %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.scheduledAt or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="2">暂无今日待发送目标。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="panel" style="box-shadow:none; margin:0; padding:14px;">
<h3>今日未处理</h3>
<div class="table-shell" style="margin-top: 12px;">
<table>
<thead>
<tr>
<th>目标</th>
<th>scheduledAt</th>
</tr>
</thead>
<tbody>
{% for item in account.unprocessed_targets %}
<tr>
<td>{{ item.target }}</td>
<td>{{ item.scheduledAt or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="2">暂无未处理目标。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
</section>
{% endfor %}
</div>
{% endblock %}