Răsfoiți Sursa

大改, 1个卡牌对应4个图

AnlaAnla 1 lună în urmă
părinte
comite
43ef3aac08
10 a modificat fișierele cu 584 adăugiri și 397 ștergeri
  1. 2 0
      .idea/encodings.xml
  2. 1 2
      Config.json
  3. 1 1
      Test/数据库测试.py
  4. 241 0
      app/api/cards.py
  5. 0 333
      app/api/image_data.py
  6. 181 0
      app/api/images.py
  7. 8 7
      app/core/config.py
  8. 74 17
      app/core/database_loader.py
  9. 6 12
      app/main.py
  10. 70 25
      app/utils/scheme.py

+ 2 - 0
.idea/encodings.xml

@@ -2,6 +2,8 @@
 <project version="4">
   <component name="Encoding">
     <file url="file://$PROJECT_DIR$/Test/test01.py" charset="GBK" />
+    <file url="file://$PROJECT_DIR$/app/utils/scheme.py" charset="UTF-8" />
+    <file url="file://$PROJECT_DIR$/run.py" charset="UTF-8" />
     <file url="PROJECT" charset="GBK" />
   </component>
 </project>

+ 1 - 2
Config.json

@@ -4,6 +4,5 @@
     "password": "123456",
     "host": "127.0.0.1"
   },
-  "database_name": "card_score_database",
-  "database_table_name": "image_records"
+  "database_name": "card_score_database"
 }

+ 1 - 1
Test/数据库测试.py

@@ -6,7 +6,7 @@ if __name__ == '__main__':
     cursor = cnx.cursor()
 
 
-    query = f"SELECT IFNULL(img_name, img_result_json) AS img_result_json FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
+    query = f"SELECT IFNULL(img_name, img_result_json) AS img_result_json FROM {settings.DB_IMAGE_TABLE_NAME} WHERE img_id = %s"
     cursor.execute(query, (3,))
     result = cursor.fetchone()
 

+ 241 - 0
app/api/cards.py

