Dify 任务提交幂等实战:避免重复点击与网络重试创建重复任务

任务页面已经能提交文件,但按钮禁用只能减少当前页面的重复点击。如果第一次请求已经入库,受理响应却没有回到浏览器,再提交一次就可能创建第二个任务。

本篇在 FastAPI 业务后端增加用户范围内的幂等提交:相同提交编号和相同文件返回原任务;编号相同但内容不同则拒绝。页面不自动换键、不自动重试,让使用者明确区分"找回原任务"和"创建新任务"。

提供完整后端扩展、页面和启动脚本,继续按 Mac、Windows 两种方式说明。配套包保留第十二篇的基础后端,不改动之前的文章文件。

1. 先定义本篇的幂等规则

本篇把一次提交意图标识为:

text 复制代码
已验证用户 owner + Idempotency-Key
场景 结果
同用户、同键、同文件 返回原 job_id,不再次安排后台执行
同用户、同键、文件内容不同 409,拒绝把同一个键用于另一批文件
同用户、新键、相同文件 视为新的提交意图,可以创建新任务
不同用户、相同键 分别处理,不跨用户复用任务
原任务 failed 或 unknown,再使用原键 仍返回原任务,不自动重新执行

"同文件"在本文指有序文件名、文件数量和实际字节等信息完全匹配。它不是语义上的"内容大概一样"。改名、改变文件顺序,或者把换行从 LF 改成 CRLF,都可能改变指纹。

Idempotency-Key 是本文业务接口的约定,不是用户身份凭据。业务 token 仍负责认证;这把键也没有被转发给 Dify,本文不依赖上游识别它。

2. 范围与版本基线

资料核对日期:2026-10-26。沿用本系列 Dify 社区版 1.17.1,以及 FastAPI 0.128.0、Uvicorn 0.40.0、requests 2.32.5 的固定后端基线;本地验证 Python 为 3.13.3。

前置条件仍是已发布 Workflow:输入 documents 文件列表,最终输出 report:String、data:Object。后台的上传、SSE、报告校验和用户隔离继续由第十二篇基础后端负责。

本文提供的是本地提交去重 。它不保证进程崩溃后任务必定执行,也不宣称 Dify 或整个分布式链路"恰好执行一次"。普通运行接口仍用于发起一次工作流执行。Dify 运行接口

保持单实例、单 worker,不启用 reload。数据库唯一约束能协调并发提交,不代表前篇的启动恢复逻辑已经适合多进程部署。

3. 新接口和文件安排

新增提交接口:

http 复制代码
POST /idempotent-jobs
Authorization: Bearer <自己的业务token>
Idempotency-Key: <本次提交编号>
Content-Type: multipart/form-data; boundary=<客户端生成>

文件字段仍为 files,支持 1~3 份 TXT,每份 1 字节~2 MiB。后端需要实际读取文件后计算指纹,不能信任浏览器传来的"文件没变化"声明。

返回 含义
202,replayed=false 新记录已受理,安排一次后台处理
200,replayed=true 找到相同意图的原任务,返回其当前本地状态
409 原键绑定了不同文件,不创建第二个任务
422 例如缺少必填的 Idempotency-Key 或文件字段
503 存储暂时不可用,保留原键和原文件,稍后人工重试

返回 200 表示找到了原记录,不表示原任务已经成功完成。后续仍通过 GET /jobs/{job_id} 查询,再按完成状态下载报告。

目录安排:

text 复制代码
idempotency-demo/
  dify_backend_app.py                 第十二篇基础后端
  dify-backend-requirements.txt        固定依赖
  dify_idempotent_app.py              本篇新增:幂等提交扩展
  dify-idempotent-page.html           本篇新增:带提交编号的页面
  start-dify-idempotent.py            本篇新增:启动入口
  api-notes-a.txt
  api-notes-b.txt

基础模块的旧 POST /jobs 仍然存在,它没有获得本篇的幂等能力。新页面明确调用 /idempotent-jobs;其他程序接入时也要使用新接口。

4. 请求指纹要覆盖实际处理内容

本例指纹包括:

text 复制代码
version:本教程的业务契约版本
operation:document-summary
files:按顺序记录每份文件的 name、size、SHA-256

先对实际文件字节计算 SHA-256,再将描述对象按稳定方式序列化并计算整体指纹。multipart boundary、浏览器临时路径或文件修改时间不作为内容指纹。

指纹不是全局文件缓存键。数据库按 owner 与提交键查找,指纹只用于判断这次重试是否仍在表达同一请求。

