فهرست منبع

修改数据库设计

AnlaAnla 1 ماه پیش
والد
کامیت
b2aac098c8
5فایلهای تغییر یافته به همراه140 افزوده شده و 265 حذف شده
  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
 from datetime import datetime
+import os
 from typing import Optional, List
 from typing import Optional, List
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from mysql.connector.pooling import PooledMySQLConnection
 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)"
         query = f"INSERT INTO {settings.DB_CARD_TABLE_NAME} (card_name) VALUES (%s)"
         cursor.execute(query, (card_name,))
         cursor.execute(query, (card_name,))
         db_conn.commit()
         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(
         return CardDetailResponse(
-            card_id=new_card_id,
+            id=new_id,
             card_name=card_name,
             card_name=card_name,
-            created_at=datetime.now(),  # 模拟值
-            updated_at=datetime.now(),  # 模拟值
+            created_at=datetime.now(),
+            updated_at=datetime.now(),
             images=[]
             images=[]
         )
         )
     except Exception as e:
     except Exception as e:
@@ -46,36 +46,25 @@ def create_card(
             cursor.close()
             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
     cursor = None
     try:
     try:
-        cursor = db_conn.cursor(dictionary=True)  # 使用字典游标方便映射
+        cursor = db_conn.cursor(dictionary=True)
 
 
         # 1. 获取卡牌信息
         # 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()
         card_data = cursor.fetchone()
         if not card_data:
         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)
         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
         return card_response
 
 
     except Exception as e:
     except Exception as e:
-        logger.error(f"查询卡牌详情失败 ({card_id}): {e}")
+        logger.error(f"查询卡牌详情失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库查询失败。")
         raise HTTPException(status_code=500, detail="数据库查询失败。")
     finally:
     finally:
@@ -100,81 +89,33 @@ def list_cards_detailed(
         db_conn: PooledMySQLConnection = db_dependency
         db_conn: PooledMySQLConnection = db_dependency
 ):
 ):
     """
     """
-    获取卡牌的详细列表,支持按 card_id 范围筛选,并返回每张卡牌关联的图片ID和名称
+    获取卡牌的基础信息列表,支持按 id 范围筛选和分页
     """
     """
     cursor = None
     cursor = None
     try:
     try:
         cursor = db_conn.cursor(dictionary=True)
         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 = []
         conditions = []
         params = []
         params = []
         if start_id is not None:
         if start_id is not None:
-            conditions.append("c.card_id >= %s")
+            conditions.append("id >= %s")
             params.append(start_id)
             params.append(start_id)
         if end_id is not None:
         if end_id is not None:
-            conditions.append("c.card_id <= %s")
+            conditions.append("id <= %s")
             params.append(end_id)
             params.append(end_id)
 
 
         if conditions:
         if conditions:
             base_query += " WHERE " + " AND ".join(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])
         params.extend([limit, skip])
 
 
         cursor.execute(base_query, tuple(params))
         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:
     except Exception as e:
         logger.error(f"查询卡牌列表失败: {e}")
         logger.error(f"查询卡牌列表失败: {e}")
@@ -184,56 +125,44 @@ def list_cards_detailed(
             cursor.close()
             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
     cursor = None
     try:
     try:
         cursor = db_conn.cursor()
         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:
         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):
             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()
         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:
     except Exception as e:
         db_conn.rollback()
         db_conn.rollback()
-        logger.error(f"删除卡牌失败 ({card_id}): {e}")
+        logger.error(f"删除卡牌失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="删除卡牌失败。")
         raise HTTPException(status_code=500, detail="删除卡牌失败。")
     finally:
     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 import APIRouter, File, UploadFile, Depends, HTTPException, Form, Path
 from fastapi.responses import JSONResponse, FileResponse
 from fastapi.responses import JSONResponse, FileResponse
 from mysql.connector.pooling import PooledMySQLConnection
 from mysql.connector.pooling import PooledMySQLConnection
+from mysql.connector import IntegrityError
 
 
 from app.core.config import settings
 from app.core.config import settings
 from app.core.logger import get_logger
 from app.core.logger import get_logger
@@ -35,15 +36,14 @@ async def upload_image_for_card(
         db_conn: PooledMySQLConnection = db_dependency
         db_conn: PooledMySQLConnection = db_dependency
 ):
 ):
     """
     """
-    上传一张图片,将其存入 card_images 表,并更新 cards 表中对应的外键字段
-    这是一个事务性操作。
+    上传一张图片,将其作为一条新记录存入 card_images 表。
+    这是一个事务性操作,并会检查是否存在重复的 (card_id, image_type) 组合
     """
     """
     try:
     try:
         detection_json = json.loads(json_data_str)
         detection_json = json.loads(json_data_str)
     except json.JSONDecodeError:
     except json.JSONDecodeError:
         raise HTTPException(status_code=400, detail="JSON格式无效。")
         raise HTTPException(status_code=400, detail="JSON格式无效。")
 
 
