IdleCloud commited on
Commit
81dec11
·
1 Parent(s): 3707546

Add custom Image Inversion job API

Browse files
README.md CHANGED
@@ -13,3 +13,36 @@ hf_oauth: true
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  ---
14
 
15
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
+
17
+ ## 自定义任务 API
18
+
19
+ 该 Space 在保留原 Gradio 页面功能的同时提供受共享密钥保护的异步标签分析接口:
20
+
21
+ - `POST /api/jobs`:创建任务,返回 `job_id`、`status_url` 与 `poll_after_seconds`。
22
+ - `GET /api/jobs/{job_id}`:运行中返回 HTTP 202;成功返回标签展示字段;失败返回脱敏错误码。
23
+ - 两个接口都必须携带 `X-API-Key`。创建接口支持最长 200 字符的 `Idempotency-Key`。
24
+
25
+ 创建请求 JSON:
26
+
27
+ ```json
28
+ {
29
+ "input_image_url": "https://example.com/input.png",
30
+ "general_threshold": 0.35,
31
+ "character_threshold": 0.85,
32
+ "show_confidence": true,
33
+ "show_general": true,
34
+ "show_character": true,
35
+ "show_ip": true,
36
+ "separator": "comma",
37
+ "show_chinese": true
38
+ }
39
+ ```
40
+
41
+ `separator` 仅接受 `comma`、`newline` 或 `space`。远程图片仅允许来自 `JOB_IMAGE_ALLOWED_HOSTS` 配置的精确 HTTPS 主机。
42
+
43
+ 部署时必须设置:
44
+
45
+ - Secret:`JOB_API_KEY`(至少 32 字符)。
46
+ - Variable:`JOB_IMAGE_ALLOWED_HOSTS`(逗号分隔的精确域名,不支持通配符)。
47
+
48
+ 可选 Variables:`JOB_RESULT_TTL_SECONDS`、`JOB_POLL_AFTER_SECONDS`、`JOB_IMAGE_FETCH_TIMEOUT_SECONDS`、`JOB_IMAGE_MAX_BYTES`、`JOB_IMAGE_MAX_PIXELS`、`JOB_IMAGE_MAX_DIMENSION`、`JOB_MAX_RECORDS`、`SPACE_HOST`。
app.py CHANGED
@@ -1,7 +1,12 @@
1
  import os
 
 
 
 
2
  import json
3
  import time
4
  import shutil
 
5
  import warnings
6
  from html import escape
7
  from pathlib import Path
@@ -12,6 +17,12 @@ from huggingface_hub import snapshot_download
12
  from PIL import Image, ImageFile
13
 
14
  from handler import EndpointHandler
 
 
 
 
 
 
15
  from translator import translate_texts
16
 
17
  # ------------------------------------------------------------------
@@ -129,13 +140,6 @@ ASSETS_REPO_ID = os.environ.get("ASSETS_REPO_ID", "pixai-labs/pixai-tagger-v0.9"
129
  ASSETS_REVISION = os.environ.get("ASSETS_REVISION")
130
  MODEL_DIR = os.environ.get("MODEL_DIR", "./assets")
131
 
132
- HF_TOKEN = (
133
- os.environ.get("HUGGINGFACE_HUB_TOKEN")
134
- or os.environ.get("HF_TOKEN")
135
- or os.environ.get("HUGGINGFACE_TOKEN")
136
- or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
137
- )
138
-
139
  REQUIRED_FILES = [
140
  "model_v0.9.pth",
141
  "tags_v0.9_13k.json",
@@ -159,7 +163,7 @@ def ensure_assets(repo_id: str, revision: Optional[str], target_dir: str) -> Non
159
  repo_id=repo_id,
160
  revision=revision,
161
  allow_patterns=REQUIRED_FILES,
162
- token=HF_TOKEN,
163
  )
164
 
165
  for fname in REQUIRED_FILES:
@@ -313,6 +317,38 @@ DEVICE_LABEL = (
313
  else "设备:UNKNOWN"
314
  )
315
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  # ------------------------------------------------------------------
317
  # Gradio UI
318
  # ------------------------------------------------------------------
@@ -639,7 +675,11 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
639
 
640
  try:
641
  img = validate_and_open_image(image_path)
642
- res, tag_categories_original_order, meta = tagger_instance.predict(img, g_th, c_th)
 
 
 
 
643
 
644
  all_tags_to_translate = []
645
  for cat_key in ["general", "characters", "ips"]:
@@ -802,7 +842,113 @@ with gr.Blocks(theme=gr.themes.Soft(), title="AI 图像标签分析器", css=cus
802
  )
803
 
804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
805
  if __name__ == "__main__":
806
  if tagger_instance is None:
807
  print("CRITICAL: Tagger 未能初始化,应用功能将受限。请检查之前的错误信息。")
808
- demo.queue(max_size=8).launch(server_name="0.0.0.0", server_port=7860)
 
 
 
 
 
1
  import os
2
+
3
+ # 模型仓库均为公开资源;必须在任何 Hub 间接依赖导入前禁用隐式私有 token。
4
+ os.environ.setdefault("HF_HUB_DISABLE_IMPLICIT_TOKEN", "1")
5
+
6
  import json
7
  import time
8
  import shutil
9
+ import threading
10
  import warnings
11
  from html import escape
12
  from pathlib import Path
 
17
  from PIL import Image, ImageFile
18
 
19
  from handler import EndpointHandler
20
+ from image_inversion_job_api import (
21
+ ImageInversionJobAPI,
22
+ ImageInversionJobRequest,
23
+ ImageInversionJobSettings,
24
+ create_job_api_lifespan,
25
+ )
26
  from translator import translate_texts
27
 
28
  # ------------------------------------------------------------------
 
140
  ASSETS_REVISION = os.environ.get("ASSETS_REVISION")
141
  MODEL_DIR = os.environ.get("MODEL_DIR", "./assets")
142
 
 
 
 
 
 
 
 
143
  REQUIRED_FILES = [
144
  "model_v0.9.pth",
145
  "tags_v0.9_13k.json",
 
163
  repo_id=repo_id,
164
  revision=revision,
165
  allow_patterns=REQUIRED_FILES,
166
+ token=False,
167
  )
168
 
169
  for fname in REQUIRED_FILES:
 
317
  else "设备:UNKNOWN"
318
  )
319
 
320
+ # UI 与自定义任务 API 共用单推理门闩,避免 CPU 模型被并发调用导致资源争用。
321
+ INFERENCE_SLOT = threading.Lock()
322
+
323
+
324
+ def predict_tags_with_shared_slot(
325
+ image: Image.Image,
326
+ general_threshold: float,
327
+ character_threshold: float,
328
+ ):
329
+ """在共享单推理门闩内调用现有标签模型。
330
+
331
+ Args:
332
+ image: 已完成安全校验与 RGB 转换的输入图片。
333
+ general_threshold: 通用标签阈值。
334
+ character_threshold: 角色标签阈值。
335
+
336
+ Returns:
337
+ Tagger.predict 返回的标签、翻译顺序和元数据三元组。
338
+ """
339
+ if tagger_instance is None:
340
+ raise RuntimeError("标签分析器尚未成功初始化。")
341
+ if not INFERENCE_SLOT.acquire(blocking=False):
342
+ raise RuntimeError("标签分析服务正忙,请稍后重试。")
343
+ try:
344
+ return tagger_instance.predict(
345
+ image,
346
+ general_threshold,
347
+ character_threshold,
348
+ )
349
+ finally:
350
+ INFERENCE_SLOT.release()
351
+
352
  # ------------------------------------------------------------------
353
  # Gradio UI
354
  # ------------------------------------------------------------------
 
675
 
676
  try:
677
  img = validate_and_open_image(image_path)
678
+ res, tag_categories_original_order, meta = predict_tags_with_shared_slot(
679
+ img,
680
+ g_th,
681
+ c_th,
682
+ )
683
 
684
  all_tags_to_translate = []
685
  for cat_key in ["general", "characters", "ips"]:
 
842
  )
843
 
844
 
845
+ def execute_image_inversion_job(
846
+ payload: ImageInversionJobRequest,
847
+ input_image: Image.Image,
848
+ job_id: str,
849
+ ) -> dict:
850
+ """复用现有 Tagger、翻译与展示格式生成自定义 API 结果。
851
+
852
+ Args:
853
+ payload: 已通过公开 Schema 校验的标签分析参数。
854
+ input_image: 已由任务 API 安全抓取并转换为 RGB 的图片。
855
+ job_id: 当前异步任务标识,仅用于隔离任务上下文。
856
+
857
+ Returns:
858
+ 包含现有前端所需六个展示字段的 JSON 对象。
859
+ """
860
+ del job_id
861
+ if tagger_instance is None:
862
+ raise RuntimeError("标签分析器尚未成功初始化。")
863
+
864
+ # ImageInversionJobAPI 已持有 INFERENCE_SLOT,此处不能重复加锁。
865
+ result, tag_order, metadata = tagger_instance.predict(
866
+ input_image,
867
+ payload.general_threshold,
868
+ payload.character_threshold,
869
+ )
870
+
871
+ all_tags: list[str] = []
872
+ for category_name in ("general", "characters", "ips"):
873
+ all_tags.extend(tag_order.get(category_name, []))
874
+
875
+ translated_tags: list[str] = []
876
+ if all_tags:
877
+ try:
878
+ translated_tags = translate_texts(
879
+ all_tags,
880
+ src_lang="auto",
881
+ tgt_lang="zh",
882
+ )
883
+ except Exception as exc:
884
+ print(f"标签翻译失败,将仅返回英文标签:{type(exc).__name__}")
885
+ translated_tags = [""] * len(all_tags)
886
+
887
+ translations: dict[str, list[str]] = {}
888
+ offset = 0
889
+ for category_name in ("general", "characters", "ips"):
890
+ category_tags = tag_order.get(category_name, [])
891
+ tag_count = len(category_tags)
892
+ translations[category_name] = translated_tags[offset : offset + tag_count]
893
+ offset += tag_count
894
+
895
+ separator_names = {
896
+ "comma": "逗号",
897
+ "newline": "换行",
898
+ "space": "空格",
899
+ }
900
+ summary_text = generate_summary_text_content(
901
+ result,
902
+ translations,
903
+ payload.show_general,
904
+ payload.show_character,
905
+ payload.show_ip,
906
+ separator_names[payload.separator],
907
+ payload.show_chinese,
908
+ )
909
+
910
+ return {
911
+ "status_markdown": "✅ 分析完成!",
912
+ "general_tags_html": format_tags_html(
913
+ result.get("general", {}),
914
+ translations.get("general", []),
915
+ "general",
916
+ payload.show_confidence,
917
+ True,
918
+ ),
919
+ "character_tags_html": format_tags_html(
920
+ result.get("characters", {}),
921
+ translations.get("characters", []),
922
+ "characters",
923
+ payload.show_confidence,
924
+ True,
925
+ ),
926
+ "ip_tags_html": format_tags_html(
927
+ result.get("ips", {}),
928
+ translations.get("ips", []),
929
+ "ips",
930
+ payload.show_confidence,
931
+ True,
932
+ ),
933
+ "summary_text": summary_text,
934
+ "metadata": metadata,
935
+ }
936
+
937
+
938
+ IMAGE_INVERSION_JOB_SETTINGS = ImageInversionJobSettings.from_env()
939
+ IMAGE_INVERSION_JOB_API = ImageInversionJobAPI(
940
+ settings=IMAGE_INVERSION_JOB_SETTINGS,
941
+ executor=execute_image_inversion_job,
942
+ inference_slot=INFERENCE_SLOT,
943
+ )
944
+ JOB_API_LIFESPAN = create_job_api_lifespan(IMAGE_INVERSION_JOB_API)
945
+
946
+
947
  if __name__ == "__main__":
948
  if tagger_instance is None:
949
  print("CRITICAL: Tagger 未能初始化,应用功能将受限。请检查之前的错误信息。")