将来如果增加影响处理结果的表单参数,也要把它们纳入指纹。不要只检查文件名,或者沿用一个没有包含新业务参数的旧指纹版本。

5. 完整后端扩展

保存为 dify_idempotent_app.py:

python 复制代码
"""Owner-scoped idempotent submission; reuses the article 12 task backend."""
from contextlib import asynccontextmanager
import hashlib
import hmac
import json
from pathlib import Path
import re
import shutil
import sqlite3
import uuid

from fastapi import BackgroundTasks, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from starlette.concurrency import run_in_threadpool
import dify_backend_app as base


def fingerprint(items):
    value = {"version": 1, "operation": "document-summary", "files": [
        {"name": name, "size": len(content), "sha256": hashlib.sha256(content).hexdigest()}
        for name, content in items
    ]}
    canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def existing(conn, owner, key, digest):
    row = conn.execute("SELECT fingerprint,job_id FROM job_submissions WHERE owner=? AND idem_key=?", (owner, key)).fetchone()
    if row is None:
        return None
    if row["fingerprint"] != digest:
        raise HTTPException(409, "This submission key is already bound to different files")
    job = conn.execute("SELECT id,state FROM jobs WHERE id=? AND owner=?", (row["job_id"], owner)).fetchone()
    if job is None:
        raise HTTPException(500, "Stored submission cannot be verified")
    return {"job_id": job["id"], "status": job["state"], "replayed": True}


def reserve_job(app, owner, key, items):
    digest = fingerprint(items)
    with base.database(app) as conn:
        previous = existing(conn, owner, key, digest)
    if previous is not None:
        return previous
    job_id = uuid.uuid4().hex
    folder = app.state.root / job_id
    folder.mkdir()
    committed = False
    try:
        for index, (_, content) in enumerate(items, start=1):
            (folder / f"input-{index}.txt").write_bytes(content)
        with base.database(app) as conn:
            conn.execute("BEGIN IMMEDIATE")
            previous = existing(conn, owner, key, digest)
            if previous is not None:
                return previous
            timestamp = base.now()
            conn.execute("INSERT INTO jobs(id,owner,state,names,created_at,updated_at) VALUES(?,?,?,?,?,?)",
                (job_id, owner, "queued", json.dumps([name for name, _ in items], ensure_ascii=False), timestamp, timestamp))
            conn.execute("INSERT INTO job_submissions(owner,idem_key,fingerprint,job_id,created_at) VALUES(?,?,?,?,?)",
                (owner, key, digest, job_id, timestamp))
        committed = True
        return {"job_id": job_id, "status": "queued", "replayed": False}
    finally:
        if not committed:
            shutil.rmtree(folder, ignore_errors=True)


def create_app():
    app = base.create_app()
    original_lifespan = app.router.lifespan_context

    @asynccontextmanager
    async def lifespan(app):
        async with original_lifespan(app):
            with base.database(app) as conn:
                conn.execute("""CREATE TABLE IF NOT EXISTS job_submissions (
                    owner TEXT NOT NULL, idem_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
                    job_id TEXT NOT NULL, created_at TEXT NOT NULL,
                    PRIMARY KEY(owner,idem_key))""")
            yield

    app.router.lifespan_context = lifespan

    def owner(credentials=Depends(base.bearer)):
        if credentials is not None and credentials.scheme.lower() == "bearer":
            for user, token in app.state.tokens.items():
                if hmac.compare_digest(credentials.credentials.encode(), token.encode()):
                    return user
        raise HTTPException(401, "Invalid business token", headers={"WWW-Authenticate": "Bearer"})

    @app.get("/", include_in_schema=False)
    def page():
        return FileResponse(Path(__file__).with_name("dify-idempotent-page.html"), headers={"Cache-Control": "no-store"})

    @app.post("/idempotent-jobs", responses={202: {"description": "New job accepted"},
                                            409: {"description": "Key conflicts with submitted files"}})
    async def submit(background_tasks: BackgroundTasks, files: list[UploadFile] = File(...),
                     key: str = Header(..., alias="Idempotency-Key"), user=Depends(owner)):
        items = []
        try:
            if not re.fullmatch(r"[A-Za-z0-9_-]{16,128}", key):
                raise HTTPException(400, "Invalid submission key")
            if not 1 <= len(files) <= 3:
                raise HTTPException(400, "Submit 1-3 TXT files")
            for upload in files:
                name = upload.filename or ""
                if not name or len(name) > 255 or re.search(r"[/\\\x00-\x1f\x7f]", name) or not name.lower().endswith(".txt"):
                    raise HTTPException(400, "Invalid TXT filename")
                content = await upload.read(base.MAX_BYTES + 1)
                if not content or len(content) > base.MAX_BYTES:
                    raise HTTPException(400, "Each TXT must contain 1 byte to 2 MiB")
                items.append((name, content))
        finally:
            for upload in files:
                await upload.close()
        try:
            result = await run_in_threadpool(reserve_job, app, user, key, items)
        except HTTPException:
            raise
        except sqlite3.OperationalError:
            raise HTTPException(503, "Submission storage is temporarily unavailable; retain the same key") from None
        except Exception:
            raise HTTPException(500, "Could not save submission; retain the same key") from None
        if not result["replayed"]:
            background_tasks.add_task(base.run_job, app, result["job_id"])
        return JSONResponse(result, status_code=200 if result["replayed"] else 202)

    return app


