cards.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. from datetime import datetime, date
  2. import os
  3. from typing import Optional, List
  4. from fastapi import APIRouter, Depends, HTTPException, Query
  5. from app.core.minio_client import minio_client
  6. from mysql.connector.pooling import PooledMySQLConnection
  7. from app.core.config import settings
  8. from app.core.logger import get_logger
  9. from app.core.database_loader import get_db_connection
  10. from app.api.users import get_current_user, require_admin_user
  11. from app.utils.scheme import (
  12. CardDetailResponse, CardListDetailResponse, CardType, SortBy, CardNoList,
  13. SortOrder, CardListResponseWrapper, CardListWithTotal, ReviewUpdate
  14. )
  15. from app.crud import crud_card
  16. logger = get_logger(__name__)
  17. router = APIRouter()
  18. db_dependency = Depends(get_db_connection)
  19. @router.post("/created", response_model=CardDetailResponse, status_code=201, summary="创建一个新的卡牌记录")
  20. def create_card(
  21. card_name: Optional[str] = Query(None, summary="卡牌的名称"),
  22. cardNo: Optional[str] = Query(None, summary="卡牌编号"),
  23. card_type: CardType = Query(CardType.pokemon, summary="卡牌类型"),
  24. db_conn: PooledMySQLConnection = db_dependency
  25. ):
  26. """创建一个新的卡牌实体,此时它不关联任何图片。"""
  27. try:
  28. with db_conn.cursor(dictionary=True) as cursor:
  29. # 检查重复 cardNo 并删除旧数据
  30. if cardNo:
  31. cursor.execute(
  32. f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE cardNo = %s",
  33. (cardNo,)
  34. )
  35. existing_rows = cursor.fetchall()
  36. for row in existing_rows:
  37. # 调用已有的删除函数删除旧卡牌
  38. delete_card(row['id'], db_conn)
  39. query = f"INSERT INTO {settings.DB_CARD_TABLE_NAME} (card_name, cardNo, card_type) VALUES (%s, %s, %s)"
  40. cursor.execute(query, (card_name, cardNo, card_type.value))
  41. db_conn.commit()
  42. new_id = cursor.lastrowid
  43. logger.info(f"新卡牌已创建, ID: {new_id}, 类型: {card_type.value}")
  44. # 返回刚创建的空卡牌信息
  45. cursor.execute(f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s", (new_id,))
  46. new_card_data = cursor.fetchone()
  47. response_data = {**new_card_data, "images": []}
  48. return CardDetailResponse.model_validate(response_data)
  49. except Exception as e:
  50. db_conn.rollback()
  51. logger.error(f"创建卡牌失败: {e}")
  52. raise HTTPException(status_code=500, detail="数据库插入失败。")
  53. @router.get("/query", response_model=CardDetailResponse, summary="获取指定卡牌的详细信息")
  54. def get_card_details(card_id: int, db_conn: PooledMySQLConnection = db_dependency):
  55. """获取卡牌元数据以及所有与之关联的图片信息,包含 id_prev 和 id_next。"""
  56. # 1. 获取基本信息
  57. card_data = crud_card.get_card_with_details(db_conn, card_id)
  58. if not card_data:
  59. raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。")
  60. # 2. 获取 id_prev 和 id_next
  61. try:
  62. with db_conn.cursor() as cursor:
  63. # 查询上一个ID (ID < current ORDER BY DESC LIMIT 1)
  64. sql_prev = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id < %s ORDER BY id DESC LIMIT 1"
  65. cursor.execute(sql_prev, (card_id,))
  66. row_prev = cursor.fetchone()
  67. card_data['id_prev'] = row_prev[0] if row_prev else None
  68. # 查询下一个ID (ID > current ORDER BY ASC LIMIT 1)
  69. sql_next = f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id > %s ORDER BY id ASC LIMIT 1"
  70. cursor.execute(sql_next, (card_id,))
  71. row_next = cursor.fetchone()
  72. card_data['id_next'] = row_next[0] if row_next else None
  73. except Exception as e:
  74. logger.error(f"获取卡牌 {card_id} 的前后ID失败: {e}")
  75. # 不中断主流程,可以设为 None
  76. card_data['id_prev'] = None
  77. card_data['id_next'] = None
  78. return CardDetailResponse.model_validate(card_data)
  79. @router.get("/query_next", response_model=CardDetailResponse, summary="获取指定卡牌id的下一个卡的详细信息")
  80. def get_next_card_details(card_id: int, db_conn: PooledMySQLConnection = db_dependency): # Renamed function
  81. """获取指定ID的下一张卡牌的元数据以及所有与之关联的图片信息。"""
  82. try:
  83. with db_conn.cursor(dictionary=True) as cursor:
  84. query_next_card = (
  85. f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
  86. f"WHERE id > %s ORDER BY id ASC LIMIT 1"
  87. )
  88. cursor.execute(query_next_card, (card_id,))
  89. next_card_row = cursor.fetchone()
  90. if not next_card_row:
  91. raise HTTPException(status_code=200, detail=f"没有下一张")
  92. next_card_id = next_card_row['id']
  93. # 获取单个卡牌的完整信息
  94. card_data = crud_card.get_card_with_details(db_conn, next_card_id)
  95. if not card_data:
  96. raise HTTPException(status_code=404, detail=f"下一个卡牌ID {next_card_id} 未找到详细信息。")
  97. return CardDetailResponse.model_validate(card_data)
  98. except Exception as e:
  99. logger.error(f"查询下一个卡牌详情失败 (基准ID: {card_id}): {e}")
  100. if isinstance(e, HTTPException): raise e
  101. raise HTTPException(status_code=500, detail="服务器内部错误,查询数据库失败。")
  102. @router.get("/card_list_filter", response_model=CardListResponseWrapper, summary="获取卡牌列表和总数")
  103. def card_list_filter(
  104. card_id: Optional[int] = Query(None, description="筛选:卡牌ID"),
  105. cardNo: Optional[str] = Query(None, description="筛选:卡牌编号"),
  106. card_name: Optional[str] = Query(None, description="筛选:卡牌名称"),
  107. card_type: Optional[CardType] = Query(None, description="筛选:卡牌类型"),
  108. is_edited: Optional[bool] = Query(None, description="筛选:是否已编辑"),
  109. review_state: Optional[int] = Query(None, ge=1, le=4,
  110. description="筛选:审核状态(1待复检, 2已复检, 3审核未通过, 4审核通过)"),
  111. min_detection_score: Optional[float] = Query(None, ge=0, le=10),
  112. max_detection_score: Optional[float] = Query(None, ge=0, le=10),
  113. min_modified_score: Optional[float] = Query(None, ge=0, le=10),
  114. max_modified_score: Optional[float] = Query(None, ge=0, le=10),
  115. created_start: Optional[date] = Query(None),
  116. created_end: Optional[date] = Query(None),
  117. updated_start: Optional[date] = Query(None),
  118. updated_end: Optional[date] = Query(None),
  119. sort_by: SortBy = Query(SortBy.updated_at),
  120. sort_order: SortOrder = Query(SortOrder.desc),
  121. skip: int = Query(0, ge=0),
  122. page_num: int = Query(None, ge=1),
  123. limit: int = Query(100, ge=1, le=1000),
  124. current_user: dict = Depends(get_current_user),
  125. db_conn: PooledMySQLConnection = db_dependency
  126. ):
  127. """
  128. 获取卡牌列表,返回格式包含 total 和 list。
  129. 结构: { "data": { "total": 100, "list": [...] } }
  130. """
  131. if page_num is not None:
  132. skip = (page_num - 1) * limit
  133. try:
  134. result = crud_card.get_card_list_and_count(
  135. db_conn,
  136. card_id, cardNo, card_name, card_type, is_edited, review_state,
  137. min_detection_score, max_detection_score,
  138. min_modified_score, max_modified_score,
  139. created_start, created_end,
  140. updated_start, updated_end,
  141. sort_by, sort_order, skip, limit,
  142. None if current_user.get("is_admin") else current_user["id"]
  143. )
  144. # 组装返回数据,注意这里要进行 model_validate 转换 list 中的每一项
  145. # 1. 先把 list 里的字典转成 Pydantic 对象
  146. validated_list = [CardListDetailResponse.model_validate(c) for c in result['list']]
  147. # 2. 构造 data 部分
  148. data_content = CardListWithTotal(
  149. total=result['total'],
  150. list=validated_list
  151. )
  152. # 3. 构造最外层包装
  153. return CardListResponseWrapper(data=data_content)
  154. except Exception as e:
  155. logger.error(f"查询卡牌列表(带总数)失败: {e}")
  156. raise HTTPException(status_code=500, detail="获取数据列表失败。")
  157. @router.delete("/delete/{id}", status_code=200, summary="删除卡牌及其所有关联图片")
  158. def delete_card(id: int, db_conn: PooledMySQLConnection = db_dependency):
  159. """
  160. 删除一张卡牌及其所有关联的图片记录和物理文件。
  161. 利用了数据库的 ON DELETE CASCADE 特性。
  162. """
  163. try:
  164. with db_conn.cursor() as cursor:
  165. # 1. 查询所有关联图片的所有物理文件路径
  166. query_paths = (f"SELECT image_path, detection_image_path, modified_image_path "
  167. f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s")
  168. cursor.execute(query_paths, (id,))
  169. image_paths_to_delete = [path for row in cursor.fetchall() for path in row if path]
  170. # 2. 删除卡牌记录
  171. query_delete_card = f"DELETE FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"
  172. cursor.execute(query_delete_card, (id,))
  173. if cursor.rowcount == 0:
  174. raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
  175. db_conn.commit()
  176. logger.info(f"ID {id} 的卡牌和关联数据已成功删除。")
  177. # 3. 删除物理文件 (改为 MinIO 删除)
  178. for path in image_paths_to_delete:
  179. # path 通常形如 /Data/xxx.jpg 或者 /DefectImage/xxx.jpg
  180. object_name = f"{settings.MINIO_BASE_PREFIX}{path}"
  181. try:
  182. minio_client.remove_object(settings.MINIO_BUCKET, object_name)
  183. logger.info(f"图片文件已从MinIO删除: {object_name}")
  184. except Exception as e:
  185. logger.error(f"删除MinIO文件失败 {object_name}: {e}")
  186. return {"message": f"成功删除卡牌 ID {id} 及其所有关联数据"}
  187. except Exception as e:
  188. db_conn.rollback()
  189. logger.error(f"删除卡牌失败 ({id}): {e}")
  190. if isinstance(e, HTTPException): raise e
  191. raise HTTPException(status_code=500, detail="删除卡牌失败。")
  192. @router.put("/review_state/{id}", status_code=200, summary="修改卡牌的审核状态")
  193. def update_review_state(
  194. id: int,
  195. data: ReviewUpdate,
  196. current_user: dict = Depends(require_admin_user),
  197. db_conn: PooledMySQLConnection = db_dependency
  198. ):
  199. """
  200. 修改某张卡牌的审核状态。
  201. review_state 参数说明:
  202. - 1: 待复检 (默认状态)
  203. - 2: 已复检
  204. - 3: 审核未通过
  205. - 4: 审核通过
  206. """
  207. review_state = data.review_state
  208. try:
  209. with db_conn.cursor() as cursor:
  210. cursor.execute(f"SELECT 1 FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s", (id,))
  211. if not cursor.fetchone():
  212. raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
  213. # 更新指定 card_id 的 review_state 字段
  214. query_update = f"UPDATE {settings.DB_CARD_TABLE_NAME} SET review_state = %s WHERE id = %s"
  215. cursor.execute(query_update, (review_state, id))
  216. db_conn.commit()
  217. logger.info(f"卡牌 ID {id} 的审核状态已成功修改为 {review_state}。")
  218. return {"message": f"成功修改卡牌 ID {id} 的审核状态为 {review_state}"}
  219. except Exception as e:
  220. db_conn.rollback()
  221. logger.error(f"修改卡牌 {id} 审核状态失败: {e}")
  222. if isinstance(e, HTTPException):
  223. raise e
  224. raise HTTPException(status_code=500, detail="修改审核状态失败,数据库操作错误。")
  225. @router.post("/batch_query/id_score", status_code=200, summary="批量查询id和分数")
  226. def batch_query_id_score(
  227. data: CardNoList,
  228. db_conn: PooledMySQLConnection = db_dependency
  229. ):
  230. """
  231. 根据给定的 cardNo 列表批量查询卡牌对应的卡牌ID (id) 和最终分数 (score)。
  232. 分数返回逻辑:优先返回 modified_score,如果没有 modified_score 则返回 detection_score。
  233. """
  234. card_nos = data.cardNoList
  235. # 如果输入的列表为空,直接返回空结果
  236. if not card_nos:
  237. return {"cardNoList": []}
  238. try:
  239. with db_conn.cursor(dictionary=True) as cursor:
  240. # 构造 SQL 的 IN 子句
  241. format_strings = ','.join(['%s'] * len(card_nos))
  242. query = f"""
  243. SELECT id, cardNo, detection_score, modified_score
  244. FROM {settings.DB_CARD_TABLE_NAME}
  245. WHERE cardNo IN ({format_strings})
  246. """
  247. # 执行查询
  248. cursor.execute(query, tuple(card_nos))
  249. results = cursor.fetchall()
  250. # 组装返回数据
  251. response_list = []
  252. for row in results:
  253. # 判断分数:优先取 modified_score,如果为 None 则取 detection_score
  254. score = row.get('modified_score')
  255. if score is None:
  256. score = row.get('detection_score')
  257. response_list.append({
  258. "cardNo": row['cardNo'],
  259. "evaluateNo": row['id'],
  260. "score": score
  261. })
  262. return {"cardNoList": response_list}
  263. except Exception as e:
  264. logger.error(f"批量查询卡牌ID和分数失败: {e}")
  265. raise HTTPException(status_code=500, detail="批量查询失败,数据库操作错误。")