Procházet zdrojové kódy

为cards表增加几个字段, 各种crud操作会影响cards的对应id的字段, 包括分数等

AnlaAnla před 4 týdny
rodič
revize
338081f560

+ 1 - 0
.idea/encodings.xml

@@ -6,6 +6,7 @@
     <file url="file://$PROJECT_DIR$/Test/test01.py" charset="GBK" />
     <file url="file://$PROJECT_DIR$/app/api/cards.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/app/api/images.py" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/app/crud/crud_card.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/app/utils/card_score_calculate.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/app/utils/scheme.py" charset="UTF-8" />
     <file url="file://$PROJECT_DIR$/run.py" charset="UTF-8" />

+ 5 - 2
Test/img_score_and_insert.py

@@ -8,8 +8,11 @@ from datetime import datetime
 
 # --- 配置区域 ---
 # 1. 服务 URL
-INFERENCE_SERVICE_URL = "http://192.168.31.243:7744"
-STORAGE_SERVICE_URL = "http://192.168.31.243:7745"
+INFERENCE_SERVICE_URL = "http://127.0.0.1:7744"
+STORAGE_SERVICE_URL = "http://127.0.0.1:7745"
+
+# INFERENCE_SERVICE_URL = "http://192.168.31.243:7744"
+# STORAGE_SERVICE_URL = "http://192.168.31.243:7745"
 
 # 2. 要处理的卡片信息
 formate_time = datetime.now().strftime("%Y-%m-%d_%H:%M")

+ 2 - 2
Test/test01.py

@@ -33,8 +33,8 @@ def send(url):
 
 
 if __name__ == '__main__':
-    # base_url = 'http://127.0.0.1:7745/api/cards/query/9'
-    base_url = 'http://127.0.0.1:7745/api/cards/query_next/5'
+    base_url = 'http://127.0.0.1:7745/api/cards/query/1'
+    # base_url = 'http://127.0.0.1:7745/api/cards/query_next/5'
 
     # base_url = 'http://192.168.31.243:7745/api/cards/query/6'
     send(base_url)

+ 77 - 145
app/api/cards.py

@@ -2,13 +2,16 @@ from datetime import datetime
 import os
 from typing import Optional, List
 from fastapi import APIRouter, Depends, HTTPException, Query
+
 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.utils.card_score_calculate import card_score_calculate
-from app.utils.scheme import CardDetailResponse, CardImageResponse, CardListDetailResponse
+from app.utils.scheme import (
+    CardDetailResponse, CardListDetailResponse, CardType, SortBy, SortOrder
+)
+from app.crud import crud_card
 
 logger = get_logger(__name__)
 router = APIRouter()
@@ -18,152 +21,90 @@ db_dependency = Depends(get_db_connection)
 @router.post("/created", response_model=CardDetailResponse, status_code=201, summary="创建一个新的卡牌记录")
 def create_card(
         card_name: Optional[str] = Query(None, summary="卡牌的名称"),
+        card_type: CardType = Query(CardType.pokemon, summary="卡牌类型"),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """创建一个新的卡牌实体,此时它不关联任何图片。"""
-    cursor = None
     try:
-        cursor = db_conn.cursor()
-        query = f"INSERT INTO {settings.DB_CARD_TABLE_NAME} (card_name) VALUES (%s)"
-        cursor.execute(query, (card_name,))
-        db_conn.commit()
-        new_id = cursor.lastrowid
-        logger.info(f"新卡牌已创建, ID: {new_id}")
-
-        # 返回刚创建的空卡牌信息
-        return CardDetailResponse(
-            id=new_id,
-            card_name=card_name,
-            created_at=datetime.now(),
-            updated_at=datetime.now(),
-            images=[]
-        )
+        with db_conn.cursor(dictionary=True) as cursor:
+
+            query = f"INSERT INTO {settings.DB_CARD_TABLE_NAME} (card_name, card_type) VALUES (%s, %s)"
+            cursor.execute(query, (card_name, card_type.value))
+            db_conn.commit()
+            new_id = cursor.lastrowid
+            logger.info(f"新卡牌已创建, ID: {new_id}, 类型: {card_type.value}")
+
+            # 返回刚创建的空卡牌信息
+            cursor.execute(f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s", (new_id,))
+            new_card_data = cursor.fetchone()
+
+            response_data = {**new_card_data, "images": []}
+            return CardDetailResponse.model_validate(response_data)
+
     except Exception as e:
         db_conn.rollback()
         logger.error(f"创建卡牌失败: {e}")
         raise HTTPException(status_code=500, detail="数据库插入失败。")
-    finally:
-        if cursor:
-            cursor.close()
 
 
 @router.get("/query/{id}", response_model=CardDetailResponse, summary="获取指定卡牌的详细信息")
 def get_card_details(id: int, db_conn: PooledMySQLConnection = db_dependency):
-    """获取卡牌元数据以及所有与之关联的图片信息。"""
-    cursor = None
-    try:
-        cursor = db_conn.cursor(dictionary=True)
-
-        # 1. 获取卡牌信息
-        query_card = f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"
-        cursor.execute(query_card, (id,))
-        card_data = cursor.fetchone()
-        if not card_data:
-            raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
-
-        # 2. 获取所有关联的图片信息
-        query_images = f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
-        cursor.execute(query_images, (id,))
-        image_records = cursor.fetchall()
-        images = [CardImageResponse.model_validate(row) for row in image_records]
-
-        # 计算总分数(只有当图片数量为 4 时才计算)
-        card_response = card_score_calculate(card_data, images)
-
-        return card_response
-
-    except Exception as e:
-        logger.error(f"查询卡牌详情失败 ({id}): {e}")
-        if isinstance(e, HTTPException): raise e
-        raise HTTPException(status_code=500, detail="数据库查询失败。")
-    finally:
-        if cursor:
-            cursor.close()
+    """获取卡牌元数据以及所有与之关联的图片信息。分数是预先计算好的。"""
+    # REFACTORED: Use CRUD function
+    card_data = crud_card.get_card_with_details(db_conn, id)
+    if not card_data:
+        raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
+    return CardDetailResponse.model_validate(card_data)
 
 
 @router.get("/query_next/{id}", response_model=CardDetailResponse, summary="获取指定卡牌id的下一个卡的详细信息")
-def get_card_details(id: int, db_conn: PooledMySQLConnection = db_dependency):
+def get_next_card_details(id: int, db_conn: PooledMySQLConnection = db_dependency):  # Renamed function
     """获取指定ID的下一张卡牌的元数据以及所有与之关联的图片信息。"""
     try:
         with db_conn.cursor(dictionary=True) as cursor:
-
-            # 1. 获取下一张卡牌的
             query_next_card = (
-                f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} "
+                f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} "
                 f"WHERE id > %s ORDER BY id ASC LIMIT 1"
             )
             cursor.execute(query_next_card, (id,))
