设计实践

Leonardo AI:Webhook 回调接口与签名认证

官方建议用 webhook 代替轮询:创建 API Key 时填写回调地址(必须是 HTTPS)和一个 webhook API Key,Leonardo 回调时会带上 authorization: Bearer 。 这篇写一个接收端: - 校验 Key(常量时间比较); - 同一个生成 ID 只处理一次(并发重复投递的情况也覆盖); - nsfw 图片不对外

BINBHO · 图文教程

官方建议用 webhook 代替轮询:创建 API Key 时填写回调地址(必须是 HTTPS)和一个 webhook API Key,Leonardo 回调时会带上 authorization: Bearer <这个 Key>。 这篇写一个接收端:

  • 校验 Key(常量时间比较);
  • 同一个生成 ID 只处理一次(并发重复投递的情况也覆盖);
  • nsfw 图片不对外展示;
  • 其他事件类型返回 200,但不处理。

先看清成品(box 上真实运行,使用官方 webhook 指南中的示例负载)

  • 第一次投递返回 ok,再次投递返回 duplicate;20 个并发投递中只有 1 个是 ok,其余 19 个是 duplicate。
  • Key 错误或缺失时返回 401;负载格式错误时返回 400。pytest 5 项通过。

合规提醒:Leonardo API 需要单独购买 API 额度,和网页版订阅是分开的。API Key 只放在服务端环境变量里,不要放进前端代码,也不要共享账号或 Key。生成内容要遵守 Leonardo 的条款和内容政策:官方会默认拦截 NSFW 提示词,并在结果里用 nsfw 字段标记图片。不要尝试绕过审核,也不要生成侵犯他人肖像权、商标或版权的内容。请求体里的 public 字段决定图片是否出现在社区公开流中,处理敏感素材时设为 false。本文不列价格。

运行说明:接收端和测试都在 box 上真实运行过,但没有收到 Leonardo 的真实回调(需要 API Key 和公网 HTTPS 地址)。示例负载取自官方 webhook 指南,我删掉了大量与本例无关的字段。

做完之后你会得到什么?

成果 说明
webhook_server.py 接收端,用 SQLite 存储
sample_webhook.json 官方示例负载(删减版)
test_webhook.py 5 项测试,包括并发重复投递

需要准备哪些环境?

Python 3.9 以上(只用标准库);测试需要 pip install pytest。上线时需要一个公网 HTTPS 域名和反向代理。

关键事实(来自官方 webhook 指南)

事实 内容
设置位置 创建 production API Key 时填写回调 URL(必须是 HTTPS)和 webhook callback API key
鉴权方式 回调请求带有 authorization: Bearer $yourWebhookCallbackApiKey
修改设置 新建一个 Key 并填写新的回调设置,切换过去后再删除旧 Key
事件 "type": "image_generation.complete",数据在 data.object(包含 images[].url 和 nsfw)

文件结构

63-leonardo-ai-C-src/
├── webhook_server.py
├── sample_webhook.json
└── test_webhook.py

第 1 步:接收端

"""接收 Leonardo 的 webhook 回调(标准库实现,教学用)。
依据官方《Guide to the Webhook Callback Feature》:
- 回调地址必须是 HTTPS(本地开发用 HTTP,上线时放在 HTTPS 反向代理之后)
- Leonardo 会带上请求头 authorization: Bearer <你在创建 API Key 时填写的 webhook callback API key>
- 生成完成的事件类型是 image_generation.complete,数据在 data.object 中
处理原则:
- Key 不对就返回 401,并且用 hmac.compare_digest 做常量时间比较
- 同一个生成 id 只处理一次(重复投递时直接返回 200)
- nsfw 为 true 的图片不对外展示,只记录
- 只做轻量处理并尽快返回 200,耗时任务交给队列"""
import hmac, json, os, sqlite3, sys, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

MAX_BODY = 2 * 1024 * 1024

class Store:
    def __init__(self, path=":memory:"):
        self.db = sqlite3.connect(path, check_same_thread=False); self.lock = threading.Lock()
        self.db.execute("create table if not exists gen(id text primary key, prompt text, received_at text default current_timestamp)")
        self.db.execute("create table if not exists img(id text primary key, gen_id text, url text, nsfw int)")
    def add(self, obj):
        """返回 True 表示是新的生成;False 表示重复投递。"""
        with self.lock, self.db:
            cur = self.db.execute("insert or ignore into gen(id, prompt) values (?, ?)", (obj["id"], obj.get("prompt")))
            if cur.rowcount == 0:
                return False
            for im in obj.get("images", []):
                self.db.execute("insert or ignore into img values (?, ?, ?, ?)", (im["id"], obj["id"], im["url"], int(bool(im.get("nsfw")))))
            return True
    def public_images(self):
        return [r[0] for r in self.db.execute("select url from img where nsfw = 0 order by id")]

def make_handler(secret, store):
    class H(BaseHTTPRequestHandler):
        def log_message(self, *a):
            pass
        def _reply(self, code, text):
            raw = text.encode(); self.send_response(code); self.send_header("Content-Type", "text/plain; charset=utf-8")
            self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
        def do_POST(self):
            auth = self.headers.get("authorization", "")
            if not hmac.compare_digest(auth.encode(), f"Bearer {secret}".encode()):
                return self._reply(401, "unauthorized")
            n = int(self.headers.get("Content-Length") or 0)
            if n <= 0 or n > MAX_BODY:
                return self._reply(413 if n > MAX_BODY else 400, "bad length")
            try:
                ev = json.loads(self.rfile.read(n))
                obj = ev["data"]["object"]
            except (ValueError, KeyError, TypeError):
                return self._reply(400, "bad payload")
            if ev.get("type") != "image_generation.complete":
                return self._reply(200, f"ignored {ev.get('type')}")      # 其他事件:确认收到,但不处理
            new = store.add(obj)
            self._reply(200, "ok" if new else "duplicate")
    return H

