auto_import.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644
  1. import json
  2. import asyncio
  3. import aiohttp
  4. from typing import Dict, Any, Tuple, Optional, Union, List
  5. from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form
  6. from app.core.config import settings
  7. from app.core.logger import get_logger
  8. from app.utils.scheme import CardType, ImageType
  9. logger = get_logger(__name__)
  10. router = APIRouter()
  11. IMPORT_REQUEST_TIMEOUT = aiohttp.ClientTimeout(
  12. total=3600,
  13. connect=30,
  14. sock_connect=30,
  15. sock_read=1800,
  16. )
  17. # 推理服务 stitch 接口需要的表单字段顺序,按 ring + gray + stripe1..4 组装
  18. STITCH_FORM_FIELDS = ["ring", "gray", "stripe1", "stripe2", "stripe3", "stripe4"]
  19. # 一面(front/back)对应的所有 image_type
  20. SIDE_IMAGE_TYPES: Dict[str, Dict[str, str]] = {
  21. "front": {
  22. "fusion": ImageType.front_fusion.value,
  23. "ring": ImageType.front_ring.value,
  24. "gray": ImageType.front_gray.value,
  25. "stripe1": ImageType.front_stripe1.value,
  26. "stripe2": ImageType.front_stripe2.value,
  27. "stripe3": ImageType.front_stripe3.value,
  28. "stripe4": ImageType.front_stripe4.value,
  29. },
  30. "back": {
  31. "fusion": ImageType.back_fusion.value,
  32. "ring": ImageType.back_ring.value,
  33. "gray": ImageType.back_gray.value,
  34. "stripe1": ImageType.back_stripe1.value,
  35. "stripe2": ImageType.back_stripe2.value,
  36. "stripe3": ImageType.back_stripe3.value,
  37. "stripe4": ImageType.back_stripe4.value,
  38. },
  39. }
  40. # 主表落库的图(带 JSON)
  41. MAIN_IMAGE_TYPES = {ImageType.front_fusion.value, ImageType.back_fusion.value}
  42. def _flat_image_types() -> List[str]:
  43. """按接口参数顺序展开 14 个 image_type,便于日志输出。"""
  44. flat = []
  45. for side in ("front", "back"):
  46. side_map = SIDE_IMAGE_TYPES[side]
  47. flat.extend([
  48. side_map["fusion"],
  49. side_map["ring"],
  50. side_map["gray"],
  51. side_map["stripe1"],
  52. side_map["stripe2"],
  53. side_map["stripe3"],
  54. side_map["stripe4"],
  55. ])
  56. return flat
  57. # --- 内部辅助函数 ---
  58. async def _post_form(
  59. session: aiohttp.ClientSession,
  60. url: str,
  61. files: List[Tuple[str, bytes, str] | Tuple[str, bytes, str, str]],
  62. params: Optional[Dict[str, Any]] = None,
  63. form_fields: Optional[Dict[str, Any]] = None,
  64. headers: Optional[Dict[str, str]] = None,
  65. ) -> Tuple[int, bytes]:
  66. """通用 multipart 调用:files 元素为 (field_name, file_bytes, filename[, content_type])。"""
  67. form_data = aiohttp.FormData()
  68. if form_fields:
  69. for key, value in form_fields.items():
  70. form_data.add_field(key, str(value))
  71. for file_item in files:
  72. if len(file_item) == 4:
  73. field_name, file_bytes, filename, content_type = file_item
  74. else:
  75. field_name, file_bytes, filename = file_item
  76. content_type = "image/jpeg"
  77. form_data.add_field(
  78. field_name,
  79. file_bytes,
  80. filename=filename or f"{field_name}.jpg",
  81. content_type=content_type,
  82. )
  83. try:
  84. async with session.post(url, data=form_data, params=params, headers=headers) as response:
  85. response_content = await response.read()
  86. if not response.ok:
  87. logger.error(
  88. "[API Error] %s -> Status: %s, Msg: %s",
  89. url,
  90. response.status,
  91. response_content.decode("utf-8", errors="ignore")[:200],
  92. )
  93. return response.status, response_content
  94. except Exception as e:
  95. logger.error(f"[Conn Error] {url} -> {e}")
  96. raise
  97. async def call_stitch_inference(
  98. session: aiohttp.ClientSession,
  99. side: str,
  100. card_name: str,
  101. side_bytes: Dict[str, Tuple[bytes, str]],
  102. ) -> Dict[str, Any]:
  103. """
  104. 调用 stitch_score_inference 接口,一次处理一面(front/back)。
  105. side_bytes 的 key 必须包含 STITCH_FORM_FIELDS 全部字段,value 是 (bytes, filename)。
  106. """
  107. missing = [k for k in STITCH_FORM_FIELDS if k not in side_bytes]
  108. if missing:
  109. raise HTTPException(
  110. status_code=400,
  111. detail=f"{side} 面缺少推理所需图片字段: {missing}",
  112. )
  113. files: List[Tuple[str, bytes, str]] = []
  114. for field in STITCH_FORM_FIELDS:
  115. f_bytes, f_name = side_bytes[field]
  116. if len(f_bytes) == 0:
  117. raise HTTPException(status_code=400, detail=f"{side} 面 {field} 文件内容为空: {f_name}")
  118. files.append((field, f_bytes, f_name))
  119. inference_base_url = settings.SCORE_UPDATE_SERVER_URL
  120. url = f"{inference_base_url}/api/card_score/stitch_score_inference"
  121. params = {"score_type": side, "card_name": card_name}
  122. logger.info(
  123. "调用 stitch_score_inference: side=%s card_name=%s fields=%s",
  124. side, card_name, ",".join(STITCH_FORM_FIELDS),
  125. )
  126. status, body = await _post_form(session, url=url, files=files, params=params)
  127. if status >= 300:
  128. raise HTTPException(status_code=500, detail=f"{side} 面 stitch 推理失败 status={status}")
  129. try:
  130. return json.loads(body)
  131. except json.JSONDecodeError as e:
  132. raise HTTPException(status_code=500, detail=f"{side} 面推理结果解析失败: {e}")
  133. async def call_rectify_and_center(
  134. session: aiohttp.ClientSession,
  135. image_type: str,
  136. file_bytes: bytes,
  137. filename: str,
  138. ) -> Tuple[bytes, str]:
  139. """调用推理服务转正居中接口,返回转正后的图片 bytes。"""
  140. if len(file_bytes) == 0:
  141. raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空: {filename}")
  142. inference_base_url = settings.SCORE_UPDATE_SERVER_URL
  143. url = f"{inference_base_url}/api/card_inference/card_rectify_and_center"
  144. logger.info("调用 card_rectify_and_center: image_type=%s filename=%s", image_type, filename)
  145. status, body = await _post_form(
  146. session,
  147. url=url,
  148. files=[("file", file_bytes, filename or f"{image_type}.jpg")],
  149. )
  150. if status >= 300:
  151. raise HTTPException(status_code=500, detail=f"图片转正居中失败: {image_type} status={status}")
  152. name_root = filename.rsplit(".", 1)[0] if filename else image_type
  153. return body, f"{name_root}_rectified.jpg"
  154. async def rectify_side_images(
  155. session: aiohttp.ClientSession,
  156. side: str,
  157. side_bytes: Dict[str, Tuple[bytes, str]],
  158. ) -> Dict[str, Tuple[bytes, str]]:
  159. """将 stitch 需要的一面 6 张图全部转正居中。"""
  160. side_map = SIDE_IMAGE_TYPES[side]
  161. async def rectify_one(field: str) -> Tuple[str, Tuple[bytes, str]]:
  162. file_bytes, filename = side_bytes[field]
  163. image_type = side_map[field]
  164. rectified_bytes, rectified_filename = await call_rectify_and_center(
  165. session=session,
  166. image_type=image_type,
  167. file_bytes=file_bytes,
  168. filename=filename,
  169. )
  170. return field, (rectified_bytes, rectified_filename)
  171. logger.info("%s 面开始转正居中: fields=%s", side, ",".join(STITCH_FORM_FIELDS))
  172. rectified_pairs = await asyncio.gather(*[rectify_one(field) for field in STITCH_FORM_FIELDS])
  173. logger.info("%s 面转正居中完成", side)
  174. return dict(rectified_pairs)
  175. async def create_card_record(
  176. session: aiohttp.ClientSession,
  177. base_url: str,
  178. card_name: str,
  179. cardNo: Optional[str],
  180. card_type: CardType,
  181. forward_headers: Optional[Dict[str, str]] = None,
  182. ) -> int:
  183. """调用自身服务创建新的卡牌记录"""
  184. url = f"{base_url}{settings.API_PREFIX}/cards/created"
  185. params = {"card_name": card_name, "card_type": card_type.value}
  186. if cardNo:
  187. params["cardNo"] = cardNo
  188. async with session.post(url, params=params, headers=forward_headers) as response:
  189. if response.status == 201:
  190. data = await response.json()
  191. return data.get("id")
  192. text = await response.text()
  193. raise HTTPException(status_code=response.status, detail=f"创建卡牌记录失败: {text}")
  194. async def upload_main_image(
  195. session: aiohttp.ClientSession,
  196. base_url: str,
  197. card_id: int,
  198. image_type: str,
  199. file_bytes: bytes,
  200. filename: str,
  201. score_json: Dict[str, Any],
  202. forward_headers: Optional[Dict[str, str]] = None,
  203. ):
  204. """带 JSON 的图走主图入库接口(fusion / ring / stripe 共用同一面的 detection_json)。"""
  205. url = f"{base_url}{settings.API_PREFIX}/images/insert/{card_id}"
  206. form_fields = {
  207. "image_type": image_type,
  208. }
  209. json_bytes = json.dumps(score_json, ensure_ascii=False).encode("utf-8")
  210. status, body = await _post_form(
  211. session,
  212. url=url,
  213. files=[
  214. ("image", file_bytes, filename or f"{image_type}.jpg"),
  215. ("json_data_file", json_bytes, f"{image_type}_score.json", "application/json"),
  216. ],
  217. form_fields=form_fields,
  218. headers=forward_headers,
  219. )
  220. if status != 201:
  221. logger.error(
  222. "[主表图上传失败] image_type=%s status=%s msg=%s",
  223. image_type, status, body.decode("utf-8", errors="ignore")[:200],
  224. )
  225. raise HTTPException(status_code=500, detail=f"主表图保存失败: {image_type}")
  226. async def upload_auxiliary_image(
  227. session: aiohttp.ClientSession,
  228. base_url: str,
  229. card_id: int,
  230. image_type: str,
  231. file_bytes: bytes,
  232. filename: str,
  233. forward_headers: Optional[Dict[str, str]] = None,
  234. ):
  235. """ring / gray / stripe 这些辅助图走灰度图入库接口(不带 JSON)。"""
  236. url = f"{base_url}{settings.API_PREFIX}/images/insert/gray/{card_id}"
  237. form_fields = {"image_type": image_type}
  238. status, body = await _post_form(
  239. session,
  240. url=url,
  241. files=[("image", file_bytes, filename or f"{image_type}.jpg")],
  242. form_fields=form_fields,
  243. headers=forward_headers,
  244. )
  245. if status != 201:
  246. logger.error(
  247. "[辅助图上传失败] image_type=%s status=%s msg=%s",
  248. image_type, status, body.decode("utf-8", errors="ignore")[:200],
  249. )
  250. raise HTTPException(status_code=500, detail=f"辅助图保存失败: {image_type}")
  251. def _collect_side_bytes(
  252. side: str,
  253. bytes_map: Dict[str, Tuple[bytes, str]],
  254. ) -> Optional[Dict[str, Tuple[bytes, str]]]:
  255. """
  256. 根据一面(front/back)的 image_type,从 bytes_map 中抽出 stitch 推理所需的 6 张图。
  257. 如果有任意一张缺失则返回 None,表示该面无法推理。
  258. """
  259. side_map = SIDE_IMAGE_TYPES[side]
  260. side_bytes: Dict[str, Tuple[bytes, str]] = {}
  261. for field in STITCH_FORM_FIELDS:
  262. image_type = side_map[field]
  263. if image_type not in bytes_map:
  264. return None
  265. side_bytes[field] = bytes_map[image_type]
  266. return side_bytes
  267. async def _run_import_flow(
  268. local_base_url: str,
  269. card_name: str,
  270. cardNo: Optional[str],
  271. card_type: CardType,
  272. strict_mode: bool,
  273. bytes_map: Dict[str, Tuple[bytes, str]],
  274. non_gray_to_main: bool = False,
  275. forward_headers: Optional[Dict[str, str]] = None,
  276. ) -> Dict[str, Any]:
  277. """
  278. 共用导入流程:bytes_map 的 key 是 image_type,value 是 (bytes, filename)。
  279. non_gray_to_main:
  280. False -> 仅融合图进主表(带 JSON),ring/gray/stripe 进辅助表(无 JSON)。
  281. True -> 除灰度图外(fusion/ring/stripe)都进主表,并共用所在面的 detection_json;
  282. 灰度图仍进辅助表。
  283. """
  284. expected_types = _flat_image_types()
  285. provided = [t for t in expected_types if t in bytes_map]
  286. missing = [t for t in expected_types if t not in bytes_map]
  287. if strict_mode and missing:
  288. raise HTTPException(
  289. status_code=400,
  290. detail=f"严格模式开启,必须提供全部 14 张图,缺少: {missing}",
  291. )
  292. if not provided:
  293. raise HTTPException(status_code=400, detail="未提供任何图片文件,无法创建。")
  294. logger.info(
  295. "auto_import start card_name=%s cardNo=%s card_type=%s strict_mode=%s provided=%s missing=%s",
  296. card_name,
  297. cardNo or "",
  298. card_type.value,
  299. strict_mode,
  300. ",".join(provided) or "none",
  301. ",".join(missing) or "none",
  302. )
  303. connector = aiohttp.TCPConnector(limit=20, force_close=True)
  304. async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
  305. # 1. 正反面分别调用 stitch 推理(要求该面 6 张推理图齐全)
  306. side_score_json: Dict[str, Optional[Dict[str, Any]]] = {"front": None, "back": None}
  307. for side in ("front", "back"):
  308. side_bytes = _collect_side_bytes(side, bytes_map)
  309. if side_bytes is None:
  310. logger.warning("%s 面推理跳过,缺少 ring/gray/stripe1..4 中的某些图", side)
  311. continue
  312. rectified_side_bytes = await rectify_side_images(
  313. session=session,
  314. side=side,
  315. side_bytes=side_bytes,
  316. )
  317. side_score_json[side] = await call_stitch_inference(
  318. session=session,
  319. side=side,
  320. card_name=card_name,
  321. side_bytes=rectified_side_bytes,
  322. )
  323. # 2. 创建卡牌
  324. card_id = await create_card_record(
  325. session,
  326. local_base_url,
  327. card_name,
  328. cardNo,
  329. card_type,
  330. forward_headers=forward_headers,
  331. )
  332. logger.info("auto_import card_created card_name=%s card_id=%s", card_name, card_id)
  333. # 3. 串行上传所有图:避免同时打满本服务的数据库连接池
  334. upload_count = 0
  335. for side in ("front", "back"):
  336. side_map = SIDE_IMAGE_TYPES[side]
  337. score_json = side_score_json.get(side)
  338. # 按 fusion -> ring -> gray -> stripe1..4 顺序处理该面所有图
  339. for field in ["fusion"] + STITCH_FORM_FIELDS:
  340. image_type = side_map[field]
  341. data = bytes_map.get(image_type)
  342. if data is None:
  343. continue
  344. f_bytes, f_name = data
  345. is_gray = (field == "gray")
  346. # 进主表(带该面 detection_json)的条件:
  347. # - 非灰度图
  348. # - 该面有 stitch 结果
  349. # - URL 模式(non_gray_to_main) 下所有非灰度图都进;文件模式仅 fusion 进
  350. to_main = (
  351. not is_gray
  352. and score_json is not None
  353. and (non_gray_to_main or field == "fusion")
  354. )
  355. if to_main:
  356. await upload_main_image(
  357. session, local_base_url, card_id,
  358. image_type, f_bytes, f_name, score_json,
  359. forward_headers=forward_headers,
  360. )
  361. else:
  362. if field == "fusion" and score_json is None:
  363. logger.warning(
  364. "%s 面缺少 stitch 推理结果,融合图 %s 退化为辅助图入库",
  365. side, image_type,
  366. )
  367. await upload_auxiliary_image(
  368. session, local_base_url, card_id,
  369. image_type, f_bytes, f_name,
  370. forward_headers=forward_headers,
  371. )
  372. upload_count += 1
  373. logger.info(
  374. "auto_import finished card_name=%s card_id=%s upload_task_count=%s "
  375. "front_score=%s back_score=%s",
  376. card_name, card_id, upload_count,
  377. "yes" if side_score_json["front"] is not None else "no",
  378. "yes" if side_score_json["back"] is not None else "no",
  379. )
  380. return {
  381. "message": "导入成功",
  382. "card_id": card_id,
  383. "card_name": card_name,
  384. "cardNo": cardNo,
  385. }
  386. # --- 暴露的API接口 ---
  387. @router.post("/process_and_import", summary="自动化处理并导入卡牌数据[相机后台调用]")
  388. async def auto_import_script_api(
  389. request: Request,
  390. card_name: str = Form(..., description="卡牌名称"),
  391. cardNo: Optional[str] = Form(None, description="卡牌编号"),
  392. card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
  393. is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
  394. strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图"),
  395. front_fusion: Union[UploadFile, str, None] = File(None, description="正面融合图"),
  396. back_fusion: Union[UploadFile, str, None] = File(None, description="反面融合图"),
  397. front_ring: Union[UploadFile, str, None] = File(None, description="正面环光图"),
  398. front_gray: Union[UploadFile, str, None] = File(None, description="正面灰度图"),
  399. front_stripe1: Union[UploadFile, str, None] = File(None, description="正面调光图1"),
  400. front_stripe2: Union[UploadFile, str, None] = File(None, description="正面调光图2"),
  401. front_stripe3: Union[UploadFile, str, None] = File(None, description="正面调光图3"),
  402. front_stripe4: Union[UploadFile, str, None] = File(None, description="正面调光图4"),
  403. back_ring: Union[UploadFile, str, None] = File(None, description="反面环光图"),
  404. back_gray: Union[UploadFile, str, None] = File(None, description="反面灰度图"),
  405. back_stripe1: Union[UploadFile, str, None] = File(None, description="反面调光图1"),
  406. back_stripe2: Union[UploadFile, str, None] = File(None, description="反面调光图2"),
  407. back_stripe3: Union[UploadFile, str, None] = File(None, description="反面调光图3"),
  408. back_stripe4: Union[UploadFile, str, None] = File(None, description="反面调光图4"),
  409. ):
  410. """
  411. 按 image_type 直接命名 14 个文件字段:
  412. front_fusion / back_fusion
  413. front_ring / back_ring
  414. front_gray / back_gray
  415. front_stripe1..4 / back_stripe1..4
  416. 推理服务调用 stitch_score_inference,按面(front/back)一次性提交
  417. ring + gray + stripe1..4,返回的 JSON 落到对应 fusion 主图记录中。
  418. """
  419. local_base_url = str(request.base_url).rstrip("/")
  420. forward_headers: Dict[str, str] = {}
  421. auth = request.headers.get("authorization")
  422. if auth:
  423. forward_headers["Authorization"] = auth
  424. raw_inputs: Dict[str, Union[UploadFile, str, None]] = {
  425. ImageType.front_fusion.value: front_fusion,
  426. ImageType.back_fusion.value: back_fusion,
  427. ImageType.front_ring.value: front_ring,
  428. ImageType.front_gray.value: front_gray,
  429. ImageType.front_stripe1.value: front_stripe1,
  430. ImageType.front_stripe2.value: front_stripe2,
  431. ImageType.front_stripe3.value: front_stripe3,
  432. ImageType.front_stripe4.value: front_stripe4,
  433. ImageType.back_ring.value: back_ring,
  434. ImageType.back_gray.value: back_gray,
  435. ImageType.back_stripe1.value: back_stripe1,
  436. ImageType.back_stripe2.value: back_stripe2,
  437. ImageType.back_stripe3.value: back_stripe3,
  438. ImageType.back_stripe4.value: back_stripe4,
  439. }
  440. valid_uploads: Dict[str, Any] = {
  441. k: v for k, v in raw_inputs.items()
  442. if getattr(v, "filename", None) and callable(getattr(v, "read", None))
  443. }
  444. # 读取所有字节
  445. bytes_map: Dict[str, Tuple[bytes, str]] = {}
  446. for image_type, upload in valid_uploads.items():
  447. data = await upload.read()
  448. if len(data) == 0:
  449. raise HTTPException(status_code=400, detail=f"图片文件 {image_type} 内容为空")
  450. bytes_map[image_type] = (data, upload.filename)
  451. logger.info(
  452. "auto_import_script_api received is_reflect_card=%s strict_mode=%s provided_count=%s",
  453. is_reflect_card,
  454. strict_mode,
  455. len(bytes_map),
  456. )
  457. try:
  458. return await _run_import_flow(
  459. local_base_url=local_base_url,
  460. card_name=card_name,
  461. cardNo=cardNo,
  462. card_type=card_type,
  463. strict_mode=strict_mode,
  464. bytes_map=bytes_map,
  465. non_gray_to_main=True,
  466. forward_headers=forward_headers or None,
  467. )
  468. except HTTPException as e:
  469. logger.error(
  470. "auto_import_script_api failed_http card_name=%s detail=%s status_code=%s",
  471. card_name, e.detail, e.status_code,
  472. )
  473. raise
  474. except Exception as e:
  475. logger.exception("auto_import_script_api failed_unexpected card_name=%s", card_name)
  476. raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")
  477. @router.post("/process_and_import_url", summary="通过URL自动化处理并导入卡牌数据")
  478. async def auto_import_url_script_api(
  479. request: Request,
  480. card_name: str = Form(..., description="卡牌名称"),
  481. cardNo: Optional[str] = Form(None, description="卡牌编号"),
  482. card_type: CardType = Form(CardType.pokemon, description="卡牌类型"),
  483. is_reflect_card: bool = Form(True, description="是否是反光卡(保留参数,兼容旧调用)"),
  484. strict_mode: bool = Form(False, description="如果为True,必须提供所有14张图URL"),
  485. front_fusion: Optional[str] = Form(None, description="正面融合图URL"),
  486. back_fusion: Optional[str] = Form(None, description="反面融合图URL"),
  487. front_ring: Optional[str] = Form(None, description="正面环光图URL"),
  488. front_gray: Optional[str] = Form(None, description="正面灰度图URL"),
  489. front_stripe1: Optional[str] = Form(None, description="正面调光图1 URL"),
  490. front_stripe2: Optional[str] = Form(None, description="正面调光图2 URL"),
  491. front_stripe3: Optional[str] = Form(None, description="正面调光图3 URL"),
  492. front_stripe4: Optional[str] = Form(None, description="正面调光图4 URL"),
  493. back_ring: Optional[str] = Form(None, description="反面环光图URL"),
  494. back_gray: Optional[str] = Form(None, description="反面灰度图URL"),
  495. back_stripe1: Optional[str] = Form(None, description="反面调光图1 URL"),
  496. back_stripe2: Optional[str] = Form(None, description="反面调光图2 URL"),
  497. back_stripe3: Optional[str] = Form(None, description="反面调光图3 URL"),
  498. back_stripe4: Optional[str] = Form(None, description="反面调光图4 URL"),
  499. ):
  500. local_base_url = str(request.base_url).rstrip("/")
  501. forward_headers: Dict[str, str] = {}
  502. auth = request.headers.get("authorization")
  503. if auth:
  504. forward_headers["Authorization"] = auth
  505. url_inputs: Dict[str, Optional[str]] = {
  506. ImageType.front_fusion.value: front_fusion,
  507. ImageType.back_fusion.value: back_fusion,
  508. ImageType.front_ring.value: front_ring,
  509. ImageType.front_gray.value: front_gray,
  510. ImageType.front_stripe1.value: front_stripe1,
  511. ImageType.front_stripe2.value: front_stripe2,
  512. ImageType.front_stripe3.value: front_stripe3,
  513. ImageType.front_stripe4.value: front_stripe4,
  514. ImageType.back_ring.value: back_ring,
  515. ImageType.back_gray.value: back_gray,
  516. ImageType.back_stripe1.value: back_stripe1,
  517. ImageType.back_stripe2.value: back_stripe2,
  518. ImageType.back_stripe3.value: back_stripe3,
  519. ImageType.back_stripe4.value: back_stripe4,
  520. }
  521. valid_urls = {k: v for k, v in url_inputs.items() if v and v.strip()}
  522. if not valid_urls:
  523. raise HTTPException(status_code=400, detail="未提供任何图片URL,无法创建。")
  524. logger.info(
  525. "auto_import_url_script_api received is_reflect_card=%s strict_mode=%s provided=%s",
  526. is_reflect_card,
  527. strict_mode,
  528. ",".join(valid_urls.keys()),
  529. )
  530. connector = aiohttp.TCPConnector(limit=20, force_close=True)
  531. async with aiohttp.ClientSession(timeout=IMPORT_REQUEST_TIMEOUT, connector=connector) as session:
  532. async def fetch_image(image_type: str, img_url: str) -> Tuple[str, Tuple[bytes, str]]:
  533. try:
  534. async with session.get(img_url) as resp:
  535. if resp.status != 200:
  536. raise HTTPException(status_code=400, detail=f"下载图片失败: {image_type} -> {resp.status}")
  537. file_bytes = await resp.read()
  538. filename = img_url.split("/")[-1].split("?")[0]
  539. if not filename or "." not in filename:
  540. filename = f"{image_type}.jpg"
  541. return image_type, (file_bytes, filename)
  542. except HTTPException:
  543. raise
  544. except Exception as e:
  545. raise HTTPException(status_code=400, detail=f"访问图片URL异常: {image_type} -> {e}")
  546. downloaded = await asyncio.gather(*[fetch_image(k, u) for k, u in valid_urls.items()])
  547. bytes_map: Dict[str, Tuple[bytes, str]] = {}
  548. for image_type, payload in downloaded:
  549. file_bytes, filename = payload
  550. if len(file_bytes) == 0:
  551. raise HTTPException(status_code=400, detail=f"图片文件 {filename} 内容为空")
  552. bytes_map[image_type] = (file_bytes, filename)
  553. try:
  554. return await _run_import_flow(
  555. local_base_url=local_base_url,
  556. card_name=card_name,
  557. cardNo=cardNo,
  558. card_type=card_type,
  559. strict_mode=strict_mode,
  560. bytes_map=bytes_map,
  561. non_gray_to_main=True,
  562. forward_headers=forward_headers or None,
  563. )
  564. except HTTPException:
  565. raise
  566. except Exception as e:
  567. logger.exception("auto_import_url_script_api failed_unexpected card_name=%s", card_name)
  568. raise HTTPException(status_code=500, detail=f"自动化处理异常: {e}")