950
+ demo.queue(max_size=8).launch(
951
+ server_name="0.0.0.0",
952
+ server_port=7860,
953
+ app_kwargs={"lifespan": JOB_API_LIFESPAN},
954
+ )
image_inversion_job_api.py ADDED
@@ -0,0 +1,1328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import io
6
+ import json
7
+ import logging
8
+ import os
9
+ import secrets
10
+ import socket
11
+ import threading
12
+ import time
13
+ import warnings
14
+ from contextlib import asynccontextmanager
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Callable, Literal
17
+ from urllib.parse import SplitResult, urlsplit
18
+
19
+ import httpx
20
+ from fastapi import APIRouter, HTTPException, Request
21
+ from fastapi import status as http_status
22
+ from fastapi.responses import JSONResponse
23
+ from PIL import Image, ImageOps, UnidentifiedImageError
24
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
25
+
26
+
27
+ LOGGER = logging.getLogger(__name__)
28
+ DNS_RESOLVER_SLOTS = threading.BoundedSemaphore(4)
29
+ IMAGE_FETCH_SLOTS = threading.BoundedSemaphore(4)
30
+ MIN_IMAGE_DIMENSION = 32
31
+ DEFAULT_MAX_IMAGE_BYTES = 8 * 1024 * 1024
32
+ MAX_CONFIGURABLE_IMAGE_BYTES = 20 * 1024 * 1024
33
+ DEFAULT_MAX_IMAGE_PIXELS = 4_194_304
34
+ MAX_CONFIGURABLE_IMAGE_PIXELS = 20_000_000
35
+ DEFAULT_MAX_IMAGE_DIMENSION = 4096
36
+ MAX_CONFIGURABLE_IMAGE_DIMENSION = 8192
37
+ DEFAULT_MAX_RECORDS = 256
38
+ MIN_MAX_RECORDS = 8
39
+ MAX_MAX_RECORDS = 4096
40
+
41
+ JobStatus = Literal["queued", "running", "succeeded", "failed"]
42
+ SummarySeparator = Literal["comma", "newline", "space"]
43
+ AnalysisExecutor = Callable[
44
+ ["ImageInversionJobRequest", Image.Image, str],
45
+ dict[str, Any],
46
+ ]
47
+ ImageFetcher = Callable[[str, "ImageInversionJobSettings"], Image.Image]
48
+
49
+
50
+ def _read_int_env(name: str, default: int) -> int:
51
+ """读取整数环境变量。
52
+
53
+ Args:
54
+ name: 环境变量名称。
55
+ default: 变量为空时采用的默认值。
56
+
57
+ Returns:
58
+ 解析完成的整数。
59
+ """
60
+ raw_value = os.getenv(name)
61
+ if raw_value is None or not raw_value.strip():
62
+ return default
63
+ try:
64
+ return int(raw_value)
65
+ except ValueError as exc:
66
+ raise RuntimeError(f"{name} must be an integer") from exc
67
+
68
+
69
+ def _read_float_env(name: str, default: float) -> float:
70
+ """读取有限浮点环境变量。
71
+
72
+ Args:
73
+ name: 环境变量名称。
74
+ default: 变量为空时采用的默认值。
75
+
76
+ Returns:
77
+ 解析完成的有限浮点数。
78
+ """
79
+ raw_value = os.getenv(name)
80
+ if raw_value is None or not raw_value.strip():
81
+ return default
82
+ try:
83
+ parsed_value = float(raw_value)
84
+ except ValueError as exc:
85
+ raise RuntimeError(f"{name} must be a number") from exc
86
+ if not float("-inf") < parsed_value < float("inf"):
87
+ raise RuntimeError(f"{name} must be finite")
88
+ return parsed_value
89
+
90
+
91
+ def normalize_allowed_hosts(raw_hosts: str) -> frozenset[str]:
92
+ """规范化远程图片精确主机白名单。
93
+
94
+ Args:
95
+ raw_hosts: 以逗号分隔的主机名,不允许协议、端口、路径或通配符。
96
+
97
+ Returns:
98
+ 完成小写与 IDNA 转换的不可变主机集合。
99
+ """
100
+ normalized_hosts: set[str] = set()
101
+ for raw_host in raw_hosts.split(","):
102
+ host = raw_host.strip().rstrip(".")
103
+ if not host:
104
+ continue
105
+ if any(marker in host for marker in ("://", "/", "?", "#", "*", "@", ":")):
106
+ raise RuntimeError(
107
+ "JOB_IMAGE_ALLOWED_HOSTS must contain exact hostnames only"
108
+ )
109
+ try:
110
+ normalized_hosts.add(host.encode("idna").decode("ascii").lower())
111
+ except UnicodeError as exc:
112
+ raise RuntimeError(
113
+ "JOB_IMAGE_ALLOWED_HOSTS contains an invalid hostname"
114
+ ) from exc
115
+ if not normalized_hosts:
116
+ raise RuntimeError("JOB_IMAGE_ALLOWED_HOSTS must contain at least one hostname")
117
+ return frozenset(normalized_hosts)
118
+
119
+
120
+ @dataclass(frozen=True, slots=True)
121
+ class ImageInversionJobSettings:
122
+ """保存 Image Inversion 自定义任务 API 的部署边界与资源限制。"""
123
+
124
+ api_key: str
125
+ allowed_hosts: frozenset[str]
126
+ result_ttl_seconds: int = 1800
127
+ poll_after_seconds: int = 2
128
+ fetch_timeout_seconds: float = 15.0
129
+ max_image_bytes: int = DEFAULT_MAX_IMAGE_BYTES
130
+ max_image_pixels: int = DEFAULT_MAX_IMAGE_PIXELS
131
+ max_image_dimension: int = DEFAULT_MAX_IMAGE_DIMENSION
132
+ max_records: int = DEFAULT_MAX_RECORDS
133
+ space_host: str = ""
134
+
135
+ @classmethod
136
+ def from_env(cls) -> "ImageInversionJobSettings":
137
+ """从 Space Secret 与 Variables 读取并校验任务 API 配置。
138
+
139
+ Args:
140
+ 此方法不接收参数,配置统一从当前进程环境读取。
141
+
142
+ Returns:
143
+ 已完成密钥、网络及图片资源边界校验的设置。
144
+ """
145
+ api_key = os.getenv("JOB_API_KEY", "").strip()
146
+ if len(api_key) < 32:
147
+ raise RuntimeError(
148
+ "JOB_API_KEY must be configured as a Space Secret with at least 32 characters"
149
+ )
150
+
151
+ result_ttl_seconds = _read_int_env("JOB_RESULT_TTL_SECONDS", 1800)
152
+ poll_after_seconds = _read_int_env("JOB_POLL_AFTER_SECONDS", 2)
153
+ fetch_timeout_seconds = _read_float_env(
154
+ "JOB_IMAGE_FETCH_TIMEOUT_SECONDS",
155
+ 15.0,
156
+ )
157
+ max_image_bytes = _read_int_env(
158
+ "JOB_IMAGE_MAX_BYTES",
159
+ DEFAULT_MAX_IMAGE_BYTES,
160
+ )
161
+ max_image_pixels = _read_int_env(
162
+ "JOB_IMAGE_MAX_PIXELS",
163
+ DEFAULT_MAX_IMAGE_PIXELS,
164
+ )
165
+ max_image_dimension = _read_int_env(
166
+ "JOB_IMAGE_MAX_DIMENSION",
167
+ DEFAULT_MAX_IMAGE_DIMENSION,
168
+ )
169
+ max_records = _read_int_env("JOB_MAX_RECORDS", DEFAULT_MAX_RECORDS)
170
+
171
+ if not 60 <= result_ttl_seconds <= 86400:
172
+ raise RuntimeError("JOB_RESULT_TTL_SECONDS must be between 60 and 86400")
173
+ if not 1 <= poll_after_seconds <= 30:
174
+ raise RuntimeError("JOB_POLL_AFTER_SECONDS must be between 1 and 30")
175
+ if not 1.0 <= fetch_timeout_seconds <= 15.0:
176
+ raise RuntimeError(
177
+ "JOB_IMAGE_FETCH_TIMEOUT_SECONDS must be between 1 and 15"
178
+ )
179
+ if not 1024 <= max_image_bytes <= MAX_CONFIGURABLE_IMAGE_BYTES:
180
+ raise RuntimeError("JOB_IMAGE_MAX_BYTES must be between 1 KiB and 20 MiB")
181
+ if not 1 <= max_image_pixels <= MAX_CONFIGURABLE_IMAGE_PIXELS:
182
+ raise RuntimeError(
183
+ "JOB_IMAGE_MAX_PIXELS must be between 1 and 20000000"
184
+ )
185
+ if not MIN_IMAGE_DIMENSION <= max_image_dimension <= MAX_CONFIGURABLE_IMAGE_DIMENSION:
186
+ raise RuntimeError(
187
+ "JOB_IMAGE_MAX_DIMENSION must be between 32 and 8192"
188
+ )
189
+ if not MIN_MAX_RECORDS <= max_records <= MAX_MAX_RECORDS:
190
+ raise RuntimeError("JOB_MAX_RECORDS must be between 8 and 4096")
191
+
192
+ return cls(
193
+ api_key=api_key,
194
+ allowed_hosts=normalize_allowed_hosts(
195
+ os.getenv("JOB_IMAGE_ALLOWED_HOSTS", "")
196
+ ),
197
+ result_ttl_seconds=result_ttl_seconds,
198
+ poll_after_seconds=poll_after_seconds,
199
+ fetch_timeout_seconds=fetch_timeout_seconds,
200
+ max_image_bytes=max_image_bytes,
201
+ max_image_pixels=max_image_pixels,
202
+ max_image_dimension=max_image_dimension,
203
+ max_records=max_records,
204
+ space_host=os.getenv("SPACE_HOST", "").strip().rstrip("/"),
205
+ )
206
+
207
+
208
+ class ImageInversionJobRequest(BaseModel):
209
+ """定义 Image Inversion 标签分析任务的公开请求参数。"""
210
+
211
+ model_config = ConfigDict(
212
+ extra="forbid",
213
+ str_strip_whitespace=True,
214
+ allow_inf_nan=False,
215
+ )
216
+
217
+ input_image_url: str = Field(min_length=1, max_length=4096)
218
+ general_threshold: float = Field(default=0.35, ge=0.0, le=1.0)
219
+ character_threshold: float = Field(default=0.85, ge=0.0, le=1.0)
220
+ show_confidence: bool = True
221
+ show_general: bool = True
222
+ show_character: bool = True
223
+ show_ip: bool = True
224
+ separator: SummarySeparator = "comma"
225
+ show_chinese: bool = True
226
+
227
+ @field_validator("input_image_url")
228
+ @classmethod
229
+ def require_https_image_url(cls, value: str) -> str:
230
+ """在 Schema 阶段拒绝非 HTTPS、凭据、片段和非标准端口。
231
+
232
+ Args:
233
+ value: 调用方提交的远程图片 URL。
234
+
235
+ Returns:
236
+ 通过基础 HTTPS 语法检查的原始 URL。
237
+ """
238
+ try:
239
+ parsed = urlsplit(value)
240
+ port = parsed.port
241
+ except ValueError as exc:
242
+ raise ValueError("image URL is invalid") from exc
243
+ if (
244
+ parsed.scheme.lower() != "https"
245
+ or not parsed.hostname
246
+ or parsed.username is not None
247
+ or parsed.password is not None
248
+ or parsed.fragment
249
+ or port not in {None, 443}
250
+ ):
251
+ raise ValueError(
252
+ "image URL must use credential-free HTTPS on the default port"
253
+ )
254
+ return value
255
+
256
+
257
+ @dataclass(frozen=True, slots=True)
258
+ class ValidatedImageURL:
259
+ """保存通过协议、主机与公网地址检查的图片 URL。"""
260
+
261
+ value: str
262
+ host: str
263
+
264
+
265
+ class ImageSourceError(RuntimeError):
266
+ """表示图片来源不合法或暂时无法抓取。"""
267
+
268
+ def __init__(self, status_code: int, public_message: str) -> None:
269
+ """创建携带脱敏公开信息的图片来源错误。
270
+
271
+ Args:
272
+ status_code: API 应返回的 HTTP 状态码。
273
+ public_message: 不包含 URL、凭据或底层网络细节的公开文案。
274
+
275
+ Returns:
276
+ 此初始化方法不返回数据。
277
+ """
278
+ super().__init__(public_message)
279
+ self.status_code = status_code
280
+ self.public_message = public_message
281
+
282
+
283
+ def _resolve_host_addresses(host: str, timeout_seconds: float) -> list[Any]:
284
+ """在硬期限内解析白名单主机的地址。
285
+
286
+ Args:
287
+ host: 已通过精确白名单校验的规范化主机名。
288
+ timeout_seconds: DNS 解析允许占用的最长墙钟秒数。
289
+
290
+ Returns:
291
+ socket.getaddrinfo 返回的非空地址记录列表。
292
+ """
293
+ completed = threading.Event()
294
+ result: dict[str, Any] = {}
295
+ if not DNS_RESOLVER_SLOTS.acquire(blocking=False):
296
+ raise ImageSourceError(
297
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
298
+ "The image host resolution is temporarily busy.",
299
+ )
300
+
301
+ def resolve() -> None:
302
+ try:
303
+ result["records"] = socket.getaddrinfo(
304
+ host,
305
+ 443,
306
+ type=socket.SOCK_STREAM,
307
+ )
308
+ except Exception as exc:
309
+ result["error"] = exc
310
+ finally:
311
+ DNS_RESOLVER_SLOTS.release()
312
+ completed.set()
313
+
314
+ resolver = threading.Thread(
315
+ target=resolve,
316
+ name="image-inversion-dns",
317
+ daemon=True,
318
+ )
319
+ try:
320
+ resolver.start()
321
+ except Exception:
322
+ DNS_RESOLVER_SLOTS.release()
323
+ raise
324
+ if not completed.wait(timeout=max(0.001, timeout_seconds)):
325
+ raise ImageSourceError(
326
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
327
+ "The image host resolution timed out.",
328
+ )
329
+ resolution_error = result.get("error")
330
+ if resolution_error is not None:
331
+ raise ImageSourceError(
332
+ http_status.HTTP_502_BAD_GATEWAY,
333
+ "The image host could not be resolved.",
334
+ ) from resolution_error
335
+ address_records = result.get("records")
336
+ if not address_records:
337
+ raise ImageSourceError(
338
+ http_status.HTTP_502_BAD_GATEWAY,
339
+ "The image host did not return an address.",
340
+ )
341
+ return address_records
342
+
343
+
344
+ def validate_image_url(
345
+ url: str,
346
+ settings: ImageInversionJobSettings,
347
+ ) -> ValidatedImageURL:
348
+ """校验远程图片 URL 只指向白名单内的公网 HTTPS 主机。
349
+
350
+ Args:
351
+ url: 调用方提交的完整图片 URL。
352
+ settings: 包含白名单与网络资源限制的任务 API 设置。
353
+
354
+ Returns:
355
+ 已规范化主机名并可安全用于抓取的 URL 描述。
356
+ """
357
+ try:
358
+ parsed: SplitResult = urlsplit(url)
359
+ port = parsed.port
360
+ except ValueError as exc:
361
+ raise ImageSourceError(
362
+ http_status.HTTP_400_BAD_REQUEST,
363
+ "The image URL is invalid.",
364
+ ) from exc
365
+
366
+ if parsed.scheme.lower() != "https" or not parsed.hostname:
367
+ raise ImageSourceError(
368
+ http_status.HTTP_400_BAD_REQUEST,
369
+ "Image URLs must use HTTPS.",
370
+ )
371
+ if parsed.username is not None or parsed.password is not None or parsed.fragment:
372
+ raise ImageSourceError(
373
+ http_status.HTTP_400_BAD_REQUEST,
374
+ "The image URL contains unsupported credentials or fragments.",
375
+ )
376
+ if port not in {None, 443}:
377
+ raise ImageSourceError(
378
+ http_status.HTTP_400_BAD_REQUEST,
379
+ "The image URL must use the default HTTPS port.",
380
+ )
381
+
382
+ try:
383
+ normalized_host = (
384
+ parsed.hostname.rstrip(".").encode("idna").decode("ascii").lower()
385
+ )
386
+ except UnicodeError as exc:
387
+ raise ImageSourceError(
388
+ http_status.HTTP_400_BAD_REQUEST,
389
+ "The image URL hostname is invalid.",
390
+ ) from exc
391
+ if normalized_host not in settings.allowed_hosts:
392
+ raise ImageSourceError(
393
+ http_status.HTTP_400_BAD_REQUEST,
394
+ "The image URL host is not allowed.",
395
+ )
396
+
397
+ address_records = _resolve_host_addresses(
398
+ normalized_host,
399
+ settings.fetch_timeout_seconds,
400
+ )
401
+ # 白名单限定预期域名;逐个拒绝非公网解析结果可阻断错误配置和 DNS 绕过。
402
+ for address_record in address_records:
403
+ raw_address = address_record[4][0].split("%", 1)[0]
404
+ try:
405
+ resolved_ip = ipaddress.ip_address(raw_address)
406
+ except ValueError as exc:
407
+ raise ImageSourceError(
408
+ http_status.HTTP_400_BAD_REQUEST,
409
+ "The image host resolved to an invalid address.",
410
+ ) from exc
411
+ if not resolved_ip.is_global:
412
+ raise ImageSourceError(
413
+ http_status.HTTP_400_BAD_REQUEST,
414
+ "The image host must resolve only to public addresses.",
415
+ )
416
+
417
+ return ValidatedImageURL(value=url, host=normalized_host)
418
+
419
+
420
+ def _validate_connected_peer(response: httpx.Response) -> None:
421
+ """确认 HTTP 客户端实际连接的对端仍是公网 IP。
422
+
423
+ Args:
424
+ response: 已建立 TLS 连接并收到响应头的 httpx 响应。
425
+
426
+ Returns:
427
+ 对端地址可验证且属于公网时不返回数据。
428
+ """
429
+ network_stream = response.extensions.get("network_stream")
430
+ if network_stream is None:
431
+ raise ImageSourceError(
432
+ http_status.HTTP_502_BAD_GATEWAY,
433
+ "The image connection peer could not be verified.",
434
+ )
435
+ try:
436
+ peer_address = network_stream.get_extra_info("server_addr")
437
+ raw_address = peer_address[0].split("%", 1)[0]
438
+ peer_ip = ipaddress.ip_address(raw_address)
439
+ except (AttributeError, IndexError, TypeError, ValueError) as exc:
440
+ raise ImageSourceError(
441
+ http_status.HTTP_502_BAD_GATEWAY,
442
+ "The image connection peer could not be verified.",
443
+ ) from exc
444
+ if not peer_ip.is_global:
445
+ raise ImageSourceError(
446
+ http_status.HTTP_400_BAD_REQUEST,
447
+ "The image connection peer must be a public address.",
448
+ )
449
+
450
+
451
+ def decode_image_bytes(
452
+ image_bytes: bytes,
453
+ content_type: str,
454
+ settings: ImageInversionJobSettings,
455
+ ) -> Image.Image:
456
+ """校验远程内容并解码为脱离底层流的 RGB 图片。
457
+
458
+ Args:
459
+ image_bytes: 在字节上限内完整读取的图片内容。
460
+ content_type: 远程响应声明的 MIME 类型,仅为接口兼容保留。
461
+ settings: 图片字节、像素及单边尺寸限制。
462
+
463
+ Returns:
464
+ 完成 EXIF 方向修正并复制到内存的 RGB PIL 图片。
465
+ """
466
+ if not image_bytes or len(image_bytes) > settings.max_image_bytes:
467
+ raise ImageSourceError(
468
+ http_status.HTTP_400_BAD_REQUEST,
469
+ "The remote image is empty or exceeds the configured size limit.",
470
+ )
471
+
472
+ # 远端可合法返回 application/octet-stream;只信任 Pillow 识别的真实格式。
473
+ _ = content_type
474
+ try:
475
+ with warnings.catch_warnings():
476
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
477
+ with Image.open(io.BytesIO(image_bytes)) as source_image:
478
+ if source_image.format not in {"JPEG", "PNG", "WEBP"}:
479
+ raise ImageSourceError(
480
+ http_status.HTTP_400_BAD_REQUEST,
481
+ "The remote image format is not supported.",
482
+ )
483
+ width, height = source_image.size
484
+ if width < MIN_IMAGE_DIMENSION or height < MIN_IMAGE_DIMENSION:
485
+ raise ImageSourceError(
486
+ http_status.HTTP_400_BAD_REQUEST,
487
+ "The remote image dimensions are not supported.",
488
+ )
489
+ if (
490
+ width > settings.max_image_dimension
491
+ or height > settings.max_image_dimension
492
+ or width * height > settings.max_image_pixels
493
+ ):
494
+ raise ImageSourceError(
495
+ http_status.HTTP_400_BAD_REQUEST,
496
+ "The remote image exceeds the configured dimension limit.",
497
+ )
498
+ source_image.load()
499
+ return ImageOps.exif_transpose(source_image).convert("RGB").copy()
500
+ except ImageSourceError:
501
+ raise
502
+ except (
503
+ UnidentifiedImageError,
504
+ OSError,
505
+ ValueError,
506
+ Image.DecompressionBombError,
507
+ Image.DecompressionBombWarning,
508
+ ) as exc:
509
+ raise ImageSourceError(
510
+ http_status.HTTP_400_BAD_REQUEST,
511
+ "The remote image could not be decoded safely.",
512
+ ) from exc
513
+
514
+
515
+ def _map_image_request_error(
516
+ exc: Exception,
517
+ deadline_reached: threading.Event,
518
+ ) -> ImageSourceError:
519
+ """把底层 HTTP 错误映射为不含远程 URL 的公开错误。
520
+
521
+ Args:
522
+ exc: httpx 或客户端关闭路径产生的底层异常。
523
+ deadline_reached: 硬期限看门狗是否已经触发。
524
+
525
+ Returns:
526
+ 可由 API 层安全公开的图片来源错误。
527
+ """
528
+ if deadline_reached.is_set() or isinstance(exc, httpx.TimeoutException):
529
+ return ImageSourceError(
530
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
531
+ "The image download timed out.",
532
+ )
533
+ return ImageSourceError(
534
+ http_status.HTTP_502_BAD_GATEWAY,
535
+ "The image host is temporarily unavailable.",
536
+ )
537
+
538
+
539
+ def _fetch_remote_image_inner(
540
+ url: str,
541
+ settings: ImageInversionJobSettings,
542
+ ) -> Image.Image:
543
+ """从白名单公网主机流式抓取并安全解码图片。
544
+
545
+ Args:
546
+ url: 调用方提交的图片 HTTPS URL。
547
+ settings: 图片白名单、超时、体积、像素与尺寸边界。
548
+
549
+ Returns:
550
+ 可直接交给标签模型的 RGB PIL 图片。
551
+ """
552
+ started_at = time.monotonic()
553
+ validated_url = validate_image_url(url, settings)
554
+ remaining_seconds = settings.fetch_timeout_seconds - (
555
+ time.monotonic() - started_at
556
+ )
557
+ if remaining_seconds <= 0:
558
+ raise ImageSourceError(
559
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
560
+ "The image download timed out.",
561
+ )
562
+ timeout = httpx.Timeout(
563
+ remaining_seconds,
564
+ connect=min(5.0, remaining_seconds),
565
+ )
566
+ deadline_reached = threading.Event()
567
+ client = httpx.Client(
568
+ timeout=timeout,
569
+ follow_redirects=False,
570
+ trust_env=False,
571
+ )
572
+
573
+ def stop_at_deadline() -> None:
574
+ deadline_reached.set()
575
+ try:
576
+ client.close()
577
+ except Exception:
578
+ pass
579
+
580
+ deadline_timer = threading.Timer(remaining_seconds, stop_at_deadline)
581
+ deadline_timer.daemon = True
582
+ try:
583
+ with client:
584
+ deadline_timer.start()
585
+ with client.stream(
586
+ "GET",
587
+ validated_url.value,
588
+ headers={"Accept": "image/png,image/jpeg,image/webp"},
589
+ ) as response:
590
+ if time.monotonic() - started_at > settings.fetch_timeout_seconds:
591
+ raise ImageSourceError(
592
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
593
+ "The image download timed out.",
594
+ )
595
+ _validate_connected_peer(response)
596
+ if 300 <= response.status_code < 400:
597
+ raise ImageSourceError(
598
+ http_status.HTTP_400_BAD_REQUEST,
599
+ "Image URL redirects are not allowed.",
600
+ )
601
+ if response.status_code == http_status.HTTP_408_REQUEST_TIMEOUT:
602
+ raise ImageSourceError(
603
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
604
+ "The image download timed out.",
605
+ )
606
+ if response.status_code == 429 or response.status_code >= 500:
607
+ raise ImageSourceError(
608
+ http_status.HTTP_502_BAD_GATEWAY,
609
+ "The image host is temporarily unavailable.",
610
+ )
611
+ if response.status_code != http_status.HTTP_200_OK:
612
+ raise ImageSourceError(
613
+ http_status.HTTP_400_BAD_REQUEST,
614
+ "The image URL did not return a readable resource.",
615
+ )
616
+
617
+ declared_length = response.headers.get("content-length")
618
+ if declared_length:
619
+ try:
620
+ parsed_length = int(declared_length)
621
+ if parsed_length < 0:
622
+ raise ValueError("negative content length")
623
+ if parsed_length > settings.max_image_bytes:
624
+ raise ImageSourceError(
625
+ http_status.HTTP_400_BAD_REQUEST,
626
+ "The remote image exceeds the configured size limit.",
627
+ )
628
+ except ValueError as exc:
629
+ raise ImageSourceError(
630
+ http_status.HTTP_400_BAD_REQUEST,
631
+ "The image host returned an invalid content length.",
632
+ ) from exc
633
+
634
+ image_buffer = bytearray()
635
+ for chunk in response.iter_bytes():
636
+ if time.monotonic() - started_at > settings.fetch_timeout_seconds:
637
+ raise ImageSourceError(
638
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
639
+ "The image download timed out.",
640
+ )
641
+ image_buffer.extend(chunk)
642
+ if len(image_buffer) > settings.max_image_bytes:
643
+ raise ImageSourceError(
644
+ http_status.HTTP_400_BAD_REQUEST,
645
+ "The remote image exceeds the configured size limit.",
646
+ )
647
+ decoded_image = decode_image_bytes(
648
+ bytes(image_buffer),
649
+ response.headers.get("content-type", ""),
650
+ settings,
651
+ )
652
+ deadline_timer.cancel()
653
+ if (
654
+ deadline_reached.is_set()
655
+ or time.monotonic() - started_at > settings.fetch_timeout_seconds
656
+ ):
657
+ decoded_image.close()
658
+ raise ImageSourceError(
659
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
660
+ "The image download timed out.",
661
+ )
662
+ return decoded_image
663
+ except ImageSourceError:
664
+ raise
665
+ except (httpx.RequestError, RuntimeError) as exc:
666
+ raise _map_image_request_error(exc, deadline_reached) from exc
667
+ finally:
668
+ deadline_timer.cancel()
669
+
670
+
671
+ def fetch_remote_image(
672
+ url: str,
673
+ settings: ImageInversionJobSettings,
674
+ ) -> Image.Image:
675
+ """以有界后台抓取器执行下载并强制墙钟总期限。
676
+
677
+ Args:
678
+ url: 调用方提交的图片 HTTPS URL。
679
+ settings: 白名单、硬超时、字节、像素与尺寸边界。
680
+
681
+ Returns:
682
+ 在总期限内完成验证、EXIF 转正和 RGB 转换的 PIL 图片。
683
+ """
684
+ if not IMAGE_FETCH_SLOTS.acquire(blocking=False):
685
+ raise ImageSourceError(
686
+ http_status.HTTP_502_BAD_GATEWAY,
687
+ "The image fetch service is temporarily busy.",
688
+ )
689
+
690
+ completed = threading.Event()
691
+ state_lock = threading.Lock()
692
+ state: dict[str, Any] = {"abandoned": False}
693
+
694
+ def fetch() -> None:
695
+ fetched_image: Image.Image | None = None
696
+ try:
697
+ fetched_image = _fetch_remote_image_inner(url, settings)
698
+ should_close = False
699
+ with state_lock:
700
+ if state["abandoned"]:
701
+ should_close = True
702
+ else:
703
+ state["image"] = fetched_image
704
+ fetched_image = None
705
+ if should_close and fetched_image is not None:
706
+ fetched_image.close()
707
+ fetched_image = None
708
+ except Exception as exc:
709
+ with state_lock:
710
+ if not state["abandoned"]:
711
+ state["error"] = exc
712
+ finally:
713
+ if fetched_image is not None:
714
+ try:
715
+ fetched_image.close()
716
+ except Exception as exc:
717
+ LOGGER.warning(
718
+ "Failed to close an abandoned fetched image: %s",
719
+ type(exc).__name__,
720
+ )
721
+ IMAGE_FETCH_SLOTS.release()
722
+ completed.set()
723
+
724
+ fetch_thread = threading.Thread(
725
+ target=fetch,
726
+ name="image-inversion-fetch",
727
+ daemon=True,
728
+ )
729
+ try:
730
+ fetch_thread.start()
731
+ except Exception as exc:
732
+ IMAGE_FETCH_SLOTS.release()
733
+ raise ImageSourceError(
734
+ http_status.HTTP_502_BAD_GATEWAY,
735
+ "The image fetch service could not start.",
736
+ ) from exc
737
+
738
+ if not completed.wait(timeout=settings.fetch_timeout_seconds):
739
+ abandoned_image: Image.Image | None = None
740
+ with state_lock:
741
+ state["abandoned"] = True
742
+ abandoned_image = state.pop("image", None)
743
+ if abandoned_image is not None:
744
+ try:
745
+ abandoned_image.close()
746
+ except Exception as exc:
747
+ LOGGER.warning(
748
+ "Failed to close a timed-out fetched image: %s",
749
+ type(exc).__name__,
750
+ )
751
+ raise ImageSourceError(
752
+ http_status.HTTP_504_GATEWAY_TIMEOUT,
753
+ "The image download timed out.",
754
+ )
755
+
756
+ with state_lock:
757
+ fetch_error = state.get("error")
758
+ fetched_image = state.get("image")
759
+ if fetch_error is not None:
760
+ if isinstance(fetch_error, ImageSourceError):
761
+ raise fetch_error
762
+ raise ImageSourceError(
763
+ http_status.HTTP_502_BAD_GATEWAY,
764
+ "The image host is temporarily unavailable.",
765
+ ) from fetch_error
766
+ if fetched_image is None:
767
+ raise ImageSourceError(
768
+ http_status.HTTP_502_BAD_GATEWAY,
769
+ "The image response could not be completed.",
770
+ )
771
+ return fetched_image
772
+
773
+
774
+ def _constant_time_text_equal(supplied: str, expected: str) -> bool:
775
+ """以字节形式比较不可信文本。
776
+
777
+ Args:
778
+ supplied: 请求携带的待校验文本。
779
+ expected: 服务端保存的期望文本。
780
+
781
+ Returns:
782
+ 两个 UTF-8 字节序列完全一致时返回 True。
783
+ """
784
+ return secrets.compare_digest(
785
+ supplied.encode("utf-8", errors="surrogatepass"),
786
+ expected.encode("utf-8", errors="surrogatepass"),
787
+ )
788
+
789
+
790
+ def fingerprint_request(payload: ImageInversionJobRequest) -> str:
791
+ """生成与 JSON 字段顺序无关的任务请求指纹。
792
+
793
+ Args:
794
+ payload: 已通过 Pydantic 校验的标签分析参数。
795
+
796
+ Returns:
797
+ 用于幂等键复用校验的 SHA-256 摘要。
798
+ """
799
+ canonical_payload = json.dumps(
800
+ payload.model_dump(mode="json"),
801
+ ensure_ascii=False,
802
+ separators=(",", ":"),
803
+ sort_keys=True,
804
+ )
805
+ return hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest()
806
+
807
+
808
+ @dataclass(slots=True)
809
+ class ImageInversionJobRecord:
810
+ """保存单个标签分析任务在当前 Space 进程内的生命周期数据。"""
811
+
812
+ job_id: str
813
+ payload: ImageInversionJobRequest
814
+ request_fingerprint: str
815
+ input_image: Image.Image | None
816
+ idempotency_key: str | None = None
817
+ status: JobStatus = "queued"
818
+ created_at: float = field(default_factory=time.time)
819
+ started_at: float | None = None
820
+ completed_at: float | None = None
821
+ result: dict[str, Any] | None = None
822
+ error_type: str | None = None
823
+
824
+
825
+ class ImageInversionJobAPI:
826
+ """实现 Image Inversion 异步 JSON 任务 API 状态机。"""
827
+
828
+ def __init__(
829
+ self,
830
+ settings: ImageInversionJobSettings,
831
+ executor: AnalysisExecutor,
832
+ inference_slot: threading.Lock,
833
+ image_fetcher: ImageFetcher = fetch_remote_image,
834
+ ) -> None:
835
+ """创建任务服务并注册 Image Inversion 自定义路由。
836
+
837
+ Args:
838
+ settings: 已校验的部署配置和资源限制。
839
+ executor: 复用现有标签分析链路的执行适配函数。
840
+ inference_slot: 与 UI 共用的非阻塞单推理门闩。
841
+ image_fetcher: 可替换的安全远程图片抓取器。
842
+
843
+ Returns:
844
+ 此初始化方法不返回数据。
845
+ """
846
+ self.settings = settings
847
+ self.executor = executor
848
+ self.inference_slot = inference_slot
849
+ self.image_fetcher = image_fetcher
850
+ self.jobs: dict[str, ImageInversionJobRecord] = {}
851
+ self.idempotency_jobs: dict[str, str] = {}
852
+ self.jobs_lock = threading.RLock()
853
+ self.active_job_id: str | None = None
854
+
855
+ self.router = APIRouter(tags=["jobs"])
856
+ self.router.add_api_route(
857
+ "/api/jobs",
858
+ self.create_job,
859
+ methods=["POST"],
860
+ name="image_inversion_create_job",
861
+ )
862
+ self.router.add_api_route(
863
+ "/api/jobs/{job_id}",
864
+ self.get_job_status,
865
+ methods=["GET"],
866
+ name="image_inversion_get_job_status",
867
+ )
868
+
869
+ def install_on_app(self, app: Any) -> None:
870
+ """把自定义任务路由安装到 Gradio FastAPI 应用前部。
871
+
872
+ Args:
873
+ app: Gradio 在 launch 阶段创建的 FastAPI 应用。
874
+
875
+ Returns:
876
+ 路由已存在或安装完成后不返回数据。
877
+ """
878
+ if getattr(app.state, "image_inversion_job_api_registered", False):
879
+ return
880
+ original_route_count = len(app.router.routes)
881
+ app.include_router(self.router)
882
+ added_routes = app.router.routes[original_route_count:]
883
+ original_routes = app.router.routes[:original_route_count]
884
+ # Gradio 含宽泛路由,自定义 API 必须排在其前面才能稳定命中。
885
+ app.router.routes[:] = [*added_routes, *original_routes]
886
+ app.state.image_inversion_job_api_registered = True
887
+
888
+ def _require_api_key(self, request: Request) -> None:
889
+ supplied_key = request.headers.get("x-api-key", "")
890
+ if not supplied_key or not _constant_time_text_equal(
891
+ supplied_key,
892
+ self.settings.api_key,
893
+ ):
894
+ raise HTTPException(
895
+ status_code=http_status.HTTP_401_UNAUTHORIZED,
896
+ detail="A valid X-API-Key header is required.",
897
+ )
898
+
899
+ def _public_route_url(
900
+ self,
901
+ request: Request,
902
+ route_name: str,
903
+ **path_params: str,
904
+ ) -> str:
905
+ path = str(request.app.url_path_for(route_name, **path_params))
906
+ if self.settings.space_host:
907
+ space_host = self.settings.space_host
908
+ if not space_host.startswith(("http://", "https://")):
909
+ space_host = f"https://{space_host}"
910
+ return f"{space_host}{path}"
911
+ return str(request.url_for(route_name, **path_params))
912
+
913
+ def _is_expired(
914
+ self,
915
+ record: ImageInversionJobRecord,
916
+ now: float | None = None,
917
+ ) -> bool:
918
+ current_time = time.time() if now is None else now
919
+ if record.completed_at is None:
920
+ return False
921
+ return current_time - record.completed_at > self.settings.result_ttl_seconds
922
+
923
+ @staticmethod
924
+ def _close_images(*images: Image.Image | None) -> None:
925
+ """逐一关闭已知图片并隔离单个清理异常。
926
+
927
+ Args:
928
+ images: 要释放的零个或多个明确图片对象。
929
+
930
+ Returns:
931
+ 清理完成后不返回数据;关闭错误仅写入脱敏日志。
932
+ """
933
+ for image in images:
934
+ if image is not None:
935
+ try:
936
+ image.close()
937
+ except Exception as exc:
938
+ LOGGER.warning(
939
+ "Failed to close an Image Inversion job image: %s",
940
+ type(exc).__name__,
941
+ )
942
+
943
+ @staticmethod
944
+ def _close_job_image(record: ImageInversionJobRecord | None) -> None:
945
+ if record is None:
946
+ return
947
+ ImageInversionJobAPI._close_images(record.input_image)
948
+ record.input_image = None
949
+
950
+ def _remove_record_locked(self, record: ImageInversionJobRecord) -> None:
951
+ self.jobs.pop(record.job_id, None)
952
+ if record.idempotency_key is not None:
953
+ if self.idempotency_jobs.get(record.idempotency_key) == record.job_id:
954
+ self.idempotency_jobs.pop(record.idempotency_key, None)
955
+
956
+ def remove_expired_jobs(self) -> None:
957
+ """清理当前所有过期终态任务及其输入图片引用。
958
+
959
+ Args:
960
+ 此方法不接收参数。
961
+
962
+ Returns:
963
+ 清理完成后不返回数据。
964
+ """
965
+ expired_records: list[ImageInversionJobRecord] = []
966
+ now = time.time()
967
+ with self.jobs_lock:
968
+ for record in tuple(self.jobs.values()):
969
+ if self._is_expired(record, now):
970
+ self._remove_record_locked(record)
971
+ expired_records.append(record)
972
+ for expired_record in expired_records:
973
+ self._close_job_image(expired_record)
974
+
975
+ def _ensure_capacity_for_new_record(self) -> None:
976
+ """为一个新任务腾出有限记录容量。
977
+
978
+ Args:
979
+ 此方法不接收���数。
980
+
981
+ Returns:
982
+ 容量已经可用时不返回数据;无法安全腾出容量时抛出 HTTP 503。
983
+ """
984
+ evicted_records: list[ImageInversionJobRecord] = []
985
+ with self.jobs_lock:
986
+ now = time.time()
987
+ for record in tuple(self.jobs.values()):
988
+ if self._is_expired(record, now):
989
+ self._remove_record_locked(record)
990
+ evicted_records.append(record)
991
+
992
+ while len(self.jobs) >= self.settings.max_records:
993
+ terminal_records = [
994
+ record
995
+ for record in self.jobs.values()
996
+ if record.status in {"succeeded", "failed"}
997
+ and record.completed_at is not None
998
+ ]
999
+ if not terminal_records:
1000
+ raise HTTPException(
1001
+ status_code=http_status.HTTP_503_SERVICE_UNAVAILABLE,
1002
+ detail="The prediction job store is full.",
1003
+ headers={"Retry-After": "5"},
1004
+ )
1005
+ # 仅淘汰最老终态;绝不删除 queued/running 任务。
1006
+ oldest_terminal = min(
1007
+ terminal_records,
1008
+ key=lambda record: record.completed_at or record.created_at,
1009
+ )
1010
+ self._remove_record_locked(oldest_terminal)
1011
+ evicted_records.append(oldest_terminal)
1012
+
1013
+ for evicted_record in evicted_records:
1014
+ self._close_job_image(evicted_record)
1015
+
1016
+ def _job_or_404(self, job_id: str) -> ImageInversionJobRecord:
1017
+ expired_record: ImageInversionJobRecord | None = None
1018
+ with self.jobs_lock:
1019
+ record = self.jobs.get(job_id)
1020
+ if record is not None and self._is_expired(record):
1021
+ expired_record = record
1022
+ self._remove_record_locked(record)
1023
+ record = None
1024
+ self._close_job_image(expired_record)
1025
+ if record is None:
1026
+ raise HTTPException(
1027
+ status_code=http_status.HTTP_404_NOT_FOUND,
1028
+ detail="Job not found or result expired.",
1029
+ )
1030
+ return record
1031
+
1032
+ def _status_response(
1033
+ self,
1034
+ request: Request,
1035
+ record: ImageInversionJobRecord,
1036
+ ) -> JSONResponse:
1037
+ status_url = self._public_route_url(
1038
+ request,
1039
+ "image_inversion_get_job_status",
1040
+ job_id=record.job_id,
1041
+ )
1042
+ return JSONResponse(
1043
+ status_code=http_status.HTTP_202_ACCEPTED,
1044
+ headers={
1045
+ "Location": status_url,
1046
+ "Cache-Control": "no-store",
1047
+ },
1048
+ content={
1049
+ "job_id": record.job_id,
1050
+ "status_url": status_url,
1051
+ "poll_after_seconds": self.settings.poll_after_seconds,
1052
+ },
1053
+ )
1054
+
1055
+ def _lookup_idempotent_job(
1056
+ self,
1057
+ idempotency_key: str | None,
1058
+ request_fingerprint: str,
1059
+ ) -> ImageInversionJobRecord | None:
1060
+ if idempotency_key is None:
1061
+ return None
1062
+ with self.jobs_lock:
1063
+ existing_job_id = self.idempotency_jobs.get(idempotency_key)
1064
+ record = self.jobs.get(existing_job_id or "")
1065
+ if record is not None and self._is_expired(record):
1066
+ self._remove_record_locked(record)
1067
+ record = None
1068
+ if record is None:
1069
+ if existing_job_id is not None:
1070
+ self.idempotency_jobs.pop(idempotency_key, None)
1071
+ return None
1072
+ if not secrets.compare_digest(
1073
+ record.request_fingerprint,
1074
+ request_fingerprint,
1075
+ ):
1076
+ raise HTTPException(
1077
+ status_code=http_status.HTTP_409_CONFLICT,
1078
+ detail=(
1079
+ "Idempotency-Key was already used with a different "
1080
+ "request body."
1081
+ ),
1082
+ )
1083
+ return record
1084
+
1085
+ def create_job(
1086
+ self,
1087
+ payload: ImageInversionJobRequest,
1088
+ request: Request,
1089
+ ) -> JSONResponse:
1090
+ """验证远程图片并创建立即执行的异步标签分析任务。
1091
+
1092
+ Args:
1093
+ payload: 已通过 Pydantic 校验的命名分析参数。
1094
+ request: 包含共享密钥的 FastAPI 请求。
1095
+
1096
+ Returns:
1097
+ HTTP 202 任务标识、状态 URL 与建议轮询间隔。
1098
+ """
1099
+ self._require_api_key(request)
1100
+ self.remove_expired_jobs()
1101
+
1102
+ idempotency_key = request.headers.get("idempotency-key", "").strip() or None
1103
+ if idempotency_key is not None and len(idempotency_key) > 200:
1104
+ raise HTTPException(
1105
+ status_code=http_status.HTTP_400_BAD_REQUEST,
1106
+ detail="Idempotency-Key must be 200 characters or fewer.",
1107
+ )
1108
+ request_fingerprint = fingerprint_request(payload)
1109
+ existing_record = self._lookup_idempotent_job(
1110
+ idempotency_key,
1111
+ request_fingerprint,
1112
+ )
1113
+ if existing_record is not None:
1114
+ return self._status_response(request, existing_record)
1115
+ self._ensure_capacity_for_new_record()
1116
+
1117
+ with self.jobs_lock:
1118
+ active_record = self.jobs.get(self.active_job_id or "")
1119
+ api_job_active = active_record is not None and active_record.status in {
1120
+ "queued",
1121
+ "running",
1122
+ }
1123
+ # 抓图前先做快速忙检查;抓图后仍在状态锁内做权威二次判定。
1124
+ if api_job_active or self.inference_slot.locked():
1125
+ raise HTTPException(
1126
+ status_code=http_status.HTTP_503_SERVICE_UNAVAILABLE,
1127
+ detail="The prediction service is busy.",
1128
+ headers={"Retry-After": "5"},
1129
+ )
1130
+
1131
+ slot_owned_by_request = False
1132
+ record: ImageInversionJobRecord | None = None
1133
+ replay_record: ImageInversionJobRecord | None = None
1134
+ input_image: Image.Image | None = None
1135
+ try:
1136
+ try:
1137
+ input_image = self.image_fetcher(
1138
+ payload.input_image_url,
1139
+ self.settings,
1140
+ )
1141
+ except ImageSourceError as exc:
1142
+ raise HTTPException(
1143
+ status_code=exc.status_code,
1144
+ detail=exc.public_message,
1145
+ ) from exc
1146
+
1147
+ with self.jobs_lock:
1148
+ # 相同幂等键可并发抓图;只有二次判定有权创建唯一任务。
1149
+ replay_record = self._lookup_idempotent_job(
1150
+ idempotency_key,
1151
+ request_fingerprint,
1152
+ )
1153
+ if replay_record is None:
1154
+ self._ensure_capacity_for_new_record()
1155
+ active_record = self.jobs.get(self.active_job_id or "")
1156
+ if active_record is not None and active_record.status in {
1157
+ "queued",
1158
+ "running",
1159
+ }:
1160
+ raise HTTPException(
1161
+ status_code=http_status.HTTP_503_SERVICE_UNAVAILABLE,
1162
+ detail="The prediction service is busy.",
1163
+ headers={"Retry-After": "5"},
1164
+ )
1165
+ if not self.inference_slot.acquire(blocking=False):
1166
+ raise HTTPException(
1167
+ status_code=http_status.HTTP_503_SERVICE_UNAVAILABLE,
1168
+ detail="The prediction service is busy.",
1169
+ headers={"Retry-After": "5"},
1170
+ )
1171
+ slot_owned_by_request = True
1172
+
1173
+ job_id = secrets.token_urlsafe(24)
1174
+ record = ImageInversionJobRecord(
1175
+ job_id=job_id,
1176
+ payload=payload,
1177
+ request_fingerprint=request_fingerprint,
1178
+ input_image=input_image,
1179
+ idempotency_key=idempotency_key,
1180
+ )
1181
+ self.jobs[job_id] = record
1182
+ self.active_job_id = job_id
1183
+ if idempotency_key is not None:
1184
+ self.idempotency_jobs[idempotency_key] = job_id
1185
+
1186
+ if replay_record is not None:
1187
+ self._close_images(input_image)
1188
+ input_image = None
1189
+ return self._status_response(request, replay_record)
1190
+ if record is None:
1191
+ raise RuntimeError("Job initialization did not produce a record.")
1192
+
1193
+ response = self._status_response(request, record)
1194
+ worker = threading.Thread(
1195
+ target=self._execute_job,
1196
+ args=(record,),
1197
+ name=f"image-inversion-job-{record.job_id[:8]}",
1198
+ daemon=True,
1199
+ )
1200
+ worker.start()
1201
+ # 线程启动成功后,槽位所有权转交给 worker 最外层 finally。
1202
+ slot_owned_by_request = False
1203
+ return response
1204
+ except Exception:
1205
+ with self.jobs_lock:
1206
+ if record is not None:
1207
+ self._remove_record_locked(record)
1208
+ if record is not None and self.active_job_id == record.job_id:
1209
+ self.active_job_id = None
1210
+ self._close_job_image(record)
1211
+ if record is None:
1212
+ self._close_images(input_image)
1213
+ if slot_owned_by_request:
1214
+ self.inference_slot.release()
1215
+ raise
1216
+
1217
+ def _execute_job(self, record: ImageInversionJobRecord) -> None:
1218
+ try:
1219
+ with self.jobs_lock:
1220
+ current_record = self.jobs.get(record.job_id)
1221
+ if current_record is not record or record.status != "queued":
1222
+ return
1223
+ record.status = "running"
1224
+ record.started_at = time.time()
1225
+
1226
+ if record.input_image is None:
1227
+ raise RuntimeError("The job input image is unavailable.")
1228
+ result = self.executor(
1229
+ record.payload,
1230
+ record.input_image,
1231
+ record.job_id,
1232
+ )
1233
+ if not isinstance(result, dict):
1234
+ raise RuntimeError("The prediction executor did not return an object.")
1235
+ # 任务结果必须在后台线程内完成 JSON 校验,避免轮询阶段才暴露序列化错误。
1236
+ normalized_result = json.loads(
1237
+ json.dumps(result, ensure_ascii=False, allow_nan=False)
1238
+ )
1239
+
1240
+ with self.jobs_lock:
1241
+ current_record = self.jobs.get(record.job_id)
1242
+ if current_record is record and record.status == "running":
1243
+ record.status = "succeeded"
1244
+ record.result = normalized_result
1245
+ record.completed_at = time.time()
1246
+ except Exception as exc:
1247
+ LOGGER.error(
1248
+ "Image Inversion job %s failed with %s",
1249
+ record.job_id[:8],
1250
+ type(exc).__name__,
1251
+ )
1252
+ with self.jobs_lock:
1253
+ current_record = self.jobs.get(record.job_id)
1254
+ if current_record is record and record.status in {"queued", "running"}:
1255
+ record.status = "failed"
1256
+ record.error_type = type(exc).__name__
1257
+ record.completed_at = time.time()
1258
+ finally:
1259
+ with self.jobs_lock:
1260
+ if self.active_job_id == record.job_id:
1261
+ self.active_job_id = None
1262
+ self._close_job_image(record)
1263
+ self.inference_slot.release()
1264
+ self.remove_expired_jobs()
1265
+
1266
+ def get_job_status(self, job_id: str, request: Request) -> JSONResponse:
1267
+ """读取标签分析任务状态并在成功后返回 JSON 结果。
1268
+
1269
+ Args:
1270
+ job_id: POST 创建任务时返回的自定义任务标识。
1271
+ request: 用于鉴权的 FastAPI 请求。
1272
+
1273
+ Returns:
1274
+ 运行中返回 202,成功返回 result,失败返回脱敏 500。
1275
+ """
1276
+ self._require_api_key(request)
1277
+ record = self._job_or_404(job_id)
1278
+ with self.jobs_lock:
1279
+ status_value = record.status
1280
+ result_value = record.result
1281
+
1282
+ if status_value in {"queued", "running"}:
1283
+ return JSONResponse(
1284
+ status_code=http_status.HTTP_202_ACCEPTED,
1285
+ headers={
1286
+ "Retry-After": str(self.settings.poll_after_seconds),
1287
+ "Cache-Control": "no-store",
1288
+ },
1289
+ content={"status": status_value},
1290
+ )
1291
+ if status_value == "failed":
1292
+ return JSONResponse(
1293
+ status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
1294
+ headers={"Cache-Control": "no-store"},
1295
+ content={"error": {"code": "PREDICTION_FAILED"}},
1296
+ )
1297
+ if result_value is None:
1298
+ raise RuntimeError("A succeeded prediction job has no result.")
1299
+ return JSONResponse(
1300
+ content=result_value,
1301
+ headers={"Cache-Control": "no-store"},
1302
+ )
1303
+
1304
+
1305
+ def create_job_api_lifespan(job_api: ImageInversionJobAPI):
1306
+ """创建供 Gradio launch 组合使用的自定义路由 lifespan。
1307
+
1308
+ Args:
1309
+ job_api: 已配置执行器和共享推理锁的标签分析任务服务。
1310
+
1311
+ Returns:
1312
+ 可传给 Gradio app_kwargs 的异步 lifespan 上下文管理器。
1313
+ """
1314
+
1315
+ @asynccontextmanager
1316
+ async def job_api_lifespan(app: Any):
1317
+ """在 Gradio 应用接收请求前安装自定义任务路由。
1318
+
1319
+ Args:
1320
+ app: Gradio 创建并传入 lifespan 的 FastAPI 应用。
1321
+
1322
+ Returns:
1323
+ lifespan 启动与关闭阶段不返回业务数据。
1324
+ """
1325
+ job_api.install_on_app(app)
1326
+ yield
1327
+
1328
+ return job_api_lifespan
requirements.txt CHANGED
@@ -1,4 +1,5 @@
1
- gradio>=4.31.0
 