-            next_card_data = cursor.fetchone()
+            next_card_row = cursor.fetchone()
 
-            # 如果没有找到下一张卡牌,则抛出 404 错误
-            if not next_card_data:
+            if not next_card_row:
                 raise HTTPException(status_code=404, detail=f"ID为 {id} 的下一个卡牌未找到。")
 
-            # 从获取到的下一张卡牌数据中提取其ID
-            next_card_id = next_card_data['id']
-
-            # 2. 使用【下一个卡牌的ID】来获取所有关联的图片信息
-            query_images = f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
-            cursor.execute(query_images, (next_card_id,))  # <-- 关键修正:使用 next_card_id
-            image_records = cursor.fetchall()
-            images = [CardImageResponse.model_validate(row) for row in image_records]
-
-            # 3. 使用【正确的下一张卡牌数据】和【正确的图片数据】进行计算
-            card_response = card_score_calculate(next_card_data, images)  # <-- 关键修正:传入 next_card_data
+            next_card_id = next_card_row['id']
+            # 获取单个卡牌的完整信息
+            card_data = crud_card.get_card_with_details(db_conn, next_card_id)
+            if not card_data:
+                raise HTTPException(status_code=404, detail=f"下一个卡牌ID {next_card_id} 未找到详细信息。")
 
-            return card_response
+            return CardDetailResponse.model_validate(card_data)
 
     except Exception as e:
         logger.error(f"查询下一个卡牌详情失败 (基准ID: {id}): {e}")
