cards.py 11 KB

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