2
  huggingface_hub>=0.24.0
3
  numpy
4
  pandas
@@ -7,4 +8,6 @@ onnxruntime
7
  torch
8
  torchvision
9
  timm
10
- requests
 
 
 
1
+ gradio==5.29.0
2
+ fastapi>=0.115.2,<1
3
  huggingface_hub>=0.24.0
4
  numpy
5
  pandas
 
8
  torch
9
  torchvision
10
  timm
11
+ requests
12
+ httpx==0.28.1
13
+ pydantic>=2,<2.12
tests/test_image_inversion_job_api.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import io
5
+ import logging
6
+ import os
7
+ import socket
8
+ import tempfile
9
+ import threading
10
+ import time
11
+ import unittest
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from pathlib import Path
14
+ from unittest.mock import patch
15
+
16
+ import httpx
17
+ from fastapi import FastAPI
18
+ from fastapi.testclient import TestClient
19
+ from PIL import Image
20
+ from pydantic import ValidationError
21
+
22
+ from image_inversion_job_api import (
23
+ ImageInversionJobAPI,
24
+ ImageInversionJobRecord,
25
+ ImageInversionJobRequest,
26
+ ImageInversionJobSettings,
27
+ ImageSourceError,
28
+ create_job_api_lifespan,
29
+ decode_image_bytes,
30
+ fetch_remote_image,
31
+ fingerprint_request,
32
+ validate_image_url,
33
+ )
34
+
35
+
36
+ API_KEY = "k" * 48
37
+ EXPECTED_RESULT = {
38
+ "status_markdown": "done",
39
+ "general_tags_html": "<p>general</p>",
40
+ "character_tags_html": "<p>character</p>",
41
+ "ip_tags_html": "<p>ip</p>",
42
+ "summary_text": "tag one, tag two",
43
+ "metadata": {"device": "cpu", "latency_s_total": 0.1},
44
+ }
45
+
46
+
47
+ def _make_png_bytes(size: tuple[int, int] = (96, 80)) -> bytes:
48
+ """生成远程抓图测试使用的有效 PNG。
49
+
50
+ Args:
51
+ size: PNG 的宽高。
52
+
53
+ Returns:
54
+ 完整 PNG 文件字节。
55
+ """
56
+ image_buffer = io.BytesIO()
57
+ with Image.new("RGB", size, "green") as image:
58
+ image.save(image_buffer, format="PNG")
59
+ return image_buffer.getvalue()
60
+
61
+
62
+ PNG_BYTES = _make_png_bytes()
63
+
64
+
65
+ class _TrackedImage:
66
+ """记录任务输入图是否被 API 生命周期可靠关闭。"""
67
+
68
+ def __init__(self) -> None:
69
+ self.closed = False
70
+
71
+ def close(self) -> None:
72
+ """标记当前测试图片已经关闭。"""
73
+ self.closed = True
74
+
75
+
76
+ class _FakeStreamResponse:
77
+ """提供 httpx 流式响应所需的最小测试替身。"""
78
+
79
+ def __init__(
80
+ self,
81
+ status_code: int,
82
+ body: bytes,
83
+ *,
84
+ peer_address: str = "93.184.216.34",
85
+ extra_headers: dict[str, str] | None = None,
86
+ ) -> None:
87
+ self.status_code = status_code
88
+ self.body = body
89
+ self.headers = {
90
+ "content-type": "image/png",
91
+ "content-length": str(len(body)),
92
+ }
93
+ if extra_headers:
94
+ self.headers.update(extra_headers)
95
+ self.extensions = {
96
+ "network_stream": type(
97
+ "NetworkStreamStub",
98
+ (),
99
+ {
100
+ "get_extra_info": lambda self, name: (
101
+ (peer_address, 443) if name == "server_addr" else None
102
+ )
103
+ },
104
+ )()
105
+ }
106
+
107
+ def __enter__(self) -> "_FakeStreamResponse":
108
+ """进入流式响应上下文并返回自身。"""
109
+ return self
110
+
111
+ def __exit__(self, exc_type, exc, traceback) -> bool:
112
+ """退出响应上下文且不吞掉异常。"""
113
+ del exc_type, exc, traceback
114
+ return False
115
+
116
+ def iter_bytes(self):
117
+ """以单个分块返回完整响应体。"""
118
+ yield self.body
119
+
120
+
121
+ class ImageInversionJobAPITest(unittest.TestCase):
122
+ """验证 Image Inversion 自定义任务 API 的公开契约与安全边界。"""
123
+
124
+ def setUp(self) -> None:
125
+ """登记每个测试需要在结束时关闭的客户端和线程事件。"""
126
+ self.clients: list[TestClient] = []
127
+ self.release_events: list[threading.Event] = []
128
+
129
+ def tearDown(self) -> None:
130
+ """释放测试线程和 TestClient,不创建持久化测试产物。"""
131
+ for release_event in self.release_events:
132
+ release_event.set()
133
+ time.sleep(0.02)
134
+ for client in self.clients:
135
+ client.close()
136
+
137
+ @staticmethod
138
+ def _settings(**overrides) -> ImageInversionJobSettings:
139
+ """构造不依赖进程环境的隔离任务设置。
140
+
141
+ Args:
142
+ overrides: 要覆盖的 ImageInversionJobSettings 字段。
143
+
144
+ Returns:
145
+ 可供单元测试直接注入的设置。
146
+ """
147
+ values = {
148
+ "api_key": API_KEY,
149
+ "allowed_hosts": frozenset({"allowed.example"}),
150
+ "result_ttl_seconds": 1800,
151
+ "poll_after_seconds": 1,
152
+ "fetch_timeout_seconds": 15.0,
153
+ "max_image_bytes": 8 * 1024 * 1024,
154
+ "max_image_pixels": 4_194_304,
155
+ "max_image_dimension": 4096,
156
+ "max_records": 256,
157
+ "space_host": "https://space.example",
158
+ }
159
+ values.update(overrides)
160
+ return ImageInversionJobSettings(**values)
161
+
162
+ @staticmethod
163
+ def _payload(**overrides) -> dict:
164
+ """生成可直接提交给创建接口的合法请求体。
165
+
166
+ Args:
167
+ overrides: 要覆盖或增加的 JSON 字段。
168
+
169
+ Returns:
170
+ 包含远程输入图和全部展示参数的请求字典。
171
+ """
172
+ payload = {
173
+ "input_image_url": "https://allowed.example/input.png?source=private",
174
+ "general_threshold": 0.35,
175
+ "character_threshold": 0.85,
176
+ "show_confidence": True,
177
+ "show_general": True,
178
+ "show_character": True,
179
+ "show_ip": True,
180
+ "separator": "comma",
181
+ "show_chinese": True,
182
+ }
183
+ payload.update(overrides)
184
+ return payload
185
+
186
+ @staticmethod
187
+ def _headers(**overrides) -> dict[str, str]:
188
+ """生成包含共享密钥的请求头。
189
+
190
+ Args:
191
+ overrides: 要覆盖或追加的请求头。
192
+
193
+ Returns:
194
+ 可传给 TestClient 的请求头字典。
195
+ """
196
+ headers = {"X-API-Key": API_KEY}
197
+ headers.update(overrides)
198
+ return headers
199
+
200
+ def _make_service(
201
+ self,
202
+ *,
203
+ executor=None,
204
+ image_fetcher=None,
205
+ inference_slot: threading.Lock | None = None,
206
+ settings: ImageInversionJobSettings | None = None,
207
+ add_catch_all: bool = False,
208
+ ) -> tuple[TestClient, ImageInversionJobAPI, list[_TrackedImage], dict[str, int]]:
209
+ """创建完全隔离且不导入模型的 FastAPI 测试服务。
210
+
211
+ Args:
212
+ executor: 可选假标签执行器。
213
+ image_fetcher: 可选假远程图片抓取器。
214
+ inference_slot: 可选 UI/API 共享锁。
215
+ settings: 可选任务设置。
216
+ add_catch_all: 是否先注册模拟 Gradio 的宽泛路由。
217
+
218
+ Returns:
219
+ TestClient、API 对象、输入图片列表与调用计数器。
220
+ """
221
+ settings = settings or self._settings()
222
+ created_images: list[_TrackedImage] = []
223
+ counters = {"fetch": 0, "execute": 0}
224
+ counter_lock = threading.Lock()
225
+
226
+ if image_fetcher is None:
227
+
228
+ def image_fetcher(url, current_settings):
229
+ """返回可追踪关闭状态的假输入图。"""
230
+ del url, current_settings
231
+ with counter_lock:
232
+ counters["fetch"] += 1
233
+ image = _TrackedImage()
234
+ created_images.append(image)
235
+ return image
236
+
237
+ if executor is None:
238
+
239
+ def executor(payload, input_image, job_id):
240
+ """返回与新接口契约一致的六字段 JSON 结果。"""
241
+ del payload, input_image, job_id
242
+ with counter_lock:
243
+ counters["execute"] += 1
244
+ return EXPECTED_RESULT
245
+
246
+ api = ImageInversionJobAPI(
247
+ settings=settings,
248
+ executor=executor,
249
+ inference_slot=inference_slot or threading.Lock(),
250
+ image_fetcher=image_fetcher,
251
+ )
252
+ app = FastAPI()
253
+ if add_catch_all:
254
+
255
+ @app.api_route("/{path:path}", methods=["GET", "POST"])
256
+ def catch_all(path: str):
257
+ """模拟可能抢先匹配 API 请求的 Gradio 宽泛路由。"""
258
+ return {"catch_all": path}
259
+
260
+ api.install_on_app(app)
261
+ client = TestClient(app, base_url="https://space.example")
262
+ self.clients.append(client)
263
+ return client, api, created_images, counters
264
+
265
+ def _wait_for_terminal(
266
+ self,
267
+ client: TestClient,
268
+ job_id: str,
269
+ timeout_seconds: float = 3.0,
270
+ ):
271
+ """轮询测试任务直到返回成功或失败终态。
272
+
273
+ Args:
274
+ client: 当前隔离服务客户端。
275
+ job_id: 创建接口返回的任务标识。
276
+ timeout_seconds: 允许后台测试线程运行的最长时间。
277
+
278
+ Returns:
279
+ 第一个非 202 状态响应。
280
+ """
281
+ deadline = time.monotonic() + timeout_seconds
282
+ while time.monotonic() < deadline:
283
+ response = client.get(
284
+ f"/api/jobs/{job_id}",
285
+ headers={"X-API-Key": API_KEY},
286
+ )
287
+ if response.status_code != 202:
288
+ return response
289
+ time.sleep(0.01)
290
+ self.fail(f"job {job_id} did not reach a terminal state")
291
+
292
+ def test_auth_schema_cpu_contract_and_route_precedence(self) -> None:
293
+ """验证鉴权、CPU 请求契约及自定义路由优先级。"""
294
+ client, api, _, counters = self._make_service(add_catch_all=True)
295
+ self.assertTrue(callable(create_job_api_lifespan(api)))
296
+
297
+ unauthorized = client.post("/api/jobs", json=self._payload())
298
+ self.assertEqual(unauthorized.status_code, 401)
299
+ self.assertEqual(counters["fetch"], 0)
300
+
301
+ unknown_field = client.post(
302
+ "/api/jobs",
303
+ json=self._payload(unknown=True),
304
+ headers=self._headers(),
305
+ )
306
+ self.assertEqual(unknown_field.status_code, 422)
307
+ self.assertEqual(counters["fetch"], 0)
308
+
309
+ # cpu-basic Space 不依赖 ZeroGPU 的 x-ip-token。
310
+ accepted = client.post(
311
+ "/api/jobs",
312
+ json=self._payload(),
313
+ headers=self._headers(),
314
+ )
315
+ self.assertEqual(accepted.status_code, 202)
316
+ self.assertNotIn("catch_all", accepted.json())
317
+ terminal = self._wait_for_terminal(client, accepted.json()["job_id"])
318
+ self.assertEqual(terminal.status_code, 200)
319
+ self.assertEqual(terminal.json(), EXPECTED_RESULT)
320
+
321
+ def test_success_contract_does_not_retain_caller_credentials(self) -> None:
322
+ """验证成功链路仅向执行器传业务参数且结果无包装层。"""
323
+ seen: dict = {}
324
+
325
+ def executor(payload, input_image, job_id):
326
+ """记录后台适配器参数并返回固定六字段结果。"""
327
+ seen.update({"payload": payload, "input_image": input_image, "job_id": job_id})
328
+ return EXPECTED_RESULT
329
+
330
+ client, api, images, _ = self._make_service(executor=executor)
331
+ created = client.post(
332
+ "/api/jobs",
333
+ json=self._payload(separator="newline"),
334
+ headers=self._headers(
335
+ Authorization="Bearer must-not-be-retained",
336
+ Cookie="must-not-be-retained",
337
+ **{"X-IP-Token": "not-needed-on-cpu"},
338
+ ),
339
+ )
340
+ self.assertEqual(created.status_code, 202)
341
+ self.assertEqual(created.headers["Cache-Control"], "no-store")
342
+ self.assertEqual(created.headers["Location"], created.json()["status_url"])
343
+ job_id = created.json()["job_id"]
344
+ terminal = self._wait_for_terminal(client, job_id)
345
+ self.assertEqual(terminal.status_code, 200)
346
+ self.assertEqual(set(terminal.json()), set(EXPECTED_RESULT))
347
+ self.assertEqual(seen["job_id"], job_id)
348
+ self.assertEqual(seen["payload"].separator, "newline")
349
+ self.assertTrue(all(image.closed for image in images))
350
+ self.assertFalse(hasattr(api.jobs[job_id], "zero_gpu_headers"))
351
+
352
+ def test_idempotent_replay_conflict_and_busy_submission(self) -> None:
353
+ """验证幂等重放、冲突及单任务繁忙状态。"""
354
+ release_event = threading.Event()
355
+ started_event = threading.Event()
356
+ self.release_events.append(release_event)
357
+ execution_count = 0
358
+ count_lock = threading.Lock()
359
+
360
+ def blocking_executor(payload, input_image, job_id):
361
+ """阻塞首个任务,使测试可观察幂等和忙状态。"""
362
+ nonlocal execution_count
363
+ del payload, input_image, job_id
364
+ with count_lock:
365
+ execution_count += 1
366
+ started_event.set()
367
+ release_event.wait(timeout=3)
368
+ return EXPECTED_RESULT
369
+
370
+ client, _, _, counters = self._make_service(executor=blocking_executor)
371
+ headers = self._headers(**{"Idempotency-Key": "same-request"})
372
+ first = client.post("/api/jobs", json=self._payload(), headers=headers)
373
+ self.assertEqual(first.status_code, 202)
374
+ self.assertTrue(started_event.wait(timeout=1))
375
+
376
+ replay = client.post("/api/jobs", json=self._payload(), headers=headers)
377
+ self.assertEqual(replay.status_code, 202)
378
+ self.assertEqual(replay.json()["job_id"], first.json()["job_id"])
379
+ self.assertEqual(counters["fetch"], 1)
380
+
381
+ conflict = client.post(
382
+ "/api/jobs",
383
+ json=self._payload(general_threshold=0.5),
384
+ headers=headers,
385
+ )
386
+ self.assertEqual(conflict.status_code, 409)
387
+
388
+ busy = client.post(
389
+ "/api/jobs",
390
+ json=self._payload(character_threshold=0.7),
391
+ headers=self._headers(**{"Idempotency-Key": "different-request"}),
392
+ )
393
+ self.assertEqual(busy.status_code, 503)
394
+ self.assertEqual(busy.headers["Retry-After"], "5")
395
+ self.assertEqual(counters["fetch"], 1)
396
+
397
+ release_event.set()
398
+ terminal = self._wait_for_terminal(client, first.json()["job_id"])
399
+ self.assertEqual(terminal.status_code, 200)
400
+ self.assertEqual(execution_count, 1)
401
+
402
+ def test_concurrent_same_idempotency_key_executes_once(self) -> None:
403
+ """验证并发同键请求最终只执行一次。"""
404
+ fetch_barrier = threading.Barrier(2)
405
+ fetch_count = 0
406
+ execute_count = 0
407
+ count_lock = threading.Lock()
408
+
409
+ def racing_fetcher(url, settings):
410
+ """让两个请求都完成第一次幂等检查后再返回图片。"""
411
+ nonlocal fetch_count
412
+ del url, settings
413
+ with count_lock:
414
+ fetch_count += 1
415
+ fetch_barrier.wait(timeout=2)
416
+ return _TrackedImage()
417
+
418
+ def counting_executor(payload, input_image, job_id):
419
+ """统计实际执行次数并返回固定结果。"""
420
+ nonlocal execute_count
421
+ del payload, input_image, job_id
422
+ with count_lock:
423
+ execute_count += 1
424
+ return EXPECTED_RESULT
425
+
426
+ client, _, _, _ = self._make_service(
427
+ image_fetcher=racing_fetcher,
428
+ executor=counting_executor,
429
+ )
430
+ headers = self._headers(**{"Idempotency-Key": "concurrent-key"})
431
+
432
+ def submit_job(_index: int):
433
+ """提交一个并发幂等请求。"""
434
+ return client.post("/api/jobs", json=self._payload(), headers=headers)
435
+
436
+ with ThreadPoolExecutor(max_workers=2) as pool:
437
+ responses = list(pool.map(submit_job, range(2)))
438
+ self.assertEqual([response.status_code for response in responses], [202, 202])
439
+ self.assertEqual(responses[0].json()["job_id"], responses[1].json()["job_id"])
440
+ terminal = self._wait_for_terminal(client, responses[0].json()["job_id"])
441
+ self.assertEqual(terminal.status_code, 200)
442
+ self.assertEqual(fetch_count, 2)
443
+ self.assertEqual(execute_count, 1)
444
+
445
+ def test_failure_is_sanitized_and_releases_image_and_slot(self) -> None:
446
+ """验证内部异常不泄密,且图片与共享锁始终释放。"""
447
+ shared_slot = threading.Lock()
448
+
449
+ def failing_executor(payload, input_image, job_id):
450
+ """模拟包含敏感内部信息的预测错误。"""
451
+ del payload, input_image, job_id
452
+ raise RuntimeError("INTERNAL-SECRET-STACK-DATA")
453
+
454
+ client, api, images, _ = self._make_service(
455
+ executor=failing_executor,
456
+ inference_slot=shared_slot,
457
+ )
458
+ with self.assertLogs("image_inversion_job_api", level=logging.ERROR) as logs:
459
+ created = client.post(
460
+ "/api/jobs",
461
+ json=self._payload(),
462
+ headers=self._headers(Authorization="Bearer sensitive"),
463
+ )
464
+ terminal = self._wait_for_terminal(client, created.json()["job_id"])
465
+
466
+ self.assertEqual(terminal.status_code, 500)
467
+ self.assertEqual(terminal.json(), {"error": {"code": "PREDICTION_FAILED"}})
468
+ combined_logs = "\n".join(logs.output)
469
+ self.assertNotIn("INTERNAL-SECRET-STACK-DATA", combined_logs)
470
+ self.assertNotIn(API_KEY, combined_logs)
471
+ self.assertTrue(all(image.closed for image in images))
472
+ self.assertTrue(shared_slot.acquire(blocking=False))
473
+ shared_slot.release()
474
+ self.assertEqual(api.jobs[created.json()["job_id"]].error_type, "RuntimeError")
475
+
476
+ def test_thread_start_failure_rolls_back_and_retry_succeeds(self) -> None:
477
+ """验证后台线程启动失败时任务、幂等索引和锁均回滚。"""
478
+ client, api, images, counters = self._make_service()
479
+ with patch(
480
+ "image_inversion_job_api.threading.Thread.start",
481
+ side_effect=RuntimeError("thread-start-failed"),
482
+ ):
483
+ with self.assertRaises(RuntimeError):
484
+ client.post(
485
+ "/api/jobs",
486
+ json=self._payload(),
487
+ headers=self._headers(**{"Idempotency-Key": "rollback-key"}),
488
+ )
489
+
490
+ self.assertEqual(api.jobs, {})
491
+ self.assertEqual(api.idempotency_jobs, {})
492
+ self.assertTrue(all(image.closed for image in images))
493
+ self.assertFalse(api.inference_slot.locked())
494
+
495
+ retry = client.post(
496
+ "/api/jobs",
497
+ json=self._payload(),
498
+ headers=self._headers(**{"Idempotency-Key": "rollback-key"}),
499
+ )
500
+ self.assertEqual(retry.status_code, 202)
501
+ self.assertEqual(
502
+ self._wait_for_terminal(client, retry.json()["job_id"]).status_code,
503
+ 200,
504
+ )
505
+ self.assertEqual(counters["execute"], 1)
506
+
507
+ def test_ttl_removes_all_expired_terminal_records(self) -> None:
508
+ """验证 TTL 清理会移除全部已过期终态记录。"""
509
+ settings = self._settings(result_ttl_seconds=60)
510
+ client, api, _, _ = self._make_service(settings=settings)
511
+ job_ids: list[str] = []
512
+ for threshold in (0.2, 0.3):
513
+ created = client.post(
514
+ "/api/jobs",
515
+ json=self._payload(general_threshold=threshold),
516
+ headers=self._headers(),
517
+ )
518
+ job_id = created.json()["job_id"]
519
+ self.assertEqual(self._wait_for_terminal(client, job_id).status_code, 200)
520
+ api.jobs[job_id].completed_at = time.time() - 100
521
+ job_ids.append(job_id)
522
+
523
+ api.remove_expired_jobs()
524
+ self.assertEqual(api.jobs, {})
525
+ self.assertEqual(len(job_ids), 2)
526
+
527
+ def test_capacity_evicts_oldest_terminal_and_preserves_running_records(self) -> None:
528
+ """验证容量上限只淘汰最老终态且满是运行态时返回 503。"""
529
+ settings = self._settings(max_records=2)
530
+ client, api, _, _ = self._make_service(settings=settings)
531
+ completed_job_ids: list[str] = []
532
+ for threshold in (0.2, 0.3):
533
+ created = client.post(
534
+ "/api/jobs",
535
+ json=self._payload(general_threshold=threshold),
536
+ headers=self._headers(),
537
+ )
538
+ job_id = created.json()["job_id"]
539
+ self.assertEqual(self._wait_for_terminal(client, job_id).status_code, 200)
540
+ completed_job_ids.append(job_id)
541
+ time.sleep(0.01)
542
+
543
+ third = client.post(
544
+ "/api/jobs",
545
+ json=self._payload(general_threshold=0.4),
546
+ headers=self._headers(),
547
+ )
548
+ self.assertEqual(third.status_code, 202)
549
+ self.assertNotIn(completed_job_ids[0], api.jobs)
550
+ self.assertIn(completed_job_ids[1], api.jobs)
551
+ self.assertLessEqual(len(api.jobs), 2)
552
+ self.assertEqual(self._wait_for_terminal(client, third.json()["job_id"]).status_code, 200)
553
+
554
+ full_settings = self._settings(max_records=1)
555
+ full_client, full_api, _, counters = self._make_service(settings=full_settings)
556
+ running_record = ImageInversionJobRecord(
557
+ job_id="running-record",
558
+ payload=ImageInversionJobRequest(**self._payload()),
559
+ request_fingerprint="fingerprint",
560
+ input_image=_TrackedImage(),
561
+ status="running",
562
+ )
563
+ full_api.jobs[running_record.job_id] = running_record
564
+ blocked = full_client.post(
565
+ "/api/jobs",
566
+ json=self._payload(general_threshold=0.6),
567
+ headers=self._headers(),
568
+ )
569
+ self.assertEqual(blocked.status_code, 503)
570
+ self.assertEqual(blocked.headers["Retry-After"], "5")
571
+ self.assertIn(running_record.job_id, full_api.jobs)
572
+ self.assertEqual(counters["fetch"], 0)
573
+
574
+ def test_settings_schema_and_fingerprint(self) -> None:
575
+ """验证环境设置、公开 Schema 和请求指纹边界。"""
576
+ with patch.dict(
577
+ os.environ,
578
+ {
579
+ "JOB_API_KEY": API_KEY,
580
+ "JOB_IMAGE_ALLOWED_HOSTS": "Allowed.Example,cdn.example",
581
+ },
582
+ clear=True,
583
+ ):
584
+ settings = ImageInversionJobSettings.from_env()
585
+ self.assertEqual(
586
+ settings.allowed_hosts,
587
+ frozenset({"allowed.example", "cdn.example"}),
588
+ )
589
+ self.assertEqual(settings.max_image_bytes, 8 * 1024 * 1024)
590
+ self.assertEqual(settings.max_records, 256)
591
+
592
+ for invalid_max_records in (7, 4097):
593
+ with self.subTest(max_records=invalid_max_records), patch.dict(
594
+ os.environ,
595
+ {
596
+ "JOB_API_KEY": API_KEY,
597
+ "JOB_IMAGE_ALLOWED_HOSTS": "allowed.example",
598
+ "JOB_MAX_RECORDS": str(invalid_max_records),
599
+ },
600
+ clear=True,
601
+ ):
602
+ with self.assertRaises(RuntimeError):
603
+ ImageInversionJobSettings.from_env()
604
+
605
+ minimum = ImageInversionJobRequest(
606
+ input_image_url="https://allowed.example/input.png",
607
+ general_threshold=0.0,
608
+ character_threshold=0.0,
609
+ separator="space",
610
+ )
611
+ maximum = ImageInversionJobRequest(
612
+ input_image_url="https://allowed.example/input.png",
613
+ general_threshold=1.0,
614
+ character_threshold=1.0,
615
+ separator="newline",
616
+ )
617
+ self.assertNotEqual(fingerprint_request(minimum), fingerprint_request(maximum))
618
+ reordered = ImageInversionJobRequest.model_validate(
619
+ minimum.model_dump(mode="json")
620
+ )
621
+ self.assertEqual(fingerprint_request(minimum), fingerprint_request(reordered))
622
+
623
+ for invalid_payload in (
624
+ self._payload(input_image_url="http://allowed.example/input.png"),
625
+ self._payload(general_threshold=-0.01),
626
+ self._payload(character_threshold=1.01),
627
+ self._payload(general_threshold=float("nan")),
628
+ self._payload(separator="逗号"),
629
+ self._payload(unknown=True),
630
+ ):
631
+ with self.subTest(payload=invalid_payload):
632
+ with self.assertRaises(ValidationError):
633
+ ImageInversionJobRequest(**invalid_payload)
634
+
635
+ def test_url_policy_decode_and_fetch_do_not_leak_headers(self) -> None:
636
+ """验证白名单、SSRF、图片解码和无凭据抓取边界。"""
637
+ settings = self._settings()
638
+ public_record = (
639
+ socket.AF_INET,
640
+ socket.SOCK_STREAM,
641
+ socket.IPPROTO_TCP,
642
+ "",
643
+ ("93.184.216.34", 443),
644
+ )
645
+ with patch(
646
+ "image_inversion_job_api.socket.getaddrinfo",
647
+ return_value=[public_record],
648
+ ):
649
+ validated = validate_image_url(
650
+ "https://allowed.example/image.png?private=query",
651
+ settings,
652
+ )
653
+ self.assertEqual(validated.host, "allowed.example")
654
+ for invalid_url in (
655
+ "http://allowed.example/image.png",
656
+ "https://user:password@allowed.example/image.png",
657
+ "https://allowed.example:444/image.png",
658
+ "https://sub.allowed.example/image.png",
659
+ "https://allowed.example/image.png#fragment",
660
+ ):
661
+ with self.subTest(url=invalid_url):
662
+ with self.assertRaises(ImageSourceError):
663
+ validate_image_url(invalid_url, settings)
664
+
665
+ private_record = (
666
+ socket.AF_INET,
667
+ socket.SOCK_STREAM,
668
+ socket.IPPROTO_TCP,
669
+ "",
670
+ ("127.0.0.1", 443),
671
+ )
672
+ with patch(
673
+ "image_inversion_job_api.socket.getaddrinfo",
674
+ return_value=[public_record, private_record],
675
+ ):
676
+ with self.assertRaises(ImageSourceError):
677
+ validate_image_url("https://allowed.example/image.png", settings)
678
+
679
+ decoded = decode_image_bytes(PNG_BYTES, "application/octet-stream", settings)
680
+ self.assertEqual(decoded.mode, "RGB")
681
+ decoded.close()
682
+
683
+ captured: dict = {}
684
+
685
+ class FakeClient:
686
+ """捕获 httpx 客户端配置并返回假响应。"""
687
+
688
+ def __init__(self, **kwargs) -> None:
689
+ captured["client_kwargs"] = kwargs
690
+
691
+ def __enter__(self):
692
+ """进入客户端上下文并返回自身。"""
693
+ return self
694
+
695
+ def __exit__(self, exc_type, exc, traceback) -> bool:
696
+ """退出客户端上下文且不吞掉异常。"""
697
+ del exc_type, exc, traceback
698
+ return False
699
+
700
+ def close(self) -> None:
701
+ """模拟关闭客户端。"""
702
+ captured["closed"] = True
703
+
704
+ def stream(self, method, url, headers):
705
+ """记录公开请求参数并返回假响应。"""
706
+ captured["method"] = method
707
+ captured["url"] = url
708
+ captured["headers"] = dict(headers)
709
+ return captured["response"]
710
+
711
+ with patch(
712
+ "image_inversion_job_api.socket.getaddrinfo",
713
+ return_value=[public_record],
714
+ ), patch("image_inversion_job_api.httpx.Client", FakeClient):
715
+ captured["response"] = _FakeStreamResponse(200, PNG_BYTES)
716
+ fetched = fetch_remote_image(
717
+ "https://allowed.example/image.png?secret=value",
718
+ settings,
719
+ )
720
+ fetched.close()
721
+ self.assertFalse(captured["client_kwargs"]["follow_redirects"])
722
+ self.assertFalse(captured["client_kwargs"]["trust_env"])
723
+ self.assertEqual(
724
+ captured["headers"],
725
+ {"Accept": "image/png,image/jpeg,image/webp"},
726
+ )
727
+ for header_name in ("Authorization", "Cookie", "X-API-Key", "X-IP-Token"):
728
+ self.assertNotIn(header_name, captured["headers"])
729
+
730
+ captured["response"] = _FakeStreamResponse(302, b"")
731
+ with self.assertRaises(ImageSourceError):
732
+ fetch_remote_image("https://allowed.example/redirect", settings)
733
+
734
+ captured["response"] = _FakeStreamResponse(
735
+ 200,
736
+ PNG_BYTES,
737
+ peer_address="127.0.0.1",
738
+ )
739
+ with self.assertRaises(ImageSourceError):
740
+ fetch_remote_image("https://allowed.example/rebound", settings)
741
+
742
+ def test_app_wires_job_lifespan_and_disables_private_hf_token(self) -> None:
743
+ """静态验证应用接线、六字段结果与匿名模型下载设置。"""
744
+ app_path = Path(__file__).resolve().parents[1] / "app.py"
745
+ app_source = app_path.read_text(encoding="utf-8")
746
+ app_tree = ast.parse(app_source)
747
+
748
+ executor_function = next(
749
+ node
750
+ for node in app_tree.body
751
+ if isinstance(node, ast.FunctionDef)
752
+ and node.name == "execute_image_inversion_job"
753
+ )
754
+ result_return = next(
755
+ node
756
+ for node in ast.walk(executor_function)
757
+ if isinstance(node, ast.Return) and isinstance(node.value, ast.Dict)
758
+ )
759
+ result_keys = {
760
+ key.value
761
+ for key in result_return.value.keys
762
+ if isinstance(key, ast.Constant) and isinstance(key.value, str)
763
+ }
764
+ self.assertEqual(result_keys, set(EXPECTED_RESULT))
765
+ self.assertIn('app_kwargs={"lifespan": JOB_API_LIFESPAN}', app_source)
766
+ self.assertIn('token=False', app_source)
767
+ self.assertIn('HF_HUB_DISABLE_IMPLICIT_TOKEN', app_source)
768
+ self.assertNotIn('os.environ.get("HF_TOKEN")', app_source)
769
+
770
+
771
+ if __name__ == "__main__":
772
+ unittest.main()