-        # 如果异常已经是 HTTPException,直接重新抛出,否则包装成 500 错误
-        if isinstance(e, HTTPException):
-            raise e
+        if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="服务器内部错误,查询数据库失败。")
 
 
-@router.get("/card_list", response_model=List[CardListDetailResponse], summary="获取卡牌列表")
-def list_cards_detailed(
-        start_id: Optional[int] = Query(None, description="筛选条件:起始 card_id"),
-        end_id: Optional[int] = Query(None, description="筛选条件:结束 card_id"),
+@router.get("/card_list", response_model=List[CardListDetailResponse], summary="获取卡牌列表(支持筛选和排序)")
+def list_cards_detailed(  # MODIFIED
+        card_name: Optional[str] = Query(None, description="筛选条件:卡牌名称 (模糊匹配)"),
+        card_type: Optional[CardType] = Query(None, description="筛选条件:卡牌类型"),
+        sort_by: SortBy = Query(SortBy.updated_at, description="排序字段"),
+        sort_order: SortOrder = Query(SortOrder.desc, description="排序顺序"),
         skip: int = Query(0, ge=0, description="分页:跳过的记录数"),
         limit: int = Query(100, ge=1, le=1000, description="分页:每页的记录数"),
         db_conn: PooledMySQLConnection = db_dependency
 ):
-    """
-    获取卡牌的基础信息列表,支持按 id 范围筛选和分页。
-    """
-    cursor = None
+    """获取卡牌的基础信息列表,支持按名称、类型筛选,以及多字段排序和分页。"""
     try:
-        cursor = db_conn.cursor(dictionary=True)
-
-        base_query = f"SELECT id, card_name, created_at, updated_at FROM {settings.DB_CARD_TABLE_NAME}"
-
-        conditions = []
-        params = []
-        if start_id is not None:
-            conditions.append("id >= %s")
-            params.append(start_id)
-        if end_id is not None:
-            conditions.append("id <= %s")
-            params.append(end_id)
-
-        if conditions:
-            base_query += " WHERE " + " AND ".join(conditions)
-
-        base_query += " ORDER BY id DESC LIMIT %s OFFSET %s"
-        params.extend([limit, skip])
-
-        cursor.execute(base_query, tuple(params))
-        results = cursor.fetchall()
-
-        return [CardListDetailResponse.model_validate(row) for row in results]
-
+        cards_with_images = crud_card.get_card_list_with_images(
+            db_conn, card_name, card_type, sort_by, sort_order, skip, limit
+        )
+        return [CardListDetailResponse.model_validate(c) for c in cards_with_images]
     except Exception as e:
         logger.error(f"查询卡牌列表失败: {e}")
         raise HTTPException(status_code=500, detail="获取数据列表失败。")
-    finally:
-        if cursor:
-            cursor.close()
 
 
 @router.delete("/delete/{id}", status_code=200, summary="删除卡牌及其所有关联图片")
@@ -172,47 +113,38 @@ def delete_card(id: int, db_conn: PooledMySQLConnection = db_dependency):
     删除一张卡牌及其所有关联的图片记录和物理文件。
     利用了数据库的 ON DELETE CASCADE 特性。
     """
-    cursor = None
     try:
-        cursor = db_conn.cursor()
-
-        # 1. 查询所有关联图片的所有物理文件路径,以便稍后删除 (修改部分)
-        query_paths = (f"SELECT image_path, detection_image_path, modified_image_path "
-                       f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s")
-        cursor.execute(query_paths, (id,))
-
-        image_paths_to_delete = []
-        for row in cursor.fetchall():
-            # 将每一行中非空的路径都添加到待删除列表
-            image_paths_to_delete.extend([path for path in row if path])
-
-        # 2. 删除卡牌记录。数据库会自动级联删除 card_images 表中的相关记录
-        query_delete_card = f"DELETE FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"
-        cursor.execute(query_delete_card, (id,))
-
-        if cursor.rowcount == 0:
-            raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
-
-        # 3. 删除物理文件
-        for path in image_paths_to_delete:
-            absolute_path = settings.BASE_PATH / path.lstrip('/\\')
-
-            if os.path.exists(absolute_path):
-                try:
-                    os.remove(absolute_path)
-                    logger.info(f"图片文件已删除: {absolute_path}")
-                except OSError as e:
-                    logger.error(f"删除文件失败 {absolute_path}: {e}")
-
-        db_conn.commit()
-        logger.info(f"ID {id} 的卡牌和关联数据已成功删除。")
-        return {"message": f"成功删除卡牌 ID {id} 及其所有关联数据"}
+        with db_conn.cursor() as cursor:
+            # 1. 查询所有关联图片的所有物理文件路径
+            query_paths = (f"SELECT image_path, detection_image_path, modified_image_path "
+                           f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s")
+            cursor.execute(query_paths, (id,))
+            image_paths_to_delete = [path for row in cursor.fetchall() for path in row if path]
+
+            # 2. 删除卡牌记录
+            query_delete_card = f"DELETE FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"
+            cursor.execute(query_delete_card, (id,))
+
+            if cursor.rowcount == 0:
+                raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
+
+            db_conn.commit()
+            logger.info(f"ID {id} 的卡牌和关联数据已成功删除。")
+
+            # 3. 删除物理文件
+            for path in image_paths_to_delete:
+                absolute_path = settings.BASE_PATH / path.lstrip('/\\')
+                if os.path.exists(absolute_path):
+                    try:
+                        os.remove(absolute_path)
+                        logger.info(f"图片文件已删除: {absolute_path}")
+                    except OSError as e:
+                        logger.error(f"删除文件失败 {absolute_path}: {e}")
+
+            return {"message": f"成功删除卡牌 ID {id} 及其所有关联数据"}
 
     except Exception as e:
         db_conn.rollback()
         logger.error(f"删除卡牌失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="删除卡牌失败。")
-    finally:
-        if cursor:
-            cursor.close()

+ 21 - 9
app/api/images.py

@@ -14,6 +14,7 @@ from app.core.logger import get_logger
 from app.utils.scheme import CardImageResponse, ImageJsonPairResponse, ResultImagePathType
 from app.utils.scheme import ImageType, IMAGE_TYPE_TO_SCORE_TYPE
 from app.core.database_loader import get_db_connection
+from app.crud import crud_card
 
 logger = get_logger(__name__)
 router = APIRouter()
@@ -73,6 +74,13 @@ async def upload_image_for_card(
         db_conn.commit()
         logger.info(f"图片 {new_id} 已成功关联到卡牌 {card_id},类型为 {image_type.value}。")
 
+        try:
+            crud_card.update_card_scores_and_status(db_conn, card_id)
+            logger.info(f"卡牌 {card_id} 的分数和状态已更新。")
+        except Exception as score_update_e:
+            # 即使分数更新失败,图片也已经成功插入,记录错误但不回滚
+            logger.error(f"更新卡牌 {card_id} 分数失败: {score_update_e}")
+
         cursor.execute(f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s", (new_id,))
         new_image_data = cursor.fetchone()
         columns = [desc[0] for desc in cursor.description]
@@ -133,6 +141,7 @@ async def update_image_modified_json(
     根据 id 获取 image_type, 调用外部接口重新计算分数, 并更新 modified_json。
     updated_at 会自动更新
     """
+    card_id_to_update = None
     cursor = None
     try:
         cursor = db_conn.cursor(dictionary=True)
@@ -143,6 +152,7 @@ async def update_image_modified_json(
         if not row:
             raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
 
+        card_id_to_update = row["card_id"]
         image_type = row["image_type"]
         score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
         if not score_type:
@@ -150,14 +160,10 @@ async def update_image_modified_json(
 
         # 2️ 调用远程计算接口
         try:
-            params = {"score_type": score_type}
-            payload = new_json_data
             response = await run_in_threadpool(
                 lambda: requests.post(
                     settings.SCORE_RECALCULATE_ENDPOINT,
-                    params=params,
-                    json=payload,
-                    timeout=20
+                    params={"score_type": score_type}, json=new_json_data, timeout=20
                 )
             )
         except Exception as e:
@@ -168,21 +174,27 @@ async def update_image_modified_json(
             raise HTTPException(status_code=response.status_code,
                                 detail=f"分数计算接口返回错误: {response.text}")
 
-        recalculated_json = response.json()
-
         # 3️ 保存结果到数据库
-        recalculated_json_str = json.dumps(recalculated_json, ensure_ascii=False)
+        recalculated_json_str = json.dumps(response.json(), ensure_ascii=False)
         update_query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET modified_json = %s WHERE id = %s"
+
         cursor.execute(update_query, (recalculated_json_str, id))
 
         logger.info(f"更新 id={id} 的操作影响了 {cursor.rowcount} 行。")
 
         if cursor.rowcount == 0:
-            raise HTTPException(status_code=500, detail=f"更新失败,ID为 {id} 的记录未找到或数据未发生变化。")
+            logger.warning(f"更新 id={id} 的 modified_json 未影响任何行。")
 
         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}")
+
         return {
             "message": f"成功更新图片ID {id} 的JSON数据",
             "image_type": image_type,

+ 3 - 4
app/core/config.py

@@ -5,12 +5,11 @@ import json
 
 
 class Settings:
-
     BASE_PATH = Path(__file__).parent.parent.parent.absolute()
 
     CONFIG_PATH = BASE_PATH / 'Config.json'
 
-    API_PREFIX: str = "/api" # 通用前缀
+    API_PREFIX: str = "/api"  # 通用前缀
 
     DATA_DIR = BASE_PATH / "Data"
     SCORE_CONFIG_PATH = BASE_PATH / "app/core/scoring_config.json"
@@ -24,7 +23,7 @@ class Settings:
     # 从 Config.json 读取的旧表名, 我们将不再使用它, 但保留以兼容旧文件
     # 建议直接在代码中定义新表名, 避免混淆
     DB_CARD_TABLE_NAME = 'cards'
-    DB_IMAGE_TABLE_NAME = 'card_images' # 新的图片表名
+    DB_IMAGE_TABLE_NAME = 'card_images'  # 新的图片表名
 
     DATABASE_CONFIG: Dict[str, str] = {
         'user': 'root',
@@ -47,4 +46,4 @@ class Settings:
 
 settings = Settings()
 print(f"项目根目录: {settings.BASE_PATH}")
-print(f"数据存储目录: {settings.DATA_DIR}")
+print(f"数据存储目录: {settings.DATA_DIR}")

+ 4 - 0
app/core/database_loader.py

@@ -29,6 +29,10 @@ def init_database():
             f"CREATE TABLE IF NOT EXISTS `{settings.DB_CARD_TABLE_NAME}` ("
             "  `id` INT AUTO_INCREMENT PRIMARY KEY,"
             "  `card_name` VARCHAR(255) NULL COMMENT '卡牌的通用名称',"
+            "  `card_type` VARCHAR(50) NOT NULL DEFAULT 'pokemon' COMMENT '卡牌类型 (pokemon, basketball, etc)',"
+            "  `detection_score` DECIMAL(4, 2) NULL COMMENT '原始检测总分',"
+            "  `modified_score` DECIMAL(4, 2) NULL COMMENT '修改后总分',"
+            "  `is_edited` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否被编辑过',"
             "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
             "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
             ") ENGINE=InnoDB COMMENT='存储实体卡牌的核心信息'"

+ 0 - 0
app/crud/__init__.py


+ 137 - 0
app/crud/crud_card.py

@@ -0,0 +1,137 @@
+from typing import Optional, List, Dict, Any
+from mysql.connector.pooling import PooledMySQLConnection
+import json
+from datetime import datetime
+
+from app.core.config import settings
+from app.utils.scheme import CardImageResponse, CardType, SortBy, SortOrder
+from app.utils.card_score_calculate import calculate_scores_from_images
+
+
+def update_card_scores_and_status(db_conn: PooledMySQLConnection, card_id: int):
+    """
+    根据卡牌关联的图片数量和内容,更新cards表中的分数和状态。
+    - 如果图片满4张,计算并更新 detection_score, modified_score, is_edited。
+    - 如果图片不足4张,将分数置为NULL,is_edited置为False。
+    - 自动更新 updated_at 字段。
+    """
+    with db_conn.cursor(dictionary=True) as cursor:
+        # 1. 获取所有关联图片
+        query_images = f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
+        cursor.execute(query_images, (card_id,))
+        image_records = cursor.fetchall()
+
+        # 将数据库行转换为Pydantic模型,便于处理JSON
+        images = [CardImageResponse.model_validate(row) for row in image_records]
+
+        # 2. 计算分数和状态
+        scores_data = calculate_scores_from_images(images)
+
+        # 3. 更新 cards 表
+        # 注意: updated_at 会由数据库自动更新
+        query_update_card = (
+            f"UPDATE {settings.DB_CARD_TABLE_NAME} SET "
+            "detection_score = %s, modified_score = %s, is_edited = %s, updated_at = %s "
+            "WHERE id = %s"
+        )
+        params = (
+            scores_data["detection_score"],
+            scores_data["modified_score"],
+            scores_data["is_edited"],
+            datetime.now(),  # 手动更新时间戳以确保触发
+            card_id,
+        )
+        cursor.execute(query_update_card, params)
+        db_conn.commit()
+
+
+def get_card_with_details(db_conn: PooledMySQLConnection, card_id: int) -> Optional[Dict[str, Any]]:
+    """获取单个卡牌的完整信息,包括预计算的分数和所有图片详情。"""
+    with db_conn.cursor(dictionary=True) as cursor:
+        # 1. 获取卡牌信息
+        query_card = f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s"
+        cursor.execute(query_card, (card_id,))
+        card_data = cursor.fetchone()
+        if not card_data:
+            return None
+
+        # 2. 获取所有关联的图片信息
+        query_images = f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
+        cursor.execute(query_images, (card_id,))
+        image_records = cursor.fetchall()
+        images = [CardImageResponse.model_validate(row) for row in image_records]
+
+        # 3. 获取分数详情 (如果需要)
+        # 只有当图片满4张时,分数详情才有意义
+        score_details = calculate_scores_from_images(images)
+        card_data.update({
+            "images": images,
+            "detection_score_detail": score_details["detection_score_detail"],
+            "modified_score_detail": score_details["modified_score_detail"]
+        })
+
+        return card_data
+
+
+def get_card_list_with_images(
+        db_conn: PooledMySQLConnection,
+        card_name: Optional[str],
+        card_type: Optional[CardType],
+        sort_by: SortBy,
+        sort_order: SortOrder,
+        skip: int,
+        limit: int
+) -> List[Dict[str, Any]]:
+    """获取带筛选和排序功能的卡牌列表,并附带其关联的图片信息。"""
+    with db_conn.cursor(dictionary=True) as cursor:
+        # 1. 构建动态查询
+        query = f"SELECT * FROM {settings.DB_CARD_TABLE_NAME}"
+        conditions = []
+        params = []
+
+        if card_name:
+            conditions.append("card_name LIKE %s")
+            params.append(f"%{card_name}%")
+        if card_type:
+            conditions.append("card_type = %s")
+            params.append(card_type.value)
+
+        if conditions:
+            query += " WHERE " + " AND ".join(conditions)
+
+        # 添加排序和分页
+        query += f" ORDER BY {sort_by.value} {sort_order.value}, id DESC"
+        query += " LIMIT %s OFFSET %s"
+        params.extend([limit, skip])
+
+        cursor.execute(query, tuple(params))
+        cards = cursor.fetchall()
+
+        if not cards:
+            return []
+
+        # 2. 一次性获取所有相关卡牌的图片 (避免 N+1 查询)
+        card_ids = [card['id'] for card in cards]
+
+        # 使用 IN 子句和占位符
+        format_strings = ','.join(['%s'] * len(card_ids))
+        image_query = (
+            f"SELECT id, card_id, image_type, image_path, detection_image_path, modified_image_path "
+            f"FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id IN ({format_strings})"
+        )
+        cursor.execute(image_query, tuple(card_ids))
+        images = cursor.fetchall()
+
+        # 3. 将图片按 card_id 分组
+        images_by_card_id = {}
+        for image in images:
+            cid = image['card_id']
+            if cid not in images_by_card_id:
+                images_by_card_id[cid] = []
+            images_by_card_id[cid].append(image)
+
+        # 4. 将图片附加到对应的卡牌上
+        for card in cards:
+            card['images'] = images_by_card_id.get(card['id'], [])
+
+        return cards

+ 0 - 0
app/utils/__init__.py


+ 125 - 125
app/utils/card_score_calculate.py

@@ -1,144 +1,144 @@
-from app.utils.scheme import CardDetailResponse, ImageType
+from app.utils.scheme import ImageType, CardImageResponse
 from app.core.logger import get_logger
-from typing import List
+from typing import List, Dict, Any
 
 logger = get_logger(__name__)
 
 
-def card_score_calculate(card_data: dict, images: List) -> CardDetailResponse:
-    card_data["detection_score"] = None
-    card_data["modified_score"] = None
-    card_data["detection_score_detail"] = {}
-    card_data["modified_score_detail"] = {}
-    if len(images) == 4:
-        try:
-            # ---------- detection_score ----------
-            detection_score = 10.0
-            detection_center_score = 10.0
-            detection_corner_score = 10.0
-            detection_edge_score = 10.0
-            detection_face_score = 10.0
+def calculate_scores_from_images(images: List[CardImageResponse]) -> Dict[str, Any]:
+    """
+    根据一个包含4张图片的列表,计算原始分数和修改后分数。
+    返回一个包含分数详情的字典。
+    """
+    scores = {
+        "detection_score": None,
+        "modified_score": None,
+        "detection_score_detail": {
+            "detection_center_score": None,
+            "detection_corner_score": None,
+            "detection_edge_score": None,
+            "detection_face_score": None
+        },
+        "modified_score_detail": {
+            "modified_center_score": None,
+            "modified_corner_score": None,
+            "modified_edge_score": None,
+            "modified_face_score": None
+        },
+        "is_edited": False,
+    }
+
+    if len(images) != 4:
+        return scores
+
+    try:
+        # ---------- detection_score ----------
+        detection_score = 10.0
+        detection_center_score = 10.0
+        detection_corner_score = 10.0
+        detection_edge_score = 10.0
+        detection_face_score = 10.0
+        for img in images:
+            try:
+                add_val = img.detection_json.get("result", {}).get("_used_compute_deduct_score", 0)
+                detection_score += float(add_val or 0)
+
+                if img.image_type == ImageType.front_edge:
+                    center_reduct_val = img.detection_json.get("result", {}).get("center_result", {}).get(
+                        "deduct_score", 0)
+                    corner_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "front_corner_deduct_score", 0)
+                    edge_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "front_edge_deduct_score", 0)
+                    detection_center_score += float(center_reduct_val or 0)
+                    detection_corner_score += float(corner_reduct_val or 0)
+                    detection_edge_score += float(edge_reduct_val or 0)
+                elif img.image_type == ImageType.back_edge:
+                    center_reduct_val = img.detection_json.get("result", {}).get("center_result", {}).get(
+                        "deduct_score", 0)
+                    corner_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "back_corner_deduct_score", 0)
+                    edge_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "back_edge_deduct_score", 0)
+                    detection_center_score += float(center_reduct_val or 0)
+                    detection_corner_score += float(corner_reduct_val or 0)
+                    detection_edge_score += float(edge_reduct_val or 0)
+                elif img.image_type == ImageType.front_face:
+                    face_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "front_face_deduct_score", 0)
+                    detection_face_score += float(face_reduct_val or 0)
+                elif img.image_type == ImageType.back_face:
+                    face_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        "back_face_deduct_score", 0)
+                    detection_face_score += float(face_reduct_val or 0)
+            except Exception as e:
+                logger.warning(f"解析 detection_json 分数失败 (image_id={img.id}): {e}")
+
+        scores["detection_score"] = detection_score
+        scores["detection_score_detail"] = {
+            "detection_center_score": detection_center_score,
+            "detection_corner_score": detection_corner_score,
+            "detection_edge_score": detection_edge_score,
+            "detection_face_score": detection_face_score,
+        }
+
+        # ---------- modified_score and is_edited status ----------
+        modified_score = 10.0
+        modified_center_score = 10.0
+        modified_corner_score = 10.0
+        modified_edge_score = 10.0
+        modified_face_score = 10.0
+
+        # 检查是否存在任何非空的 modified_json
+        is_edited = any(img.modified_json is not None for img in images)
+        scores["is_edited"] = is_edited
+
+        if is_edited:
             for img in images:
+                src = img.modified_json if img.modified_json is not None else img.detection_json
                 try:
-                    # 总分的计算
-                    add_val = img.detection_json.get("result", {}).get("_used_compute_deduct_score", 0)
-                    detection_score += float(add_val or 0)
+                    add_val = src.get("result", {}).get("_used_compute_deduct_score", 0)
+                    modified_score += float(add_val or 0)
 
-                    # 累加不同类型的扣分项
                     if img.image_type == ImageType.front_edge:
-                        center_reduct_val = img.detection_json.get("result", {}).get("center_result", {}).get(
-                            "deduct_score", 0)
-                        corner_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        center_reduct_val = src.get("result", {}).get("center_result", {}).get("deduct_score", 0)
+                        corner_reduct_val = src.get("result", {}).get("defect_result", {}).get(
                             "front_corner_deduct_score", 0)
-                        edge_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
-                            "front_edge_deduct_score", 0)
-
-                        detection_center_score += float(center_reduct_val or 0)
-                        detection_corner_score += float(corner_reduct_val or 0)
-                        detection_edge_score += float(edge_reduct_val or 0)
+                        edge_reduct_val = src.get("result", {}).get("defect_result", {}).get("front_edge_deduct_score",
+                                                                                             0)
+                        modified_center_score += float(center_reduct_val or 0)
+                        modified_corner_score += float(corner_reduct_val or 0)
+                        modified_edge_score += float(edge_reduct_val or 0)
                     elif img.image_type == ImageType.back_edge:
-                        center_reduct_val = img.detection_json.get("result", {}).get("center_result", {}).get(
-                            "deduct_score", 0
-                        )
-                        corner_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
+                        center_reduct_val = src.get("result", {}).get("center_result", {}).get("deduct_score", 0)
+                        corner_reduct_val = src.get("result", {}).get("defect_result", {}).get(
                             "back_corner_deduct_score", 0)
-                        edge_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
-                            "back_edge_deduct_score", 0)
-
-                        detection_center_score += float(center_reduct_val or 0)
-                        detection_corner_score += float(corner_reduct_val or 0)
-                        detection_edge_score += float(edge_reduct_val or 0)
+                        edge_reduct_val = src.get("result", {}).get("defect_result", {}).get("back_edge_deduct_score",
+                                                                                             0)
+                        modified_center_score += float(center_reduct_val or 0)
+                        modified_corner_score += float(corner_reduct_val or 0)
+                        modified_edge_score += float(edge_reduct_val or 0)
                     elif img.image_type == ImageType.front_face:
-                        face_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
-                            "front_face_deduct_score", 0)
-
-                        detection_face_score += float(face_reduct_val or 0)
+                        face_reduct_val = src.get("result", {}).get("defect_result", {}).get("front_face_deduct_score",
+                                                                                             0)
+                        modified_face_score += float(face_reduct_val or 0)
                     elif img.image_type == ImageType.back_face:
-                        face_reduct_val = img.detection_json.get("result", {}).get("defect_result", {}).get(
-                            "back_face_deduct_score", 0)
-
-                        detection_face_score += float(face_reduct_val or 0)
+                        face_reduct_val = src.get("result", {}).get("defect_result", {}).get("back_face_deduct_score",
+                                                                                             0)
+                        modified_face_score += float(face_reduct_val or 0)
 
                 except Exception as e:
-                    logger.warning(f"解析 detection_json 分数失败 (image_id={img.id}): {e}")
-            card_data["detection_score"] = detection_score
-            card_data["detection_score_detail"]["detection_center_score"] = detection_center_score
-            card_data["detection_score_detail"]["detection_corner_score"] = detection_corner_score
-            card_data["detection_score_detail"]["detection_edge_score"] = detection_edge_score
-            card_data["detection_score_detail"]["detection_face_score"] = detection_face_score
-
-            # ---------- modified_score ----------
-            modified_score = 10.0
-            modified_center_score = 10.0
-            modified_corner_score = 10.0
-            modified_edge_score = 10.0
-            modified_face_score = 10.0
-
-            all_modified_none = all(img.modified_json is None for img in images)
-            if not all_modified_none:
-
-                for img in images:
-                    src = img.modified_json if img.modified_json is not None else img.detection_json
-                    try:
-                        # 总分的计算
-                        add_val = src.get("result", {}).get("_used_compute_deduct_score", 0)
-                        modified_score += float(add_val or 0)
-
-                        # 累加不同修改后数据类型的扣分项
-                        if img.image_type == ImageType.front_edge:
-                            center_reduct_val = src.get("result", {}).get("center_result", {}).get(
-                                "deduct_score", 0)
-                            corner_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "front_corner_deduct_score", 0)
-                            edge_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "front_edge_deduct_score", 0)
-
-                            modified_center_score += float(center_reduct_val or 0)
-                            modified_corner_score += float(corner_reduct_val or 0)
-                            modified_edge_score += float(edge_reduct_val or 0)
-                        elif img.image_type == ImageType.back_edge:
-                            center_reduct_val = src.get("result", {}).get("center_result", {}).get(
-                                "deduct_score", 0
-                            )
-                            corner_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "back_corner_deduct_score", 0)
-                            edge_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "back_edge_deduct_score", 0)
-
-                            modified_center_score += float(center_reduct_val or 0)
-                            modified_corner_score += float(corner_reduct_val or 0)
-                            modified_edge_score += float(edge_reduct_val or 0)
-                        elif img.image_type == ImageType.front_face:
-                            face_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "front_face_deduct_score", 0)
-
-                            modified_face_score += float(face_reduct_val or 0)
-                        elif img.image_type == ImageType.back_face:
-                            face_reduct_val = src.get("result", {}).get("defect_result", {}).get(
-                                "back_face_deduct_score", 0)
-
-                            modified_face_score += float(face_reduct_val or 0)
-
-                    except Exception as e:
-                        logger.warning(f"解析 modified_json 分数失败 (image_id={img.id}): {e}")
-                card_data["modified_score"] = modified_score
-
-                card_data["modified_score_detail"]["modified_center_score"] = modified_center_score
-                card_data["modified_score_detail"]["modified_corner_score"] = modified_corner_score
-                card_data["modified_score_detail"]["modified_edge_score"] = modified_edge_score
-                card_data["modified_score_detail"]["modified_face_score"] = modified_face_score
-            else:
-                card_data["modified_score"] = None
-                card_data["modified_score_detail"]["modified_center_score"] = None
-                card_data["modified_score_detail"]["modified_corner_score"] = None
-                card_data["modified_score_detail"]["modified_edge_score"] = None
-                card_data["modified_score_detail"]["modified_face_score"] = None
+                    logger.warning(f"解析 modified_json 分数失败 (image_id={img.id}): {e}")
 
