Ver Fonte

修改数据库设计

AnlaAnla há 1 mês atrás
pai
commit
b2aac098c8
5 ficheiros alterados com 140 adições e 265 exclusões
  1. 52 123
      app/api/cards.py
  2. 53 49
      app/api/images.py
  3. 22 65
      app/core/database_loader.py
  4. 1 1
      app/main.py
  5. 12 27
      app/utils/scheme.py

+ 52 - 123
app/api/cards.py

@@ -1,5 +1,5 @@
-import os
 from datetime import datetime
+import os
 from typing import Optional, List
 from fastapi import APIRouter, Depends, HTTPException, Query
 from mysql.connector.pooling import PooledMySQLConnection
@@ -26,15 +26,15 @@ def create_card(
         query = f"INSERT INTO {settings.DB_CARD_TABLE_NAME} (card_name) VALUES (%s)"
         cursor.execute(query, (card_name,))
         db_conn.commit()
-        new_card_id = cursor.lastrowid
-        logger.info(f"新卡牌已创建, ID: {new_card_id}")
+        new_id = cursor.lastrowid
+        logger.info(f"新卡牌已创建, ID: {new_id}")
 
         # 返回刚创建的空卡牌信息
         return CardDetailResponse(
-            card_id=new_card_id,
+            id=new_id,
             card_name=card_name,
-            created_at=datetime.now(),  # 模拟值
-            updated_at=datetime.now(),  # 模拟值
+            created_at=datetime.now(),
+            updated_at=datetime.now(),
             images=[]
         )
     except Exception as e:
@@ -46,36 +46,25 @@ def create_card(
             cursor.close()
 
 
-@router.get("/query/{card_id}", response_model=CardDetailResponse, summary="获取指定卡牌的详细信息")
-def get_card_details(card_id: int, db_conn: PooledMySQLConnection = db_dependency):
+@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)  # 使用字典游标方便映射
+        cursor = db_conn.cursor(dictionary=True)
 
         # 1. 获取卡牌信息
-        query_card = f"SELECT * FROM {settings.DB_CARD_TABLE_NAME} WHERE card_id = %s"
-        cursor.execute(query_card, (card_id,))
+        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为 {card_id} 的卡牌未找到。")
-
-        # 2. 获取所有关联的图片ID
-        image_ids = [
-            card_data['front_face_id'], card_data['back_face_id'],
-            card_data['front_edge_id'], card_data['back_edge_id']
-        ]
-        # 过滤掉 NULL 值
-        valid_image_ids = [img_id for img_id in image_ids if img_id is not None]
-
-        images = []
-        if valid_image_ids:
-            # 使用 IN 子句一次性查询所有图片
-            format_strings = ','.join(['%s'] * len(valid_image_ids))
-            query_images = f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_id IN ({format_strings})"
-            cursor.execute(query_images, tuple(valid_image_ids))
-            image_records = cursor.fetchall()
-            images = [CardImageResponse.model_validate(row) for row in image_records]
+            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]
 
         # 组合成最终响应
         card_response = CardDetailResponse.model_validate(card_data)
