Procházet zdrojové kódy

用户系统初步架构

AnlaAnla před 2 týdny
rodič
revize
7747877f47

+ 3 - 3
Test/auto_img_insert.py

@@ -45,10 +45,10 @@ def auto_import(data, target_url):
 
 
 if __name__ == '__main__':
-    # target_url = f"http://192.168.77.249:7755/api/import/process_and_import"
-    target_url = f"http://127.0.0.1:7755/api/import/process_and_import"
+    target_url = f"http://192.168.77.249:7755/api/import/process_and_import"
+    # target_url = f"http://127.0.0.1:7755/api/import/process_and_import"
 
-    for i in range(2, 4):
+    for i in range(2, 11):
         request_data = {
             "card_name": f"测试卡-{i}",
             "cardNo": f"testNo-{i}",

+ 1 - 1
Test/img_score_and_insert2.py

@@ -328,7 +328,7 @@ if __name__ == "__main__":
     BASE_PATH = r"C:\Code\ML\Image\Card\img20_test"
 
     # 模拟循环处理
-    for img_num in range(1, 11):
+    for img_num in range(2, 11):
         print(f">>>>> 处理图片组: {img_num}")
 
         # 构造路径 (假设文件名格式如下,可根据实际修改)

+ 5 - 1
app/api/cards.py

@@ -8,6 +8,7 @@ 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.api.users import get_current_user, require_admin_user
 from app.utils.scheme import (
     CardDetailResponse, CardListDetailResponse, CardType, SortBy, CardNoList,
     SortOrder, CardListResponseWrapper, CardListWithTotal, ReviewUpdate
@@ -146,6 +147,7 @@ def card_list_filter(
         skip: int = Query(0, ge=0),
         page_num: int = Query(None, ge=1),
         limit: int = Query(100, ge=1, le=1000),
+        current_user: dict = Depends(get_current_user),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
@@ -163,7 +165,8 @@ def card_list_filter(
             min_modified_score, max_modified_score,
             created_start, created_end,
             updated_start, updated_end,
-            sort_by, sort_order, skip, limit
+            sort_by, sort_order, skip, limit,
+            None if current_user.get("is_admin") else current_user["id"]
         )
 
         # 组装返回数据,注意这里要进行 model_validate 转换 list 中的每一项
@@ -231,6 +234,7 @@ def delete_card(id: int, db_conn: PooledMySQLConnection = db_dependency):
 def update_review_state(
         id: int,
         data: ReviewUpdate,
+        current_user: dict = Depends(require_admin_user),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """

+ 7 - 3
app/api/config_proxy.py

@@ -1,6 +1,7 @@
-from fastapi import APIRouter, HTTPException, Body, status, Query
+from fastapi import APIRouter, Depends, HTTPException, Body, status, Query
 from fastapi.responses import JSONResponse
 import httpx
+from app.api.users import require_admin_user
 from ..core.logger import get_logger
 from ..core.config import settings
 from contextlib import asynccontextmanager
@@ -15,7 +16,7 @@ http_client = httpx.AsyncClient(timeout=10.0, limits=httpx.Limits(max_keepalive_
 
 
 @router.get("/scoring_config", summary="[代理] 获取评分配置")
-async def proxy_get_scoring_config():
+async def proxy_get_scoring_config(current_user: dict = Depends(require_admin_user)):
     target_url = settings.SCORE_SERVER_CONFIG_URL
 
     try:
@@ -36,7 +37,10 @@ async def proxy_get_scoring_config():
 
 
 @router.put("/scoring_config", summary="[代理] 更新评分配置")
-async def proxy_update_scoring_config(config_data: dict = Body(...)):
+async def proxy_update_scoring_config(
+        config_data: dict = Body(...),
+        current_user: dict = Depends(require_admin_user)
+):
     target_url = settings.SCORE_SERVER_CONFIG_URL
 
     try:

+ 5 - 0
app/api/formate_xy.py

@@ -10,6 +10,7 @@ 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.api.users import check_card_permission, get_current_user
 from app.utils.scheme import (
     CardDetailResponse, IMAGE_TYPE_TO_SCORE_TYPE, ImageType
 )
@@ -132,6 +133,7 @@ def get_card_details(
 async def update_image_modified_json(
         id: int,
         new_json_data: dict = Body(..., description="前端传来的包含xy对象格式的JSON"),
+        current_user: dict = Depends(get_current_user),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
@@ -158,6 +160,7 @@ async def update_image_modified_json(
             raise HTTPException(status_code=404, detail=f"ID为 {id} 的图片未找到。")
 
         card_id_to_update = row["card_id"]
+        check_card_permission(db_conn, current_user, card_id_to_update)
         image_type = row["image_type"]
         score_type = IMAGE_TYPE_TO_SCORE_TYPE.get(image_type)
         if not score_type:
@@ -249,6 +252,7 @@ async def update_image_modified_json(
 async def update_gray_image_json(
         id: int,
         new_json_data: dict = Body(..., description="前端传来的灰度图编辑后的JSON(xy格式)"),
+        current_user: dict = Depends(get_current_user),
         db_conn: PooledMySQLConnection = db_dependency
 ):
     """
@@ -272,6 +276,7 @@ async def update_gray_image_json(
             raise HTTPException(status_code=404, detail=f"ID为 {id} 的灰度图未找到。")
 
         card_id = gray_row['card_id']
+        check_card_permission(db_conn, current_user, card_id)
         gray_image_type = gray_row['image_type']
 
         # 3. 确定目标 Ring 图类型

+ 417 - 0
app/api/users.py

@@ -0,0 +1,417 @@
+import base64
+import hashlib
+import hmac
+import json
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Optional, List
+
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+from mysql.connector.pooling import PooledMySQLConnection
+from pydantic import BaseModel, ConfigDict, Field
+
+from app.core.config import settings
+from app.core.database_loader import get_db_connection
+from app.core.logger import get_logger
+
+logger = get_logger(__name__)
+router = APIRouter()
+db_dependency = Depends(get_db_connection)
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_PREFIX}/users/login")
+
+# Hard-coded by design: used only for promoting the current user to admin.
+ADMIN_GRANT_KEY = settings.ADMIN_GRANT_KEY
+TOKEN_SECRET_KEY = settings.TOKEN_SECRET_KEY
+TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
+
+
+class UserRegisterRequest(BaseModel):
+    username: str = Field(..., min_length=6, max_length=20, pattern=r"^[A-Za-z0-9]+$")
+    nickname: str = Field(..., min_length=1, max_length=20)
+    password: str = Field(..., min_length=6, max_length=20, pattern=r"^[A-Za-z0-9]+$")
+
+
+class UserUpdateRequest(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    nickname: Optional[str] = Field(None, min_length=1, max_length=20)
+    password: Optional[str] = Field(None, min_length=6, max_length=20, pattern=r"^[A-Za-z0-9]+$")
+
+
+class UserLoginRequest(BaseModel):
+    username: str = Field(..., min_length=6, max_length=20, pattern=r"^[A-Za-z0-9]+$")
+    password: str = Field(..., min_length=6, max_length=20, pattern=r"^[A-Za-z0-9]+$")
+
+
+class AdminGrantRequest(BaseModel):
+    key: str
+    user_id: Optional[int] = None
+
+
+class BindCardRequest(BaseModel):
+    user_id: int
+    card_id: List[int] = Field(..., min_length=1)
+
+
+class UserResponse(BaseModel):
+    id: int
+    username: str
+    nickname: str
+    is_admin: bool
+
+
+class UserListItem(BaseModel):
+    id: int
+    username: str
+    nickname: str
+    is_admin: bool
+    created_at: datetime
+    updated_at: datetime
+
+
+class UserListWithTotal(BaseModel):
+    total: int
+    list: List[UserListItem]
+
+
+class UserListResponseWrapper(BaseModel):
+    data: UserListWithTotal
+
+
+class TokenResponse(BaseModel):
+    access_token: str
+    token_type: str = "bearer"
+
+
+def _auth_exception() -> HTTPException:
+    return HTTPException(
+        status_code=status.HTTP_401_UNAUTHORIZED,
+        detail="Invalid or expired token",
+        headers={"WWW-Authenticate": "Bearer"},
+    )
+
+
+def _hash_password(password: str, salt: Optional[str] = None) -> str:
+    salt = salt or secrets.token_hex(16)
+    password_hash = hashlib.sha256(f"{salt}:{password}".encode("utf-8")).hexdigest()
+    return f"{salt}${password_hash}"
+
+
+def _verify_password(password: str, stored_password: str) -> bool:
+    try:
+        salt, old_hash = stored_password.split("$", 1)
+    except ValueError:
+        return False
+    return hmac.compare_digest(_hash_password(password, salt), f"{salt}${old_hash}")
+
+
+def _b64encode(data: bytes) -> str:
+    return base64.urlsafe_b64encode(data).decode("utf-8").rstrip("=")
+
+
+def _b64decode(data: str) -> bytes:
+    padding = "=" * (-len(data) % 4)
+    return base64.urlsafe_b64decode(f"{data}{padding}".encode("utf-8"))
+
+
+def _create_access_token(user_id: int) -> str:
+    expires_at = datetime.now(timezone.utc) + timedelta(minutes=TOKEN_EXPIRE_MINUTES)
+    payload = {"user_id": user_id, "exp": int(expires_at.timestamp())}
+    body = _b64encode(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
+    signature = hmac.new(TOKEN_SECRET_KEY.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest()
+    return f"{body}.{signature}"
+
+
+def _decode_access_token(token: str) -> int:
+    try:
+        body, signature = token.split(".", 1)
+        expected_signature = hmac.new(
+            TOKEN_SECRET_KEY.encode("utf-8"),
+            body.encode("utf-8"),
+            hashlib.sha256
+        ).hexdigest()
+        if not hmac.compare_digest(signature, expected_signature):
+            raise ValueError("bad signature")
+
+        payload = json.loads(_b64decode(body))
+        if int(payload.get("exp", 0)) < int(datetime.now(timezone.utc).timestamp()):
+            raise ValueError("expired")
+        return int(payload["user_id"])
+    except Exception:
+        raise _auth_exception()
+
+
+def _normalize_user(user: dict) -> dict:
+    user["is_admin"] = bool(user.get("is_admin"))
+    return user
+
+
+def _get_user_by_id(db_conn: PooledMySQLConnection, user_id: int) -> Optional[dict]:
+    with db_conn.cursor(dictionary=True) as cursor:
+        cursor.execute(
+            f"SELECT id, username, nickname, is_admin FROM `{settings.DB_USER_TABLE_NAME}` WHERE id = %s",
+            (user_id,)
+        )
+        user = cursor.fetchone()
+        return _normalize_user(user) if user else None
+
+
+def _get_user_by_username(db_conn: PooledMySQLConnection, username: str) -> Optional[dict]:
+    with db_conn.cursor(dictionary=True) as cursor:
+        cursor.execute(
+            f"SELECT id, username, nickname, password, is_admin FROM `{settings.DB_USER_TABLE_NAME}` WHERE username = %s",
+            (username,)
+        )
+        user = cursor.fetchone()
+        return _normalize_user(user) if user else None
+
+
+def get_current_user(
+        token: str = Depends(oauth2_scheme),
+        db_conn: PooledMySQLConnection = db_dependency
+) -> dict:
+    user_id = _decode_access_token(token)
+    user = _get_user_by_id(db_conn, user_id)
+    if not user:
+        raise _auth_exception()
+    return user
+
+
+def require_admin_user(current_user: dict = Depends(get_current_user)) -> dict:
+    if not current_user.get("is_admin"):
+        raise HTTPException(status_code=403, detail="该请求需要管理员权限")
+    return current_user
+
+
+def check_card_permission(db_conn: PooledMySQLConnection, current_user: dict, card_id: int):
+    if current_user.get("is_admin"):
+        return
+
+    with db_conn.cursor() as cursor:
+        cursor.execute(
+            f"SELECT 1 FROM `{settings.DB_USER_CARD_TABLE_NAME}` WHERE user_id = %s AND card_id = %s LIMIT 1",
+            (current_user["id"], card_id)
+        )
+        if cursor.fetchone():
+            return
+
+    raise HTTPException(status_code=403, detail="没有该卡片权限")
+
+
+@router.post("/register", response_model=UserResponse, summary="注册用户")
+def register_user(data: UserRegisterRequest, db_conn: PooledMySQLConnection = db_dependency):
+    try:
+        if _get_user_by_username(db_conn, data.username):
+            raise HTTPException(status_code=400, detail="该用户名已经存在")
+
+        with db_conn.cursor(dictionary=True) as cursor:
+            cursor.execute(
+                f"INSERT INTO `{settings.DB_USER_TABLE_NAME}` (username, nickname, password) VALUES (%s, %s, %s)",
+                (data.username, data.nickname, _hash_password(data.password))
+            )
+            db_conn.commit()
+
+            user_id = cursor.lastrowid
+            logger.info(f"User created, id: {user_id}, username: {data.username}")
+            user = _get_user_by_id(db_conn, user_id)
+            return UserResponse.model_validate(user)
+
+    except HTTPException:
+        db_conn.rollback()
+        raise
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"Register user failed: {e}")
+        raise HTTPException(status_code=500, detail="注册用户失败")
+
+
+@router.post("/login", response_model=TokenResponse, summary="用户登录")
+def login_for_access_token(
+        form_data: OAuth2PasswordRequestForm = Depends(),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    username = form_data.username
+    password = form_data.password
+
+    user = _get_user_by_username(db_conn, username)
+    if not user or not _verify_password(password, user["password"]):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="用户名或密码错误",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+
+    return TokenResponse(access_token=_create_access_token(user["id"]))
+
+
+@router.put("/me", response_model=UserResponse, summary="修改当前的昵称或密码")
+def update_current_user(
+        data: UserUpdateRequest,
+        current_user: dict = Depends(get_current_user),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    update_fields = []
+    params = []
+
+    if data.nickname is not None:
+        update_fields.append("nickname = %s")
+        params.append(data.nickname)
+    if data.password is not None:
+        update_fields.append("password = %s")
+        params.append(_hash_password(data.password))
+
+    if not update_fields:
+        raise HTTPException(status_code=400, detail="No fields to update")
+
+    try:
+        params.append(current_user["id"])
+        with db_conn.cursor() as cursor:
+            query = (
+                f"UPDATE `{settings.DB_USER_TABLE_NAME}` SET "
+                f"{', '.join(update_fields)} WHERE id = %s"
+            )
+            cursor.execute(query, tuple(params))
+            db_conn.commit()
+
+        return UserResponse.model_validate(_get_user_by_id(db_conn, current_user["id"]))
+
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"Update user failed: {e}")
+        raise HTTPException(status_code=500, detail="修改用户失败")
+
+
+@router.get("/list", response_model=UserListResponseWrapper, summary="查询用户列表")
+def list_users(
+        nickname: Optional[str] = Query(None, description="按昵称模糊搜索"),
+        sort_order: str = Query("desc", pattern="^(asc|desc)$", description="按创建时间排序: asc 或 desc"),
+        skip: int = Query(0, ge=0),
+        page_num: Optional[int] = Query(None, ge=1),
+        limit: int = Query(100, ge=1, le=1000),
+        current_user: dict = Depends(require_admin_user),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    if page_num is not None:
+        skip = (page_num - 1) * limit
+
+    try:
+        with db_conn.cursor(dictionary=True) as cursor:
+            conditions = []
+            params = []
+
+            if nickname:
+                conditions.append("nickname LIKE %s")
+                params.append(f"%{nickname}%")
+
+            where_clause = ""
+            if conditions:
+                where_clause = " WHERE " + " AND ".join(conditions)
+
+            count_query = f"SELECT COUNT(*) as total FROM `{settings.DB_USER_TABLE_NAME}`" + where_clause
+            cursor.execute(count_query, tuple(params))
+            total_count = cursor.fetchone()["total"]
+
+            order_sql = "ASC" if sort_order.lower() == "asc" else "DESC"
+            data_query = (
+                f"SELECT id, username, nickname, is_admin, created_at, updated_at "
+                f"FROM `{settings.DB_USER_TABLE_NAME}`"
+                f"{where_clause} ORDER BY created_at {order_sql}, id DESC LIMIT %s OFFSET %s"
+            )
+            data_params = params.copy()
+            data_params.extend([limit, skip])
+            cursor.execute(data_query, tuple(data_params))
+            users = [_normalize_user(user) for user in cursor.fetchall()]
+
+            return {
+                "data": {
+                    "total": total_count,
+                    "list": users
+                }
+            }
+
+    except Exception as e:
+        logger.error(f"Query user list failed: {e}")
+        raise HTTPException(status_code=500, detail="查询用户列表失败")
+
+
+@router.post("/grant_admin", response_model=UserResponse, summary="给用户授予管理员权限")
+def grant_current_user_admin(
+        data: AdminGrantRequest,
+        current_user: dict = Depends(get_current_user),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    if not hmac.compare_digest(data.key, ADMIN_GRANT_KEY):
+        raise HTTPException(status_code=403, detail="Invalid admin key")
+
+    try:
+        target_user_id = data.user_id or current_user["id"]
+        if not _get_user_by_id(db_conn, target_user_id):
+            raise HTTPException(status_code=404, detail="未发现该用户")
+
+        with db_conn.cursor() as cursor:
+            cursor.execute(
+                f"UPDATE `{settings.DB_USER_TABLE_NAME}` SET is_admin = TRUE WHERE id = %s",
+                (target_user_id,)
+            )
+            db_conn.commit()
+        return UserResponse.model_validate(_get_user_by_id(db_conn, target_user_id))
+
+    except HTTPException:
+        db_conn.rollback()
+        raise
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"Grant admin failed: {e}")
+        raise HTTPException(status_code=500, detail="Grant admin failed")
+
+
+@router.post("/bind_card", status_code=200, summary="给用户绑定卡片ID")
+def bind_card_to_user(
+        data: BindCardRequest,
+        current_user: dict = Depends(require_admin_user),
+        db_conn: PooledMySQLConnection = db_dependency
+):
+    try:
+        # Keep the request field as card_id, but accept multiple card ids.
+        card_ids = list(dict.fromkeys(data.card_id))
+
+        with db_conn.cursor(dictionary=True) as cursor:
+            cursor.execute(f"SELECT id FROM `{settings.DB_USER_TABLE_NAME}` WHERE id = %s", (data.user_id,))
+            if not cursor.fetchone():
+                raise HTTPException(status_code=404, detail="未发现该用户")
+
+            format_strings = ",".join(["%s"] * len(card_ids))
+            cursor.execute(
+                f"SELECT id FROM `{settings.DB_CARD_TABLE_NAME}` WHERE id IN ({format_strings})",
+                tuple(card_ids)
+            )
+            existing_card_ids = {row["id"] for row in cursor.fetchall()}
+            missing_card_ids = sorted(set(card_ids) - existing_card_ids)
+            if missing_card_ids:
+                raise HTTPException(status_code=404, detail=f"卡片未发现: {missing_card_ids}")
+
+            bind_params = [(data.user_id, card_id) for card_id in card_ids]
+            cursor.executemany(
+                f"INSERT IGNORE INTO `{settings.DB_USER_CARD_TABLE_NAME}` (user_id, card_id) VALUES (%s, %s)",
+                bind_params
+            )
+            inserted_count = cursor.rowcount
+            db_conn.commit()
+
+        logger.info(f"Admin {current_user['id']} bound cards {card_ids} to user {data.user_id}")
+        return {
+            "message": "卡片绑定成功",
+            "user_id": data.user_id,
+            "card_id": card_ids,
+            "inserted_count": inserted_count
+        }
+
+    except HTTPException:
+        db_conn.rollback()
+        raise
+    except Exception as e:
+        db_conn.rollback()
+        logger.error(f"Bind card failed: {e}")
+        raise HTTPException(status_code=500, detail="卡片绑定失败")

+ 6 - 0
app/core/config.py

@@ -7,6 +7,10 @@ import json
 class Settings:
     BASE_PATH = Path(__file__).parent.parent.parent.absolute()
 
+    # 用户系统重要的KEY
+    ADMIN_GRANT_KEY = "CardScoreAdminKey2026_aaabbbccc"
+    TOKEN_SECRET_KEY = "CardScoreDataServerTokenSecret2026"
+
     # --- MinIO 配置 ---
     MINIO_ENDPOINT = "192.168.77.249:9000"
     MINIO_ACCESS_KEY = "pZEwCGnpNN05KPnmC2Yh"
@@ -38,6 +42,8 @@ class Settings:
     DB_CARD_TABLE_NAME = 'cards'
     DB_IMAGE_TABLE_NAME = 'card_images'
     DB_GRAY_IMAGE_TABLE_NAME = 'card_gray_images'  # 灰度图表名
+    DB_USER_TABLE_NAME = 'users'
+    DB_USER_CARD_TABLE_NAME = 'user_card_bindings'
     RATING_REPORT_HISTORY_TABLE_NAME = "rating_report_history"
 
     DATABASE_CONFIG: Dict[str, str] = {

+ 32 - 0
app/core/database_loader.py

@@ -41,6 +41,38 @@ def init_database():
         cursor.execute(cards_table)
         logger.info(f"数据表 '{settings.DB_CARD_TABLE_NAME}' 已准备就绪。")
 
+        # User table: username is the login account, nickname is only for display.
+        users_table = (
+            f"CREATE TABLE IF NOT EXISTS `{settings.DB_USER_TABLE_NAME}` ("
+            "  `id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `username` VARCHAR(20) NOT NULL,"
+            "  `nickname` VARCHAR(100) NOT NULL,"
+            "  `password` VARCHAR(255) NOT NULL,"
+            "  `is_admin` BOOLEAN NOT NULL DEFAULT FALSE,"
+            "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
+            "  `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
+            "  UNIQUE KEY `uk_users_username` (`username`)"
+            ") ENGINE=InnoDB COMMENT='users'"
+        )
+        cursor.execute(users_table)
+        logger.info(f"数据表 '{settings.DB_USER_TABLE_NAME}' 已准备就绪。")
+
+        # Mapping table: one card can be assigned to multiple users.
+        user_card_table = (
+            f"CREATE TABLE IF NOT EXISTS `{settings.DB_USER_CARD_TABLE_NAME}` ("
+            "  `id` INT AUTO_INCREMENT PRIMARY KEY,"
+            "  `user_id` INT NOT NULL,"
+            "  `card_id` INT NOT NULL,"
+            "  `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
+            f"  FOREIGN KEY (`user_id`) REFERENCES `{settings.DB_USER_TABLE_NAME}`(`id`) ON DELETE CASCADE,"
+            f"  FOREIGN KEY (`card_id`) REFERENCES `{settings.DB_CARD_TABLE_NAME}`(`id`) ON DELETE CASCADE,"
+            "  UNIQUE KEY `uk_user_card` (`user_id`, `card_id`),"
+            "  INDEX `idx_user_card_card_id` (`card_id`)"
+            ") ENGINE=InnoDB COMMENT='user card bindings'"
+        )
+        cursor.execute(user_card_table)
+        logger.info(f"数据表 '{settings.DB_USER_CARD_TABLE_NAME}' 已准备就绪。")
+
         # 2. 创建 card_images 表 (主要计算图)
         card_images_table = (
             f"CREATE TABLE IF NOT EXISTS `{settings.DB_IMAGE_TABLE_NAME}` ("

+ 7 - 1
app/crud/crud_card.py

@@ -322,7 +322,8 @@ def get_card_list_and_count(
         sort_by: SortBy,
         sort_order: SortOrder,
         skip: int,
-        limit: int
+        limit: int,
+        user_id: Optional[int] = None
 ) -> Dict[str, Any]:
     with db_conn.cursor(dictionary=True) as cursor:
         conditions = []
@@ -342,6 +343,11 @@ def get_card_list_and_count(
         if created_end: conditions.append("DATE(created_at) <= %s"); params.append(created_end)
         if updated_start: conditions.append("DATE(updated_at) >= %s"); params.append(updated_start)
         if updated_end: conditions.append("DATE(updated_at) <= %s"); params.append(updated_end)
+        if user_id is not None:
+            conditions.append(
+                f"id IN (SELECT card_id FROM `{settings.DB_USER_CARD_TABLE_NAME}` WHERE user_id = %s)"
+            )
+            params.append(user_id)
 
         where_clause = ""
         if conditions: where_clause = " WHERE " + " AND ".join(conditions)

+ 2 - 0
app/main.py

@@ -12,6 +12,7 @@ from app.api import formate_xy as formate_xy_router
 from app.api import config_proxy as config_proxy_router
 from app.api import rating_report as rating_report_router
 from app.api import auto_import as auto_import_router
+from app.api import users as users_router
 
 from .core.config import settings
 from .core.logger import setup_logging, get_logger
@@ -50,3 +51,4 @@ app.include_router(formate_xy_router.router, prefix=f"{settings.API_PREFIX}/form
 app.include_router(auto_import_router.router, prefix=f"{settings.API_PREFIX}/import", tags=["AutoImport"])
 app.include_router(config_proxy_router.router, prefix=f"{settings.API_PREFIX}/config", tags=["ConfigProxy"])
 app.include_router(rating_report_router.router, prefix=f"{settings.API_PREFIX}/rating", tags=["Rating"])
+app.include_router(users_router.router, prefix=f"{settings.API_PREFIX}/users", tags=["Users"])