app = create_app()

模块复用基础后端的认证配置、数据库连接、查询和后台处理。新增接口只在确定创建了新记录时,才向 BackgroundTasks 添加处理函数;重放分支不补发后台任务。

文件读取结束后,指纹、候选文件转存和数据库操作放在线程池中进行,避免把这部分同步处理直接放在异步路由的事件循环中。Starlette 线程池说明

6. 数据库如何拦住并发创建

核心唯一约束是:

sql 复制代码
PRIMARY KEY(owner, idem_key)

两个字段都显式声明 NOT NULL。不能只给 idem_key 加全局唯一限制,否则不同用户会互相占用编号。SQLite 唯一约束

创建流程:

  1. 先查已有映射,常规重试可以快速返回。
  2. 未找到时,转存一份由本次请求拥有的候选文件。
  3. 开启 BEGIN IMMEDIATE,在事务内再次检查映射。
  4. 确认仍不存在时,写入 jobs 和 job_submissions。
  5. 提交成功后,只有新任务分支安排后台处理。

第一次 SELECT 是优化,不是并发正确性的全部依据。第二次检查在写事务内进行,数据库唯一约束负责限制重复组合。SQLite 写事务与 BEGIN IMMEDIATE

任务记录和键映射在同一个 SQLite 事务里提交。映射写入失败时,任务记录也回滚;本次未获采用的候选目录会被清理,不会删除已经属于原任务的目录。

指纹和磁盘文件处理发生在数据库写事务之前,事务中不上传 Dify、不等待 SSE。遇到数据库忙或存储错误,接口返回受控错误,不换键再创建另一条记录。

7. 启动脚本与固定依赖

保存为 start-dify-idempotent.py:

python 复制代码
import getpass
import json
import os

import uvicorn


if __name__ == "__main__":
    os.environ["DIFY_API_KEY"] = getpass.getpass("Dify application API Key: ").strip()
    alice = getpass.getpass("Alice business token (16-128 letters/digits/_/-): ").strip()
    bob = getpass.getpass("Bob business token (different from Alice): ").strip()
    os.environ["APP_USER_TOKENS"] = json.dumps({"alice": alice, "bob": bob})
    os.environ.setdefault("JOB_DATA_DIR", "./job-data-idempotent")
    uvicorn.run("dify_idempotent_app:app", host="127.0.0.1", port=8000, workers=1)

默认使用新的 ./job-data-idempotent 目录,方便与前篇示例区分。重新启动后应继续使用同一个数据目录和稳定 owner 映射;业务 token 可以更换,但调用时要使用当前映射到相同 owner 的有效 token。

依赖文件 dify-backend-requirements.txt:

text 复制代码
fastapi==0.128.0
uvicorn==0.40.0
python-multipart==0.0.21
requests==2.32.5

这是既有验证基线,不是最新版本声明。本文新增模块不增加第三方依赖。

8. 完整页面:键不能在每次点击时重新生成

保存为 dify-idempotent-page.html:

html 复制代码
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>文档任务工作台 · 幂等提交</title>
<style>
:root{color-scheme:light;--brand:#7357FF;--brand-1:#F1EEFF;--nav-bg-1:#2E2266;--nav-bg-2:#171133;--success:#6DCA6E;--success-1:#F0F9F0;--warning:#F49F16;--warning-1:#FDF5E7;--error:#FF5A5A;--error-1:#FFEEEE;--link:#549DFC;--link-1:#DDEBFE;--text-1:#333;--text-2:#666;--text-3:#999;--text-4:#CCC;--fill-1:#DDD;--fill-2:#F4F4F4;--border:#EBEBEB;--radius:8px;--radius-sm:6px}
*{box-sizing:border-box}body{margin:0;color:var(--text-1);background:var(--fill-2);font:14px/22px "PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif}
header{height:64px;background:Canvas;border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 20px;gap:16px}header strong{font-size:20px;line-height:30px}header span{color:var(--text-2)}
.layout{display:grid;grid-template-columns:200px 1fr;min-height:calc(100vh - 64px)}aside{background:var(--nav-bg-1);padding:24px 16px}aside a{display:block;padding:8px 12px;color:var(--text-4);text-decoration:none;border-radius:var(--radius-sm);margin-bottom:8px}aside .selected{background:var(--brand);color:Canvas;border-left:4px solid var(--link)}
main{padding:24px 20px;max-width:1200px;width:100%}h1{font-size:24px;line-height:36px;margin:0 0 8px}h2{font-size:16px;line-height:24px;margin:0 0 16px}p{margin:0 0 16px;color:var(--text-2)}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.panel{background:Canvas;border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:20px}.row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}label{display:block;margin-bottom:8px}.field{margin-bottom:16px}
input{height:32px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;color:var(--text-1);font:inherit;max-width:100%;width:100%}input:focus-visible,button:focus-visible,a:focus-visible{outline:2px solid var(--brand);outline-offset:2px}input::placeholder{color:var(--text-4)}input[type=file]{height:auto;min-height:32px;padding:4px}
button{height:32px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 16px;background:Canvas;color:var(--text-1);font:inherit;cursor:pointer}button:hover{border-color:var(--brand);color:var(--brand)}button.primary{background:var(--brand);border-color:var(--brand);color:Canvas}button.primary:hover{background:var(--nav-bg-1)}button:disabled{background:var(--fill-2);color:var(--text-4);border-color:var(--border);cursor:not-allowed}
.note{font-size:12px;line-height:18px;color:var(--text-2);margin:8px 0 0}.badge{display:inline-block;padding:4px 12px;border-radius:var(--radius-sm);background:var(--fill-2)}.badge[data-kind=success]{background:var(--success-1)}.badge[data-kind=warning]{background:var(--warning-1)}.badge[data-kind=error]{background:var(--error-1)}
#message{min-height:22px;margin:0 0 20px;color:var(--text-2)}#message[data-kind=error]{color:var(--error)}#details{white-space:pre-wrap;overflow-wrap:anywhere;background:var(--fill-2);padding:16px;border-radius:var(--radius-sm);margin:16px 0 0;font:12px/18px monospace}.summary{margin:16px 0;color:var(--text-2)}
@media(max-width:760px){.layout{grid-template-columns:1fr}aside{display:none}.grid{grid-template-columns:1fr;gap:0}header span{display:none}}
</style>
</head>
<body>
<header><strong>文档任务工作台</strong><span>提交资料 · 核对进展 · 下载报告</span></header>
<div class="layout">
<aside aria-label="导航"><a class="selected" href="/" aria-current="page">文件处理</a><a href="/docs" target="_blank" rel="noopener">接口文档</a></aside>
<main>
<h1>处理文档,保留每份结果</h1><p>提交后请保存任务 ID。页面关闭不会取消后台任务。</p>
<section class="panel" aria-labelledby="access-title"><h2 id="access-title">当前访问身份</h2>
<label for="token">业务 token *</label><div class="row"><input id="token" type="password" autocomplete="off" placeholder="输入自己的业务 token,不是 Dify 应用密钥" style="flex:1;min-width:200px"><button id="clear" type="button">清空凭证与结果</button></div>
<p class="note">凭证仅供本页请求使用;切换凭证会清空当前任务视图。</p></section>
<div class="grid">
<section class="panel" aria-labelledby="upload-title"><h2 id="upload-title">提交新任务</h2>
<form id="upload-form"><div class="field"><label for="files">TXT 文档 *</label><input id="files" type="file" accept=".txt,text/plain" multiple><p class="note">1~3 份,每份 1 字节~2 MiB。同一编号和相同文件会返回原任务。</p></div><div class="field"><label for="submission-key">本次提交编号(幂等键) *</label><input id="submission-key" autocomplete="off"><p class="note">重试保留原编号和原文件;确认新任务时再生成新编号。刷新页面前请自行保存编号。</p></div><div class="row"><button id="submit" class="primary" type="submit">提交或重试</button><button id="new-key" type="button">生成新提交编号</button></div></form></section>
<section class="panel" aria-labelledby="lookup-title"><h2 id="lookup-title">查看已有任务</h2>
<form id="lookup-form"><div class="field"><label for="job-id">任务 ID *</label><input id="job-id" autocomplete="off" placeholder="输入已保存的本地任务 ID"></div><div class="row"><button id="lookup" type="submit">查询任务</button><button id="stop" type="button" disabled>停止自动查询</button></div></form></section>
</div>
<p id="message" role="status" aria-live="polite">请先填写自己的业务 token。</p>
<section class="panel" aria-labelledby="result-title"><h2 id="result-title">任务结果</h2>
<span id="status" class="badge">尚未查询</span><p id="summary" class="summary">未读取任务记录。</p>
<div class="row"><button id="refresh" type="button" disabled>核实原运行</button><button id="download" type="button" disabled>下载 Markdown 报告</button></div>
<p class="note">"核实原运行"只查询原任务,不重新提交文件。部分成功时请检查明细。</p>
<pre id="details">暂无记录。</pre></section>
</main></div>
<script>
"use strict";
const $ = id => document.getElementById(id);
const labels = {queued:"已受理",running:"后台处理中",succeeded:"处理完成","partial-succeeded":"部分成功,请核对明细",failed:"处理失败",stopped:"已停止",unknown:"待核实"};
let epoch = 0, active = null, timer = null, busy = false, polling = false, allowPolling = true, reads = 0, current = null;
const done = job => job && ["succeeded","partial-succeeded"].includes(job.status);
function notice(text, kind="info") { $("message").textContent = text; $("message").dataset.kind = kind; }
function buttons() {
  for (const id of ["submit","lookup","new-key"]) $(id).disabled = busy;
  $("stop").disabled = !polling;
  $("refresh").disabled = busy || !current || current.status !== "unknown" || current.has_run_id !== true;
  $("download").disabled = busy || !done(current);
}
function halt() { allowPolling = false; polling = false; clearTimeout(timer); timer = null; buttons(); }
function invalidate(clearId=true) {
  epoch++; halt(); if (active) active.abort(); active = null; busy = false; current = null; reads = 0; allowPolling = true;
  if (clearId) $("job-id").value = "";
  $("status").textContent = "尚未查询"; $("status").dataset.kind = "info";
  $("summary").textContent = "未读取任务记录。"; $("details").textContent = "暂无记录。"; buttons();
}
function newKey() {
  return [...crypto.getRandomValues(new Uint8Array(16))].map(value => value.toString(16).padStart(2,"0")).join("");
}
function submissionKey() {
  const key = $("submission-key").value.trim();
  if (!/^[A-Za-z0-9_-]{16,128}$/.test(key)) throw new Error("请填写有效的提交编号,重试时使用原编号。");
  return key;
}
$("submission-key").value = newKey();
$("new-key").onclick = () => {
  if (busy) return;
  invalidate(); $("submission-key").value = newKey(); notice("已生成新编号;再次提交可以创建新的任务。旧任务不会取消。");
};
$("submission-key").addEventListener("input", () => { invalidate(); notice("提交编号已改变;重试时应粘贴原编号并选择原文件。"); });
function token() {
  const value = $("token").value.trim();
  if (!/^[A-Za-z0-9_-]{16,128}$/.test(value)) throw new Error("请填写有效的业务 token。");
  return value;
}
function jobId(value) { if (!/^[a-f0-9]{32}$/i.test(value)) throw new Error("任务 ID 应为 32 位十六进制字符。"); return value; }
async function request(path, credential, options={}, blob=false) {
  const controller = new AbortController(); active = controller;
  const deadline = setTimeout(() => controller.abort(), 90000);
  try {
    const response = await fetch(path, {...options, headers:{...options.headers,Authorization:"Bearer " + credential}, signal:controller.signal, cache:"no-store", redirect:"error"});
    if (!response.ok) {
      const messages = {400:"文件或参数不符合要求。",401:"凭证无效,请重新输入。",404:"任务不存在或不属于当前用户。",409:"提交编号内容冲突,或任务状态已变化;请先核对文件与当前任务。",502:"暂时无法核实上游结果,请稍后查询。",503:"暂时无法受理;请保留相同编号和相同文件,稍后人工重试。"};
      throw new Error(messages[response.status] || "请求失败,请检查服务状态。");
    }
    if (blob) {
      if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("text/markdown")) throw new Error("报告类型不符合约定。");
      return await response.blob();
    }
    return await response.json();
  } finally { clearTimeout(deadline); if (active === controller) active = null; }
}
function render(job, expected) {
  if (!job || job.job_id !== expected || !Object.hasOwn(labels,job.status) || !Array.isArray(job.files)) throw new Error("任务响应不符合约定。");
  if (done(job) && (!job.data || !["record_count","success_count","skipped_count","failed_count"].every(key => Number.isInteger(job.data[key]) && job.data[key] >= 0))) throw new Error("任务计数不符合约定。");
  current = job; $("job-id").value = expected;
  $("status").textContent = labels[job.status];
  $("status").dataset.kind = job.status === "succeeded" ? "success" : ["failed","stopped"].includes(job.status) ? "error" : "warning";
  $("summary").textContent = done(job) ? `记录 ${job.data.record_count} 份;成功 ${job.data.success_count},跳过 ${job.data.skipped_count},失败 ${job.data.failed_count}。` : `文件:${job.files.join("、")};最近事件:${job.last_event || "尚无"};上游状态:${job.dify_status || "尚未记录"}。`;
  $("details").textContent = JSON.stringify(job.data || {files:job.files,error_code:job.error_code,has_run_id:job.has_run_id},null,2);
  buttons();
}
async function readJob(e, credential, id) {
  const job = await request("/jobs/" + id, credential);
  if (e !== epoch) return;
  render(job,id); reads++;
  polling = allowPolling && ["queued","running"].includes(job.status) && reads < 60;
  notice(polling ? "正在自动查询本地任务状态。" : reads >= 60 && ["queued","running"].includes(job.status) ? "已暂停自动查询,可再点查询任务;后台仍继续处理。" : "已读取任务记录,请核对状态与明细。");
}
async function action(work) {
  if (busy) return;
  const e = epoch; clearTimeout(timer); timer = null; busy = true; buttons();
  try { await work(e); }
  catch (error) {
    if (e === epoch) { halt(); notice(error.name === "AbortError" ? "等待已结束,后台任务可能仍在运行。请核对已知任务;提交响应丢失时,保留原编号和原文件,稍后人工重试。" : error instanceof TypeError || error instanceof SyntaxError ? "网络或响应读取失败。请核对已知任务;若需重试提交,保留原编号和原文件。" : error.message, "error"); }
  } finally {
    if (e === epoch) {
      busy = false; buttons();
      if (polling && current) { const id = current.job_id, credential = token(); timer = setTimeout(() => action(stamp => readJob(stamp,credential,id)),2000); }
    }
  }
}
$("token").addEventListener("input", () => { invalidate(); $("submission-key").value = newKey(); notice("凭证已改变,已生成新编号并清空旧视图;已提交任务不会取消。"); });
$("job-id").addEventListener("input", () => { invalidate(false); notice("请输入完整任务 ID 后查询。"); });
$("clear").onclick = () => { $("token").value = ""; $("submission-key").value = ""; invalidate(); notice("凭证与结果已清空;后台任务不会取消。"); };
$("stop").onclick = () => { halt(); notice("已停止后续自动查询;正在发出的查询可能仍会返回,后台任务继续处理。"); };
$("upload-form").onsubmit = event => {
  event.preventDefault(); if (busy) return;
  try {
    const credential = token(), key = submissionKey(), files = [...$("files").files];
    if (files.length < 1 || files.length > 3 || files.some(file => !file.name.toLowerCase().endsWith(".txt") || file.size < 1 || file.size > 2 * 1024 * 1024)) throw new Error("请选择 1~3 份 TXT,每份 1 字节~2 MiB。");
    invalidate(); const form = new FormData(); files.forEach(file => form.append("files",file));
    action(async e => {
      const accepted = await request("/idempotent-jobs",credential,{method:"POST",body:form,headers:{"Idempotency-Key":key}});
      if (e !== epoch) return;
      if (!accepted || !/^[a-f0-9]{32}$/i.test(accepted.job_id)) throw new Error("未取得有效任务 ID,任务可能已创建;请保留原编号和原文件,再人工重试确认。");
      const id = jobId(accepted.job_id); $("job-id").value = id;
      notice(accepted.replayed ? "已找回原任务,请保存任务 ID。" : "任务已受理,请保存任务 ID。"); await readJob(e,credential,id);
    });
  } catch (error) { notice(error.message,"error"); }
};
$("lookup-form").onsubmit = event => {
  event.preventDefault(); if (busy) return;
  try { const credential = token(), id = jobId($("job-id").value.trim()); invalidate(false); action(e => readJob(e,credential,id)); }
  catch (error) { notice(error.message,"error"); }
};
$("refresh").onclick = () => {
  if (!current || busy) return;
  const id = current.job_id, credential = token(); halt();
  action(async e => { const job = await request("/jobs/" + id + "/refresh",credential,{method:"POST"}); if (e === epoch) { render(job,id); notice("已核实原运行;仍待核实时可稍后再次操作。"); } });
};
$("download").onclick = () => {
  if (!done(current) || busy) return;
  const id = current.job_id, credential = token();
  action(async e => {
    const blob = await request("/jobs/" + id + "/report",credential,{},true);
    if (e !== epoch) return;
    const url = URL.createObjectURL(blob), link = document.createElement("a");
    link.href = url; link.download = "report-" + id + ".md"; document.body.append(link); link.click(); link.remove();
    setTimeout(() => URL.revokeObjectURL(url),1000); notice("已发起报告下载,请检查浏览器下载记录。");
  });
};
</script>
</body>
</html>