@@ -0,0 +1,241 @@
+import os
+from datetime import datetime
+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.scheme import CardDetailResponse, CardImageResponse, CardListDetailResponse
+
+logger = get_logger(__name__)
+router = APIRouter()
+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="卡牌的名称"),
+        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_card_id = cursor.lastrowid
+        logger.info(f"新卡牌已创建, ID: {new_card_id}")
+
+        # 返回刚创建的空卡牌信息
+        return CardDetailResponse(
+            card_id=new_card_id,
+            card_name=card_name,
+            created_at=datetime.now(),  # 模拟值
+            updated_at=datetime.now(),  # 模拟值
+            images=[]
+        )
+    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/{card_id}", response_model=CardDetailResponse, summary="获取指定卡牌的详细信息")
+def get_card_details(card_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 card_id = %s"
+        cursor.execute(query_card, (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]
+
+        # 组合成最终响应
+        card_response = CardDetailResponse.model_validate(card_data)
+        card_response.images = images
+        return card_response
+
+    except Exception as e:
+        logger.error(f"查询卡牌详情失败 ({card_id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="数据库查询失败。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
+@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"),
+        skip: int = Query(0, ge=0, description="分页:跳过的记录数"),
+        limit: int = Query(100, ge=1, le=1000, description="分页:每页的记录数"),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    """
+    获取卡牌的详细列表,支持按 card_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 条件
+        conditions = []
+        params = []
+        if start_id is not None:
+            conditions.append("c.card_id >= %s")
+            params.append(start_id)
+        if end_id is not None:
+            conditions.append("c.card_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"
+        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
+
+    except Exception as e:
+        logger.error(f"查询卡牌列表失败: {e}")
+        raise HTTPException(status_code=500, detail="获取数据列表失败。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
+@router.delete("/delete/{card_id}", status_code=200, summary="删除卡牌及其所有关联图片")
+def delete_card(card_id: int, db_conn: PooledMySQLConnection = db_dependency):
+    """
+    删除一张卡牌。由于外键约束ON DELETE SET NULL,仅删除卡牌记录。
+    如果需要同时删除图片,需要先查询图片并手动删除。
+    我们将实现级联删除图片文件。
+    """
+    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,))
+
+        if cursor.rowcount == 0:
+            raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到。")
+
+        # 5. 删除物理文件
+        for path in image_paths:
+            if os.path.exists(path):
+                os.remove(path)
+                logger.info(f"图片文件已删除: {path}")
+
+        db_conn.commit()
+        logger.info(f"ID {card_id} 的卡牌和关联文件已成功删除。")
+        return {"message": f"成功删除卡牌 ID {card_id} 及其所有关联数据"}
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"删除卡牌失败 ({card_id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="删除卡牌失败。")
+    finally:
+        if cursor:
+            cursor.close()

+ 0 - 333
app/api/image_data.py

@@ -1,333 +0,0 @@
-import os
-import uuid
-import json
-from datetime import date, datetime
-from typing import Optional, Dict, Any, List
-
-from fastapi import APIRouter, File, UploadFile, Depends, HTTPException, Form, Query
-from fastapi.responses import JSONResponse, FileResponse
-from mysql.connector.pooling import PooledMySQLConnection
-
-from app.core.config import settings
-from app.core.logger import get_logger
-from app.utils.scheme import ImageRecordResponse, map_row_to_model
-from app.core.database_loader import get_db_connection
-
-# --- 初始化 ---
-logger = get_logger(__name__)
-router = APIRouter()
-
-# 创建一个依赖项的别名
-db_dependency = Depends(get_db_connection)
-
-
-# --- API 接口实现 ---
-
-#  1: 存储图片和JSON数据
-@router.post("/insert", status_code=201, summary="1. 存储图片和JSON数据")
-async def create_image_data(
-        image: UploadFile = File(..., description="要上传的图片文件"),
-        json_data_str: str = Form(..., description="与图片关联的JSON格式字符串"),
-        img_name: Optional[str] = Form(None, description="图片的可选名称"),
-        db_conn: PooledMySQLConnection = db_dependency
-):
-    """
-    接收图片和JSON数据,存入数据库。
-    - 图片存储在 `Data` 目录。
-    - 记录存入MySQL,`img_id` 自动生成。
-    """
-    try:
-        img_result_json = json.loads(json_data_str)
-    except json.JSONDecodeError:
-        raise HTTPException(status_code=400, detail="`json_data_str` 格式无效。")
-
-    # 生成唯一文件名并保存图片
-    file_extension = os.path.splitext(image.filename)[1]
-    unique_filename = f"{uuid.uuid4()}{file_extension}"
-    image_path = settings.DATA_DIR / unique_filename
-
-    try:
-        with open(image_path, "wb") as buffer:
-            content = await image.read()
-            buffer.write(content)
-        logger.info(f"图片已保存到: {image_path}")
-    except Exception as e:
-        logger.error(f"保存图片失败: {e}")
-        raise HTTPException(status_code=500, detail="无法保存图片文件。")
-
-    # 将记录插入数据库
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        query = (
-            f"INSERT INTO {settings.DB_TABLE_NAME} (img_name, img_path, img_result_json) "
-            "VALUES (%s, %s, %s)"
-        )
-        # 确保存入数据库的是JSON字符串
-        params = (img_name, str(image_path), json.dumps(img_result_json, ensure_ascii=False))
-        cursor.execute(query, params)
-        db_conn.commit()
-        new_id = cursor.lastrowid
-        logger.info(f"新记录已创建, ID: {new_id}")
-        return {"message": "成功存储图片和数据", "img_id": new_id}
-    except Exception as e:
-        db_conn.rollback()
-        logger.error(f"数据库插入失败: {e}")
-        if os.path.exists(image_path):
-            os.remove(image_path)
-        raise HTTPException(status_code=500, detail="数据库插入失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-# 2: 获取数据列表 (带筛选)
-@router.get("/data_list", response_model=List[ImageRecordResponse], summary="2. 获取数据列表 (可筛选)")
-def list_image_records(
-        start_id: Optional[int] = Query(None, description="筛选条件:起始img_id"),
-        end_id: Optional[int] = Query(None, description="筛选条件:结束img_id"),
-        name_like: Optional[str] = Query(None, description="筛选条件:名称模糊搜索"),
-        start_date: Optional[date] = Query(None, description="筛选条件:起始日期 (YYYY-MM-DD)"),
-        end_date: Optional[date] = Query(None, description="筛选条件:结束日期 (YYYY-MM-DD)"),
-        skip: int = Query(0, ge=0, description="分页:跳过的记录数"),
-        limit: int = Query(100, ge=1, le=1000, description="分页:每页的记录数"),
-        db_conn: PooledMySQLConnection = db_dependency
-):
-    """
-    根据多个可选条件查询记录列表,并支持分页。
-    """
-    query = f"SELECT * FROM {settings.DB_TABLE_NAME}"
-    conditions = []
-    params = []
-
-    if start_id is not None:
-        conditions.append("img_id >= %s")
-        params.append(start_id)
-    if end_id is not None:
-        conditions.append("img_id <= %s")
-        params.append(end_id)
-    if name_like:
-        conditions.append("img_name LIKE %s")
-        params.append(f"%{name_like}%")
-    if start_date:
-        conditions.append("created_at >= %s")
-        params.append(start_date)
-    if end_date:
-        conditions.append("created_at < DATE_ADD(%s, INTERVAL 1 DAY)")
-        params.append(end_date)
-
-    if conditions:
-        query += " WHERE " + " AND ".join(conditions)
-
-    query += " ORDER BY img_id DESC LIMIT %s OFFSET %s"
-    params.extend([limit, skip])
-
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        cursor.execute(query, tuple(params))
-        columns = [desc[0] for desc in cursor.description]
-        return [map_row_to_model(row, columns) for row in cursor.fetchall()]
-    except Exception as e:
-        logger.error(f"查询列表失败: {e}")
-        raise HTTPException(status_code=500, detail="获取数据列表失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  2: 根据img_id查询
-@router.get("/query/{img_id}", response_model=ImageRecordResponse, summary="2. 根据img_id查询完整记录")
-def get_record_by_id(img_id: int, db_conn: PooledMySQLConnection = db_dependency):
-    """获取指定ID的完整数据库记录。"""
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        query = f"SELECT * FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
-        cursor.execute(query, (img_id,))
-        result = cursor.fetchone()
-        if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {img_id} 的记录未找到。")
-
-        columns = [desc[0] for desc in cursor.description]
-        return map_row_to_model(result, columns)
-    except Exception as e:
-        logger.error(f"ID查询失败 ({img_id}): {e}")
-        if isinstance(e, HTTPException): raise e
-        raise HTTPException(status_code=500, detail="数据库查询失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  3: 根据img_name查询
-@router.get("/query/name/{img_name}", response_model=List[ImageRecordResponse], summary="3. 根据img_name查询记录")
-def get_records_by_name(img_name: str, db_conn: PooledMySQLConnection = db_dependency):
-    """获取所有与指定名称匹配的记录列表。"""
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        query = f"SELECT * FROM {settings.DB_TABLE_NAME} WHERE img_name = %s"
-        cursor.execute(query, (img_name,))
-        results = cursor.fetchall()
-        if not results:
-            return []  # 未找到则返回空列表
-
-        columns = [desc[0] for desc in cursor.description]
-        return [map_row_to_model(row, columns) for row in results]
-    except Exception as e:
-        logger.error(f"名称查询失败 ({img_name}): {e}")
-        raise HTTPException(status_code=500, detail="数据库查询失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  5: 修改JSON数据
-@router.put("/update/json/{img_id}", status_code=200, summary="5. 修改指定ID记录的JSON数据")
-def update_record_json(
-        img_id: int,
-        new_json_data: Dict[str, Any],
-        db_conn: PooledMySQLConnection = db_dependency
-):
-    """根据img_id,用请求体中的新JSON覆盖原有的JSON数据。"""
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        new_json_str = json.dumps(new_json_data, ensure_ascii=False)
-        query = f"UPDATE {settings.DB_TABLE_NAME} SET img_result_json_new = %s WHERE img_id = %s"
-        cursor.execute(query, (new_json_str, img_id))
-
-        if cursor.rowcount == 0:
-            raise HTTPException(status_code=404, detail=f"ID为 {img_id} 的记录未找到。")
-
-        db_conn.commit()
-        logger.info(f"ID {img_id} 的JSON数据已更新。")
-        return {"message": f"成功更新 ID {img_id} 的JSON数据"}
-    except Exception as e:
-        db_conn.rollback()
-        logger.error(f"更新JSON失败 ({img_id}): {e}")
-        if isinstance(e, HTTPException): raise e
-        raise HTTPException(status_code=500, detail="更新JSON数据失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  6: 获取图片文件
-@router.get("/image/{img_id}", summary="6. 获取指定ID的图片文件")
-def get_image_file(img_id: int, db_conn: PooledMySQLConnection = db_dependency):
-    """根据img_id查找记录,并返回对应的图片文件。"""
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        query = f"SELECT img_path FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
-        cursor.execute(query, (img_id,))
-        result = cursor.fetchone()
-
-        if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {img_id} 的记录未找到。")
-
-        image_path = result[0]
-        if not os.path.exists(image_path):
-            logger.error(f"文件在服务器上未找到: {image_path} (数据库ID: {img_id})")
-            raise HTTPException(status_code=404, detail="图片文件在服务器上不存在。")
-
-        return FileResponse(image_path)
-    except Exception as e:
-        logger.error(f"获取图片失败 ({img_id}): {e}")
-        if isinstance(e, HTTPException): raise e
-        raise HTTPException(status_code=500, detail="获取图片文件失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  7: 获取JSON数据
-@router.get("/json/{img_id}", summary="7. 获取指定ID的JSON数据")
-def get_record_json(img_id: int, db_conn: PooledMySQLConnection = db_dependency):
-    """
-    根据img_id查找记录,并仅返回其JSON数据部分。
-    优先返回 img_result_json_new,如果为 NULL 则返回 img_result_json。
-    """
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-        query = (
-            f"SELECT IFNULL({settings.DB_TABLE_NAME}.img_result_json_new, {settings.DB_TABLE_NAME}.img_result_json) AS img_result_json "
-            f"FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
-        )
-        cursor.execute(query, (img_id,))
-        result = cursor.fetchone()  # result will be a tuple like ('{"key": "value"}',)
-        if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {img_id} 的记录未找到。")
-
-        # result[0] 已经是经过 IFNULL 处理后的目标 JSON 字符串
-        json_to_return = json.loads(result[0])
-
-        return JSONResponse(content=json_to_return)
-    except Exception as e:
-        logger.error(f"获取JSON失败 ({img_id}): {e}")
-        if isinstance(e, HTTPException): raise e
-        raise HTTPException(status_code=500, detail="获取JSON数据失败。")
-    finally:
-        if cursor:
-            cursor.close()
-
-
-#  8: 根据 img_id 删除记录
-@router.delete("/delete/{img_id}", status_code=200, summary="8. 根据img_id删除记录及其图片")
-def delete_record_by_id(
-        img_id: int,
-        db_conn: PooledMySQLConnection = db_dependency
-):
-    """
-    根据img_id删除数据库记录以及存储在服务器上的对应图片文件。
-    这是一个原子操作,如果文件删除失败,数据库更改将回滚。
-    """
-    cursor = None
-    try:
-        cursor = db_conn.cursor()
-
-        # 1. 先查询记录,获取文件路径,并确认记录存在
-        query_path = f"SELECT img_path FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
-        cursor.execute(query_path, (img_id,))
-        result = cursor.fetchone()
-
-        if not result:
-            raise HTTPException(status_code=404, detail=f"ID为 {img_id} 的记录未找到。")
-
-        image_path = result[0]
-
-        # 2. 删除数据库记录
-        query_delete = f"DELETE FROM {settings.DB_TABLE_NAME} WHERE img_id = %s"
-        cursor.execute(query_delete, (img_id,))
-
-        # 3. 删除对应的图片文件(如果存在)
-        if os.path.exists(image_path):
-            try:
-                os.remove(image_path)
-                logger.info(f"图片文件已删除: {image_path}")
-            except OSError as e:
-                # 如果文件删除失败,回滚数据库操作以保持一致性
-                db_conn.rollback()
-                logger.error(f"删除文件失败: {image_path}. 数据库操作已回滚。错误: {e}")
-                raise HTTPException(status_code=500, detail="删除文件失败,数据库操作已回滚。")
-        else:
-            logger.warning(f"数据库记录指向的文件不存在,无需删除: {image_path} (ID: {img_id})")
-
-        # 4. 提交事务
-        db_conn.commit()
-        logger.info(f"ID {img_id} 的记录和关联文件已成功删除。")
-
-        return {"message": f"成功删除 ID {img_id} 的记录及其关联文件"}
-
-    except Exception as e:
-        db_conn.rollback()
-        logger.error(f"删除记录失败 ({img_id}): {e}")
-        if isinstance(e, HTTPException):
-            raise e
-        raise HTTPException(status_code=500, detail="删除记录时发生服务器内部错误。")
-    finally:
-        if cursor:
-            cursor.close()

+ 181 - 0
app/api/images.py

@@ -0,0 +1,181 @@
+import os
+import uuid
+import json
+from typing import Optional, Dict, Any
+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 app.core.config import settings
+from app.core.logger import get_logger
+from app.utils.scheme import CardImageResponse, ImageJsonPairResponse
+from app.core.database_loader import get_db_connection
+
+logger = get_logger(__name__)
+router = APIRouter()
+db_dependency = Depends(get_db_connection)
+
+
+class ImageType(str, Enum):
+    front_face = "front_face"
+    back_face = "back_face"
+    front_edge = "front_edge"
+    back_edge = "back_edge"
+
+
+@router.post("/insert/{card_id}", response_model=CardImageResponse, status_code=201,
+             summary="为卡牌上传并关联一张图片")
+async def upload_image_for_card(
+        card_id: int = Path(..., description="要关联的卡牌ID"),
+        image_type: ImageType = Form(..., description="图片类型 (front_face, back_face, etc.)"),
+        image: UploadFile = File(..., description="图片文件"),
+        json_data_str: str = Form(..., description="与图片关联的JSON字符串"),
+        image_name: Optional[str] = Form(None, description="图片的可选名称"),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    """
+    上传一张图片,将其存入 card_images 表,并更新 cards 表中对应的外键字段。
+    这是一个事务性操作。
+    """
+    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
+    try:
+        with open(image_path, "wb") as buffer:
+            buffer.write(await image.read())
+    except Exception as e:
+        logger.error(f"保存图片失败: {e}")
+        raise HTTPException(status_code=500, detail="无法保存图片文件。")
+
+    cursor = None
+    try:
+        cursor = db_conn.cursor()
+
+        # 1. 插入图片记录到 card_images
+        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"
+        )
+        cursor.execute(query_update_card, (new_image_id, card_id))
+
+        if cursor.rowcount == 0:
+            raise HTTPException(status_code=404, detail=f"ID为 {card_id} 的卡牌未找到,操作已回滚。")
+
+        db_conn.commit()
+        logger.info(f"图片 {new_image_id} 已成功关联到卡牌 {card_id} 的 {image_type.value}。")
+
+        # 查询并返回完整的图片记录
+        cursor.execute(f"SELECT * FROM {settings.DB_IMAGE_TABLE_NAME} WHERE image_id = %s", (new_image_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 Exception as e:
+        db_conn.rollback()
+        if os.path.exists(image_path):
+            os.remove(image_path)  # 如果数据库操作失败,删除已上传的文件
+        logger.error(f"关联图片到卡牌失败: {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="数据库操作失败,所有更改已回滚。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
+@router.get("/jsons/{image_id}", response_model=ImageJsonPairResponse, summary="获取图片的原始和修改后JSON")
+def get_image_jsons(image_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,))
+        result = cursor.fetchone()
+        if not result:
+            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的图片未找到。")
+
+        return ImageJsonPairResponse.model_validate(result)
+    except Exception as e:
+        logger.error(f"获取JSON对失败 ({image_id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="数据库查询失败。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
+@router.put("/update/json/{image_id}", status_code=200, summary="7. 修改图片的 modified_json")
+def update_image_modified_json(
+        image_id: int,
+        new_json_data: Dict[str, Any],
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    """根据 image_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))
+
+        if cursor.rowcount == 0:
+            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的图片未找到。")
+
+        db_conn.commit()
+        logger.info(f"图片ID {image_id} 的 modified_json 已更新。")
+        return {"message": f"成功更新图片ID {image_id} 的JSON数据"}
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"更新JSON失败 ({image_id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="更新JSON数据失败。")
+    finally:
+        if cursor:
+            cursor.close()
+
+
+@router.get("/image_file/{image_id}", summary="获取指定ID的图片文件")
+def get_image_file(image_id: int, db_conn: PooledMySQLConnection = db_dependency):
+    """根据 image_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,))
+        result = cursor.fetchone()
+
+        if not result:
+            raise HTTPException(status_code=404, detail=f"ID为 {image_id} 的记录未找到。")
+
+        image_path = result[0]
+        if not os.path.exists(image_path):
+            logger.error(f"文件在服务器上未找到: {image_path} (数据库ID: {image_id})")
+            raise HTTPException(status_code=404, detail="图片文件在服务器上不存在。")
+
+        return FileResponse(image_path)
+    except Exception as e:
+        logger.error(f"获取图片失败 ({image_id}): {e}")
+        if isinstance(e, HTTPException): raise e
+        raise HTTPException(status_code=500, detail="获取图片文件失败。")
+    finally:
+        if cursor:
+            cursor.close()

+ 8 - 7
app/core/config.py

@@ -10,14 +10,19 @@ class Settings:
 
     CONFIG_PATH = BASE_PATH / 'Config.json'
 
-    API_ImageData_prefix: str = "/api/image_data"
+    API_PREFIX: str = "/api" # 通用前缀
 
     DATA_DIR = BASE_PATH / "Data"
     SCORE_CONFIG_PATH = BASE_PATH / "app/core/scoring_config.json"
 
     # --- 数据库配置 ---
     DB_NAME = 'card_score_database'
-    DB_TABLE_NAME = 'image_records'
+    # 从 Config.json 读取的旧表名, 我们将不再使用它, 但保留以兼容旧文件
+    # 建议直接在代码中定义新表名, 避免混淆
+    DB_LEGACY_TABLE_NAME = 'image_records'
+    DB_CARD_TABLE_NAME = 'cards'
+    DB_IMAGE_TABLE_NAME = 'card_images' # 新的图片表名
+
     DATABASE_CONFIG: Dict[str, str] = {
         'user': 'root',
         'password': '123456',
@@ -35,12 +40,8 @@ class Settings:
 
         self.DATABASE_CONFIG = config_json["mysql_config"]
         self.DB_NAME = config_json["database_name"]
-        self.DB_TABLE_NAME = config_json["database_table_name"]
 
 
 settings = Settings()
 print(f"项目根目录: {settings.BASE_PATH}")
-print(f"数据存储目录: {settings.DATA_DIR}")
-
-# DefectType = Enum("InferenceType", {name: name for name in settings.DEFECT_TYPE})
-# print()
+print(f"数据存储目录: {settings.DATA_DIR}")

+ 74 - 17
app/core/database_loader.py

@@ -8,6 +8,16 @@ 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():
     """
@@ -15,35 +25,82 @@ def init_database():
     """
     logger.info("--- 开始初始化数据库 ---")
 
-    # 1. 尝试连接MySQL服务器(不指定数据库)
     try:
         cnx = mysql.connector.connect(**settings.DATABASE_CONFIG)
         cursor = cnx.cursor()
 
-        # 2. 创建数据库(如果不存在)
         cursor.execute(f"CREATE DATABASE IF NOT EXISTS {settings.DB_NAME} DEFAULT CHARACTER SET 'utf8mb4'")
         logger.info(f"数据库 '{settings.DB_NAME}' 已准备就绪。")
-
-        # 3. 切换到目标数据库
         cnx.database = settings.DB_NAME
 
-        # 4. 创建表(如果不存在)
-        table_description = (
-            f"CREATE TABLE IF NOT EXISTS `{settings.DB_TABLE_NAME}` ("
-            "  `img_id` INT AUTO_INCREMENT PRIMARY KEY,"
-            "  `img_name` VARCHAR(255) NULL,"
-            "  `img_path` VARCHAR(512) NOT NULL,"
-            "  `img_result_json` JSON NOT NULL,"
-            "  `img_result_json_new` JSON NULL,"
-            "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
-            ") ENGINE=InnoDB"
+        # 1. 创建 card_images 表 (因为它被 cards 表引用)
+        card_images_table = (
+            f"CREATE TABLE IF NOT EXISTS `{settings.DB_IMAGE_TABLE_NAME}` ("
+            "  `image_id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `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数据最后修改时间'"
+            ") 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(table_description)
-        logger.info(f"数据表 '{settings.DB_TABLE_NAME}' 已准备就绪。")
+        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)  # 初始化失败直接退出程序
+        exit(1)
     finally:
         if 'cursor' in locals() and cursor:
             cursor.close()

+ 6 - 12
app/main.py

@@ -1,35 +1,29 @@
+
 from fastapi import FastAPI
 from contextlib import asynccontextmanager
-
-from .core.database_loader import init_database, load_database_pool, close_database_pool
-from app.api.image_data import router as image_data_router
 import os
 
+from .core.database_loader import init_database, load_database_pool, close_database_pool
+from app.api import cards as cards_router # 导入新路由
+from app.api import images as images_router # 导入新路由
 from .core.config import settings
 from .core.logger import setup_logging, get_logger
 
 setup_logging()
-# 获取一个用于 main 模块的日志记录器
 logger = get_logger(__name__)
 
 @asynccontextmanager
 async def lifespan(main_app: FastAPI):
     print("--- 应用启动 ---")
     settings.set_config()
-
-    # --- 文件和目录准备 ---
     os.makedirs(settings.DATA_DIR, exist_ok=True)
-
-    # --- 数据库初始化 ---
     init_database()
     load_database_pool()
-
     yield
-
     print("--- 应用关闭 ---")
     close_database_pool()
 
-
 app = FastAPI(title="卡片分数数据存储服务", lifespan=lifespan)
 
-app.include_router(image_data_router, prefix=settings.API_ImageData_prefix, tags=["Image Data"])
+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"])

+ 70 - 25
app/utils/scheme.py

@@ -1,49 +1,94 @@
-import os
-import uuid
 import json
-from datetime import date, datetime
+from datetime import datetime
 from typing import Optional, Dict, Any, List
 from pydantic import BaseModel, field_validator
 
 
 # --- Pydantic 数据模型 ---
-class ImageRecordResponse(BaseModel):
-    """用于API响应的数据模型,确保数据结构一致"""
-    img_id: int
-    img_name: Optional[str] = None
-    img_path: str
-    img_result_json: Dict[str, Any] # 这个字段在响应中代表的是 img_result_json_new 或 img_result_json 的实际值
+
+class CardImageResponse(BaseModel):
+    """用于API响应的图片数据模型"""
+    image_id: int
+    image_name: Optional[str] = None
+    image_path: str
+    detection_json: Dict[str, Any]
+    modified_json: Optional[Dict[str, Any]] = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True  # 兼容 ORM 模式
+
+    @field_validator('detection_json', 'modified_json', mode='before')
+    @classmethod
+    def parse_json_string(cls, v):
+        if v is None:
+            return None
+        if isinstance(v, str):
+            try:
+                return json.loads(v)
+            except json.JSONDecodeError:
+                raise ValueError("Invalid JSON string in database")
+        return v
+
+
+class CardDetailResponse(BaseModel):
+    """用于响应单个卡牌详细信息的模型,包含其所有图片信息"""
+    card_id: int
+    card_name: Optional[str] = None
     created_at: datetime
+    updated_at: datetime
+    images: List[CardImageResponse] = []  # 嵌套的图片列表
+
+    class Config:
+        from_attributes = True
 
-    @field_validator('img_result_json', mode='before')
+
+class ImageJsonPairResponse(BaseModel):
+    """用于获取单个图片两个JSON数据的响应模型"""
+    image_id: int
+    detection_json: Dict[str, Any]
+    modified_json: Optional[Dict[str, Any]] = None
+
+    class Config:
+        from_attributes = True
+
+    @field_validator('detection_json', 'modified_json', mode='before')
     @classmethod
     def parse_json_string(cls, v):
         """
-        这个验证器会在Pydantic进行类型检查之前运行 (因为 pre=True)。
+        这个验证器会在Pydantic进行类型检查之前运行。
         它负责将从数据库取出的JSON字符串转换为Python字典。
         """
+        if v is None:
+            return None
         if isinstance(v, str):
             try:
                 return json.loads(v)
             except json.JSONDecodeError:
-                # 如果数据库中的JSON格式错误,则抛出异常
                 raise ValueError("Invalid JSON string in database")
         return v
 
 
-def map_row_to_model(row: tuple, columns: List[str]) -> ImageRecordResponse:
-    """
-    将数据库查询出的一行数据映射到Pydantic模型。
-    优先使用 img_result_json_new,如果它为 NULL,则使用 img_result_json。
-    """
-    row_dict = dict(zip(columns, row))
+# 用于 card list的结构
+class CardImageInfo(BaseModel):
+    """用于在卡牌列表中展示的单张图片摘要信息"""
+    image_id: int
+    image_name: Optional[str] = None
 
-    # Pydantic 字段名为 img_result_json
-    if 'img_result_json_new' in row_dict and row_dict['img_result_json_new'] is not None:
-        row_dict['img_result_json'] = row_dict['img_result_json_new']
 
-    # 移除 img_result_json_new 字段,因为它不属于 ImageRecordResponse 模型
-    if 'img_result_json_new' in row_dict:
-        del row_dict['img_result_json_new']
+class CardListDetailResponse(BaseModel):
+    """为新的卡牌列表接口定义的详细响应模型"""
+    card_id: int
+    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
 
-    return ImageRecordResponse(**row_dict)
+    class Config:
+        from_attributes = True