-        except Exception as e:
-            logger.error(f"计算分数过程异常: {e}")
+            scores["modified_score"] = modified_score
+            scores["modified_score_detail"] = {
+                "modified_center_score": modified_center_score,
+                "modified_corner_score": modified_corner_score,
+                "modified_edge_score": modified_edge_score,
+                "modified_face_score": modified_face_score,
+            }
 
-    # 组合成最终响应
-    card_response = CardDetailResponse.model_validate(card_data)
-    card_response.images = images
+    except Exception as e:
+        logger.error(f"计算分数过程异常: {e}")
 
-    return card_response
+    return scores

+ 41 - 2
app/utils/scheme.py

@@ -13,12 +13,32 @@ class ImageType(str, Enum):
     back_edge = "back_edge"
 
 
-# 新增:用于指定要更新哪个结果图片路径的枚举
+class CardType(str, Enum):
+    pokemon = "pokemon"
+    basketball = "basketball"
+    baseball = "baseball"
+
+
+# 用于指定要更新哪个结果图片路径的枚举
 class ResultImagePathType(str, Enum):
     detection = "detection"
     modified = "modified"
 
 
+# 用于排序的
+class SortBy(str, Enum):  # NEW
+    card_name = "card_name"
+    created_at = "created_at"
+    updated_at = "updated_at"
+    detection_score = "detection_score"
+    modified_score = "modified_score"
+
+
+class SortOrder(str, Enum):  # NEW
+    asc = "ASC"
+    desc = "DESC"
+
+
 # "图片类型和计算分数分数类型映射表"
 IMAGE_TYPE_TO_SCORE_TYPE = {
     "front_face": "front_face",
@@ -66,6 +86,8 @@ class CardDetailResponse(BaseModel):
     card_name: Optional[str] = None
     created_at: datetime
     updated_at: datetime
+    card_type: str
+    is_edited: bool
     detection_score: Optional[float] = None
     modified_score: Optional[float] = None
     detection_score_detail: Optional[Dict[str, Any]] = None
@@ -98,12 +120,29 @@ class ImageJsonPairResponse(BaseModel):
         return v
 
 
+class CardImageInListResponse(BaseModel):
+    """为卡牌列表接口定义的简化版图片响应模型"""
+    id: int
+    image_type: str
+    image_path: str
+    detection_image_path: Optional[str] = None
+    modified_image_path: Optional[str] = None
+
+    class Config:
+        from_attributes = True
+
+
 class CardListDetailResponse(BaseModel):
-    """为新的卡牌列表接口定义的响应模型 (主键为 id)"""
+    """为新的卡牌列表接口定义的响应模型"""
     id: int
     card_name: Optional[str] = None
+    card_type: str
+    detection_score: Optional[float] = None
+    modified_score: Optional[float] = None
+    is_edited: bool
     created_at: datetime
     updated_at: datetime
+    images: List[CardImageInListResponse] = []
 
     class Config:
         from_attributes = True