本次提交编号由随机字节生成,可自行复制保存。页面不会在普通提交或网络失败后自动换键;再次点"提交或重试"仍使用当前输入框中的键。

"生成新提交编号"明确表示一个新意图,可能创建新任务。它不会取消旧任务,也不是让旧任务恢复执行。

切换业务 token 会清空旧视图并生成新编号。刷新页面后若要找回原提交,应先填写自己的业务 token,再粘贴保存的原键,并重新选择完全相同的文件。

代码不把业务 token 或提交键写入 URL、localStorage 或 sessionStorage。因此页面刷新后,需要使用者保留必要的任务 ID 或提交编号。

9. 准备演示资料并运行

api-notes-a.txt:

text 复制代码
星舟团队账号资料安排(API 演示资料 A)
记录日期:2026-10-22
以下人员和安排均为虚构。

王宁负责在 2026-10-27 前整理账号申请说明,补充服务台入口和必填信息。
说明必须保留"1 个工作日内反馈进度",不能改成承诺在 1 个工作日内完成开通。
团队还决定补充常见问题清单,但没有指定负责人和截止时间。

api-notes-b.txt:

text 复制代码
星舟团队反馈核对安排(API 演示资料 B)
记录日期:2026-10-22
以下人员和安排均为虚构。

陈澄负责在 2026-10-28 前核对 8 条试用意见,保留原文并标注重复记录。
8 条意见是待核对的记录数量,不代表已经确认 8 个独立缺陷。
有人提出自动分类的建议,但本次没有形成实施决定;正式上线日期仍未确定。

