import requests import json from enum import Enum from fastapi import APIRouter, Depends, HTTPException, Query, Body from fastapi.concurrency import run_in_threadpool from mysql.connector.pooling import PooledMySQLConnection from app.core.config import settings from app.core.logger import get_logger from app.core.database_loader import get_db_connection from app.api.users import check_card_permission, get_current_user from app.utils.scheme import ( CardDetailResponse, IMAGE_TYPE_TO_SCORE_TYPE, ImageType ) from app.crud import crud_card from app.utils.xy_process import convert_internal_to_xy_format, convert_xy_to_internal_format import hashlib from app.core.minio_client import minio_client from app.utils.rating_report_utils import crop_defect_image logger = get_logger(__name__) router = APIRouter() db_dependency = Depends(get_db_connection) class QueryMode(str, Enum): current = "current" next = "next" prev = "prev" def _process_defects_for_json(card_id: int, img_id: int, img_path: str, json_data: dict, side: str, all_images: list = None): if not json_data or "result" not in json_data: return defect_result = json_data["result"].get("defect_result", {}) defects = defect_result.get("defects", []) is_fusion = side in ("front_fusion", "back_fusion") side_prefix = "front_" if side.startswith("front_") else "back_" defect_detail_list = [] for idx, defect in enumerate(defects, start=1): min_rect = defect.get("min_rect") defect_img_url = "" location_str = "" defect_img_url_list = [] if min_rect and len(min_rect) == 3: center_x, center_y = min_rect[0] location_str = f"{int(center_x)},{int(center_y)}" # 使用坐标哈希作为缓存文件名,避免重复裁剪 rect_str = str(min_rect) rect_hash = hashlib.md5(rect_str.encode('utf-8')).hexdigest()[:8] filename = f"xy_{card_id}_{img_id}_{idx}_{rect_hash}.jpg" out_rel_path = f"/DefectImage/{filename}" out_object_name = f"{settings.MINIO_BASE_PREFIX}{out_rel_path}" try: # 检查 MinIO 中是否已有该截图,有则直接使用 minio_client.stat_object(settings.MINIO_BUCKET, out_object_name) defect_img_url = settings.get_full_url(out_rel_path) except Exception: # 不存在或异常,则执行裁剪并上传 defect_img_url = crop_defect_image(img_path, min_rect, filename) # 把同面的其他类型图在同样位置截图(不论是不是融合图都截) if all_images: same_side_images = [img for img in all_images if getattr(img, 'image_type', '').startswith(side_prefix)] for s_img in same_side_images: s_img_type = getattr(s_img, 'image_type', '') s_img_path = getattr(s_img, 'image_path', '') s_img_id = getattr(s_img, 'id', 0) s_filename = f"xy_{card_id}_{s_img_id}_{idx}_{rect_hash}.jpg" s_out_rel_path = f"/DefectImage/{s_filename}" s_out_object_name = f"{settings.MINIO_BASE_PREFIX}{s_out_rel_path}" s_url = "" try: minio_client.stat_object(settings.MINIO_BUCKET, s_out_object_name) s_url = settings.get_full_url(s_out_rel_path) except Exception: if s_img_path: s_url = crop_defect_image(s_img_path, min_rect, s_filename) if s_url: defect_img_url_list.append({ "image_type": s_img_type, "url": s_url }) # 1. 给每条缺陷带上 defectImgUrl defect["defectImgUrl"] = defect_img_url defect["defectImgUrls"] = defect_img_url_list # 2. 组装 defectDetailList 元素 raw_type = f"{defect.get('defect_type', '')}".upper().strip() type_str_map = { "CORNER": "CORNER", "EDGE": "SIDE", "FACE": "SURFACE" } type_str = type_str_map.get(raw_type, raw_type) detail_item = { "id": defect.get("id", idx), "side": side, "location": location_str, "type": type_str, "defectImgUrl": defect_img_url, "label": defect.get("label", ""), "actual_area": defect.get("actual_area", 0), "defectImgUrls": defect_img_url_list } defect_detail_list.append(detail_item) defect_result["defectDetailList"] = defect_detail_list def _process_images_to_xy_format(card_data: dict): """ 内部辅助函数:遍历卡牌数据中的图片,将 JSON 格式转换为前端需要的 XY 格式。 直接修改传入的 card_data 字典。 """ card_id = card_data.get("id") all_images = card_data.get("images", []) if all_images: for img in all_images: # 处理 detection_json if img.detection_json: d_json = img.detection_json if isinstance(d_json, str): d_json = json.loads(d_json) _process_defects_for_json(card_id, img.id, img.image_path, d_json, img.image_type, all_images) # *** 转换逻辑 *** img.detection_json = convert_internal_to_xy_format(d_json) # 处理 modified_json if img.modified_json: m_json = img.modified_json if isinstance(m_json, str): m_json = json.loads(m_json) _process_defects_for_json(card_id, img.id, img.image_path, m_json, img.image_type, all_images) # *** 转换逻辑 *** img.modified_json = convert_internal_to_xy_format(m_json) return card_data @router.get("/query", response_model=CardDetailResponse, summary="获取卡牌详细信息(格式化xy), 支持前后翻页 [用户调用]") def get_card_details( card_id: int = Query(..., description="基准卡牌ID"), mode: QueryMode = Query(QueryMode.current, description="查询模式: current(当前), next(下一个), prev(上一个)"), db_conn: PooledMySQLConnection = db_dependency ): """ 获取卡牌元数据以及所有与之关联的图片信息,并将坐标转换为 xy 格式。 同时返回上一张和下一张卡牌的ID。 - **current**: 查询 card_id 对应的卡牌。 - **next**: 查询 ID 比 card_id 大的第一张卡牌。 - **prev**: 查询 ID 比 card_id 小的第一张卡牌。 """ target_id = card_id cursor = None try: cursor = db_conn.cursor(dictionary=True) # 1. 如果是查询上一个或下一个,先计算目标ID if mode != QueryMode.current: if mode == QueryMode.next: query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} " f"WHERE id > %s ORDER BY id ASC LIMIT 1") else: # mode == QueryMode.prev query_target = (f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} " f"WHERE id < %s ORDER BY id DESC LIMIT 1") cursor.execute(query_target, (card_id,)) row = cursor.fetchone() if not row: msg = "没有下一张" if mode == QueryMode.next else "没有上一张" raise HTTPException(status_code=200, detail=msg) target_id = row['id'] # 2. 获取目标卡牌的详细数据 (Dict 格式) card_data = crud_card.get_card_with_details(db_conn, target_id) if not card_data: raise HTTPException(status_code=404, detail=f"ID为 {target_id} 的卡牌未找到。") # 3. 补充当前目标卡牌的 id_prev 和 id_next # 注意:这里需要重新获取 cursor,或者使用 cursor (非 dict 模式可能更方便取值,但 dict 模式也行) # 这里为了简单直接用 raw SQL # 查询上一个ID sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1" cursor.execute(sql_prev, (target_id,)) row_prev = cursor.fetchone() card_data['id_prev'] = row_prev['id'] if row_prev else None # 查询下一个ID sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1" cursor.execute(sql_next, (target_id,)) row_next = cursor.fetchone() card_data['id_next'] = row_next['id'] if row_next else None # 4. 遍历图片,转换格式 (使用抽取出的辅助函数) _process_images_to_xy_format(card_data) # 5. 验证并返回 return CardDetailResponse.model_validate(card_data) except HTTPException: raise except Exception as e: logger.error(f"查询卡牌详情失败 (Mode: {mode}, BaseID: {card_id}): {e}") raise HTTPException(status_code=500, detail="数据库查询失败") finally: if cursor: cursor.close() @router.put("/update/json/{id}", status_code=200, summary="接收xy格式, 还原后重计算分数并保存 [用户调用]") async def update_image_modified_json( id: int, new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"), current_user: dict = Depends(get_current_user), db_conn: PooledMySQLConnection = db_dependency ): """ 接收前端传来的特殊格式 JSON (points 为对象列表)。 1. 将格式还原为后端标准格式 (points 为 [[x,y]])。 2. 根据 id 获取 image_type。 3. 调用外部接口重新计算分数。 4. 更新 modified_json。 """ card_id_to_update = None cursor = None # *** 1. 格式还原 *** # 将前端的 xy dict 格式转回 [[x,y]],并丢弃 points 里的 id internal_json_payload = convert_xy_to_internal_format(new_json_data) try: cursor = db_conn.cursor(dictionary=True) # 2. 获取图片信息 cursor.execute(f"SELECT image_type, card_id FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (id,)) row = cursor.fetchone() if not row: raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。") card_id_to_update = row["card_id"] check_card_permission(db_conn, current_user, card_id_to_update) image_type = row["image_type"] score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type) if not score_type: raise HTTPException(status_code=400, detail=f"未知的 image_type: {image_type}") logger.info(f"开始计算分数 (ID: {id}, Type: {score_type})") # 3. 调用远程计算接口 (使用还原后的 JSON) try: response = await run_in_threadpool( lambda: requests.post( settings.SCORE_RECALCULATE_ENDPOINT, params={"score_type": score_type}, json=internal_json_payload, # 传递还原后的数据 timeout=20 ) ) except Exception as e: raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}") if response.status_code != 200: logger.error(f"分数计算接口返回错误: {response.status_code}, {response.text}") raise HTTPException(status_code=response.status_code, detail=f"分数计算接口返回错误: {response.text}") logger.info("分数计算完成") # 获取计算服务返回的结果(这个结果通常已经是标准的 internal 格式,带有分数和面积) final_json_data = response.json() # 4. 保存结果到数据库 recalculated_json_str = json.dumps(final_json_data, ensure_ascii=False) update_query = (f"UPDATE {settings.DB_IMAGE_TABLE_NAME} " f"SET modified_json = %s, is_edited = TRUE " f"WHERE id = %s") cursor.execute(update_query, (recalculated_json_str, id)) db_conn.commit() logger.info(f"图片ID {id} 的 modified_json 已更新并重新计算。") # 更新对应的 cards 的分数状态 try: crud_card.update_card_scores_and_status(db_conn, card_id_to_update) logger.info(f"卡牌 {card_id_to_update} 的分数和状态已更新。") except Exception as score_update_e: logger.error(f"更新卡牌 {card_id_to_update} 分数失败: {score_update_e}") # 更新卡牌审核状态 try: with db_conn.cursor() as cursor: review_state = 2 # 更新指定 card_id 的 review_state 字段 query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s" cursor.execute(query_update, (review_state, card_id_to_update)) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail=f"ID为 {card_id_to_update} 的卡牌未找到。") db_conn.commit() logger.info(f"卡牌 ID {card_id_to_update} 的审核状态已成功修改为 {review_state}。") except Exception as e: db_conn.rollback() logger.error(f"修改卡牌 {id} 审核状态失败: {e}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。") return { "detail": f"成功更新图片ID {id} 的JSON数据", "image_type": image_type, "score_type": score_type } except HTTPException: db_conn.rollback() raise except Exception as e: db_conn.rollback() logger.error(f"更新JSON失败 ({id}): {e}") raise HTTPException(status_code=500, detail=f"更新JSON数据失败: {e}") finally: if cursor: cursor.close() # 处理灰度如 @router.put("/update/json_gray/{id}", status_code=200, summary="[灰度] 接收xy格式, 合并至Ring图重计算并保存 [用户调用]") async def update_gray_image_json( id: int, new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"), current_user: dict = Depends(get_current_user), db_conn: PooledMySQLConnection = db_dependency ): """ 针对灰度图 (front_gray/back_gray) 的保存逻辑。 """ cursor = None # 1. 格式还原 internal_gray_json = convert_xy_to_internal_format(new_json_data) gray_defects = internal_gray_json.get("result", {}).get("defect_result", {}).get("defects", []) try: cursor = db_conn.cursor(dictionary=True) # 2. 获取灰度图信息 # 注意:灰度图存在 card_gray_images 表中 cursor.execute(f"SELECT card_id, image_type FROM {settings.DB_GRAY_IMAGE_TABLE_NAME} WHERE id = %s", (id,)) gray_row = cursor.fetchone() if not gray_row: raise HTTPException(status_code=404, detail=f"ID为 {id} 的灰度图未找到。") card_id = gray_row['card_id'] check_card_permission(db_conn, current_user, card_id) gray_image_type = gray_row['image_type'] # 3. 确定目标 Ring 图类型 target_ring_type = None if gray_image_type == ImageType.front_gray.value: target_ring_type = ImageType.front_ring.value elif gray_image_type == ImageType.back_gray.value: target_ring_type = ImageType.back_ring.value else: raise HTTPException(status_code=400, detail=f"不支持的灰度图类型: {gray_image_type}") # 4. 获取目标 Ring 图数据 (Card Images 表) cursor.execute( f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} " f"WHERE card_id = %s AND image_type = %s", (card_id, target_ring_type) ) ring_row = cursor.fetchone() if not ring_row: raise HTTPException(status_code=404, detail=f"未找到对应的 Ring 图 ({target_ring_type}),无法应用修改。") ring_image_id = ring_row['id'] # 优先使用 modified_json,如果没有则使用 detection_json source_json_str = ring_row['modified_json'] if ring_row['modified_json'] else ring_row['detection_json'] if isinstance(source_json_str, str): ring_json_data = json.loads(source_json_str) else: ring_json_data = source_json_str # 5. 合并逻辑 (Merge Logic) # 确保路径存在 if "result" not in ring_json_data: ring_json_data["result"] = {} if "defect_result" not in ring_json_data["result"]: ring_json_data["result"]["defect_result"] = {} if "defects" not in ring_json_data["result"]["defect_result"]: ring_json_data["result"]["defect_result"][ "defects"] = [] ring_defects = ring_json_data["result"]["defect_result"]["defects"] # 遍历灰度图传来的新缺陷列表 for new_defect in gray_defects: gray_id = new_defect.get("gray_id") # 只有带有 gray_id 的才进行特殊合并处理 (理论上前端编辑的都应该有,或者新生成的) # 如果没有 gray_id,视作普通新缺陷直接添加 if not gray_id: ring_defects.append(new_defect) continue # 在 Ring 图现有的缺陷中寻找匹配的 gray_id match_index = -1 for i, old_defect in enumerate(ring_defects): if old_defect.get("gray_id") == gray_id: match_index = i break if match_index != -1: # 存在:替换 (Replace) ring_defects[match_index] = new_defect else: # 不存在:添加 (Append) ring_defects.append(new_defect) # 6. 调用计算服务 (对 Ring 图数据进行重算) score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(target_ring_type) # e.g., 'front_ring' logger.info(f"开始重计算 Ring 图分数 (GrayID: {id} -> RingID: {ring_image_id}, Type: {score_type})") try: response = await run_in_threadpool( lambda: requests.post( settings.SCORE_RECALCULATE_ENDPOINT, params={"score_type": score_type}, json=ring_json_data, # 发送合并后的 Ring 数据 timeout=20 ) ) except Exception as e: raise HTTPException(status_code=500, detail=f"调用分数计算服务失败: {e}") if response.status_code != 200: logger.error(f"分数计算接口返回错误: {response.text}") raise HTTPException(status_code=response.status_code, detail=f"分数计算接口返回错误: {response.text}") final_ring_json = response.json() # 7. 保存结果到数据库 (保存到 Ring 图记录) final_json_str = json.dumps(final_ring_json, ensure_ascii=False) update_query = ( f"UPDATE {settings.DB_IMAGE_TABLE_NAME} " f"SET modified_json = %s, is_edited = TRUE " f"WHERE id = %s" ) cursor.execute(update_query, (final_json_str, ring_image_id)) db_conn.commit() logger.info(f"Ring 图 {ring_image_id} 已根据灰度图 {id} 的修改进行了更新。") # 8. 更新卡牌总分状态 try: crud_card.update_card_scores_and_status(db_conn, card_id) except Exception as e: logger.error(f"更新卡牌 {card_id} 分数状态失败: {e}") # 更新卡牌审核状态 try: with db_conn.cursor() as cursor: review_state = 2 # 更新指定 card_id 的 review_state 字段 query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s" cursor.execute(query_update, (review_state, card_id)) if cursor.rowcount == 0: raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。") db_conn.commit() logger.info(f"卡牌 ID {card_id} 的审核状态已成功修改为 {review_state}。") except Exception as e: db_conn.rollback() logger.error(f"修改卡牌 {id} 审核状态失败: {e}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。") return { "detail": f"成功应用灰度图修改到 {target_ring_type}", "target_ring_id": ring_image_id, "gray_id": id } except HTTPException: db_conn.rollback() raise except Exception as e: db_conn.rollback() logger.error(f"灰度图更新失败 ({id}): {e}") raise HTTPException(status_code=500, detail=f"系统内部错误: {e}") finally: if cursor: cursor.close()