Add unsent fallback retry workflow

This commit is contained in:
Rixuan Shao
2026-05-29 18:00:47 +08:00
parent 849731442a
commit 11a6c949db
6 changed files with 253 additions and 19 deletions
+26 -1
View File
@@ -39,7 +39,15 @@ from webui.auth import (
validate_csrf,
verify_password,
)
from webui.ops import get_ops_snapshot, read_log_tail, refresh_proxy, restart_proxy, run_task_now, update_daily_schedule
from webui.ops import (
get_ops_snapshot,
read_log_tail,
refresh_proxy,
restart_proxy,
run_task_now,
run_unsent_retry_now,
update_daily_schedule,
)
BASE_DIR = Path(__file__).resolve().parent
@@ -580,6 +588,23 @@ def create_app():
flash(request, f"Triggered a full resend run in the background (pid {pid}).", "success")
return redirect("/")
@app.post("/ops/run-unsent")
async def run_unsent(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)
pid = run_unsent_retry_now()
if pid == -1:
flash(request, "Failed to start the unsent-target fallback run. Check server logs for details.", "error")
else:
flash(request, f"Triggered an unsent-target fallback run in the background (pid {pid}).", "success")
return redirect("/ops/send-console")
@app.post("/ops/proxy/refresh")
async def proxy_refresh(request: Request):
maybe_redirect = require_user(request)
+58 -12
View File
@@ -69,11 +69,31 @@ def build_task_run_spec():
return [sys.executable, "main.py", "--doTask"], repo_root()
def build_scheduled_task_command():
def _env_shell_prefix(extra_env=None):
parts = []
for key, value in (extra_env or {}).items():
parts.append(f"{key}={shlex.quote(str(value))}")
return " ".join(parts)
def _with_env_prefix(command, extra_env=None):
env_prefix = _env_shell_prefix(extra_env)
return f"env {env_prefix} {command}" if env_prefix else command
def _compose_env_args(extra_env=None):
parts = []
for key, value in (extra_env or {}).items():
parts.extend(["-e", f"{key}={value}"])
return " ".join(shlex.quote(part) for part in parts)
def build_scheduled_task_command(extra_env=None, trigger_label="scheduled send"):
if running_in_container():
task_command = _with_env_prefix("python main.py --doTask", extra_env)
return (
"/bin/bash -lc 'timestamp=$(date -Iseconds); "
"echo \"[AUTO_TRIGGER] $timestamp scheduled send start\"; "
f"echo \"[AUTO_TRIGGER] $timestamp {trigger_label} start\"; "
"container=$(docker ps --format \"{{.Names}}\" | "
"grep -E \"^(douyin-web-hostfix|douyin-web)$\" | head -n 1); "
"if [ -z \"$container\" ]; then "
@@ -82,21 +102,35 @@ def build_scheduled_task_command():
"fi; "
"echo \"[AUTO_TRIGGER] $timestamp container=$container\"; "
"docker exec \"$container\" sh -lc "
"\"cd /app && python main.py --doTask\"'"
f"\"cd /app && {task_command}\"'"
)
if compose_file_path():
compose_root_quoted = shlex.quote(str(compose_root()))
compose_env_args = _compose_env_args(extra_env)
compose_env_suffix = f" {compose_env_args}" if compose_env_args else ""
return (
"/bin/bash -lc "
f"'echo \"[AUTO_TRIGGER] $(date -Iseconds) compose task start\"; "
f"cd {compose_root_quoted} && /usr/bin/docker compose run --rm task'"
f"'echo \"[AUTO_TRIGGER] $(date -Iseconds) compose {trigger_label} start\"; "
f"cd {compose_root_quoted} && /usr/bin/docker compose run --rm{compose_env_suffix} task'"
)
repo_root_quoted = shlex.quote(str(repo_root()))
python_quoted = shlex.quote(sys.executable)
task_command = _with_env_prefix(f"{python_quoted} main.py --doTask", extra_env)
return (
"/bin/bash -lc "
f"'echo \"[AUTO_TRIGGER] $(date -Iseconds) local task start\"; "
f"cd {repo_root_quoted} && {python_quoted} main.py --doTask'"
f"'echo \"[AUTO_TRIGGER] $(date -Iseconds) local {trigger_label} start\"; "
f"cd {repo_root_quoted} && {task_command}'"
)
def build_unsent_fallback_task_command():
return build_scheduled_task_command(
{
"SPARKFLOW_MANUAL_RUN": "1",
"SPARKFLOW_MANUAL_UNSENT_ONLY": "1",
"PYTHONUNBUFFERED": "1",
},
trigger_label="unsent fallback",
)
@@ -204,18 +238,21 @@ def get_task_container_rows():
return []
def run_task_now():
def run_task_now(*, unsent_only=False):
try:
log_file = Path(get_app_settings().get("ops_log_file") or "/var/log/douyin-sparkflow.log")
command, cwd = build_task_run_spec()
run_env = {
"SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1",
}
if unsent_only:
run_env["SPARKFLOW_MANUAL_UNSENT_ONLY"] = "1"
return run_background_command(
command,
log_file,
cwd=cwd,
env={
"SPARKFLOW_MANUAL_RUN": "1",
"PYTHONUNBUFFERED": "1",
},
env=run_env,
)
except Exception as exc:
import traceback
@@ -224,6 +261,10 @@ def run_task_now():
return -1
def run_unsent_retry_now():
return run_task_now(unsent_only=True)
def refresh_proxy():
try:
script = Path(get_app_settings().get("proxy_refresh_script") or "")
@@ -308,6 +349,7 @@ def validate_time_string(time_string):
def replace_douyin_cron_schedule(crontab_text, time_string):
schedule = parse_schedule_string(time_string)
scheduled_command = build_scheduled_task_command()
fallback_command = build_unsent_fallback_task_command()
updated = []
for raw_line in crontab_text.splitlines():
@@ -325,6 +367,10 @@ def replace_douyin_cron_schedule(crontab_text, time_string):
f"0 {schedule['endHour']} * * * "
f"{scheduled_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
updated.append(
f"{schedule['scheduleIntervalMinutes']} {schedule['endHour']} * * * "
f"{fallback_command} >> /var/log/douyin-sparkflow.log 2>&1"
)
else:
updated.append(
f"{schedule['minute']} {schedule['hour']} * * * "
+61 -3
View File
@@ -7,6 +7,10 @@
{% block topbar_actions %}
<a class="ghost-button" href="/ops/send-console">✦ 发送控制台</a>
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit">补发未成功目标</button>
</form>
<form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">✈ 补发全部对象</button>
@@ -404,6 +408,17 @@
padding: 9px 10px;
}
.friend-option input[type="checkbox"] {
position: static;
inset: auto;
width: 16px;
height: 16px;
opacity: 1;
pointer-events: auto;
accent-color: var(--primary);
flex: 0 0 auto;
}
.friend-option.selected {
background: var(--primary-soft);
border-color: rgba(47, 103, 255, 0.12);
@@ -713,10 +728,10 @@
<span>启用自动续火花</span>
</label>
<label>
<span>手动目标好友(每行一个)</span>
<textarea name="targets" rows="5">{{ account.targets|default([], true)|join('\n') }}</textarea>
<span>目标好友(每行一个,可手动编辑或从下方勾选</span>
<textarea class="targets-textarea" name="targets" rows="5">{{ account.targets|default([], true)|join('\n') }}</textarea>
</label>
<p class="muted compact">如果好友过多无法滚动读取,可直接在这里填写目标昵称并保存</p>
<p class="muted compact">未在好友缓存中的昵称也会保留在目标列表里</p>
<div class="friend-picker"
data-account-id="{{ account.unique_id }}"
@@ -866,6 +881,10 @@
</div>
</div>
<div class="button-grid">
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit">补发未成功目标</button>
</form>
<form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">补发全部对象</button>
@@ -1164,6 +1183,9 @@
const summaryEl = picker.querySelector(".friend-picker-summary");
const statusEl = picker.querySelector(".friend-picker-status");
const hiddenInputsEl = picker.querySelector(".friend-selected-inputs");
const formEl = picker.closest("form");
const targetsTextarea = formEl?.querySelector(".targets-textarea");
const currentTargetsEl = picker.querySelector(".friend-picker-current-targets span");
let friends = parseJsonScript(`friends-cache-${accountId}`);
let selected = new Set(parseJsonScript(`selected-targets-${accountId}`));
@@ -1179,6 +1201,31 @@
return merged;
};
const splitTargetText = (value) => {
const seen = new Set();
return String(value || "")
.replaceAll(",", "\n")
.split(/\r?\n/)
.map((name) => name.trim())
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
});
};
const syncTextareaFromSelected = () => {
if (targetsTextarea) {
targetsTextarea.value = [...selected].join("\n");
}
};
const syncSelectedFromTextarea = () => {
if (targetsTextarea) {
selected = new Set(splitTargetText(targetsTextarea.value));
}
};
const renderHiddenInputs = () => {
hiddenInputsEl.innerHTML = "";
[...selected].forEach((name) => {
@@ -1192,6 +1239,9 @@
const updateSummary = () => {
summaryEl.textContent = `已选 ${selected.size}`;
if (currentTargetsEl) {
currentTargetsEl.textContent = selected.size ? [...selected].join("、") : "未选择";
}
};
const renderList = () => {
@@ -1229,6 +1279,7 @@
selected.delete(value);
option?.classList.remove("selected");
}
syncTextareaFromSelected();
renderHiddenInputs();
updateSummary();
});
@@ -1264,6 +1315,13 @@
});
searchInput.addEventListener("input", renderList);
if (targetsTextarea) {
targetsTextarea.addEventListener("input", () => {
syncSelectedFromTextarea();
renderList();
});
}
syncSelectedFromTextarea();
renderList();
});
})();
@@ -7,6 +7,10 @@
{% block topbar_actions %}
<a class="ghost-button" href="/">⌂ 返回首页</a>
<form method="post" action="/ops/run-unsent">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="soft-button" type="submit">补发未成功目标</button>
</form>
<form method="post" action="/ops/run-now">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">✈ 补发全部对象</button>