-    # 保存图片文件
     file_extension = os.path.splitext(image.filename)[1]
     file_extension = os.path.splitext(image.filename)[1]
     unique_filename = f"{uuid.uuid4()}{file_extension}"
     unique_filename = f"{uuid.uuid4()}{file_extension}"
     image_path = settings.DATA_DIR / unique_filename
     image_path = settings.DATA_DIR / unique_filename
@@ -58,40 +58,45 @@ async def upload_image_for_card(
     try:
     try:
         cursor = db_conn.cursor()
         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 = (
         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()
         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()
         new_image_data = cursor.fetchone()
         columns = [desc[0] for desc in cursor.description]
         columns = [desc[0] for desc in cursor.description]
         return CardImageResponse.model_validate(dict(zip(columns, new_image_data)))
         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:
     except Exception as e:
         db_conn.rollback()
         db_conn.rollback()
         if os.path.exists(image_path):
         if os.path.exists(image_path):
-            os.remove(image_path)  # 如果数据库操作失败,删除已上传的文件
+            os.remove(image_path)
         logger.error(f"关联图片到卡牌失败: {e}")
         logger.error(f"关联图片到卡牌失败: {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库操作失败,所有更改已回滚。")
         raise HTTPException(status_code=500, detail="数据库操作失败,所有更改已回滚。")
@@ -100,21 +105,21 @@ async def upload_image_for_card(
             cursor.close()
             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。"""
     """获取指定图片ID的 detection_json 和 modified_json。"""
     cursor = None
     cursor = None
     try:
     try:
         cursor = db_conn.cursor(dictionary=True)
         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()
         result = cursor.fetchone()
         if not result:
         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)
         return ImageJsonPairResponse.model_validate(result)
     except Exception as e:
     except Exception as e:
-        logger.error(f"获取JSON对失败 ({image_id}): {e}")
+        logger.error(f"获取JSON对失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="数据库查询失败。")
         raise HTTPException(status_code=500, detail="数据库查询失败。")
     finally:
     finally:
@@ -122,30 +127,29 @@ def get_image_jsons(image_id: int, db_conn: PooledMySQLConnection = db_dependenc
             cursor.close()
             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(
 def update_image_modified_json(
-        image_id: int,
+        id: int,
         new_json_data: Dict[str, Any],
         new_json_data: Dict[str, Any],
         db_conn: PooledMySQLConnection = db_dependency
         db_conn: PooledMySQLConnection = db_dependency
 ):
 ):
-    """根据 image_id 更新 modified_json 字段。updated_at 会自动更新。"""
+    """根据 id 更新 modified_json 字段。updated_at 会自动更新。"""
     cursor = None
     cursor = None
     try:
     try:
         cursor = db_conn.cursor()
         cursor = db_conn.cursor()
         new_json_str = json.dumps(new_json_data, ensure_ascii=False)
         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:
         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()
         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:
     except Exception as e:
         db_conn.rollback()
         db_conn.rollback()
-        logger.error(f"更新JSON失败 ({image_id}): {e}")
+        logger.error(f"更新JSON失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="更新JSON数据失败。")
         raise HTTPException(status_code=500, detail="更新JSON数据失败。")
     finally:
     finally:
@@ -153,29 +157,29 @@ def update_image_modified_json(
             cursor.close()
             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
     cursor = None
     try:
     try:
         cursor = db_conn.cursor()
         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()
         result = cursor.fetchone()
 
 
         if not result:
         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]
         image_path = result[0]
         if not os.path.exists(image_path):
         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="图片文件在服务器上不存在。")
             raise HTTPException(status_code=404, detail="图片文件在服务器上不存在。")
 
 
         return FileResponse(image_path)
         return FileResponse(image_path)
     except Exception as e:
     except Exception as e:
-        logger.error(f"获取图片失败 ({image_id}): {e}")
+        logger.error(f"获取图片失败 ({id}): {e}")
         if isinstance(e, HTTPException): raise e
         if isinstance(e, HTTPException): raise e
         raise HTTPException(status_code=500, detail="获取图片文件失败。")
         raise HTTPException(status_code=500, detail="获取图片文件失败。")
     finally:
     finally:
         if cursor:
         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
 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():
 def init_database():
     """
     """
     初始化数据库:如果数据库或表不存在,则创建它们。
     初始化数据库:如果数据库或表不存在,则创建它们。
+    主键统一为 'id'。
     """
     """
     logger.info("--- 开始初始化数据库 ---")
     logger.info("--- 开始初始化数据库 ---")
 
 
@@ -33,71 +24,38 @@ def init_database():
         logger.info(f"数据库 '{settings.DB_NAME}' 已准备就绪。")
         logger.info(f"数据库 '{settings.DB_NAME}' 已准备就绪。")
         cnx.database = 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 = (
         card_images_table = (
             f"CREATE TABLE IF NOT EXISTS `{settings.DB_IMAGE_TABLE_NAME}` ("
             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_name` VARCHAR(255) NULL,"
             "  `image_path` VARCHAR(512) NOT NULL,"
             "  `image_path` VARCHAR(512) NOT NULL,"
             "  `detection_json` JSON NOT NULL COMMENT '原始检测JSON数据',"
             "  `detection_json` JSON NOT NULL COMMENT '原始检测JSON数据',"
             "  `modified_json` JSON NULL COMMENT '修改后的JSON数据',"
             "  `modified_json` JSON NULL COMMENT '修改后的JSON数据',"
             "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
             "  `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='存储所有卡牌的图片及检测数据'"
             ") ENGINE=InnoDB COMMENT='存储所有卡牌的图片及检测数据'"
         )
         )
         cursor.execute(card_images_table)
         cursor.execute(card_images_table)
         logger.info(f"数据表 '{settings.DB_IMAGE_TABLE_NAME}' 已准备就绪。")
         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:
     except mysql.connector.Error as err:
         logger.error(f"数据库初始化失败: {err}")
         logger.error(f"数据库初始化失败: {err}")
         exit(1)
         exit(1)
@@ -120,7 +78,7 @@ def load_database_pool():
         try:
         try:
             db_connection_pool = mysql.connector.pooling.MySQLConnectionPool(
             db_connection_pool = mysql.connector.pooling.MySQLConnectionPool(
                 pool_name="mypool",
                 pool_name="mypool",
-                pool_size=5,  # 池中保持的连接数
+                pool_size=5,
                 **settings.DATABASE_CONFIG_WITH_DB
                 **settings.DATABASE_CONFIG_WITH_DB
             )
             )
             logger.info("--- 数据库连接池创建成功 ---")
             logger.info("--- 数据库连接池创建成功 ---")
@@ -152,7 +110,6 @@ def get_db_connection():
         yield db_conn
         yield db_conn
     except mysql.connector.Error as err:
     except mysql.connector.Error as err:
         logger.error(f"获取数据库连接失败: {err}")
         logger.error(f"获取数据库连接失败: {err}")
-        # 这里可以根据需要抛出HTTPException
     finally:
     finally:
         if db_conn and db_conn.is_connected():
         if db_conn and db_conn.is_connected():
             db_conn.close()
             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(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 数据模型 ---
 # --- Pydantic 数据模型 ---
 
 
 class CardImageResponse(BaseModel):
 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_name: Optional[str] = None
     image_path: str
     image_path: str
     detection_json: Dict[str, Any]
     detection_json: Dict[str, Any]
@@ -17,7 +19,7 @@ class CardImageResponse(BaseModel):
     updated_at: datetime
     updated_at: datetime
 
 
     class Config:
     class Config:
-        from_attributes = True  # 兼容 ORM 模式
+        from_attributes = True
 
 
     @field_validator('detection_json', 'modified_json', mode='before')
     @field_validator('detection_json', 'modified_json', mode='before')
     @classmethod
     @classmethod
@@ -33,20 +35,20 @@ class CardImageResponse(BaseModel):
 
 
 
 
 class CardDetailResponse(BaseModel):
 class CardDetailResponse(BaseModel):
-    """用于响应单个卡牌详细信息的模型,包含其所有图片信息"""
-    card_id: int
+    """用于响应单个卡牌详细信息的模型 (主键为 id)"""
+    id: int  # 原 card_id
     card_name: Optional[str] = None
     card_name: Optional[str] = None
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
-    images: List[CardImageResponse] = []  # 嵌套的图片列表
+    images: List[CardImageResponse] = []
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
 
 
 class ImageJsonPairResponse(BaseModel):
 class ImageJsonPairResponse(BaseModel):
-    """用于获取单个图片两个JSON数据的响应模型"""
-    image_id: int
+    """用于获取单个图片两个JSON数据的响应模型 (主键为 id)"""
+    id: int  # 原 image_id
     detection_json: Dict[str, Any]
     detection_json: Dict[str, Any]
     modified_json: Optional[Dict[str, Any]] = None
     modified_json: Optional[Dict[str, Any]] = None
 
 
@@ -56,10 +58,6 @@ class ImageJsonPairResponse(BaseModel):
     @field_validator('detection_json', 'modified_json', mode='before')
     @field_validator('detection_json', 'modified_json', mode='before')
     @classmethod
     @classmethod
     def parse_json_string(cls, v):
     def parse_json_string(cls, v):
-        """
-        这个验证器会在Pydantic进行类型检查之前运行。
-        它负责将从数据库取出的JSON字符串转换为Python字典。
-        """
         if v is None:
         if v is None:
             return None
             return None
         if isinstance(v, str):
         if isinstance(v, str):
@@ -70,25 +68,12 @@ class ImageJsonPairResponse(BaseModel):
         return v
         return v
 
 
 
 
-# 用于 card list的结构
-class CardImageInfo(BaseModel):
-    """用于在卡牌列表中展示的单张图片摘要信息"""
-    image_id: int
-    image_name: Optional[str] = None
-
-
 class CardListDetailResponse(BaseModel):
 class CardListDetailResponse(BaseModel):
-    """为新的卡牌列表接口定义的详细响应模型"""
-    card_id: int
+    """为新的卡牌列表接口定义的响应模型 (主键为 id)"""
+    id: int  # 原 card_id
     card_name: Optional[str] = None
     card_name: Optional[str] = None
     created_at: datetime
     created_at: datetime
     updated_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:
     class Config:
         from_attributes = True
         from_attributes = True