保存为 UTF-8 TXT,人物和任务均为演示内容。

macOS:

bash 复制代码
python3 -m venv .venv
.venv/bin/python -m pip install -r dify-backend-requirements.txt
export DIFY_BASE_URL="http://localhost/v1"
export JOB_DATA_DIR="./job-data-idempotent"
.venv/bin/python start-dify-idempotent.py

Windows PowerShell:

powershell 复制代码
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r dify-backend-requirements.txt
$env:DIFY_BASE_URL = "http://localhost/v1"
$env:JOB_DATA_DIR = "./job-data-idempotent"
.\.venv\Scripts\python.exe start-dify-idempotent.py

将 Dify 地址改成自己的实际 API 地址。没有 py 命令时,用本机已安装的 Python 3 启动命令创建环境。

启动时输入目标 Dify 应用 Key,以及两枚不同的 Alice、Bob 业务 token。保持终端运行,浏览器打开:

text 复制代码
http://127.0.0.1:8000/

不要同时启动前篇旧入口和本篇新入口操作同一个数据目录;本篇仍保留原后台机制的单实例限制。

10. 按场景观察结果

10.1 原键与原文件重试

填写 Alice 业务 token,选择 A 文件,先复制本次提交编号,再提交。保存返回的本地任务 ID。

不改编号、不改文件,再次点击"提交或重试"。应得到同一个 job_id,replayed 为 true。页面随后查询的是原任务,当前状态可能已经发生变化。