@@ -83,7 +72,7 @@ def get_card_details(card_id: int, db_conn: PooledMySQLConnection = db_dependenc
         return card_response
 
     except Exception as e:
-        logger.error(f"查询卡牌详情失败 ({card_id}): {e}")
+        logger.error(f"查询卡牌详情失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库查询失败。")
     finally:
@@ -100,81 +89,33 @@ def list_cards_detailed(
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
-    获取卡牌的详细列表,支持按 card_id 范围筛选,并返回每张卡牌关联的图片ID和名称
+    获取卡牌的基础信息列表,支持按 id 范围筛选和分页
     """
     cursor = None
     try:
         cursor = db_conn.cursor(dictionary=True)
 
-        # 基础查询语句,使用 LEFT JOIN 连接四次 images 表
-        # 每次连接都用一个别名 (img_ff, img_bf, ...) 来区分
-        base_query = f"""
-            SELECT
-                c.card_id,
-                c.card_name,
-                c.created_at,
-                c.updated_at,
-                img_ff.image_id AS front_face_id,
-                img_ff.image_name AS front_face_name,
-                img_bf.image_id AS back_face_id,
-                img_bf.image_name AS back_face_name,
-                img_fe.image_id AS front_edge_id,
-                img_fe.image_name AS front_edge_name,
-                img_be.image_id AS back_edge_id,
-                img_be.image_name AS back_edge_name
-            FROM
-                {settings.DB_CARD_TABLE_NAME} AS c
-            LEFT JOIN {settings.DB_IMAGE_TABLE_NAME} AS img_ff ON c.front_face_id = img_ff.image_id
-            LEFT JOIN {settings.DB_IMAGE_TABLE_NAME} AS img_bf ON c.back_face_id = img_bf.image_id
-            LEFT JOIN {settings.DB_IMAGE_TABLE_NAME} AS img_fe ON c.front_edge_id = img_fe.image_id
-            LEFT JOIN {settings.DB_IMAGE_TABLE_NAME} AS img_be ON c.back_edge_id = img_be.image_id
-        """
-
-        # 动态构建 WHERE 条件
+        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("c.card_id >= %s")
+            conditions.append("id >= %s")
             params.append(start_id)
         if end_id is not None:
-            conditions.append("c.card_id <= %s")
+            conditions.append("id <= %s")
             params.append(end_id)
 
         if conditions:
             base_query += " WHERE " + " AND ".join(conditions)
 
-        # 添加排序和分页
-        base_query += " ORDER BY c.card_id DESC LIMIT %s OFFSET %s"
+        base_query += " ORDER BY id DESC LIMIT %s OFFSET %s"
         params.extend([limit, skip])
 
         cursor.execute(base_query, tuple(params))
-        sql_results = cursor.fetchall()
-
-        # 手动将扁平的SQL结果映射到嵌套的Pydantic模型
-        response_list = []
-        for row in sql_results:
-            card_data = {
-                "card_id": row["card_id"],
-                "card_name": row["card_name"],
-                "created_at": row["created_at"],
-                "updated_at": row["updated_at"],
-            }
-            # 检查并组装 front_face 数据
-            if row["front_face_id"]:
-                card_data["front_face"] = {"image_id": row["front_face_id"], "image_name": row["front_face_name"]}
-            # 检查并组装 back_face 数据
-            if row["back_face_id"]:
-                card_data["back_face"] = {"image_id": row["back_face_id"], "image_name": row["back_face_name"]}
-            # 检查并组装 front_edge 数据
-            if row["front_edge_id"]:
-                card_data["front_edge"] = {"image_id": row["front_edge_id"], "image_name": row["front_edge_name"]}
-            # 检查并组装 back_edge 数据
-            if row["back_edge_id"]:
-                card_data["back_edge"] = {"image_id": row["back_edge_id"], "image_name": row["back_edge_name"]}
-
-            response_list.append(CardListDetailResponse.model_validate(card_data))
-
-        return response_list
+        results = cursor.fetchall()
+
+        return [CardListDetailResponse.model_validate(row) for row in results]
 
     except Exception as e:
         logger.error(f"查询卡牌列表失败: {e}")
@@ -184,56 +125,44 @@ def list_cards_detailed(
             cursor.close()
 
 
-@router.delete("/delete/{card_id}", status_code=200, summary="删除卡牌及其所有关联图片")
-def delete_card(card_id: int, db_conn: PooledMySQLConnection = db_dependency):
+@router.delete("/delete/{id}", status_code=200, summary="删除卡牌及其所有关联图片")
+def delete_card(id: int, db_conn: PooledMySQLConnection = db_dependency):
     """
-    删除一张卡牌。由于外键约束ON DELETE SET NULL,仅删除卡牌记录。
-    如果需要同时删除图片,需要先查询图片并手动删除。
-    我们将实现级联删除图片文件。
+    删除一张卡牌及其所有关联的图片记录和物理文件。
+    利用了数据库的 ON DELETE CASCADE 特性。
     """
     cursor = None
     try:
         cursor = db_conn.cursor()
 
-        # 1. 查询所有关联图片的路径
-        query_paths = f"""
-            SELECT i.image_path
-            FROM {settings.DB_CARD_TABLE_NAME} c
-            JOIN {settings.DB_IMAGE_TABLE_NAME} i ON i.image_id IN 
-                (c.front_face_id, c.back_face_id, c.front_edge_id, c.back_edge_id)
-            WHERE c.card_id = %s
-        """
-        cursor.execute(query_paths, (card_id,))
-        image_paths = [row[0] for row in cursor.fetchall()]
-
-        # 2. 删除卡牌记录 (这将触发删除图片记录,因为我们下面的逻辑会删除图片)
-        # 注意: 如果直接删除 card, 外键设为 SET NULL, 图片记录不会被删。所以我们先删图片,再删card
-
-        # 3. 删除图片记录
-        if image_paths:
-            format_strings = ','.join(['%s'] * len(image_paths))
-            query_delete_images = f"DELETE FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_path IN ({format_strings})"
-            cursor.execute(query_delete_images, tuple(image_paths))
-
-        # 4. 删除卡牌记录
-        query_delete_card = f"DELETE FROM {settings.DB_CARD_TABLE_NAME} WHERE card_id = %s"
-        cursor.execute(query_delete_card, (card_id,))
+        # 1. 查询所有关联图片的物理文件路径,以便稍后删除
+        query_paths = f"SELECT image_path FROM {settings.DB_IMAGE_TABLE_NAME} WHERE card_id = %s"
+        cursor.execute(query_paths, (id,))
+        image_paths_to_delete = [row[0] for row in cursor.fetchall()]
+
+        # 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为 {card_id} 的卡牌未找到。")
+            raise HTTPException(status_code=404, detail=f"ID为 {id} 的卡牌未找到。")
 
-        # 5. 删除物理文件
-        for path in image_paths:
+        # 3. 删除物理文件
+        for path in image_paths_to_delete:
             if os.path.exists(path):
-                os.remove(path)
-                logger.info(f"图片文件已删除: {path}")
+                try:
+                    os.remove(path)
+                    logger.info(f"图片文件已删除: {path}")
+                except OSError as e:
+                    logger.error(f"删除文件失败 {path}: {e}")
 
         db_conn.commit()
-        logger.info(f"ID {card_id} 的卡牌和关联文件已成功删除。")
-        return {"message": f"成功删除卡牌 ID {card_id} 及其所有关联数据"}
+        logger.info(f"ID {id} 的卡牌和关联数据已成功删除。")
+        return {"message": f"成功删除卡牌 ID {id} 及其所有关联数据"}
+
     except Exception as e:
         db_conn.rollback()
-        logger.error(f"删除卡牌失败 ({card_id}): {e}")
+        logger.error(f"删除卡牌失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="删除卡牌失败。")
     finally:

+ 53 - 49
app/api/images.py

@@ -6,6 +6,7 @@ from enum import Enum
 from fastapi import APIRouter, File, UploadFile, Depends, HTTPException, Form, Path
 from fastapi.responses import JSONResponse, FileResponse
 from mysql.connector.pooling import PooledMySQLConnection
+from mysql.connector import IntegrityError
 
 from app.core.config import settings
 from app.core.logger import get_logger
@@ -35,15 +36,14 @@ async def upload_image_for_card(
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
-    上传一张图片,将其存入 card_images 表,并更新 cards 表中对应的外键字段
-    这是一个事务性操作。
+    上传一张图片,将其作为一条新记录存入 card_images 表。
+    这是一个事务性操作,并会检查是否存在重复的 (card_id, image_type) 组合
     """
     try:
         detection_json = json.loads(json_data_str)
     except json.JSONDecodeError:
         raise HTTPException(status_code=400, detail="JSON格式无效。")
 
-    # 保存图片文件
     file_extension = os.path.splitext(image.filename)[1]
     unique_filename = f"{uuid.uuid4()}{file_extension}"
     image_path = settings.DATA_DIR / unique_filename
@@ -58,40 +58,45 @@ async def upload_image_for_card(
     try:
         cursor = db_conn.cursor()
 
-        # 1. 插入图片记录到 card_images
+        # 检查 card_id 是否存在
+        cursor.execute(f"SELECT id FROM {settings.DB_CARD_TABLE_NAME} WHERE id = %s", (card_id,))
+        if not cursor.fetchone():
+            raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌不存在。")
+
         query_insert_image = (
-            f"INSERT INTO {settings.DB_IMAGE_TABLE_NAME} (image_name, image_path, detection_json) "
-            "VALUES (%s, %s, %s)"
-        )
-        params = (image_name, str(image_path), json.dumps(detection_json, ensure_ascii=False))
-        cursor.execute(query_insert_image, params)
-        new_image_id = cursor.lastrowid
-
-        # 2. 更新 cards 表,将新图片ID设置到对应字段
-        # image_type.value 的值是 "front_face", "back_face" 等
-        column_to_update = f"{image_type.value}_id"
-        query_update_card = (
-            f"UPDATE {settings.DB_CARD_TABLE_NAME} SET {column_to_update} = %s "
-            f"WHERE card_id = %s"
+            f"INSERT INTO {settings.DB_IMAGE_TABLE_NAME} "
+            "(card_id, image_type, image_name, image_path, detection_json) "
+            "VALUES (%s, %s, %s, %s, %s)"
         )
-        cursor.execute(query_update_card, (new_image_id, card_id))
+        params = (
+        card_id, image_type.value, image_name, str(image_path), json.dumps(detection_json, ensure_ascii=False))
 
-        if cursor.rowcount == 0:
-            raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到,操作已回滚。")
+        cursor.execute(query_insert_image, params)
+        new_id = cursor.lastrowid
 
         db_conn.commit()
-        logger.info(f"图片 {new_image_id} 已成功关联到卡牌 {card_id} 的 {image_type.value}。")
+        logger.info(f"图片 {new_id} 已成功关联到卡牌 {card_id},类型为 {image_type.value}。")
 
-        # 查询并返回完整的图片记录
-        cursor.execute(f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_id = %s", (new_image_id,))
+        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]
         return CardImageResponse.model_validate(dict(zip(columns, new_image_data)))
 
+    except IntegrityError as e:
+        db_conn.rollback()
+        if os.path.exists(image_path): os.remove(image_path)
+        if e.errno == 1062:
+            raise HTTPException(
+                status_code=409,
+                detail=f"卡牌ID {card_id} 已存在类型为 '{image_type.value}' 的图片,请勿重复添加。"
+            )
+        logger.error(f"数据库完整性错误: {e}")
+        raise HTTPException(status_code=500, detail="数据库操作失败。")
+
     except Exception as e:
         db_conn.rollback()
         if os.path.exists(image_path):
-            os.remove(image_path)  # 如果数据库操作失败,删除已上传的文件
+            os.remove(image_path)
         logger.error(f"关联图片到卡牌失败: {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库操作失败,所有更改已回滚。")
@@ -100,21 +105,21 @@ async def upload_image_for_card(
             cursor.close()
 
 
-@router.get("/jsons/{image_id}", response_model=ImageJsonPairResponse, summary="获取图片的原始和修改后JSON")
-def get_image_jsons(image_id: int, db_conn: PooledMySQLConnection = db_dependency):
+@router.get("/jsons/{id}", response_model=ImageJsonPairResponse, summary="获取图片的原始和修改后JSON")
+def get_image_jsons(id: int, db_conn: PooledMySQLConnection = db_dependency):
     """获取指定图片ID的 detection_json 和 modified_json。"""
     cursor = None
     try:
         cursor = db_conn.cursor(dictionary=True)
-        query = f"SELECT image_id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_id = %s"
-        cursor.execute(query, (image_id,))
+        query = f"SELECT id, detection_json, modified_json FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s"
+        cursor.execute(query, (id,))
         result = cursor.fetchone()
         if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的图片未找到。")
+            raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
 
         return ImageJsonPairResponse.model_validate(result)
     except Exception as e:
-        logger.error(f"获取JSON对失败 ({image_id}): {e}")
+        logger.error(f"获取JSON对失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库查询失败。")
     finally:
@@ -122,30 +127,29 @@ def get_image_jsons(image_id: int, db_conn: PooledMySQLConnection = db_dependenc
             cursor.close()
 
 
-@router.put("/update/json/{image_id}", status_code=200, summary="7. 修改图片的 modified_json")
+@router.put("/update/json/{id}", status_code=200, summary="修改图片的 modified_json")
 def update_image_modified_json(
-        image_id: int,
+        id: int,
         new_json_data: Dict[str, Any],
         db_conn: PooledMySQLConnection = db_dependency
 ):
-    """根据 image_id 更新 modified_json 字段。updated_at 会自动更新。"""
+    """根据 id 更新 modified_json 字段。updated_at 会自动更新。"""
     cursor = None
     try:
         cursor = db_conn.cursor()
         new_json_str = json.dumps(new_json_data, ensure_ascii=False)
-        # updated_at 会由数据库自动更新
-        query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET modified_json = %s WHERE image_id = %s"
-        cursor.execute(query, (new_json_str, image_id))
+        query = f"UPDATE {settings.DB_IMAGE_TABLE_NAME} SET modified_json = %s WHERE id = %s"
+        cursor.execute(query, (new_json_str, id))
 
         if cursor.rowcount == 0:
-            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的图片未找到。")
+            raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
 
         db_conn.commit()
-        logger.info(f"图片ID {image_id} 的 modified_json 已更新。")
-        return {"message": f"成功更新图片ID {image_id} 的JSON数据"}
+        logger.info(f"图片ID {id} 的 modified_json 已更新。")
+        return {"message": f"成功更新图片ID {id} 的JSON数据"}
     except Exception as e:
         db_conn.rollback()
-        logger.error(f"更新JSON失败 ({image_id}): {e}")
+        logger.error(f"更新JSON失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="更新JSON数据失败。")
     finally:
@@ -153,29 +157,29 @@ def update_image_modified_json(
             cursor.close()
 
 
-@router.get("/image_file/{image_id}", summary="获取指定ID的图片文件")
-def get_image_file(image_id: int, db_conn: PooledMySQLConnection = db_dependency):
-    """根据 image_id 查找记录,并返回对应的图片文件。"""
+@router.get("/image_file/{id}", summary="获取指定ID的图片文件")
+def get_image_file(id: int, db_conn: PooledMySQLConnection = db_dependency):
+    """根据 id 查找记录,并返回对应的图片文件。"""
     cursor = None
     try:
         cursor = db_conn.cursor()
-        query = f"SELECT image_path FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_id = %s"
-        cursor.execute(query, (image_id,))
+        query = f"SELECT image_path FROM {settings.DB_IMAGE_TABLE_NAME} WHERE id = %s"
+        cursor.execute(query, (id,))
         result = cursor.fetchone()
 
         if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的记录未找到。")
+            raise HTTPException(status_code=404, detail=f"ID为 {id} 的记录未找到。")
 
         image_path = result[0]
         if not os.path.exists(image_path):
-            logger.error(f"文件在服务器上未找到: {image_path} (数据库ID: {image_id})")
+            logger.error(f"文件在服务器上未找到: {image_path} (数据库ID: {id})")
             raise HTTPException(status_code=404, detail="图片文件在服务器上不存在。")
 
         return FileResponse(image_path)
     except Exception as e:
-        logger.error(f"获取图片失败 ({image_id}): {e}")
+        logger.error(f"获取图片失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="获取图片文件失败。")
     finally:
         if cursor:
-            cursor.close()
+            cursor.close()

+ 22 - 65
app/core/database_loader.py

@@ -8,20 +8,11 @@ logger = get_logger(__name__)
 # 全局的连接池
 db_connection_pool = None
 
-import mysql.connector
-from mysql.connector import errorcode
-from .config import settings
-from app.core.logger import get_logger
-
-logger = get_logger(__name__)
-
-# 全局的连接池
-db_connection_pool = None
-
 
 def init_database():
     """
     初始化数据库:如果数据库或表不存在,则创建它们。
+    主键统一为 'id'。
     """
     logger.info("--- 开始初始化数据库 ---")
 
@@ -33,71 +24,38 @@ def init_database():
         logger.info(f"数据库 '{settings.DB_NAME}' 已准备就绪。")
         cnx.database = settings.DB_NAME
 
-        # 1. 创建 card_images 表 (因为它被 cards 表引用)
+        # 1. 创建 cards 表 (主键为 id)
+        cards_table = (
+            f"CREATE TABLE IF NOT EXISTS `{settings.DB_CARD_TABLE_NAME}` ("
+            "  `id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `card_name` VARCHAR(255) NULL COMMENT '卡牌的通用名称',"
+            "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
+            "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
+            ") ENGINE=InnoDB COMMENT='存储实体卡牌的核心信息'"
+        )
+        cursor.execute(cards_table)
+        logger.info(f"数据表 '{settings.DB_CARD_TABLE_NAME}' 已准备就绪。")
+
+        # 2. 创建 card_images 表 (主键为 id)
         card_images_table = (
             f"CREATE TABLE IF NOT EXISTS `{settings.DB_IMAGE_TABLE_NAME}` ("
-            "  `image_id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `card_id` INT NOT NULL COMMENT '关联的卡牌ID',"
+            "  `image_type` VARCHAR(50) NOT NULL COMMENT '图片类型 (front_face, back_face, etc)',"
             "  `image_name` VARCHAR(255) NULL,"
             "  `image_path` VARCHAR(512) NOT NULL,"
             "  `detection_json` JSON NOT NULL COMMENT '原始检测JSON数据',"
             "  `modified_json` JSON NULL COMMENT '修改后的JSON数据',"
             "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
-            "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'JSON数据最后修改时间'"
+            "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
+            # 外键现在引用 cards(id)
+            f"  FOREIGN KEY (`card_id`) REFERENCES `{settings.DB_CARD_TABLE_NAME}`(`id`) ON DELETE CASCADE,"
+            "  UNIQUE KEY `uk_card_image_type` (`card_id`, `image_type`)"
             ") ENGINE=InnoDB COMMENT='存储所有卡牌的图片及检测数据'"
         )
         cursor.execute(card_images_table)
         logger.info(f"数据表 '{settings.DB_IMAGE_TABLE_NAME}' 已准备就绪。")
 
-        # 2. 创建 cards 表 (不带外键)
-        cards_table = (
-            f"CREATE TABLE IF NOT EXISTS `{settings.DB_CARD_TABLE_NAME}` ("
-            "  `card_id` INT AUTO_INCREMENT PRIMARY KEY,"
-            "  `card_name` VARCHAR(255) NULL COMMENT '卡牌的通用名称',"
-            "  `front_face_id` INT NULL,"
-            "  `back_face_id` INT NULL,"
-            "  `front_edge_id` INT NULL,"
-            "  `back_edge_id` INT NULL,"
-            "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
-            "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
-            ") ENGINE=InnoDB COMMENT='存储实体卡牌及其核心图片引用'"
-        )
-        cursor.execute(cards_table)
-        logger.info(f"数据表 '{settings.DB_CARD_TABLE_NAME}' 已准备就绪。")
-
-        # 3. 为 cards 表添加外键约束 (如果它们不存在)
-        # 这是一个更健壮的方法,可以重复运行而不会出错
-        foreign_keys = {
-            "fk_front_face": "front_face_id",
-            "fk_back_face": "back_face_id",
-            "fk_front_edge": "front_edge_id",
-            "fk_back_edge": "back_edge_id"
-        }
-        for fk_name, col_name in foreign_keys.items():
-            try:
-                # 检查外键是否存在
-                cursor.execute(f"""
-                    SELECT COUNT(*) FROM information_schema.KEY_COLUMN_USAGE
-                    WHERE TABLE_SCHEMA = '{settings.DB_NAME}'
-                    AND TABLE_NAME = '{settings.DB_CARD_TABLE_NAME}'
-                    AND CONSTRAINT_NAME = '{fk_name}';
-                """)
-                if cursor.fetchone()[0] == 0:
-                    alter_query = (
-                        f"ALTER TABLE `{settings.DB_CARD_TABLE_NAME}` "
-                        f"ADD CONSTRAINT `{fk_name}` "
-                        f"FOREIGN KEY (`{col_name}`) "
-                        f"REFERENCES `{settings.DB_IMAGE_TABLE_NAME}`(`image_id`) "
-                        "ON DELETE SET NULL"
-                    )
-                    cursor.execute(alter_query)
-                    logger.info(f"为表 '{settings.DB_CARD_TABLE_NAME}' 添加了外键 '{fk_name}'。")
-            except mysql.connector.Error as err:
-                # 1060: Duplicate column name; 1061: Duplicate key name; 1826: Duplicate foreign key
-                if err.errno in [1060, 1061, 1826]:
-                    logger.warning(f"外键 '{fk_name}' 可能已存在。跳过。")
-                else:
-                    raise err
-
     except mysql.connector.Error as err:
         logger.error(f"数据库初始化失败: {err}")
         exit(1)
@@ -120,7 +78,7 @@ def load_database_pool():
         try:
             db_connection_pool = mysql.connector.pooling.MySQLConnectionPool(
                 pool_name="mypool",
-                pool_size=5,  # 池中保持的连接数
+                pool_size=5,
                 **settings.DATABASE_CONFIG_WITH_DB
             )
             logger.info("--- 数据库连接池创建成功 ---")
@@ -152,7 +110,6 @@ def get_db_connection():
         yield db_conn
     except mysql.connector.Error as err:
         logger.error(f"获取数据库连接失败: {err}")
-        # 这里可以根据需要抛出HTTPException
     finally:
         if db_conn and db_conn.is_connected():
             db_conn.close()

+ 1 - 1
app/main.py

@@ -35,4 +35,4 @@ app.add_middleware(
 )
 
 app.include_router(cards_router.router, prefix=f"{settings.API_PREFIX}/cards", tags=["Cards"])
-app.include_router(images_router.router, prefix=f"{settings.API_PREFIX}/images", tags=["Images"])
+app.include_router(images_router.router, prefix=f"{settings.API_PREFIX}/images", tags=["Images"])

+ 12 - 27
app/utils/scheme.py

@@ -7,8 +7,10 @@ from pydantic import BaseModel, field_validator
 # --- Pydantic 数据模型 ---
 
 class CardImageResponse(BaseModel):
-    """用于API响应的图片数据模型"""
-    image_id: int
+    """用于API响应的图片数据模型 (主键为 id)"""
+    id: int  # 原 image_id
+    card_id: int
+    image_type: str
     image_name: Optional[str] = None
     image_path: str
     detection_json: Dict[str, Any]
@@ -17,7 +19,7 @@ class CardImageResponse(BaseModel):
     updated_at: datetime
 
     class Config:
-        from_attributes = True  # 兼容 ORM 模式
+        from_attributes = True
 
     @field_validator('detection_json', 'modified_json', mode='before')
     @classmethod
@@ -33,20 +35,20 @@ class CardImageResponse(BaseModel):
 
 
 class CardDetailResponse(BaseModel):
-    """用于响应单个卡牌详细信息的模型,包含其所有图片信息"""
-    card_id: int
+    """用于响应单个卡牌详细信息的模型 (主键为 id)"""
+    id: int  # 原 card_id
     card_name: Optional[str] = None
     created_at: datetime
     updated_at: datetime
-    images: List[CardImageResponse] = []  # 嵌套的图片列表
+    images: List[CardImageResponse] = []
 
     class Config:
         from_attributes = True
 
 
 class ImageJsonPairResponse(BaseModel):
-    """用于获取单个图片两个JSON数据的响应模型"""
-    image_id: int
+    """用于获取单个图片两个JSON数据的响应模型 (主键为 id)"""
+    id: int  # 原 image_id
     detection_json: Dict[str, Any]
     modified_json: Optional[Dict[str, Any]] = None
 
@@ -56,10 +58,6 @@ class ImageJsonPairResponse(BaseModel):
     @field_validator('detection_json', 'modified_json', mode='before')
     @classmethod
     def parse_json_string(cls, v):
-        """
-        这个验证器会在Pydantic进行类型检查之前运行。
-        它负责将从数据库取出的JSON字符串转换为Python字典。
-        """
         if v is None:
             return None
         if isinstance(v, str):
@@ -70,25 +68,12 @@ class ImageJsonPairResponse(BaseModel):
         return v
 
 
-# 用于 card list的结构
-class CardImageInfo(BaseModel):
-    """用于在卡牌列表中展示的单张图片摘要信息"""
-    image_id: int
-    image_name: Optional[str] = None
-
-
 class CardListDetailResponse(BaseModel):
-    """为新的卡牌列表接口定义的详细响应模型"""
-    card_id: int
+    """为新的卡牌列表接口定义的响应模型 (主键为 id)"""
+    id: int  # 原 card_id
     card_name: Optional[str] = None
     created_at: datetime
     updated_at: datetime
 
-    # 使用嵌套模型来表示每张图片的信息
-    front_face: Optional[CardImageInfo] = None
-    back_face: Optional[CardImageInfo] = None
-    front_edge: Optional[CardImageInfo] = None
-    back_edge: Optional[CardImageInfo] = None
-
     class Config:
         from_attributes = True