formate_xy.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. import requests
  2. import json
  3. import copy
  4. import hashlib
  5. from enum import Enum
  6. from time import perf_counter
  7. from fastapi import APIRouter, Depends, HTTPException, Query, Body
  8. from fastapi.concurrency import run_in_threadpool
  9. from mysql.connector.pooling import PooledMySQLConnection
  10. from app.core.config import settings
  11. from app.core.logger import get_logger
  12. from app.core.database_loader import get_db_connection
  13. from app.api.users import check_card_permission, get_current_user
  14. from app.utils.scheme import (
  15. CardDetailResponse, IMAGE_TYPE_TO_SCORE_TYPE, ImageType
  16. )
  17. from app.crud import crud_card
  18. from app.utils.xy_process import convert_internal_to_xy_format, convert_xy_to_internal_format
  19. from app.core.minio_client import minio_client
  20. from app.utils.rating_report_utils import crop_defect_image
  21. logger = get_logger(__name__)
  22. router = APIRouter()
  23. db_dependency = Depends(get_db_connection)
  24. class QueryMode(str, Enum):
  25. current = "current"
  26. next = "next"
  27. prev = "prev"
  28. def _is_center_box_shapes_empty(center_result: dict) -> bool:
  29. """
  30. 判断 center_result 中 inner/outer box 的 shapes 是否都为空。
  31. 前端某些场景会传空 shapes,直接下发给算分服务可能触发其内部越界。
  32. """
  33. if not isinstance(center_result, dict):
  34. return True
  35. box_result = center_result.get("box_result", {})
  36. if not isinstance(box_result, dict):
  37. return True
  38. inner_shapes = box_result.get("inner_box", {}).get("shapes", [])
  39. outer_shapes = box_result.get("outer_box", {}).get("shapes", [])
  40. return not inner_shapes and not outer_shapes
  41. def _normalize_center_result(center_result: dict) -> dict:
  42. """
  43. 兜底补齐算分服务依赖的 center_result 结构,避免 KeyError: 'box_result'。
  44. """
  45. normalized = center_result if isinstance(center_result, dict) else {}
  46. box_result = normalized.get("box_result")
  47. if not isinstance(box_result, dict):
  48. box_result = {}
  49. normalized["box_result"] = box_result
  50. inner_box = box_result.get("inner_box")
  51. if not isinstance(inner_box, dict):
  52. inner_box = {}
  53. box_result["inner_box"] = inner_box
  54. if not isinstance(inner_box.get("shapes"), list):
  55. inner_box["shapes"] = []
  56. outer_box = box_result.get("outer_box")
  57. if not isinstance(outer_box, dict):
  58. outer_box = {}
  59. box_result["outer_box"] = outer_box
  60. if not isinstance(outer_box.get("shapes"), list):
  61. outer_box["shapes"] = []
  62. return normalized
  63. def _prepare_recalculate_payload(edited_json: dict, source_json: dict) -> dict:
  64. """
  65. 以数据库里的原始 JSON 为底稿,合并前端编辑结果,得到更稳定的重算入参。
  66. 当前仅明确覆盖 defects;center_result 只有在前端传了非空 shapes 时才覆盖。
  67. 对前端标记为删除(edit_type=del)的缺陷,在这里直接过滤,避免重算后再次写回 modified_json。
  68. """
  69. base = copy.deepcopy(source_json) if isinstance(source_json, dict) else {}
  70. incoming = edited_json if isinstance(edited_json, dict) else {}
  71. if "id" in incoming:
  72. base["id"] = incoming["id"]
  73. if "imageWidth" in incoming:
  74. base["imageWidth"] = incoming["imageWidth"]
  75. if "imageHeight" in incoming:
  76. base["imageHeight"] = incoming["imageHeight"]
  77. base.setdefault("result", {})
  78. incoming_result = incoming.get("result", {})
  79. if not isinstance(incoming_result, dict):
  80. incoming_result = {}
  81. # defects 使用前端编辑结果覆盖;前端标记删除的项不参与重算,也不写回 modified_json
  82. incoming_defects = (
  83. incoming_result.get("defect_result", {}).get("defects", [])
  84. if isinstance(incoming_result.get("defect_result", {}), dict)
  85. else []
  86. )
  87. base["result"].setdefault("defect_result", {})
  88. filtered_defects = []
  89. if isinstance(incoming_defects, list):
  90. filtered_defects = [
  91. defect for defect in incoming_defects
  92. if not (isinstance(defect, dict) and defect.get("edit_type") == "del")
  93. ]
  94. base["result"]["defect_result"]["defects"] = filtered_defects
  95. # center_result 仅在前端有有效 shapes 时覆盖;否则沿用底稿
  96. incoming_center = incoming_result.get("center_result")
  97. if isinstance(incoming_center, dict) and not _is_center_box_shapes_empty(incoming_center):
  98. base["result"]["center_result"] = incoming_center
  99. elif "center_result" not in base["result"]:
  100. base["result"]["center_result"] = incoming_center if isinstance(incoming_center, dict) else {}
  101. base["result"]["center_result"] = _normalize_center_result(base["result"].get("center_result"))
  102. return base
  103. def _sanitize_defects_for_recalculate(defects: list):
  104. """
  105. 清理前端展示/编辑辅助字段,减少算分服务解析失败概率。
  106. """
  107. if not isinstance(defects, list):
  108. return
  109. for d in defects:
  110. if not isinstance(d, dict):
  111. continue
  112. if d.get("label") == "slight_scratch":
  113. d["label"] = "scratch"
  114. d.pop("defectImgUrl", None)
  115. d.pop("defectImgUrls", None)
  116. d.pop("gray_id", None)
  117. d.pop("fusion_id", None)
  118. d.pop("edit_type", None)
  119. d.pop("severity_level", None)
  120. d.pop("new_score", None)
  121. def _process_defects_for_json(
  122. card_id: int,
  123. img_id: int,
  124. img_path: str,
  125. json_data: dict,
  126. side: str,
  127. all_images: list = None,
  128. generate_defect_img: bool = True,
  129. generate_related_images: bool = True
  130. ):
  131. start_time = perf_counter()
  132. if not json_data or "result" not in json_data:
  133. logger.info(
  134. "耗时埋点 _process_defects_for_json: card_id=%s image_id=%s side=%s defects=0 elapsed_ms=%.2f",
  135. card_id, img_id, side, (perf_counter() - start_time) * 1000
  136. )
  137. return
  138. defect_result = json_data["result"].get("defect_result", {})
  139. defects = defect_result.get("defects", [])
  140. is_fusion = side in ("front_fusion", "back_fusion")
  141. side_prefix = "front_" if side.startswith("front_") else "back_"
  142. defect_detail_list = []
  143. for idx, defect in enumerate(defects, start=1):
  144. min_rect = defect.get("min_rect")
  145. defect_img_url = ""
  146. location_str = ""
  147. defect_img_url_list = []
  148. if min_rect and len(min_rect) == 3:
  149. center_x, center_y = min_rect[0]
  150. location_str = f"{int(center_x)},{int(center_y)}"
  151. # 使用坐标哈希作为缓存文件名,避免重复裁剪
  152. rect_str = str(min_rect)
  153. rect_hash = hashlib.md5(rect_str.encode('utf-8')).hexdigest()[:8]
  154. if generate_defect_img:
  155. filename = f"xy_{card_id}_{img_id}_{idx}_{rect_hash}.jpg"
  156. out_rel_path = f"/DefectImage/{filename}"
  157. out_object_name = f"{settings.MINIO_BASE_PREFIX}{out_rel_path}"
  158. try:
  159. # 检查 MinIO 中是否已有该截图,有则直接使用
  160. minio_client.stat_object(settings.MINIO_BUCKET, out_object_name)
  161. defect_img_url = settings.get_full_url(out_rel_path)
  162. except Exception:
  163. # 不存在或异常,则执行裁剪并上传
  164. defect_img_url = crop_defect_image(img_path, min_rect, filename)
  165. # 把同面的其他类型图在同样位置截图(不论是不是融合图都截)
  166. if generate_related_images and all_images:
  167. same_side_images = [
  168. img for img in all_images
  169. if getattr(img, 'image_type', '').startswith(side_prefix) and getattr(img, 'id', None) != img_id
  170. ]
  171. for s_img in same_side_images:
  172. s_img_type = getattr(s_img, 'image_type', '')
  173. s_img_path = getattr(s_img, 'image_path', '')
  174. s_img_id = getattr(s_img, 'id', 0)
  175. s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg"
  176. s_out_rel_path = f"/DefectImage/{s_filename}"
  177. s_out_object_name = f"{settings.MINIO_BASE_PREFIX}{s_out_rel_path}"
  178. s_url = ""
  179. try:
  180. minio_client.stat_object(settings.MINIO_BUCKET, s_out_object_name)
  181. s_url = settings.get_full_url(s_out_rel_path)
  182. except Exception:
  183. if s_img_path:
  184. s_url = crop_defect_image(s_img_path, min_rect, s_filename)
  185. if s_url:
  186. defect_img_url_list.append({
  187. "image_type": s_img_type,
  188. "url": s_url
  189. })
  190. # 1. 给每条缺陷带上 defectImgUrl
  191. defect["defectImgUrl"] = defect_img_url
  192. defect["defectImgUrls"] = defect_img_url_list
  193. # 2. 组装 defectDetailList 元素
  194. raw_type = f"{defect.get('defect_type', '')}".upper().strip()
  195. type_str_map = {
  196. "CORNER": "CORNER",
  197. "EDGE": "SIDE",
  198. "FACE": "SURFACE"
  199. }
  200. type_str = type_str_map.get(raw_type, raw_type)
  201. detail_item = {
  202. "id": defect.get("id", idx),
  203. "side": side,
  204. "location": location_str,
  205. "type": type_str,
  206. "defectImgUrl": defect_img_url,
  207. "label": defect.get("label", ""),
  208. "actual_area": defect.get("actual_area", 0),
  209. "defectImgUrls": defect_img_url_list
  210. }
  211. defect_detail_list.append(detail_item)
  212. defect_result["defectDetailList"] = defect_detail_list
  213. logger.info(
  214. "耗时埋点 _process_defects_for_json: card_id=%s image_id=%s side=%s defects=%s elapsed_ms=%.2f",
  215. card_id, img_id, side, len(defects), (perf_counter() - start_time) * 1000
  216. )
  217. def _process_images_to_xy_format(
  218. card_data: dict,
  219. generate_defect_img: bool = True,
  220. generate_related_images: bool = True
  221. ):
  222. """
  223. 内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。
  224. 直接修改传入的 card_data 字典。
  225. """
  226. start_time = perf_counter()
  227. card_id = card_data.get("id")
  228. all_images = card_data.get("images", [])
  229. if all_images:
  230. for img in all_images:
  231. d_internal = img.detection_json
  232. if isinstance(d_internal, str):
  233. d_internal = json.loads(d_internal)
  234. if d_internal:
  235. _process_defects_for_json(
  236. card_id, img.id, img.image_path, d_internal, img.image_type, all_images,
  237. generate_defect_img=generate_defect_img,
  238. generate_related_images=generate_related_images
  239. )
  240. img.detection_json = convert_internal_to_xy_format(d_internal)
  241. else:
  242. img.detection_json = convert_internal_to_xy_format({})
  243. m_internal = img.modified_json
  244. if isinstance(m_internal, str):
  245. m_internal = json.loads(m_internal)
  246. if m_internal:
  247. _process_defects_for_json(
  248. card_id, img.id, img.image_path, m_internal, img.image_type, all_images,
  249. generate_defect_img=generate_defect_img,
  250. generate_related_images=generate_related_images
  251. )
  252. img.modified_json = convert_internal_to_xy_format(m_internal)
  253. else:
  254. m_fallback = copy.deepcopy(d_internal) if d_internal else {}
  255. img.modified_json = convert_internal_to_xy_format(m_fallback)
  256. logger.info(
  257. "耗时埋点 _process_images_to_xy_format: card_id=%s image_count=%s elapsed_ms=%.2f",
  258. card_id, len(all_images), (perf_counter() - start_time) * 1000
  259. )
  260. return card_data
  261. @router.get("/query", response_model=CardDetailResponse, summary="获取卡牌详细信息(格式化xy), 支持前后翻页 [用户调用]")
  262. def get_card_details(
  263. card_id: int = Query(..., description="基准卡牌ID"),
  264. mode: QueryMode = Query(QueryMode.current, description="查询模式: current(当前), next(下一个), prev(上一个)"),
  265. db_conn: PooledMySQLConnection = db_dependency
  266. ):
  267. """
  268. 获取卡牌元数据以及所有与之关联的图片信息,并将坐标转换为 xy 格式。
  269. 同时返回上一张和下一张卡牌的ID。
  270. - **current**: 查询 card_id 对应的卡牌。
  271. - **next**: 查询 ID 比 card_id 大的第一张卡牌。
  272. - **prev**: 查询 ID 比 card_id 小的第一张卡牌。
  273. """
  274. target_id = card_id
  275. cursor = None
  276. start_time = perf_counter()
  277. try:
  278. cursor = db_conn.cursor(dictionary=True)
  279. # 1. 如果是查询上一个或下一个,先计算目标ID
  280. if mode != QueryMode.current:
  281. if mode == QueryMode.next:
  282. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  283. f"WHERE id > %s ORDER BY id ASC LIMIT 1")
  284. else: # mode == QueryMode.prev
  285. query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  286. f"WHERE id < %s ORDER BY id DESC LIMIT 1")
  287. cursor.execute(query_target, (card_id,))
  288. row = cursor.fetchone()
  289. if not row:
  290. msg = "没有下一张" if mode == QueryMode.next else "没有上一张"
  291. # 边界场景返回 404,避免与 response_model=CardDetailResponse 冲突
  292. raise HTTPException(status_code=404, detail=msg)
  293. target_id = row['id']
  294. # 2. 获取目标卡牌的详细数据 (Dict 格式)
  295. card_data = crud_card.get_card_with_details(db_conn, target_id)
  296. if not card_data:
  297. raise HTTPException(status_code=404, detail=f"ID为 {target_id} 的卡牌未找到。")
  298. # 3. 补充当前目标卡牌的 id_prev 和 id_next
  299. # 注意:这里需要重新获取 cursor,或者使用 cursor (非 dict 模式可能更方便取值,但 dict 模式也行)
  300. # 这里为了简单直接用 raw SQL
  301. # 查询上一个ID
  302. sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1"
  303. cursor.execute(sql_prev, (target_id,))
  304. row_prev = cursor.fetchone()
  305. card_data['id_prev'] = row_prev['id'] if row_prev else None
  306. # 查询下一个ID
  307. sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1"
  308. cursor.execute(sql_next, (target_id,))
  309. row_next = cursor.fetchone()
  310. card_data['id_next'] = row_next['id'] if row_next else None
  311. # 4. 遍历图片,转换格式 (使用抽取出的辅助函数)
  312. _process_images_to_xy_format(
  313. card_data,
  314. generate_defect_img=False,
  315. generate_related_images=True
  316. )
  317. # 5. 将 images 从 Pydantic 对象转为 dict,避免 model_validate 重复验证导致类型异常
  318. if "images" in card_data:
  319. card_data["images"] = [
  320. img.model_dump() if hasattr(img, 'model_dump') else img
  321. for img in card_data["images"]
  322. ]
  323. # 6. 验证并返回
  324. return CardDetailResponse.model_validate(card_data)
  325. except HTTPException:
  326. raise
  327. except Exception as e:
  328. logger.error(f"查询卡牌详情失败 (Mode: {mode}, BaseID: {card_id}): {e}")
  329. raise HTTPException(status_code=500, detail="数据库查询失败")
  330. finally:
  331. logger.info(
  332. "耗时埋点 get_card_details: base_card_id=%s target_card_id=%s mode=%s elapsed_ms=%.2f",
  333. card_id, target_id, mode, (perf_counter() - start_time) * 1000
  334. )
  335. if cursor:
  336. cursor.close()
  337. @router.put("/update/json/{id}", status_code=200, summary="接收xy格式, 还原后重计算分数并保存 [用户调用]")
  338. async def update_image_modified_json(
  339. id: int,
  340. new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"),
  341. current_user: dict = Depends(get_current_user),
  342. db_conn: PooledMySQLConnection = db_dependency
  343. ):
  344. """
  345. 接收前端传来的特殊格式 JSON (points 为对象列表)。
  346. 1. 将格式还原为后端标准格式 (points 为 [[x,y]])。
  347. 2. 根据 id 获取 image_type。
  348. 3. 调用外部接口重新计算分数。
  349. 4. 更新 modified_json。
  350. """
  351. card_id_to_update = None
  352. cursor = None
  353. # *** 1. 格式还原 ***
  354. # 将前端的 xy dict 格式转回 [[x,y]]
  355. internal_json_payload = convert_xy_to_internal_format(new_json_data)
  356. try:
  357. cursor = db_conn.cursor(dictionary=True)
  358. # 2. 获取图片信息
  359. cursor.execute(
  360. f"SELECT image_type, card_id, detection_json, modified_json "
  361. f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s",
  362. (id,)
  363. )
  364. row = cursor.fetchone()
  365. if not row:
  366. raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
  367. card_id_to_update = row["card_id"]
  368. check_card_permission(db_conn, current_user, card_id_to_update)
  369. image_type = row["image_type"]
  370. # 针对融合图,在调用外部服务算分时直接把它当成对应的 ring 图
  371. # 否则它在映射表里是 None,会导致报错
  372. target_score_type = image_type
  373. if image_type == ImageType.front_fusion.value:
  374. target_score_type = ImageType.front_ring.value
  375. elif image_type == ImageType.back_fusion.value:
  376. target_score_type = ImageType.back_ring.value
  377. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(target_score_type)
  378. if not score_type:
  379. raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}")
  380. # 3. 准备重算 payload:以库内原始 JSON 为底稿,仅覆盖编辑后的 defects
  381. # 对 fusion 图,score_type 会映射到 ring;此时底稿也应优先使用 ring 图 JSON,
  382. # 否则 fusion 图常见的空框结构会导致算分服务在 ring 逻辑下越界。
  383. source_json_str = row["modified_json"] if row["modified_json"] else row["detection_json"]
  384. if image_type in (ImageType.front_fusion.value, ImageType.back_fusion.value):
  385. target_ring_type = (
  386. ImageType.front_ring.value
  387. if image_type == ImageType.front_fusion.value
  388. else ImageType.back_ring.value
  389. )
  390. cursor.execute(
  391. f"SELECT detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  392. f"WHERE card_id = %s AND image_type = %s LIMIT 1",
  393. (card_id_to_update, target_ring_type)
  394. )
  395. ring_row = cursor.fetchone()
  396. if ring_row:
  397. source_json_str = ring_row["modified_json"] if ring_row["modified_json"] else ring_row["detection_json"]
  398. else:
  399. logger.warning(
  400. "fusion图重算未找到对应ring底稿,回退到当前图: image_id=%s card_id=%s image_type=%s target_ring_type=%s",
  401. id, card_id_to_update, image_type, target_ring_type
  402. )
  403. if isinstance(source_json_str, str):
  404. source_json_data = json.loads(source_json_str)
  405. else:
  406. source_json_data = source_json_str if isinstance(source_json_str, dict) else {}
  407. payload_for_recalculate = _prepare_recalculate_payload(internal_json_payload, source_json_data)
  408. _defects = payload_for_recalculate.get("result", {}).get("defect_result", {}).get("defects", [])
  409. _sanitize_defects_for_recalculate(_defects)
  410. logger.info(f"开始计算分数 (ID: {id}, Type: {score_type})")
  411. # 4. 调用远程计算接口
  412. try:
  413. response = await run_in_threadpool(
  414. lambda: requests.post(
  415. settings.SCORE_RECALCULATE_ENDPOINT,
  416. params={"score_type": score_type},
  417. json=payload_for_recalculate,
  418. timeout=20
  419. )
  420. )
  421. except Exception as e:
  422. logger.error(
  423. "调用分数计算服务失败(update/json): image_id=%s card_id=%s score_type=%s endpoint=%s error=%s",
  424. id, card_id_to_update, score_type, settings.SCORE_RECALCULATE_ENDPOINT, e,
  425. exc_info=True
  426. )
  427. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  428. if response.status_code != 200:
  429. logger.error(
  430. "分数计算接口返回错误(update/json): image_id=%s card_id=%s score_type=%s endpoint=%s status=%s body=%s",
  431. id, card_id_to_update, score_type, settings.SCORE_RECALCULATE_ENDPOINT, response.status_code, response.text
  432. )
  433. raise HTTPException(status_code=response.status_code,
  434. detail=f"分数计算接口返回错误: {response.text}")
  435. logger.info("分数计算完成")
  436. # 获取计算服务返回的结果(这个结果通常已经是标准的 internal 格式,带有分数和面积)
  437. final_json_data = response.json()
  438. # 5. 保存结果到数据库
  439. recalculated_json_str = json.dumps(final_json_data, ensure_ascii=False)
  440. update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  441. f"SET modified_json = %s, is_edited = TRUE "
  442. f"WHERE id = %s")
  443. cursor.execute(update_query, (recalculated_json_str, id))
  444. db_conn.commit()
  445. logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。")
  446. # 更新对应的 cards 的分数状态
  447. try:
  448. crud_card.update_card_scores_and_status(db_conn, card_id_to_update)
  449. logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。")
  450. except Exception as score_update_e:
  451. logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}")
  452. # 更新卡牌审核状态
  453. try:
  454. with db_conn.cursor() as cursor:
  455. review_state = 2
  456. # 更新指定 card_id 的 review_state 字段
  457. # 注意:MySQL 在“值未变化”时 rowcount 可能为 0,这不代表记录不存在。
  458. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  459. cursor.execute(query_update, (review_state, card_id_to_update))
  460. if cursor.rowcount == 0:
  461. cursor.execute(f"SELECT 1 FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s LIMIT 1",
  462. (card_id_to_update,))
  463. if not cursor.fetchone():
  464. raise HTTPException(status_code=404, detail=f"ID为 {card_id_to_update} 的卡牌未找到。")
  465. db_conn.commit()
  466. logger.info(f"卡牌 ID {card_id_to_update} 的审核状态已成功修改为 {review_state}。")
  467. except Exception as e:
  468. db_conn.rollback()
  469. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  470. if isinstance(e, HTTPException):
  471. raise e
  472. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  473. return {
  474. "detail": f"成功更新图片ID {id} 的JSON数据",
  475. "image_type": image_type,
  476. "score_type": score_type
  477. }
  478. except HTTPException:
  479. db_conn.rollback()
  480. raise
  481. except Exception as e:
  482. db_conn.rollback()
  483. logger.error(f"更新JSON失败 ({id}): {e}")
  484. raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}")
  485. finally:
  486. if cursor:
  487. cursor.close()
  488. # 处理灰度如
  489. @router.put("/update/json_gray/{id}", status_code=200, summary="[灰度] 接收xy格式, 合并至Ring图重计算并保存 [用户调用]")
  490. async def update_gray_image_json(
  491. id: int,
  492. new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"),
  493. current_user: dict = Depends(get_current_user),
  494. db_conn: PooledMySQLConnection = db_dependency
  495. ):
  496. """
  497. 针对灰度图 (front_gray/back_gray) 的保存逻辑。
  498. """
  499. cursor = None
  500. # 1. 格式还原
  501. internal_gray_json = convert_xy_to_internal_format(new_json_data)
  502. gray_defects = internal_gray_json.get("result", {}).get("defect_result", {}).get("defects", [])
  503. # 丢弃前端展示用的辅助字段,防止传给算分服务导致报错
  504. for d in gray_defects:
  505. if d.get("label") == "slight_scratch":
  506. d["label"] = "scratch"
  507. d.pop("defectImgUrl", None)
  508. d.pop("defectImgUrls", None)
  509. try:
  510. cursor = db_conn.cursor(dictionary=True)
  511. # 2. 获取辅助图(灰度图/融合图)信息
  512. # 以前只查 card_gray_images,现在融合图是在 card_images 表里
  513. # 先查 card_gray_images
  514. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} WHERE id = %s", (id,))
  515. gray_row = cursor.fetchone()
  516. if not gray_row:
  517. # 如果灰度表没找到,去主表找找看是不是融合图
  518. cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s AND image_type IN ('front_fusion', 'back_fusion')", (id,))
  519. gray_row = cursor.fetchone()
  520. if not gray_row:
  521. raise HTTPException(status_code=404, detail=f"ID为 {id} 的辅助图未找到。")
  522. card_id = gray_row['card_id']
  523. check_card_permission(db_conn, current_user, card_id)
  524. gray_image_type = gray_row['image_type']
  525. # 3. 确定目标 Ring 图类型
  526. target_ring_type = None
  527. if gray_image_type in (ImageType.front_gray.value, ImageType.front_fusion.value):
  528. target_ring_type = ImageType.front_ring.value
  529. elif gray_image_type in (ImageType.back_gray.value, ImageType.back_fusion.value):
  530. target_ring_type = ImageType.back_ring.value
  531. else:
  532. raise HTTPException(status_code=400, detail=f"不支持的辅助图类型: {gray_image_type}")
  533. # 4. 获取目标 Ring 图数据 (Card Images 表)
  534. cursor.execute(
  535. f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} "
  536. f"WHERE card_id = %s AND image_type = %s",
  537. (card_id, target_ring_type)
  538. )
  539. ring_row = cursor.fetchone()
  540. if not ring_row:
  541. raise HTTPException(status_code=404, detail=f"未找到对应的 Ring 图 ({target_ring_type}),无法应用修改。")
  542. ring_image_id = ring_row['id']
  543. # 优先使用 modified_json,如果没有则使用 detection_json
  544. source_json_str = ring_row['modified_json'] if ring_row['modified_json'] else ring_row['detection_json']
  545. if isinstance(source_json_str, str):
  546. ring_json_data = json.loads(source_json_str)
  547. else:
  548. ring_json_data = source_json_str
  549. # 5. 合并逻辑 (Merge Logic)
  550. # 确保路径存在
  551. if "result" not in ring_json_data: ring_json_data["result"] = {}
  552. if "defect_result" not in ring_json_data["result"]: ring_json_data["result"]["defect_result"] = {}
  553. if "defects" not in ring_json_data["result"]["defect_result"]: ring_json_data["result"]["defect_result"][
  554. "defects"] = []
  555. ring_defects = ring_json_data["result"]["defect_result"]["defects"]
  556. # 遍历灰度图传来的新缺陷列表
  557. for new_defect in gray_defects:
  558. is_fusion = gray_image_type in (ImageType.front_fusion.value, ImageType.back_fusion.value)
  559. key_to_check = "fusion_id" if is_fusion else "gray_id"
  560. identifier = new_defect.get(key_to_check)
  561. # 只有带有对应标识的才进行特殊合并处理
  562. # 如果没有,视作普通新缺陷直接添加
  563. if not identifier:
  564. ring_defects.append(new_defect)
  565. continue
  566. # 在 Ring 图现有的缺陷中寻找匹配的标识
  567. match_index = -1
  568. for i, old_defect in enumerate(ring_defects):
  569. if old_defect.get(key_to_check) == identifier:
  570. match_index = i
  571. break
  572. if match_index != -1:
  573. # 存在:替换 (Replace)
  574. ring_defects[match_index] = new_defect
  575. else:
  576. # 不存在:添加 (Append)
  577. ring_defects.append(new_defect)
  578. # 6. 调用计算服务 (对 Ring 图数据进行重算)
  579. score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(target_ring_type) # e.g., 'front_ring'
  580. logger.info(f"开始重计算 Ring 图分数 (GrayID: {id} -> RingID: {ring_image_id}, Type: {score_type})")
  581. try:
  582. response = await run_in_threadpool(
  583. lambda: requests.post(
  584. settings.SCORE_RECALCULATE_ENDPOINT,
  585. params={"score_type": score_type},
  586. json=ring_json_data, # 发送合并后的 Ring 数据
  587. timeout=20
  588. )
  589. )
  590. except Exception as e:
  591. logger.error(
  592. "调用分数计算服务失败(update/json_gray): gray_id=%s ring_id=%s card_id=%s score_type=%s endpoint=%s error=%s",
  593. id, ring_image_id, card_id, score_type, settings.SCORE_RECALCULATE_ENDPOINT, e,
  594. exc_info=True
  595. )
  596. raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}")
  597. if response.status_code != 200:
  598. logger.error(
  599. "分数计算接口返回错误(update/json_gray): gray_id=%s ring_id=%s card_id=%s score_type=%s endpoint=%s status=%s body=%s",
  600. id, ring_image_id, card_id, score_type, settings.SCORE_RECALCULATE_ENDPOINT, response.status_code, response.text
  601. )
  602. raise HTTPException(status_code=response.status_code,
  603. detail=f"分数计算接口返回错误: {response.text}")
  604. final_ring_json = response.json()
  605. # 7. 保存结果到数据库 (保存到 Ring 图记录)
  606. final_json_str = json.dumps(final_ring_json, ensure_ascii=False)
  607. update_query = (
  608. f"UPDATE {settings.DB_IMAGE_TABLE_NAME} "
  609. f"SET modified_json = %s, is_edited = TRUE "
  610. f"WHERE id = %s"
  611. )
  612. cursor.execute(update_query, (final_json_str, ring_image_id))
  613. db_conn.commit()
  614. logger.info(f"Ring 图 {ring_image_id} 已根据灰度图 {id} 的修改进行了更新。")
  615. # 8. 更新卡牌总分状态
  616. try:
  617. crud_card.update_card_scores_and_status(db_conn, card_id)
  618. except Exception as e:
  619. logger.error(f"更新卡牌 {card_id} 分数状态失败: {e}")
  620. # 更新卡牌审核状态
  621. try:
  622. with db_conn.cursor() as cursor:
  623. review_state = 2
  624. # 更新指定 card_id 的 review_state 字段
  625. # 注意:MySQL 在 “值未变化” 的情况下 rowcount 可能为 0,但这不代表记录不存在。
  626. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  627. cursor.execute(query_update, (review_state, card_id))
  628. if cursor.rowcount == 0:
  629. cursor.execute(f"SELECT 1 FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s LIMIT 1", (card_id,))
  630. if not cursor.fetchone():
  631. raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。")
  632. db_conn.commit()
  633. logger.info(f"卡牌 ID {card_id} 的审核状态已成功修改为 {review_state}。")
  634. except Exception as e:
  635. db_conn.rollback()
  636. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  637. if isinstance(e, HTTPException):
  638. raise e
  639. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  640. return {
  641. "detail": f"成功应用灰度图修改到 {target_ring_type}",
  642. "target_ring_id": ring_image_id,
  643. "gray_id": id
  644. }
  645. except HTTPException:
  646. db_conn.rollback()
  647. raise
  648. except Exception as e:
  649. db_conn.rollback()
  650. logger.error(f"灰度图更新失败 ({id}): {e}")
  651. raise HTTPException(status_code=500, detail=f"系统内部错误: {e}")
  652. finally:
  653. if cursor:
  654. cursor.close()