首次受理的结构示意:

json 复制代码
{
  "job_id": "00000000000040008000000000001401",
  "status": "queued",
  "replayed": false
}

之后重试的结构示意:

json 复制代码
{
  "job_id": "00000000000040008000000000001401",
  "status": "succeeded",
  "replayed": true
}

两个 JSON 是本地构造示例,不是实际执行记录。相同的是任务标识,HTTP 状态、replayed 标志和当前任务状态不必与首次响应完全一样。

10.2 原键换了内容

保留原键,换成 B 文件,或修改 A 文件字节后再次提交,应返回 409。页面保留当前键,不擅自改成新键绕过冲突。

如果这其实是一个新的处理需求,确认后再点击"生成新提交编号"。不要把每次发生冲突都自动换键写进重试逻辑。

10.3 受理响应丢失

如果浏览器超时或断线,无法判断服务器是否已经保存请求:保留原用户身份、原键和原文件。连接恢复后,人工使用这一组内容重试。

后端若已保存映射,就返回原任务;尚未保存时,才走新记录分支。客户端不会自动重发,也不能因为没有收到 job_id 就认定没有创建任务。

如果原文件已被编辑,必须重新取得原始字节才符合"同一请求"。如果丢失了键又没有保存任务 ID,本篇没有提供自动找回所有历史提交的接口。