def serve(secret, port=0, store=None):
    store = store or Store()
    s = ThreadingHTTPServer(("127.0.0.1", port), make_handler(secret, store))
    threading.Thread(target=s.serve_forever, daemon=True).start()
    return s, store

if __name__ == "__main__":
    secret = os.environ["LEONARDO_WEBHOOK_KEY"]
    s, store = serve(secret, int(sys.argv[1]) if len(sys.argv) > 1 else 8788, Store("webhook.db"))
    print(f"listening on http://127.0.0.1:{s.server_address[1]}(上线时放在 HTTPS 反向代理之后)")
    threading.Event().wait()

第 2 步:测试

import copy, json, urllib.error, urllib.request
import pytest
import webhook_server as W

SAMPLE = json.load(open("sample_webhook.json", encoding="utf-8"))

@pytest.fixture
def srv():
    s, store = W.serve("whk_test"); yield f"http://127.0.0.1:{s.server_address[1]}", store; s.shutdown()

def post(url, body, auth="Bearer whk_test", raw=None):
    data = raw if raw is not None else json.dumps(body).encode()
    req = urllib.request.Request(url, data=data, method="POST", headers={"authorization": auth, "content-type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, r.read().decode()
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode()

def test_official_sample_accepted_once(srv):
    url, store = srv
    assert post(url, SAMPLE) == (200, "ok")
    assert post(url, SAMPLE) == (200, "duplicate")
    assert store.public_images() == [SAMPLE["data"]["object"]["images"][0]["url"]]

def test_wrong_or_missing_key(srv):
    url, _ = srv
    assert post(url, SAMPLE, auth="Bearer nope")[0] == 401
    assert post(url, SAMPLE, auth="")[0] == 401

def test_nsfw_not_public(srv):
    url, store = srv
    ev = copy.deepcopy(SAMPLE); obj = ev["data"]["object"]; obj["id"] = "gen-2"
    obj["images"] = [{"id": "a", "url": "https://x/a.jpg", "nsfw": False}, {"id": "b", "url": "https://x/b.jpg", "nsfw": True}]
    assert post(url, ev) == (200, "ok") and store.public_images() == ["https://x/a.jpg"]

def test_other_event_and_bad_payload(srv):
    url, _ = srv
    assert post(url, {**SAMPLE, "type": "something.else"}) == (200, "ignored something.else")
    assert post(url, None, raw=b"{not json")[0] == 400
    assert post(url, {"type": "image_generation.complete"})[0] == 400

def test_concurrent_duplicates_processed_once(srv):
    import collections, concurrent.futures as cf
    url, _ = srv
    with cf.ThreadPoolExecutor(20) as ex:
        res = list(ex.map(lambda _: post(url, SAMPLE), range(20)))
    assert collections.Counter(res) == {(200, "ok"): 1, (200, "duplicate"): 19}

第 3 步:运行

$ LEONARDO_WEBHOOK_KEY=whk_xxx python webhook_server.py 8788 &
$ curl -s -X POST localhost:8788 -H 'authorization: Bearer whk_xxx' -H 'content-type: application/json' -d @sample_webhook.json
ok
$ python -m pytest -q
5 passed

调试示例

1. 去掉锁之后,并发投递会把连接弄坏。 为了确认锁是不是必要的,我用 contextlib.nullcontext() 替换掉锁,再并发发送 20 个相同的回调。结果服务器端出现 sqlite3.InterfaceError: bad parameter or other API misuse 和 sqlite3.OperationalError: cannot commit - no transaction is active,客户端收到 RemoteDisconnected。也就是说,Leonardo 会认为这次投递失败了。原因是多个线程共用了同一个 SQLite 连接。恢复锁之后,20 次投递稳定得到 1 个 ok 和 19 个 duplicate,换成文件数据库也是同样的结果,这个场景已经写进测试。

2. 示例负载里的 ID 都是占位符。 官方示例中,生成 ID 和图片 ID 都是 XXXXXXXX-XXXX-...。我把生成和图片分别放在两张表里,所以两者相同也不会冲突。实际使用时,它们是不同的 UUID。

常见问题

现象 原因 处理
收不到回调 不是 HTTPS、证书链不完整,或者防火墙拦截 按指南用 openssl s_client 检查证书
总是返回 401 系统没有读取 authorization 头,或者 Key 不一致 先把请求记录下来检查
重复处理同一个生成 没有做幂等 用生成 ID 作为主键
想更换回调地址 只能新建 Key 新建 Key,切换过去后再删除旧的

模板规则

  • 校验 Bearer Key 时使用常量时间比较。
  • 以生成 ID 作为幂等键。
  • 尽快返回 200,耗时处理放到队列里。
  • nsfw 图片不对外展示,只记录。

项目资源

用途 地址
Leonardo API 快速入门 https://docs.leonardo.ai/docs/getting-started
API FAQ https://docs.leonardo.ai/docs/api-faq
错误信息说明 https://docs.leonardo.ai/docs/api-error-messages
Webhook 指南 https://docs.leonardo.ai/docs/guide-to-the-webhook-callback-feature
并发、队列与限流 https://docs.leonardo.ai/docs/guide-to-concurrency-queue-and-rate-limit

webhook 接收端要做到两点:拒绝伪造的请求,重复投递时只处理一次。