10.4 原任务失败或待核实

原键会继续指向 failed 或 unknown 的旧任务,不重新调度。已知任务 ID 可以按前篇方法查询本地记录;有运行 ID 的 unknown 任务可以人工核实原运行。

"重试提交请求"是找回同一意图的记录;"重新执行业务任务"是另一个决策。确认需要重做后,可以明确创建新意图,但应先核实旧任务及其业务影响。

11. 这些边界必须保留

11.1 幂等键不是结果缓存

同样文件使用新键会新建任务。指纹不跨用户复用报告,也不按内容自动命中别人的任务。

编号不是凭据。查任务和下载报告仍使用业务 token,并由基础后端检查 owner。

11.2 保证依赖映射仍被保留

本例没有自动过期或清理映射。去重关系在同一数据库的记录保留期间有效;清库、换目录或手动删除映射后,不能继续声称旧键一定命中原任务。

之前通过旧 POST /jobs 创建的任务没有本篇提交键映射,也不会被自动追溯绑定。

11.3 幂等记录不等于可靠执行

SQLite 已提交后,如果进程在后台函数执行前退出,映射可能已经存在,而任务尚未真正运行。重启时基础模块把遗留 queued/running 转为 unknown,不会因为原键再次出现就自动补发。

这是一种避免重复执行的保守策略,不保证任务最终一定完成。BackgroundTasks 仍是进程内执行机制,不是带领取、确认和恢复能力的持久队列。FastAPI 后台任务边界

文件系统与 SQLite 也不是同一个原子事务。异常终止可能留下候选目录,断电一致性不在本例验证范围内;不能只根据目录数量判断是否存在已受理任务。

11.4 重试仍有处理成本

接口仍需接收文件、读取字节并计算指纹。它减少重复任务与重复后台调用,不意味着浏览器无需再次上传,或所有本地 I/O 都被消除。

12. 验证范围

本地通过 12 项后端检查,使用真实 FastAPI ASGI 内存请求和 SQLite,覆盖同键重放、内容冲突、用户隔离、8 个并发同意图提交、并发冲突、事务回滚、重启保留映射和原任务不重调度。

另通过 19 项无头 Chromium 页面检查,包含原页面回归,以及受理响应丢失后保留原键、主动新意图、冲突不换键、身份切换、刷新后粘贴原键和提交期间防连点。

所有 Dify 上传、SSE 和详情响应均为本地模拟,没有使用真实 Dify 密钥或调用模型;也未在 Windows、真实手机或多进程部署中验证。封面是生成的概念插图,不是实测截图。

正式接入前仍需在自己的实例中验证:同键重试是否保持任务 ID、冲突是否拒绝、原 Workflow 是否只被安排一次,以及断线与重启后的真实状态。不要把本地模拟检查写成上游服务的"恰好一次"证明。

下一步可以继续把后台执行换成可恢复的任务机制,处理"记录已经提交,但后台尚未执行"的窗口。

相关推荐
一级新生2 天前
提示词工程:跟 AI 打交道的手艺
人工智能·大模型·dify·1024程序员节·提示词工程
情 九3 天前
Dify SSE 流式调用实战:执行进度、运行 ID 与断线核实
dify
一级新生3 天前
Dify 接入大模型:本地还是云端?实战与选型
人工智能·大模型·dify·ollama
一级新生4 天前
Ollama 是什么、干什么用、怎么用
人工智能·dify·ollama·本地大模型
一级新生4 天前
Dify 日常使用手册(小白版)
人工智能·docker·云原生·eureka·dify·ai工具
情 九5 天前
Dify HTTP 请求节点实战:发送 JSON、解析响应与处理异常
网络协议·http·json·dify
yuanlaile13 天前
大模型应用层开发实战:从 Prompt 工程到 Dify、私有 RAG、Agent 完整落地总结
prompt·agent·dify·大模型应用开发·llm 应用·prompt 工程
SatVision炼金士18 天前
DIFY本地部署开源模型配置ollama,提示修改成功却没有加载模型问题解决
开源·dify·本地部署·ollama
更深兼春远20 天前
第3篇:给 Dify 接上大模型——云端 API 与本地 Ollama 两种方式
大模型·dify·ollama·deepseek·apikey