commit 4c92f4e0705e173adf3d6ced229bcd25ce0931c2 Author: 龙大 Date: Thu Apr 16 14:27:41 2026 +0800 v1.2.100 - 性能优化与小程序兼容 - 修复藏品列表N+1查询问题(图片预加载) - Stats API改用SQL聚合查询 - 新增成交行情分类汇总API - Cookie登录支持(小程序webview兼容) - 寻配号网络匹配扩展到所有藏品 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..85de19b --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.2.100 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..73f416f --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.pyo +.git +.env +uploads/* +!uploads/.gitkeep +logs/* +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..9f5443e --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,26 @@ +# 数据库配置 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac + +# JWT配置 +SECRET_KEY=your-production-secret-key-change-this +ACCESS_TOKEN_EXPIRE_MINUTES=60 + +# 阿里云 DashScope OCR API(必须配置!) +DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f + +# 阿里云 OSS配置 +OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG +OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1 +OSS_BUCKET_NAME=jiachenlong-oss +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com + +# 阿里云短信配置(必须配置!) +SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE +SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 +SMS_SIGN_NAME=苏州算力 +SMS_TEMPLATE_CODE=SMS_501590956 + +# 服务配置 +PORT=3000 +HOST=0.0.0.0 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f4c2a67 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +# FastAPI 后端 Docker 镜像 +FROM python:3.12-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 依赖 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 复制应用代码 +COPY . . + +# 创建上传目录 +RUN mkdir -p uploads + +# 暴露端口 +EXPOSE 3000 + +# 启动命令 +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..834be5c --- /dev/null +++ b/backend/README.md @@ -0,0 +1,42 @@ +# 后端服务 - FastAPI + +## 启动方式 + +### 开发环境 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动服务 +python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 --reload +``` + +### 生产环境 + +```bash +# 后台运行 +nohup python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 > /tmp/uvicorn.log 2>&1 & + +# 或使用 systemd +sudo systemctl start zodiac-backend +``` + +## 配置说明 + +编辑 `.env` 文件: + +```ini +# 数据库 +DATABASE_URL=postgresql://postgres:密码@localhost:5432/zodiac + +# JWT +SECRET_KEY=你的密钥 + +# OCR API +DASHSCOPE_API_KEY=sk-你的密钥 +``` + +## API 文档 + +启动后访问:http://localhost:3000/docs diff --git a/backend/VERSION b/backend/VERSION new file mode 100644 index 0000000..85de19b --- /dev/null +++ b/backend/VERSION @@ -0,0 +1 @@ +1.2.100 diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..2fe7a71 --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,89 @@ +# 认证模块 +import os +import bcrypt +from datetime import datetime, timedelta +from typing import Optional +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.models.models import User + +# 配置 +SECRET_KEY = os.getenv("SECRET_KEY") +if not SECRET_KEY: + raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 + +# HTTP Bearer 认证 +security = HTTPBearer(auto_error=False) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + try: + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + except Exception: + return False + + +def get_password_hash(password: str) -> str: + """生成密码哈希""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> Optional[dict]: + """解码访问令牌""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: Session = Depends(lambda: SessionLocal()) +) -> Optional[User]: + """获取当前用户(可返回None)""" + if not credentials: + return None + + token = credentials.credentials + payload = decode_access_token(token) + + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的令牌", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的令牌", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + return None + + return user diff --git a/backend/app/core/coolbot_db.py b/backend/app/core/coolbot_db.py new file mode 100644 index 0000000..a0b9014 --- /dev/null +++ b/backend/app/core/coolbot_db.py @@ -0,0 +1,37 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import QueuePool + +COOLBOT_DB_URL = os.getenv( + "COOLBOT_DB_URL", + "postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6cno.pg.rds.aliyuncs.com:5432/coolbot_data" +) + +coolbot_engine = create_engine( + COOLBOT_DB_URL, + poolclass=QueuePool, + pool_size=10, + max_overflow=20, + pool_timeout=30, + pool_recycle=1800, + pool_pre_ping=True, + echo=False, + connect_args={ + "connect_timeout": 10, + "application_name": "zodiac-coolbot" + } +) + +CoolbotSession = sessionmaker(autocommit=False, autoflush=False, bind=coolbot_engine) + +def get_coolbot_db(): + """获取coolbot数据库会话""" + db = CoolbotSession() + try: + yield db + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..8683b0b --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,69 @@ +import os +import time +from sqlalchemy import create_engine, event +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import QueuePool +from typing import Generator +import logging + +logger = logging.getLogger(__name__) + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:postgres@127.0.0.1:5432/zodiac" +) + +# 增强版数据库引擎配置 +engine = create_engine( + DATABASE_URL, + # 连接池配置 + poolclass=QueuePool, + pool_size=20, # 常规连接数 + max_overflow=40, # 允许超出的连接数(高并发时) + pool_timeout=30, # 获取连接超时时间(秒) + pool_recycle=1800, # 连接回收时间(30分钟),避免连接过期 + pool_pre_ping=True, # 每次获取连接前检查连接是否有效 + echo=False, + # 连接参数优化 + connect_args={ + "connect_timeout": 10, + "application_name": "zodiac-api", + "options": "-c statement_timeout=30000" # 查询超时30秒 + } +) + +# 添加连接事件监听器 +@event.listens_for(engine, "connect") +def set_connect_timeout(dbapi_conn, connection_record): + """设置连接参数""" + cursor = dbapi_conn.cursor() + cursor.execute("SET statement_timeout = 30000") + cursor.close() + +@event.listens_for(engine, "checkout") +def check_connection(dbapi_conn, connection_record, connection_proxy): + """检出连接时检查""" + try: + cursor = dbapi_conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + except Exception as e: + logger.warning(f"连接检查失败: {e}") + raise Exception("数据库连接无效") + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db() -> Generator: + """获取数据库会话,带错误处理""" + db = SessionLocal(expire_on_commit=False) + try: + yield db + except Exception as e: + logger.error(f"数据库会话错误: {e}") + db.rollback() + raise + finally: + db.close() diff --git a/backend/app/core/error_handler.py b/backend/app/core/error_handler.py new file mode 100644 index 0000000..c3d29ca --- /dev/null +++ b/backend/app/core/error_handler.py @@ -0,0 +1,174 @@ +# 统一错误处理 +from fastapi import FastAPI, Request, HTTPException, status +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from pydantic import ValidationError +from app.core.logging_config import logger + +# 错误码定义 +ERROR_CODES = { + # 认证错误 (10-19) + 401: "E00010", # 未授权 + 403: "E00014", # 禁止访问 + + # 验证错误 (20-29) + 400: "E00000", # 请求错误 + 422: "E00000", # 验证错误 + + # 资源错误 (30-39) + 404: "E00033", # 资源不存在 + + # 服务器错误 (50-59) + 500: "E00003", # 服务器内部错误 +} + +# 错误信息映射 +ERROR_MESSAGES = { + "E00010": "未登录或登录已过期", + "E00011": "用户名或密码错误", + "E00012": "验证码错误", + "E00014": "无权访问此资源", + "E00015": "令牌无效或已过期", + "E00020": "请输入用户名和密码", + "E00021": "用户名至少 3 个字符", + "E00022": "密码至少 6 个字符", + "E00023": "用户名已存在", + "E00024": "邮箱已被注册", + "E00030": "藏品名称不能为空", + "E00031": "藏品名称至少 2 个字符", + "E00032": "藏品分类不能为空", + "E00033": "藏品不存在", + "E00034": "禁止重复:此冠字号已存在", + "E00035": "成本价格必须>=0", + "E00036": "目标价格必须>=0", + "E00037": "发行年份必须是 4 位数字", + "E00040": "请选择图片文件", + "E00041": "图片尺寸太小,无法识别", + "E00042": "OCR 识别失败,请重试", + "E00050": "仅管理员可访问", + "E00051": "用户不存在", + "E00000": "请求失败", + "E00001": "网络连接失败", + "E00003": "服务器内部错误", +} + +def setup_error_handlers(app: FastAPI): + """设置全局错误处理器""" + + @app.exception_handler(HTTPException) + async def http_exception_handler(request: Request, exc: HTTPException): + """处理 HTTP 异常""" + # 从 detail 中提取错误码 + detail = exc.detail + error_code = ERROR_CODES.get(exc.status_code, "E00000") + + # 如果 detail 已经包含错误码,直接使用 + if isinstance(detail, str) and detail.startswith("E"): + parts = detail.split(":", 1) + error_code = parts[0] + message = parts[1].strip() if len(parts) > 1 else ERROR_MESSAGES.get(error_code, detail) + else: + message = ERROR_MESSAGES.get(error_code, detail if isinstance(detail, str) else "请求失败") + + return JSONResponse( + status_code=exc.status_code, + content={ + "error": { + "code": error_code, + "message": message, + "status": exc.status_code + } + } + ) + + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request: Request, exc: RequestValidationError): + """处理请求验证错误""" + errors = exc.errors() + if errors: + error = errors[0] + field = ".".join(str(x) for x in error.get("loc", [])) + msg = error.get("msg", "验证失败") + + # 根据字段和消息匹配错误码 + # 先检查请求路径,区分用户接口和藏品接口 + path = request.url.path + + if "cost_price" in field or "价格" in msg: + error_code = "E00035" + message = "成本价格必须>=0" + elif "target_price" in field: + error_code = "E00036" + message = "目标价格必须>=0" + elif "issue_year" in field or "年份" in msg: + error_code = "E00037" + message = "发行年份必须是 4 位数字" + elif "name" in field: + # 根据路径区分用户 name 和藏品 name + if "/auth/" in path or "/users/" in path or "/admin/users/" in path: + error_code = "E00021" + message = "用户名至少 3 个字符" + else: + error_code = "E00031" + message = "藏品名称至少 2 个字符" + elif "category" in field: + error_code = "E00032" + message = "藏品分类不能为空" + else: + error_code = "E00000" + message = f"{field}: {msg}" + + return JSONResponse( + status_code=422, + content={ + "error": { + "code": error_code, + "message": message, + "status": 422, + "field": field + } + } + ) + + return JSONResponse( + status_code=422, + content={ + "error": { + "code": "E00000", + "message": "验证失败", + "status": 422 + } + } + ) + + @app.exception_handler(404) + async def not_found_handler(request: Request, exc: Exception): + """处理 404 错误""" + return JSONResponse( + status_code=404, + content={ + "error": { + "code": "E00033", + "message": "接口不存在", + "status": 404 + } + } + ) + + @app.exception_handler(Exception) + async def general_exception_handler(request: Request, exc: Exception): + """处理未捕获的异常""" + import traceback + error_trace = traceback.format_exc() + logger.error(f"未捕获异常:{str(exc)}\n{error_trace}") + + return JSONResponse( + status_code=500, + content={ + "error": { + "code": "E00003", + "message": "服务器内部错误", + "status": 500 + } + } + ) diff --git a/backend/app/core/logging_config.py b/backend/app/core/logging_config.py new file mode 100644 index 0000000..d5b2960 --- /dev/null +++ b/backend/app/core/logging_config.py @@ -0,0 +1,113 @@ +# 日志系统配置 +import logging +import json +import os +from datetime import datetime +from typing import Optional +import uuid +from logging.handlers import RotatingFileHandler + +class JSONFormatter(logging.Formatter): + """JSON 格式日志处理器""" + + def format(self, record): + log_data = { + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'level': record.levelname, + 'logger': record.name, + 'message': record.getMessage(), + 'trace_id': getattr(record, 'trace_id', str(uuid.uuid4())), + 'user_id': getattr(record, 'user_id', None), + 'request_id': getattr(record, 'request_id', str(uuid.uuid4())), + 'ip_address': getattr(record, 'ip_address', None), + 'duration_ms': getattr(record, 'duration_ms', None), + } + + # 添加额外字段 + if hasattr(record, 'data'): + log_data['data'] = record.data + + # 添加异常信息 + if record.exc_info: + log_data['exception'] = self.formatException(record.exc_info) + + return json.dumps(log_data, ensure_ascii=False, default=str) + + +def setup_logging( + log_file: str = 'logs/app.log', + level: str = 'INFO', + max_bytes: int = 10*1024*1024, # 10MB + backup_count: int = 5 +): + """配置日志系统""" + + # 创建日志目录 + os.makedirs(os.path.dirname(log_file), exist_ok=True) + + # 根日志器 + logger = logging.getLogger() + logger.setLevel(level) + + # 清空现有处理器 + logger.handlers.clear() + + # 文件处理器(带轮转) + file_handler = RotatingFileHandler( + log_file, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + file_handler.setFormatter(JSONFormatter()) + logger.addHandler(file_handler) + + # 控制台处理器(仅开发环境) + if os.getenv('ENV', 'development') == 'development': + console_handler = logging.StreamHandler() + console_handler.setFormatter(JSONFormatter()) + logger.addHandler(console_handler) + + return logger + + +# 创建日志器实例 +logger = setup_logging() + + +# 日志装饰器 +def log_operation(operation_name: str): + """记录操作日志的装饰器""" + def decorator(func): + import functools + @functools.wraps(func) + def wrapper(*args, **kwargs): + import time + start_time = time.time() + + try: + result = func(*args, **kwargs) + duration = (time.time() - start_time) * 1000 + + logger.info( + f"{operation_name} 成功", + extra={ + 'duration_ms': duration, + 'data': {'function': func.__name__} + } + ) + return result + + except Exception as e: + duration = (time.time() - start_time) * 1000 + logger.error( + f"{operation_name} 失败:{str(e)}", + extra={ + 'duration_ms': duration, + 'data': {'function': func.__name__, 'error': str(e)} + }, + exc_info=True + ) + raise + return wrapper + return decorator diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0587121 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,115 @@ +# FastAPI 应用入口 +import os +from pathlib import Path +from dotenv import load_dotenv +load_dotenv() + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.core.database import engine, Base +from app.core.logging_config import logger, setup_logging +from app.core.error_handler import setup_error_handlers +from app.middleware.logging import logging_middleware +from app.routers import auth, collections, operations +from app.routers import ocr as ocr_router +from app.routers import users as users_router +from app.routers import information as information_router +from app.routers import yichens as yichens_router +from app.routers import seek as seek_router +from app.routers import deal as deal_router + +# 版本信息 - 从 config/VERSION 文件读取 +def get_version(): + """从 config/VERSION 文件读取版本号""" + try: + version_file = Path(__file__).parent.parent.parent / "config" / "VERSION" + if version_file.exists(): + with open(version_file, 'r', encoding='utf-8') as f: + for line in f: + if line.startswith('VERSION='): + return line.strip().split('=', 1)[1] + except Exception as e: + logger.error(f"读取 VERSION 文件失败:{e}") + return "0.0.0" # 默认版本号 + +__version__ = get_version() +__app_name__ = "甲辰收藏系统 FastAPI 后端" + +# 启动时创建数据库表 +Base.metadata.create_all(bind=engine) + +# 初始化日志系统 +setup_logging() +logger.info(f"{__app_name__} v{__version__} 启动成功") + +# 创建 FastAPI 应用 +app = FastAPI( + title=__app_name__, + version=__version__, + description="生肖纪念钞收藏管理系统后端 API" +) + +# 设置全局错误处理器 +setup_error_handlers(app) + +# CORS 配置 - 生产环境限制域名 +ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://47.103.29.111,http://120.55.81.21,https://socoolbot.com").split(",") +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 挂载静态文件目录(图片上传和项目静态资源) +uploads_dir = "uploads" +os.makedirs(uploads_dir, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") + +# 挂载项目静态资源目录 +static_dir = Path(__file__).parent.parent / "static" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +# 添加日志中间件 +app.middleware("http")(logging_middleware) + +# 注册路由 +app.include_router(auth.router) +app.include_router(collections.router) +app.include_router(operations.router) +app.include_router(ocr_router.router) # OCR 识别 +app.include_router(users_router.router) # 当前用户接口 +app.include_router(users_router.admin_router) # 管理员用户管理 +app.include_router(information_router.router) +app.include_router(yichens_router.router) # 一尘看板 +app.include_router(seek_router.router) # 寻配号 +app.include_router(deal_router.router) # 成交行情 + + +@app.get("/") +def root(): + """根路径""" + return { + "name": __app_name__, + "version": __version__, + "status": "running" + } + + +@app.get("/health") +def health_check(): + """健康检查""" + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("PORT", "3000")) + uvicorn.run(app, host="0.0.0.0", port=port) + +from app.routers import seek as seek_router +from app.routers import deal as deal_router diff --git a/backend/app/middleware/logging.py b/backend/app/middleware/logging.py new file mode 100644 index 0000000..684a55a --- /dev/null +++ b/backend/app/middleware/logging.py @@ -0,0 +1,88 @@ +# 请求日志中间件 - 优化版 +import time +import uuid +from fastapi import Request, Response +from app.core.logging_config import logger + + +async def logging_middleware(request: Request, call_next): + """记录所有 API 请求的日志 - 优化版""" + + # 生成请求 ID + request_id = str(uuid.uuid4()) + start_time = time.time() + + # 获取用户信息(如果已登录) + user_id = None + try: + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + user_id = "authenticated" + except Exception as e: + # 静默失败,不影响主流程 + pass + + # 执行请求 + response_status = 500 + try: + response = await call_next(request) + response_status = response.status_code + except Exception as e: + duration = (time.time() - start_time) * 1000 + try: + logger.error( + f"API 请求异常:{request.method} {request.url.path}", + extra={ + 'request_id': request_id, + 'user_id': user_id, + 'ip_address': request.client.host if request.client else None, + 'duration_ms': duration, + 'data': { + 'method': request.method, + 'path': request.url.path, + 'query': str(request.query_params), + 'error': str(e) + } + }, + exc_info=True + ) + except: + pass # 日志记录失败不影响主流程 + raise + + # 记录响应 + duration = (time.time() - start_time) * 1000 + + try: + log_level = 'INFO' + if response_status >= 500: + log_level = 'ERROR' + elif response_status >= 400: + log_level = 'WARNING' + + getattr(logger, log_level)( + f"API 请求完成:{request.method} {request.url.path}", + extra={ + 'request_id': request_id, + 'user_id': user_id, + 'ip_address': request.client.host if request.client else None, + 'duration_ms': duration, + 'data': { + 'method': request.method, + 'path': request.url.path, + 'query': str(request.query_params), + 'status_code': response_status + } + } + ) + except Exception as e: + # 日志记录失败不影响主流程 + pass + + # 在响应头中添加请求 ID + try: + response.headers['X-Request-ID'] = request_id + except: + pass + + return response diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/deal_info.py b/backend/app/models/deal_info.py new file mode 100644 index 0000000..82e3206 --- /dev/null +++ b/backend/app/models/deal_info.py @@ -0,0 +1,54 @@ +from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime +from sqlalchemy.sql import func +from app.core.database import Base +import uuid + +def generate_uuid(): + return str(uuid.uuid4()) + +class DealInfo(Base): + __tablename__ = "deal_info" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), nullable=True, index=True) + + # 标题和内容 + title = Column(String(255), nullable=False) + content = Column(Text, nullable=True) + + # 成交信息 + deal_price = Column(Float, nullable=True) # 成交价格 + deal_date = Column(Date, nullable=True) # 成交日期 + deal_no = Column(String(20), nullable=True, index=True) # 行情编号(从A000001开始递增) + + # 包装和分类 + packaging = Column(String(50), nullable=True) # 包装(标百/标十/单张) + category = Column(String(100), nullable=True) # 分类 + + # 评级相关 + is_graded = Column(Boolean, default=False) # 是否评级 + grading_company = Column(String(100), nullable=True) # 评级机构 + grading_score = Column(String(50), nullable=True) # 评级分数 + + # 号码特征 + tail_number = Column(String(10), nullable=True) # 尾号 + size_type = Column(String(20), nullable=True) # 大小号 + + # 版别 + version = Column(String(50), nullable=True) # 版别 + + # 交易信息 + platform = Column(String(50), nullable=True) # 成交平台 + seller = Column(String(100), nullable=True) # 出售者 + buyer = Column(String(100), nullable=True) # 购买者 + + # 状态 + status = Column(String(20), default="active") + + # 统计 + view_count = Column(Integer, default=0) + contact_count = Column(Integer, default=0) + + # 时间 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..6ea0838 --- /dev/null +++ b/backend/app/models/models.py @@ -0,0 +1,268 @@ +# 数据库模型 - 使用字段编码 +from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base +import uuid + + +def generate_uuid(): + """生成 UUID 字符串""" + return str(uuid.uuid4()) + + +class User(Base): + __tablename__ = "users" + + # f99 系统字段 + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + user_code = Column(String(10), unique=True, nullable=True, index=True) # 用户编码 + f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True) + f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username + email = Column(String(255), unique=True, nullable=True, index=True) + phone = Column(String(50), nullable=True) + avatar = Column(String(500), nullable=True) + address = Column(String(500), nullable=True) + bio = Column(Text, nullable=True) + password = Column(String(255), nullable=False) + role = Column(String(50), default="user") + # 时间字段 + f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now()) + f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + # 新增字段 + f99_94_level = Column(String(20), default="青铜") # 会员等级 + f99_95_ai_count = Column(Integer, default=0) # ai识别次数 + f99_96_search_count = Column(Integer, default=0) # 寻号使用次数 + f99_97_collection_count = Column(Integer, default=0) # 藏品数量 + f01_06_phone_verified = Column(Boolean, default=False) # 手机号已核验 + f99_98_login_count = Column(Integer, default=0) # 登录次数 + f99_99_last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间 + f01_07_gender = Column(String(10), nullable=True) # 性别 + f01_08_birthday = Column(Date, nullable=True) # 生日 + f01_09_region = Column(String(100), nullable=True) # 地区 + f01_10_realname_verified = Column(Boolean, default=False) # 实名认证 + f99_100_points = Column(Integer, default=0) # 积分 + f01_11_balance = Column(Float, default=0) # 余额 + f01_12_total_amount = Column(Float, default=0) # 累计金额 + f01_13_invite_code = Column(String(20), nullable=True) # 邀请码(自己的邀请码) + f99_101_invited_count = Column(Integer, default=0) # 通过自己邀请码注册的用户数量 + + collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan") + operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan") + custom_fields = relationship("CustomField", back_populates="user", cascade="all, delete-orphan") + + +class Collection(Base): + __tablename__ = "collections" + + # f99 系统字段 + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + f99_91_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # f01 基本信息 + f01_01_name = Column(String(255), nullable=False) + f01_02_code = Column(String(50), nullable=True, index=True) + f01_03_category = Column(String(100), nullable=False, index=True) + f01_04_status = Column(String(50), default="in_collection", index=True) + f01_05_remark = Column(Text, nullable=True) + + # f02 详细字段 + f02_10_prefix_serial = Column(String(50), nullable=True, index=True) + f02_11_version = Column(String(100), nullable=True, index=True) + f02_12_packaging = Column(String(100), nullable=True, index=True) + f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度 + f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类 + + # f03 评级信息 + f03_20_is_graded = Column(Boolean, default=False, index=True) + f03_21_grading_company = Column(String(100), nullable=True, index=True) + f03_22_grading_score = Column(String(20), nullable=True, index=True) + f03_23_three_star = Column(Boolean, default=False, index=True) + + # f04 特殊信息 + f04_30_special_mark = Column(String(200), nullable=True, index=True) + f04_31_serial_feature = Column(String(100), nullable=True, index=True) + f04_32_issuer = Column(String(100), nullable=True, index=True) + f04_33_issue_year = Column(String(20), nullable=True, index=True) + f04_34_material = Column(String(50), nullable=True) + f04_35_denomination = Column(String(20), nullable=True) + f04_36_issue_quantity = Column(String(50), nullable=True) + + # f05 价格信息 + f05_40_cost_price = Column(Float, nullable=True, index=True) + f05_41_target_price = Column(Float, nullable=True, index=True) + f05_42_goal_price = Column(Float, nullable=True) + f05_43_repair_fee = Column(Float, nullable=True) + f05_44_grading_fee = Column(Float, nullable=True) + + # f06 其他信息 + f06_50_purpose = Column(String(100), nullable=True) + + user = relationship("User", back_populates="collections") + images = relationship("CollectionImage", back_populates="collection", cascade="all, delete-orphan") + operations = relationship("Operation", back_populates="collection", cascade="all, delete-orphan") + + +class CollectionImage(Base): + __tablename__ = "collection_images" + + id = Column(String(36), primary_key=True, default=generate_uuid) + collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + filename = Column(String(255), nullable=False) + original_name = Column(String(255), nullable=True) + path = Column(String(500), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + collection = relationship("Collection", back_populates="images") + + +class Operation(Base): + __tablename__ = "operations" + + f99_90_id = Column(String(36), primary_key=True, default=generate_uuid) + f99_91_user_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + f99_92_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + type = Column(String(50), nullable=False, index=True) + price = Column(Float, nullable=True) + note = Column(Text, nullable=True) + f99_93_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + + collection = relationship("Collection", back_populates="operations") + user = relationship("User", back_populates="operations") + + +class CustomField(Base): + __tablename__ = "custom_fields" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(100), nullable=False) + field_type = Column(String(50), default="text") + options = Column(Text, nullable=True) + required = Column(Boolean, default=False) + visible = Column(Boolean, default=True) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User", back_populates="custom_fields") + + +# 资讯模型 +class Information(Base): + __tablename__ = "information" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + + # 信息类型: seek-寻配号, deal-成交数据, publish-发布 + info_type = Column(String(20), nullable=False, index=True) + + # 标题 + title = Column(String(255), nullable=False) + + # 内容描述 + content = Column(Text, nullable=True) + + # 关联藏品ID + collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True) + + # 期望条件 (寻配号用) + expect_category = Column(String(100), nullable=True) # 期望类别 + expect_version = Column(String(100), nullable=True) # 期望版别 + expect_packaging = Column(String(100), nullable=True) # 期望包装 + expect_number = Column(String(50), nullable=True) # 期望号码 + expect_price_min = Column(Float, nullable=True) # 期望价格区间 + expect_price_max = Column(Float, nullable=True) + + # 成交价格 (成交数据用) + deal_price = Column(Float, nullable=True) + deal_date = Column(Date, nullable=True) + + # 评级相关字段 + packaging = Column(String(50), nullable=True) + is_graded = Column(Boolean, default=False) + grading_company = Column(String(100), nullable=True) + grading_score = Column(String(50), nullable=True) + category = Column(String(100), nullable=True) + + # 行情编号 + deal_no = Column(String(50), nullable=True, index=True) + + # 状态: active-有效, closed-已关闭, expired-已过期 + status = Column(String(20), default="active", index=True) + + # 匹配状态: pending-尚未匹配, matched-已经匹配 + is_matched = Column(String(20), default="pending", index=True) + + # 匹配的用户ID(当用户愿意交换联系方式时) + matched_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="SET NULL"), nullable=True) + + # 匹配者的联系方式(只有发布者和匹配者可见) + matched_contact = Column(String(100), nullable=True) + + # 浏览/联系次数 + view_count = Column(Integer, default=0) + contact_count = Column(Integer, default=0) + + created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + user = relationship("User", foreign_keys=[user_id]) + collection = relationship("Collection") + + +# 资讯评论/留言 +class InformationComment(Base): + __tablename__ = "information_comments" + + id = Column(String(36), primary_key=True, default=generate_uuid) + information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + content = Column(Text, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + user = relationship("User", foreign_keys=[user_id]) + + +# 资讯联系方式查看记录 +class InformationContactView(Base): + __tablename__ = "information_contact_views" + + id = Column(String(36), primary_key=True, default=generate_uuid) + information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True) + viewer_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + viewer = relationship("User", foreign_keys=[viewer_id]) + + +# 资讯关联用户 (收藏/点赞) +class InformationLike(Base): + __tablename__ = "information_likes" + + id = Column(String(36), primary_key=True, default=generate_uuid) + information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True) + user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + UniqueConstraint('information_id', 'user_id', name='uq_information_user'), + ) + + +# 角色常量 +class UserRole: + ADMIN = "admin" # 管理员:全部权限 + EDITOR = "editor" # 信息员:可发布信息、管理资讯 + USER = "user" # 普通用户:基本功能 + + @classmethod + def get_role_name(cls, role): + names = { + cls.ADMIN: "管理员", + cls.EDITOR: "信息员", + cls.USER: "用户" + } + return names.get(role, "用户") diff --git a/backend/app/models/seek_info.py b/backend/app/models/seek_info.py new file mode 100644 index 0000000..eb3fd6c --- /dev/null +++ b/backend/app/models/seek_info.py @@ -0,0 +1,39 @@ +from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime +from sqlalchemy.sql import func +from app.core.database import Base +import uuid + +def generate_uuid(): + return str(uuid.uuid4()) + +class SeekInfo(Base): + __tablename__ = "seek_info" + + id = Column(String(36), primary_key=True, default=generate_uuid) + user_id = Column(String(36), nullable=False, index=True) + + # 标题和内容 + title = Column(String(255), nullable=False) + content = Column(Text, nullable=True) + + # 期望条件(求购条件) + expect_category = Column(String(100), nullable=True) # 期望类别 + expect_version = Column(String(100), nullable=True) # 期望版别 + expect_packaging = Column(String(100), nullable=True) # 期望包装 + expect_number = Column(String(50), nullable=True) # 期望号码 + expect_price_min = Column(Float, nullable=True) # 期望最低价 + expect_price_max = Column(Float, nullable=True) # 期望最高价 + + # 匹配状态 + status = Column(String(20), default="active") # active/closed/expired + is_matched = Column(String(10), default="false") # 是否已匹配 + matched_user_id = Column(String(36), nullable=True) # 匹配的用户ID + matched_contact = Column(String(100), nullable=True) # 匹配的联系方式 + + # 统计 + view_count = Column(Integer, default=0) + contact_count = Column(Integer, default=0) + + # 时间 + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..1841872 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,254 @@ +# 认证路由 - 使用字段编码 +from fastapi import APIRouter, Depends, HTTPException, status, Body, Response +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user +from app.models.models import User +from app.schemas.schemas import Token, UserCreate, UserResponse + +router = APIRouter(prefix="/api/auth", tags=["认证"]) + + + + +def generate_user_code(db): + """生成用户编码,从201开始,按自然数顺序递增,跳过已存在的""" + # 查找最大的user_code + max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first() + if max_code and max_code[0]: + try: + num = int(max_code[0]) + 1 + if num < 201: + num = 201 + # 检查是否已存在,如果存在则继续递增 + while db.query(User).filter(User.user_code == str(num)).first(): + num += 1 + return str(num) + except: + pass + return "201" + +@router.post("/register", response_model=UserResponse) +def register(user_data: UserCreate, db: Session = Depends(get_db)): + """用户注册""" + # 检查用户名是否已存在 + existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first() + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="f01_01_name: 用户名已存在" + ) + + # 检查邮箱是否已存在 + + # 检查手机号是否已存在 + if user_data.phone: + existing_phone = db.query(User).filter(User.phone == user_data.phone).first() + if existing_phone: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="E00040:该手机号已被注册,请更换手机号" + ) + if user_data.email: + existing_email = db.query(User).filter(User.email == user_data.email).first() + if existing_email: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="E00041:该邮箱已被注册,请更换邮箱" + ) + + # 处理邀请码 + invited_by_user = None + if user_data.invite_code: + # 查找邀请人 + invited_by_user = db.query(User).filter(User.user_code == user_data.invite_code).first() + if not invited_by_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="E00042:邀请码无效" + ) + + # 创建用户 + import uuid + hashed_password = get_password_hash(user_data.password) + generated_code = generate_user_code(db) + user = User( + f99_90_id=str(uuid.uuid4()), + f99_91_user_id=str(uuid.uuid4()), + user_code=generated_code, + f01_01_name=user_data.f01_01_name, + email=user_data.email, + phone=user_data.phone, + avatar=user_data.avatar, + address=user_data.address, + bio=user_data.bio, + password=hashed_password, + role="user" + ) + + db.add(user) + db.flush() # 确保获取user ID + + # 更新邀请人、被邀请人的关联关系 + if invited_by_user: + # 记录是被谁邀请的 + user.f01_13_invite_code = invited_by_user.user_code + # 增加邀请人的邀请计数 + invited_by_user.f99_101_invited_count = (invited_by_user.f99_101_invited_count or 0) + 1 + + # 生成自己的邀请码(用自己的user_code) + user.f01_13_invite_code = generated_code + + db.commit() + db.refresh(user) + + # 返回用户信息(避免Pydantic序列化问题) + return { + "id": user.f99_90_id, + "username": user.f01_01_name, + "user_code": user.user_code, + "email": user.email, + "phone": user.phone, + "avatar": user.avatar, + "role": user.role, + "level": user.f99_94_level, + "aiCount": user.f99_95_ai_count or 0, + "searchCount": user.f99_96_search_count or 0, + "collectionCount": user.f99_97_collection_count or 0 + } + + +@router.post("/login") +def login( + form_data: OAuth2PasswordRequestForm = Depends(), + db: Session = Depends(get_db), + response: Response = None +): + """用户登录 - 支持用户名或用户编码登录,返回Token并设置Cookie""" + # 先尝试用户名登录 + user = db.query(User).filter(User.f01_01_name == form_data.username).first() + # 如果用户名不存在,尝试用户编码登录 + if not user: + user = db.query(User).filter(User.user_code == form_data.username).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="E00011: 用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 验证密码 + if not verify_password(form_data.password, user.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="E00011: 用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # 更新登录次数和最后登录时间 + from datetime import datetime + user.f99_98_login_count = (user.f99_98_login_count or 0) + 1 + user.f99_99_last_login = datetime.now() + db.commit() + + # 生成 token + access_token = create_access_token(data={"sub": user.f99_90_id}) + + # 设置Cookie(有效期7天) + if response: + response.set_cookie( + key="token", + value=access_token, + httponly=False, # 允许JS读取(小程序需要) + max_age=7 * 24 * 60 * 60, # 7天 + samesite="lax", + path="/" + ) + + return { + "access_token": access_token, + "token_type": "bearer" + } + + +@router.get("/me", response_model=UserResponse) +def get_current_user_info( + current_user: User = Depends(lambda: None) +): + """获取当前用户信息""" + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="请使用正确的依赖注入" + ) + + +@router.post("/change-password") +def change_password( + old_password: str = Body(...), + new_password: str = Body(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """修改当前用户密码""" + from app.core.auth import verify_password, get_password_hash + + # 在当前session中重新查询用户 + user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 验证旧密码 + if not verify_password(old_password, user.password): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前密码错误" + ) + + # 更新密码 + user.password = get_password_hash(new_password) + db.commit() + + return {"message": "密码修改成功"} + + +# ============ 短信验证码接口 ============ + +@router.post("/send-verification-code") +def send_verification_code( + phone: str = Body(..., min_length=11, max_length=11), + purpose: str = Body("register") # register | login | reset_password +): + """发送短信验证码""" + from app.services.sms import send_verification_code as send_sms + + # 验证手机号格式 + if not phone.startswith("1") or len(phone) != 11: + return {"success": False, "message": "手机号格式不正确"} + + result = send_sms(phone) + + if result["success"]: + return { + "success": True, + "message": f"验证码已发送到 {phone[:3]}****{phone[7:]}", + "expire": result.get("expire", 300) + } + else: + return result + + +@router.post("/verify-code") +def verify_code( + phone: str = Body(...), + code: str = Body(..., min_length=6, max_length=6) +): + """验证短信验证码(仅验证,不执行后续操作)""" + from app.services.sms import verify_code as check_code + + is_valid = check_code(phone, code) + + if is_valid: + return {"success": True, "message": "验证成功"} + else: + return {"success": False, "message": "验证码错误或已过期"} diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py new file mode 100644 index 0000000..7daf3fe --- /dev/null +++ b/backend/app/routers/collections.py @@ -0,0 +1,887 @@ +# 藏品路由 - 使用字段编码 +import os +import uuid +import re +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File +from sqlalchemy import func, text +from sqlalchemy.orm import Session, joinedload +from app.core.database import get_db +from app.core.auth import get_current_user +from app.core.logging_config import logger +from app.models.models import User, Collection, CollectionImage, Operation +from app.schemas.schemas import ( + CollectionCreate, CollectionUpdate, CollectionResponse, + CollectionListResponse, CollectionImageResponse +) +from app.services.oss import upload_to_oss, get_oss_path, delete_from_oss, get_public_url + +router = APIRouter(prefix="/api/collections", tags=["藏品"]) + + +def to_camel_case(data: dict) -> dict: + """将字段编码转换为 camelCase 格式""" + if not data: + return data + + mapping = { + 'f99_90_id': 'id', + 'f99_91_user_id': 'userId', + 'f99_92_created_at': 'createdAt', + 'f99_93_updated_at': 'updatedAt', + 'f01_01_name': 'name', + 'f01_02_code': 'code', + 'f01_03_category': 'category', + 'f01_04_status': 'status', + 'f01_05_remark': 'remark', + 'f02_10_prefix_serial': 'prefixSerial', + 'f02_11_version': 'version', + 'f02_12_packaging': 'packaging', + 'f02_13_rarity': 'rarity', + 'f02_14_number_category': 'numberCategory', + 'f03_20_is_graded': 'isGraded', + 'f03_21_grading_company': 'gradingCompany', + 'f03_22_grading_score': 'gradingScore', + 'f03_23_three_star': 'threeStar', + 'f04_30_special_mark': 'specialMark', + 'f04_31_serial_feature': 'serialFeature', + 'f04_32_issuer': 'issuer', + 'f04_33_issue_year': 'issueYear', + 'f04_34_material': 'material', + 'f04_35_denomination': 'denomination', + 'f04_36_issue_quantity': 'issueQuantity', + 'f05_40_cost_price': 'costPrice', + 'f05_41_target_price': 'targetPrice', + 'f05_42_goal_price': 'goalPrice', + 'f05_43_repair_fee': 'repairFee', + 'f05_44_grading_fee': 'gradingFee', + 'f06_50_purpose': 'purpose', + 'images': 'images', + } + + return {mapping.get(k, k): v for k, v in data.items()} + + +# 编码生成函数 +def generate_code(version: str, user_id: str, db: Session) -> str: + """自动生成藏品编号 - 按用户独立编码""" + import re + + # 查询当前用户的非空编码(不与其他用户混算)- 使用行锁防止并发 + user_codes = db.query(Collection.f01_02_code).filter( + Collection.f01_02_code.isnot(None), + Collection.f99_91_user_id == user_id + ).with_for_update().all() + + max_num = 0 + for (code,) in user_codes: + # 处理纯数字编码(支持4位和5位) + if re.match(r'^\d{4,5}$', code): + try: + num = int(code) + if num > max_num: + max_num = num + except (ValueError, TypeError): + pass + + # 当前用户最大号 +1 + next_num = max_num + 1 + + # 如果超过9999,使用5位;否则使用4位 + if next_num > 9999: + return str(next_num).zfill(5) + else: + return str(next_num).zfill(4) + + +@router.get("/next-code") +def get_next_code( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取下一个藏品编号""" + next_code = generate_code("2024 龙", current_user.f99_90_id, db) + return {"code": 200, "data": {"nextCode": next_code}} + + +@router.get("") +def get_collections( + id: str = Query(None, description="filter by collection id"), + category: Optional[str] = None, + status: Optional[str] = None, + search: Optional[str] = None, + specialMark: Optional[str] = Query(None, description="special mark filter"), + numberCategory: Optional[str] = Query(None, description="number category filter"), + gradingCompany: Optional[str] = Query(None, description="grading company filter"), + gradingScore: Optional[str] = Query(None, description="grading score filter"), + packaging: Optional[str] = Query(None, description="packaging filter"), + rarity: Optional[str] = Query(None, description="rarity filter"), + version: Optional[str] = Query(None, description="version filter"), + profitLoss: Optional[str] = Query(None, description="profit loss filter: profit or loss"), + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=500), + sortBy: str = Query('createdAt'), + sortOrder: str = Query('desc'), + all_users: bool = Query(False, description="return all users data for admin"), + user_id: str = Query(None, description="filter by user id"), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取藏品列表""" + # admin 用户可以看到所有藏品,普通用户只能看到自己的 + # 如果指定 all_users=true,则返回所有用户藏品 + from sqlalchemy.orm import joinedload + + # 管理员默认查看全库,普通用户只看自己,未登录返回空列表 + if current_user is None or current_user.role != "admin": + # 非管理员或未登录用户只能查看自己的藏品(如果没有登录则返回空) + if current_user is None: + return {"data": [], "total": 0, "page": 1, "limit": 20} + query = db.query(Collection).filter(Collection.f99_91_user_id == current_user.f99_90_id) + else: + # 管理员查看所有藏品 + query = db.query(Collection, User.f01_01_name.label('owner_name')).join( + User, Collection.f99_91_user_id == User.f99_90_id, isouter=True + ) + + # 如果指定了user_id参数,则只返回该用户的藏品 + if user_id: + query = query.filter(Collection.f99_91_user_id == user_id) + + # 按ID精确筛选 + if id: + query = query.filter(Collection.f99_90_id == id) + + if category: + query = query.filter(Collection.f01_03_category == category) + if status: + query = query.filter(Collection.f01_04_status == status) + if specialMark: + query = query.filter(Collection.f04_30_special_mark == specialMark) + if gradingCompany: + query = query.filter(Collection.f03_21_grading_company.contains(gradingCompany)) + if gradingScore: + query = query.filter(Collection.f03_22_grading_score.contains(gradingScore)) + if numberCategory: + query = query.filter(Collection.f02_14_number_category.contains(numberCategory)) + if packaging: + query = query.filter(Collection.f02_12_packaging== packaging) + if rarity: + query = query.filter(Collection.f02_13_rarity.contains(rarity)) + if version: + query = query.filter(Collection.f02_11_version.contains(version)) + + # 盈亏筛选(只对已售藏品有效) + if profitLoss: + if profitLoss == 'profit': + # 盈利:售价 > 成本价 + query = query.filter( + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price > Collection.f05_40_cost_price + ) + elif profitLoss == 'loss': + # 亏损:售价 <= 成本价 + query = query.filter( + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price <= Collection.f05_40_cost_price + ) + + if search: + query = query.filter( + (Collection.f01_01_name.contains(search)) | + (Collection.f01_05_remark.contains(search)) + ) + + # 总数(应用筛选条件后的数量) + total = query.count() + + # 使用joinedload预加载图片,避免N+1查询问题 + query = query.options(joinedload(Collection.images)) + + # 分页 + data = query.order_by(Collection.f99_92_created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + # 转换为字典列表并转为 camelCase + data_list = [] + for item in data: + # 处理联表查询结果 + if current_user is not None and current_user.role == "admin": + collection_item, owner_name = item + else: + collection_item = item + owner_name = None + + item_dict = { + 'f99_90_id': collection_item.f99_90_id, + 'f99_91_user_id': collection_item.f99_91_user_id, + 'owner_name': owner_name, # 所属用户名(仅管理员可见) + 'f01_01_name': collection_item.f01_01_name, + 'f01_02_code': collection_item.f01_02_code, + 'f01_03_category': collection_item.f01_03_category, + 'f01_04_status': collection_item.f01_04_status, + 'f01_05_remark': collection_item.f01_05_remark, + 'f02_10_prefix_serial': collection_item.f02_10_prefix_serial, + 'f02_11_version': collection_item.f02_11_version, + 'f02_12_packaging': collection_item.f02_12_packaging, + 'f02_13_rarity': collection_item.f02_13_rarity, + 'f02_14_number_category': collection_item.f02_14_number_category, + 'f03_20_is_graded': collection_item.f03_20_is_graded, + 'f03_21_grading_company': collection_item.f03_21_grading_company, + 'f03_22_grading_score': collection_item.f03_22_grading_score, + 'f03_23_three_star': collection_item.f03_23_three_star, + 'f04_30_special_mark': collection_item.f04_30_special_mark, + 'f04_31_serial_feature': collection_item.f04_31_serial_feature, + 'f04_32_issuer': collection_item.f04_32_issuer, + 'f04_33_issue_year': collection_item.f04_33_issue_year, + 'f04_34_material': collection_item.f04_34_material, + 'f04_35_denomination': collection_item.f04_35_denomination, + 'f04_36_issue_quantity': collection_item.f04_36_issue_quantity, + 'f05_40_cost_price': float(collection_item.f05_40_cost_price) if collection_item.f05_40_cost_price else None, + 'f05_41_target_price': float(collection_item.f05_41_target_price) if collection_item.f05_41_target_price else None, + 'f05_42_goal_price': float(collection_item.f05_42_goal_price) if collection_item.f05_42_goal_price else None, + 'f05_43_repair_fee': float(collection_item.f05_43_repair_fee) if collection_item.f05_43_repair_fee else None, + 'f05_44_grading_fee': float(collection_item.f05_44_grading_fee) if collection_item.f05_44_grading_fee else None, + 'f06_50_purpose': collection_item.f06_50_purpose, + 'f99_92_created_at': collection_item.f99_92_created_at.isoformat() if collection_item.f99_92_created_at else None, + 'images': [] + } + + # 直接使用预加载的图片数据,无需再查询 + for img in collection_item.images: + item_dict['images'].append({ + 'id': img.id, + 'filename': img.filename, + 'original_name': img.original_name, + 'path': img.path, + 'created_at': img.created_at.isoformat() if img.created_at else None + }) + + data_list.append(to_camel_case(item_dict)) + + return { + "data": data_list, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit + } + } + + +@router.get("/stats") +def get_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取藏品统计 - 使用SQL聚合查询优化性能""" + + # 非管理员或未登录用户只能查看自己的藏品 + if current_user is None: + return { + "totalCount": 0, + "byCategory": [], + "byStatus": [], + "byGrading": [], + "byPackaging": [], + "byRarity": [], + "byVersion": [], + "byGradingCompany": [], + "byGradingScore": [], + "bySpecialMark": [], + "byNumberCategory": [], + "byProfitLoss": [], + "totalCost": 0, + "totalTarget": 0, + "expectedProfit": 0, + "totalRevenue": 0, + "totalProfit": 0 + } + + # 构建基础查询条件 + is_admin = current_user.role == "admin" + + if not is_admin: + base_filter = Collection.f99_91_user_id == current_user.f99_90_id + else: + base_filter = None + + # 总数 - 使用SQL COUNT + total_count = db.query(func.count(Collection.f99_90_id)).filter( + base_filter if base_filter is not True else True + ).scalar() + if base_filter is not True: + total_count = db.query(func.count(Collection.f99_90_id)).filter(base_filter).scalar() + else: + total_count = db.query(func.count(Collection.f99_90_id)).scalar() + + # 按分类统计 - 使用SQL GROUP BY + if base_filter is not True: + by_category = db.query( + Collection.f01_03_category, + func.count(Collection.f99_90_id) + ).filter(base_filter).group_by(Collection.f01_03_category).all() + + by_status = db.query( + Collection.f01_04_status, + func.count(Collection.f99_90_id) + ).filter(base_filter).group_by(Collection.f01_04_status).all() + + by_graded = db.query( + Collection.f03_20_is_graded, + func.count(Collection.f99_90_id) + ).filter(base_filter).group_by(Collection.f03_20_is_graded).all() + + by_packaging = db.query( + Collection.f02_12_packaging, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f02_12_packaging.isnot(None)).group_by(Collection.f02_12_packaging).all() + + by_rarity = db.query( + Collection.f02_13_rarity, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f02_13_rarity.isnot(None)).group_by(Collection.f02_13_rarity).all() + + by_version = db.query( + Collection.f02_11_version, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f02_11_version.isnot(None)).group_by(Collection.f02_11_version).all() + + by_grading_company = db.query( + Collection.f03_21_grading_company, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f03_20_is_graded == True, Collection.f03_21_grading_company.isnot(None)).group_by(Collection.f03_21_grading_company).all() + + by_grading_score = db.query( + Collection.f03_22_grading_score, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f03_20_is_graded == True, Collection.f03_22_grading_score.isnot(None)).group_by(Collection.f03_22_grading_score).all() + + by_special_mark = db.query( + Collection.f04_30_special_mark, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f04_30_special_mark.isnot(None)).group_by(Collection.f04_30_special_mark).all() + + by_number_category = db.query( + Collection.f02_14_number_category, + func.count(Collection.f99_90_id) + ).filter(base_filter, Collection.f02_14_number_category.isnot(None)).group_by(Collection.f02_14_number_category).all() + + # 成本相关统计 - 使用SQL SUM + cost_result = db.query( + func.coalesce(func.sum(Collection.f05_40_cost_price), 0) + + func.coalesce(func.sum(Collection.f05_43_repair_fee), 0) + + func.coalesce(func.sum(Collection.f05_44_grading_fee), 0) + ).filter(base_filter).first() + total_cost = cost_result[0] if cost_result else 0 + + # 预期利润 + expected_profit_result = db.query( + func.coalesce(func.sum(Collection.f05_41_target_price - Collection.f05_40_cost_price), 0) + ).filter(base_filter, Collection.f05_41_target_price.isnot(None), Collection.f05_41_target_price > 0).first() + expected_profit = expected_profit_result[0] if expected_profit_result else 0 + + # 已售藏品统计 + sold_collections = db.query(Collection).filter( + base_filter, + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price.isnot(None), + Collection.f05_42_goal_price > 0 + ).all() + + else: + # 管理员查看所有数据 + by_category = db.query( + Collection.f01_03_category, + func.count(Collection.f99_90_id) + ).group_by(Collection.f01_03_category).all() + + by_status = db.query( + Collection.f01_04_status, + func.count(Collection.f99_90_id) + ).group_by(Collection.f01_04_status).all() + + by_graded = db.query( + Collection.f03_20_is_graded, + func.count(Collection.f99_90_id) + ).group_by(Collection.f03_20_is_graded).all() + + by_packaging = db.query( + Collection.f02_12_packaging, + func.count(Collection.f99_90_id) + ).filter(Collection.f02_12_packaging.isnot(None)).group_by(Collection.f02_12_packaging).all() + + by_rarity = db.query( + Collection.f02_13_rarity, + func.count(Collection.f99_90_id) + ).filter(Collection.f02_13_rarity.isnot(None)).group_by(Collection.f02_13_rarity).all() + + by_version = db.query( + Collection.f02_11_version, + func.count(Collection.f99_90_id) + ).filter(Collection.f02_11_version.isnot(None)).group_by(Collection.f02_11_version).all() + + by_grading_company = db.query( + Collection.f03_21_grading_company, + func.count(Collection.f99_90_id) + ).filter(Collection.f03_20_is_graded == True, Collection.f03_21_grading_company.isnot(None)).group_by(Collection.f03_21_grading_company).all() + + by_grading_score = db.query( + Collection.f03_22_grading_score, + func.count(Collection.f99_90_id) + ).filter(Collection.f03_20_is_graded == True, Collection.f03_22_grading_score.isnot(None)).group_by(Collection.f03_22_grading_score).all() + + by_special_mark = db.query( + Collection.f04_30_special_mark, + func.count(Collection.f99_90_id) + ).filter(Collection.f04_30_special_mark.isnot(None)).group_by(Collection.f04_30_special_mark).all() + + by_number_category = db.query( + Collection.f02_14_number_category, + func.count(Collection.f99_90_id) + ).filter(Collection.f02_14_number_category.isnot(None)).group_by(Collection.f02_14_number_category).all() + + # 总成本 + cost_result = db.query( + func.coalesce(func.sum(Collection.f05_40_cost_price), 0) + + func.coalesce(func.sum(Collection.f05_43_repair_fee), 0) + + func.coalesce(func.sum(Collection.f05_44_grading_fee), 0) + ).first() + total_cost = cost_result[0] if cost_result else 0 + + # 预期利润 + expected_profit_result = db.query( + func.coalesce(func.sum(Collection.f05_41_target_price - Collection.f05_40_cost_price), 0) + ).filter(Collection.f05_41_target_price.isnot(None), Collection.f05_41_target_price > 0).first() + expected_profit = expected_profit_result[0] if expected_profit_result else 0 + + # 已售藏品 + sold_collections = db.query(Collection).filter( + Collection.f01_04_status == 'sold', + Collection.f05_42_goal_price.isnot(None), + Collection.f05_42_goal_price > 0 + ).all() + + # 总收入和总利润(已售藏品) + total_revenue = sum(c.f05_42_goal_price or 0 for c in sold_collections) + total_profit = sum( + (c.f05_42_goal_price or 0) - (c.f05_40_cost_price or 0) - (c.f05_43_repair_fee or 0) - (c.f05_44_grading_fee or 0) + for c in sold_collections + ) + + # 盈亏统计 + profit_count = sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price > c.f05_40_cost_price) + loss_count = sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price <= c.f05_40_cost_price) + + # 目标价格总和 + total_target_result = db.query(func.coalesce(func.sum(Collection.f05_41_target_price), 0)).filter( + base_filter if base_filter is not True else True + ).first() + if base_filter is not True: + total_target_result = db.query(func.coalesce(func.sum(Collection.f05_41_target_price), 0)).filter(base_filter).first() + else: + total_target_result = db.query(func.coalesce(func.sum(Collection.f05_41_target_price), 0)).first() + total_target = total_target_result[0] if total_target_result else 0 + + return { + "totalCount": total_count, + "byCategory": [{"category": c, "count": n} for c, n in by_category], + "byStatus": [{"status": s, "count": n} for s, n in by_status], + "byGrading": [{"isGraded": g, "count": n} for g, n in by_graded], + "byPackaging": [{"packaging": p, "count": n} for p, n in by_packaging], + "byRarity": [{"rarity": r, "count": n} for r, n in by_rarity], + "byVersion": [{"version": v, "count": n} for v, n in by_version], + "byGradingCompany": [{"company": c, "count": n} for c, n in by_grading_company], + "byGradingScore": [{"score": s, "count": n} for s, n in by_grading_score], + "bySpecialMark": [{"mark": m, "count": n} for m, n in by_special_mark], + "byNumberCategory": [{"numberCategory": n, "count": c} for n, c in by_number_category], + "byProfitLoss": [ + {"type": "profit", "label": "盈利", "count": profit_count}, + {"type": "loss", "label": "亏损", "count": loss_count} + ], + "totalCost": total_cost, + "totalTarget": total_target, + "expectedProfit": expected_profit, + "totalRevenue": total_revenue, + "totalProfit": total_profit + } + + +@router.get("/{collection_id}") +def get_collection( + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个藏品详情""" + result = db.execute( + text("SELECT * FROM collections WHERE f99_90_id = :id"), + {"id": collection_id} + ).fetchone() + + if not result: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + collection = dict(result._mapping) + + # 非管理员只能查看自己的藏品 + if (current_user is None or current_user.role != "admin") and collection.get('f99_91_user_id') != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + result_dict = { + 'f99_90_id': collection.get('f99_90_id'), + 'f99_91_user_id': collection.get('f99_91_user_id'), + 'f01_01_name': collection.get('f01_01_name'), + 'f01_02_code': collection.get('f01_02_code'), + 'f01_03_category': collection.get('f01_03_category'), + 'f01_04_status': collection.get('f01_04_status'), + 'f01_05_remark': collection.get('f01_05_remark'), + 'f02_10_prefix_serial': collection.get('f02_10_prefix_serial'), + 'f02_11_version': collection.get('f02_11_version'), + 'f02_12_packaging': collection.get('f02_12_packaging'), + 'f02_13_rarity': collection.get('f02_13_rarity'), + 'f02_14_number_category': collection.get('f02_14_number_category'), + 'f03_20_is_graded': collection.get('f03_20_is_graded'), + 'f03_21_grading_company': collection.get('f03_21_grading_company'), + 'f03_22_grading_score': collection.get('f03_22_grading_score'), + 'f03_23_three_star': collection.get('f03_23_three_star'), + 'f04_30_special_mark': collection.get('f04_30_special_mark'), + 'f04_31_serial_feature': collection.get('f04_31_serial_feature'), + 'f04_32_issuer': collection.get('f04_32_issuer'), + 'f04_33_issue_year': collection.get('f04_33_issue_year'), + 'f04_34_material': collection.get('f04_34_material'), + 'f04_35_denomination': collection.get('f04_35_denomination'), + 'f04_36_issue_quantity': collection.get('f04_36_issue_quantity'), + 'f05_40_cost_price': float(collection.get('f05_40_cost_price')) if collection.get('f05_40_cost_price') else None, + 'f05_41_target_price': float(collection.get('f05_41_target_price')) if collection.get('f05_41_target_price') else None, + 'f05_42_goal_price': float(collection.get('f05_42_goal_price')) if collection.get('f05_42_goal_price') else None, + 'f05_43_repair_fee': float(collection.get('f05_43_repair_fee')) if collection.get('f05_43_repair_fee') else None, + 'f05_44_grading_fee': float(collection.get('f05_44_grading_fee')) if collection.get('f05_44_grading_fee') else None, + 'f06_50_purpose': collection.get('f06_50_purpose'), + 'f99_92_created_at': collection.get('f99_92_created_at').isoformat() if collection.get('f99_92_created_at') else None, + 'images': [] + } + + # 加载图片数据 + images = db.query(CollectionImage).filter( + CollectionImage.collection_id == collection_id + ).all() + + for img in images: + result_dict['images'].append({ + 'id': img.id, + 'filename': img.filename, + 'original_name': img.original_name, + 'path': img.path, + 'created_at': img.created_at.isoformat() if img.created_at else None + }) + + return to_camel_case(result_dict) + + +@router.post("") +def create_collection( + collection_data: CollectionCreate, + force: bool = False, # 是否强制保存(忽略重复警告) + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建藏品 - 支持冠字号查重""" + from app.core.logging_config import logger + + # 自动生成编码 + final_code = collection_data.f01_02_code or generate_code( + collection_data.f02_11_version or '2024 龙', + current_user.f99_90_id, + db + ) + + # 编号查重(如果提供了编号且不是强制保存) + if not force and final_code: + existing_code = db.query(Collection).filter( + Collection.f01_02_code == final_code, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if existing_code: + logger.warning(f"发现重复编号:{final_code}, 已存在藏品 ID: {existing_code.f99_90_id}") + return { + "error": { + "code": "DUPLICATE_CODE", + "message": f"藏品编号 {final_code} 已存在,请使用其他编号" + } + } + + # 冠字号查重(如果提供了冠字号且不是强制保存) + if not force and collection_data.f02_10_prefix_serial: + # 查询当前用户是否有相同冠字号的藏品 + existing = db.query(Collection).filter( + Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if existing: + logger.warning(f"发现重复冠字号:{collection_data.f02_10_prefix_serial}, 已存在藏品 ID: {existing.f99_90_id}") + # 返回警告信息,让前端询问用户是否继续 + return { + "warning": { + "code": "DUPLICATE_SERIAL", + "message": f"发现重复冠字号:{collection_data.f02_10_prefix_serial}", + "existing_collection": { + "id": existing.f99_90_id, + "name": existing.f01_01_name, + "code": existing.f01_02_code, + "prefix_serial": existing.f02_10_prefix_serial + } + }, + "data": { + "ask_continue": True + } + } + + # 自动分类:如果未提供号码分类,则根据冠字号自动分类 + if not collection_data.f02_14_number_category and collection_data.f02_10_prefix_serial: + from app.utils.number_category import get_number_category + collection_data.f02_14_number_category = get_number_category(collection_data.f02_10_prefix_serial) + + collection = Collection( + f99_91_user_id=current_user.f99_90_id, + f01_01_name=collection_data.f01_01_name, + f01_02_code=final_code, + f01_03_category=collection_data.f01_03_category, + f01_04_status=collection_data.f01_04_status or "in_collection", + f01_05_remark=collection_data.f01_05_remark, + f02_10_prefix_serial=collection_data.f02_10_prefix_serial, + f02_11_version=collection_data.f02_11_version, + f02_12_packaging=collection_data.f02_12_packaging, + f02_13_rarity=collection_data.f02_13_rarity, + f02_14_number_category=collection_data.f02_14_number_category, + f03_20_is_graded=collection_data.f03_20_is_graded or False, + f03_21_grading_company=collection_data.f03_21_grading_company, + f03_22_grading_score=collection_data.f03_22_grading_score, + f03_23_three_star=collection_data.f03_23_three_star or False, + f04_30_special_mark=collection_data.f04_30_special_mark, + f04_31_serial_feature=collection_data.f04_31_serial_feature, + f04_32_issuer=collection_data.f04_32_issuer, + f04_33_issue_year=collection_data.f04_33_issue_year, + f04_34_material=collection_data.f04_34_material, + f04_35_denomination=collection_data.f04_35_denomination, + f04_36_issue_quantity=collection_data.f04_36_issue_quantity, + f05_40_cost_price=collection_data.f05_40_cost_price, + f05_41_target_price=collection_data.f05_41_target_price, + f05_42_goal_price=collection_data.f05_42_goal_price, + f05_43_repair_fee=collection_data.f05_43_repair_fee, + f05_44_grading_fee=collection_data.f05_44_grading_fee, + f06_50_purpose=collection_data.f06_50_purpose + ) + + db.add(collection) + db.commit() + db.refresh(collection) + + return { + 'f99_90_id': collection.f99_90_id, + 'f99_91_user_id': collection.f99_91_user_id, + 'f01_01_name': collection.f01_01_name, + 'f01_02_code': collection.f01_02_code, + 'f01_03_category': collection.f01_03_category, + 'f01_04_status': collection.f01_04_status, + 'f01_05_remark': collection.f01_05_remark, + 'f99_92_created_at': collection.f99_92_created_at.isoformat() if collection.f99_92_created_at else None, + 'message': '创建成功' + } + + +@router.put("/{collection_id}") +def update_collection( + collection_id: str, + collection_data: CollectionUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新藏品""" + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 更新字段 - 使用 model_fields_set 检查哪些字段被设置 + for field_name in collection_data.model_fields_set: + value = getattr(collection_data, field_name) + if value is not None: + setattr(collection, field_name, value) + + db.commit() + db.refresh(collection) + + return { + "f99_90_id": collection.f99_90_id, + "message": "更新成功" + } + + +@router.delete("/{collection_id}") +def delete_collection( + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除藏品""" + # 验证权限并检查是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 使用原生 SQL 删除(避免 ORM 级联查询字段不匹配问题) + from sqlalchemy import text + # 1. 删除关联的 operations(f99_91_user_id 关联到 collections.f99_90_id) + db.execute(text("DELETE FROM operations WHERE f99_91_user_id = :id"), {"id": collection_id}) + # 2. 删除关联的图片 + db.execute(text("DELETE FROM collection_images WHERE collection_id = :id"), {"id": collection_id}) + # 3. 删除藏品本身 + db.execute(text("DELETE FROM collections WHERE f99_90_id = :id"), {"id": collection_id}) + db.commit() + + return {"message": "删除成功"} + + +@router.post("/upload-image") +async def upload_image( + collection_id: str = None, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """上传藏品图片 - 文件名格式:用户名 - 藏品编号 - 冠字号""" + try: + # 验证藏品是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="E00033: 藏品不存在") + + # 获取用户信息(用于文件名) + owner = db.query(User).filter(User.f99_90_id == collection.f99_91_user_id).first() + username = owner.f01_01_name if owner else "unknown" + + # 获取藏品信息(用于文件名) + code = collection.f01_02_code or "0000" + prefix_serial = collection.f02_10_prefix_serial or "" + + # 检查文件类型 + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="E00038: 只能上传图片文件") + + # 检查文件大小(限制 10MB) + file_size = 0 + content = await file.read() + file_size = len(content) + if file_size > 10 * 1024 * 1024: # 10MB + raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB(当前{file_size // 1024 // 1024}MB)") + + # 生成OSS存储路径 + user_id = collection.f99_91_user_id + file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg' + + # 清理特殊字符 + clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username) + clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial) + + # 文件名格式:用户名-藏品编号-冠字号.jpg + if clean_serial: + filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}" + else: + filename = f"{clean_username}-{code}.{file_extension}" + + # 生成OSS key + oss_key, unique_name = get_oss_path("collections", user_id=user_id, filename=filename) + + # 上传到OSS + image_url = upload_to_oss(content, oss_key) + + # 创建图片记录(保存OSS URL) + image = CollectionImage( + id=str(uuid.uuid4()), + collection_id=collection_id, + filename=unique_name, + original_name=file.filename, + path=image_url # 保存OSS URL + ) + + db.add(image) + db.commit() + db.refresh(image) + + logger.info(f"图片上传成功:{image_url}, collection_id={collection_id}") + + return { + "message": "上传成功", + "image_id": image.id, + "filename": unique_name, + "url": image_url + } + except HTTPException: + raise + except Exception as e: + logger.error(f"图片上传失败:{str(e)}") + raise HTTPException(status_code=500, detail="上传失败") + + +@router.delete("/images/{image_id}") +async def delete_image( + image_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除藏品图片""" + try: + # 查找图片记录 + image = db.query(CollectionImage).filter( + CollectionImage.id == image_id + ).first() + + if not image: + raise HTTPException(status_code=404, detail="E00033: 图片不存在") + + # 检查权限 + collection = db.query(Collection).filter( + Collection.f99_90_id == image.collection_id + ).first() + + if collection and (current_user is None or current_user.role != "admin") and collection.f99_91_user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="E00014: 无权删除此图片") + + # 删除OSS文件(如果path是OSS URL) + if image.path and image.path.startswith("https://"): + # 从OSS URL提取key + try: + oss_key = image.path.replace("https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com/", "") + delete_from_oss(oss_key) + except Exception as e: + logger.warning(f"OSS文件删除失败: {e}") + elif image.path and os.path.exists(image.path): + # 兼容旧的本地上传 + os.remove(image.path) + + # 删除数据库记录 + db.delete(image) + db.commit() + + return {"message": "删除成功"} + except HTTPException: + raise + except Exception as e: + logger.error(f"图片删除失败:{str(e)}") + raise HTTPException(status_code=500, detail="删除失败") diff --git a/backend/app/routers/deal.py b/backend/app/routers/deal.py new file mode 100644 index 0000000..4817aff --- /dev/null +++ b/backend/app/routers/deal.py @@ -0,0 +1,302 @@ +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional +from datetime import datetime, date +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.deal_info import DealInfo + +router = APIRouter(prefix="/api/deal", tags=["成交行情"]) + +# ============ Schema ============ +class DealInfoCreate(BaseModel): + title: str + content: Optional[str] = None + deal_price: Optional[float] = None + deal_date: Optional[str] = None # YYYY-MM-DD + packaging: Optional[str] = None + category: Optional[str] = None + is_graded: Optional[bool] = False + grading_company: Optional[str] = None + grading_score: Optional[str] = None + tail_number: Optional[str] = None + size_type: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + seller: Optional[str] = None + buyer: Optional[str] = None + +class DealInfoUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + deal_price: Optional[float] = None + deal_date: Optional[str] = None + packaging: Optional[str] = None + category: Optional[str] = None + is_graded: Optional[bool] = None + grading_company: Optional[str] = None + grading_score: Optional[str] = None + tail_number: Optional[str] = None + size_type: Optional[str] = None + version: Optional[str] = None + platform: Optional[str] = None + seller: Optional[str] = None + buyer: Optional[str] = None + status: Optional[str] = None + +class DealInfoResponse(BaseModel): + id: str + user_id: Optional[str] + title: str + content: Optional[str] + deal_price: Optional[float] + deal_date: Optional[date] + deal_no: Optional[str] + packaging: Optional[str] + category: Optional[str] + is_graded: Optional[bool] + grading_company: Optional[str] + grading_score: Optional[str] + tail_number: Optional[str] + size_type: Optional[str] + version: Optional[str] + platform: Optional[str] + seller: Optional[str] + buyer: Optional[str] + status: str + view_count: int + contact_count: int + created_at: Optional[datetime] + updated_at: Optional[datetime] + + class Config: + from_attributes = True + +# 生成行情编号 +def generate_deal_no(db: Session): + """生成行情编号,从A000001开始递增""" + last = db.query(DealInfo).order_by(DealInfo.deal_no.desc()).first() + if last and last.deal_no: + # 例如 A000001 -> 2 -> A000002 + num = int(last.deal_no[1:]) + 1 + return f"A{num:06d}" + return "A000001" + +# ============ API ============ +@router.get("/list", response_model=list[DealInfoResponse]) +def get_deal_list( + status: str = Query("active"), + deal_date: Optional[str] = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + user_only: bool = Query(False), # 是否只查看自己的 + current_user: Optional = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取成交行情列表""" + query = db.query(DealInfo).filter(DealInfo.status == status) + + # 我的行情:只查看自己的(管理员也只看自己的) + if user_only and current_user: + query = query.filter(DealInfo.user_id == current_user.f99_90_id) + + # 成交日期过滤 + if deal_date: + query = query.filter(DealInfo.deal_date == deal_date) + + # 排序:优先成交日期倒序,同日按编号倒序 + query = query.order_by(DealInfo.deal_date.desc().nullslast(), DealInfo.deal_no.desc().nullslast()) + + # 分页 + offset = (page - 1) * page_size + items = query.offset(offset).limit(page_size).all() + + return items + +@router.get("/stats") +def get_deal_stats( + db: Session = Depends(get_db) +): + """获取成交行情统计""" + total = db.query(DealInfo).filter(DealInfo.status == "active").count() + + # 按日期统计 + from sqlalchemy import func + date_stats = db.query( + DealInfo.deal_date, + func.count(DealInfo.id).label('count') + ).filter( + DealInfo.status == "active", + DealInfo.deal_date.isnot(None) + ).group_by(DealInfo.deal_date).order_by(DealInfo.deal_date.desc()).limit(10).all() + + return { + "total": total, + "by_date": [{"date": str(d.deal_date), "count": d.count} for d in date_stats] + } + +@router.post("", response_model=DealInfoResponse) +def create_deal( + data: DealInfoCreate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建成交行情""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + # 生成行情编号 + deal_no = generate_deal_no(db) + + # 解析日期 + deal_date = None + if data.deal_date: + try: + deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date() + except: + pass + + deal = DealInfo( + user_id=current_user.f99_90_id if current_user else None, + title=data.title, + content=data.content, + deal_price=data.deal_price, + deal_date=deal_date, + deal_no=deal_no, + packaging=data.packaging, + category=data.category, + is_graded=data.is_graded or False, + grading_company=data.grading_company, + grading_score=data.grading_score, + tail_number=data.tail_number, + size_type=data.size_type, + version=data.version, + platform=data.platform, + seller=data.seller, + buyer=data.buyer, + status="active" + ) + db.add(deal) + db.commit() + db.refresh(deal) + return deal + +@router.get("/category-stats") +def get_deal_category_stats( + version: str = Query("龙钞", description="版本筛选:龙钞、马钞、蛇钞、其他"), + db: Session = Depends(get_db) +): + """获取成交行情分类汇总统计数据 - 后端计算优化版""" + from collections import defaultdict + + # 定义版本前缀映射 + version_prefix_map = {"龙钞": "J0", "马钞": "J1", "蛇钞": "J3"} + packagings = ["标百", "标十", "单张"] + category_map = {"通货": "带4号", "无4": "带7号", "永恒": "永恒号", "钻石": "钻石号"} + + # 构建查询 + query = db.query(DealInfo).filter( + DealInfo.status == "active", DealInfo.deal_price.isnot(None), DealInfo.deal_price > 0 + ) + if version != "其他" and version in version_prefix_map: + query = query.filter(DealInfo.title.startswith(version_prefix_map[version])) + + deals = query.all() + stats = defaultdict(lambda: defaultdict(lambda: {"count": 0, "total": 0})) + + for deal in deals: + content = deal.content or "" + packaging = deal.packaging + if not packaging and "包装:" in content: + packaging = content.split("包装:")[1].split("\n")[0].strip() + category = deal.category + if not category and "分类:" in content: + category = content.split("分类:")[1].split("\n")[0].strip() + if category in category_map: + category = category_map[category] + packaging = packaging or "单张" + category = category or "带4号" + stats[packaging][category]["count"] += 1 + stats[packaging][category]["total"] += deal.deal_price + + result = [] + category_order = ['带4号', '带7号', '永恒号', '天马号', '天马王', '金山号', '金山王', '钻石号', '朦胧号', '朦胧王', '金马号', '金马王', '倒置号', '圆圆号'] + for cat in category_order: + row = {"category": cat} + has_data = False + for pkg in packagings: + data = stats[pkg][cat] + if data["count"] > 0: + row[pkg] = {"avg": round(data["total"] / data["count"]), "count": data["count"]} + has_data = True + else: + row[pkg] = None + if has_data: + result.append(row) + return {"version": version, "data": result} + +@router.get("/{deal_id}", response_model=DealInfoResponse) +def get_deal( + deal_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取成交行情详情""" + deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first() + if not deal: + raise HTTPException(status_code=404, detail="成交行情不存在") + + # 增加浏览数 + deal.view_count += 1 + db.commit() + + return deal + +@router.put("/{deal_id}", response_model=DealInfoResponse) +def update_deal( + deal_id: str, + data: DealInfoUpdate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新成交行情""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first() + if not deal: + raise HTTPException(status_code=404, detail="成交行情不存在") + + # 处理日期 + if data.deal_date: + try: + data.deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date() + except: + data.deal_date = None + + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(deal, key, value) + + db.commit() + db.refresh(deal) + return deal + +@router.delete("/{deal_id}") +def delete_deal( + deal_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除成交行情""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first() + if not deal: + raise HTTPException(status_code=404, detail="成交行情不存在") + + deal.status = "deleted" + db.commit() + + return {"message": "删除成功"} \ No newline at end of file diff --git a/backend/app/routers/information.py b/backend/app/routers/information.py new file mode 100644 index 0000000..fc65d26 --- /dev/null +++ b/backend/app/routers/information.py @@ -0,0 +1,1066 @@ +# 资讯API路由 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime, date + +from app.core.database import get_db +from app.core.auth import get_current_user +from app.core.coolbot_db import coolbot_engine +from sqlalchemy import text +from app.models.models import User, Information, Collection + +router = APIRouter(prefix="/api/information", tags=["资讯"]) + + +# Schema +class InformationCreate(BaseModel): + info_type: str # seek-寻配号, deal-成交数据, publish-发布 + title: str + content: Optional[str] = None + collection_id: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + status: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationResponse(BaseModel): + id: str + user_id: str + info_type: str + title: str + content: Optional[str] + collection_id: Optional[str] + expect_category: Optional[str] + expect_version: Optional[str] + expect_packaging: Optional[str] + expect_number: Optional[str] + expect_price_min: Optional[float] + expect_price_max: Optional[float] + deal_price: Optional[float] + deal_date: Optional[date] + status: str + is_matched: Optional[str] = "pending" + matched_user_id: Optional[str] = None + matched_contact: Optional[str] = None + view_count: int + contact_count: int + created_at: datetime + # 用户信息 + user_name: Optional[str] = None + user_avatar: Optional[str] = None + # 关联藏品信息 + collection_name: Optional[str] = None + collection_category: Optional[str] = None + collection_version: Optional[str] = None + collection_number: Optional[str] = None + # 匹配数量(我的藏品中满足条件的数量) + matched_count: Optional[int] = 0 + + class Config: + from_attributes = True + + +# 资讯列表 +@router.get("/list", response_model=List[InformationResponse]) +def get_information_list( + info_type: Optional[str] = Query(None, description="类型: seek/deal/publish"), + status: str = Query("active", description="状态: active/closed/expired"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取资讯列表(公开,无需登录)""" + query = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.status == status) + + if info_type: + query = query.filter(Information.info_type == info_type) + + # 按创建时间倒序 + query = query.order_by(Information.created_at.desc()) + + # 分页 + offset = (page - 1) * page_size + items = query.offset(offset).limit(page_size).all() + + # 转换结果 + result = [] + for item in items: + # 计算匹配数量(仅对seek类型,且用户登录时) + matched_count = 0 + if item.info_type == 'seek' and item.expect_number and current_user: + matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number) + + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + matched_count=matched_count, + )) + + return result + + +def match_collections_count(db: Session, user_id: str, expect_number: str) -> int: + """根据号码特征计算匹配藏品数量""" + if not expect_number or len(expect_number) != 10: + return 0 + + # 固定前缀 + if not expect_number.startswith('J0'): + return 0 + + pattern = expect_number[2:] # 后8位 + if not pattern: + return 0 + + # 获取用户所有藏品 + collections = db.query(Collection).filter( + Collection.f99_91_user_id == user_id, + Collection.f01_04_status == "in_collection" + ).all() + + count = 0 + for c in collections: + number = c.f02_10_prefix_serial or '' + # 去掉J0前缀后取前8位 + if len(number) >= 10 and number.startswith('J0'): + col_pattern = number[2:10] + if match_pattern(col_pattern, pattern): + count += 1 + elif len(number) >= 8: + col_pattern = number[:8] + if match_pattern(col_pattern, pattern): + count += 1 + + return count + + +def match_collections_count_from_coolbot(expect_number: str) -> int: + """根据号码特征计算匹配藏品数量(从coolbot_data数据库,匹配所有藏品)""" + if not expect_number or len(expect_number) < 4: + return 0 + + # 取后8位或更少进行匹配 + pattern = expect_number[2:] if len(expect_number) > 2 else expect_number + if not pattern: + return 0 + + # 查询所有藏品,不限制前缀 + query = text(""" + SELECT id, crown_code FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 8 + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + match_count = 0 + for row in result: + crown_code = row[1] + if crown_code and len(crown_code) >= 8: + # 取后8位进行匹配 + col_pattern = crown_code[-8:] + if match_pattern(col_pattern, pattern): + match_count += 1 + return match_count + except Exception as e: + print("Error querying coolbot_data: {}".format(e)) + return 0 + + +def match_collections_list_from_coolbot(expect_number: str, limit: int = 20) -> List[dict]: + """获取匹配的藏品列表(从coolbot_data数据库,匹配所有藏品)""" + if not expect_number or len(expect_number) < 4: + return [] + + # 取后8位或更少进行匹配 + pattern = expect_number[2:] if len(expect_number) > 2 else expect_number + if not pattern: + return [] + + # 查询所有藏品,不限制前缀 + query = text(""" + SELECT id, name, category, crown_code, price, post_title, post_url, author, post_crawled_at + FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 8 + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + matched = [] + for row in result: + crown_code = row[3] + if crown_code and len(crown_code) >= 10: + col_pattern = crown_code[-8:] + if match_pattern(col_pattern, pattern): + matched.append({ + "id": row[0], + "name": row[1], + "category": row[2], + "crown_code": crown_code, + "price": float(row[4]) if row[4] else None, + "post_title": row[5], + "post_url": row[6], + "author": row[7], + "post_crawled_at": str(row[8]) if row[8] else None + }) + if len(matched) >= limit: + break + return matched + except Exception as e: + print(f"Error querying coolbot_data: {e}") + return [] + + + +def match_pattern(col_number: str, pattern: str) -> bool: + """匹配号码特征模式""" + # X = 任意数字 + # A = 非4 + # B = 非47 + # C = 非347 + # D = 非247 + # E = 非2347 + # F = 非23457 + # G = 非123457 + + # 注意:col_number已经是去掉J0前缀后的8位号码,不需要再处理 + col_num = col_number + + for i, p in enumerate(pattern): + if i >= len(col_num): + return False + + c = col_num[i] + + if p == 'X': + if not c.isdigit(): + return False + elif p == 'A': + if c == '4': + return False + elif p == 'B': + if c in '47': + return False + elif p == 'C': + if c in '347': + return False + elif p == 'D': + if c in '247': + return False + elif p == 'E': + if c in '2347': + return False + elif p == 'F': + if c in '23457': + return False + elif p == 'G': + if c in '123457': + return False + else: + # 数字或字母必须完全匹配 + if p != c: + return False + + return True + + +# 获取单条资讯 +@router.get("/{info_id}", response_model=InformationResponse) +def get_information( + info_id: str, + db: Session = Depends(get_db) +): + """获取资讯详情""" + item = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.id == info_id).first() + + if not item: + raise HTTPException(status_code=404, detail="资讯不存在") + + # 增加浏览次数 + item.view_count += 1 + db.commit() + + return InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + ) + + +# 发布资讯 +@router.post("/", response_model=InformationResponse) +def create_information( + data: InformationCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """发布资讯""" + info = Information( + user_id=current_user.f99_90_id, + info_type=data.info_type, + title=data.title, + content=data.content, + collection_id=data.collection_id, + expect_category=data.expect_category, + expect_version=data.expect_version, + expect_packaging=data.expect_packaging, + expect_number=data.expect_number, + expect_price_min=data.expect_price_min, + expect_price_max=data.expect_price_max, + deal_price=data.deal_price, + deal_date=data.deal_date, + status="active" + ) + db.add(info) + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=None, + collection_category=None, + collection_version=None, + collection_number=None, + ) + + +# 更新资讯 +@router.put("/{info_id}", response_model=InformationResponse) +def update_information( + info_id: str, + data: InformationUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新资讯""" + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权修改") + + # 更新字段 + if data.title is not None: + info.title = data.title + if data.content is not None: + info.content = data.content + if data.status is not None: + info.status = data.status + if data.expect_category is not None: + info.expect_category = data.expect_category + if data.expect_version is not None: + info.expect_version = data.expect_version + if data.expect_packaging is not None: + info.expect_packaging = data.expect_packaging + if data.expect_number is not None: + info.expect_number = data.expect_number + if data.expect_price_min is not None: + info.expect_price_min = data.expect_price_min + if data.expect_price_max is not None: + info.expect_price_max = data.expect_price_max + if data.deal_price is not None: + info.deal_price = data.deal_price + if data.deal_date is not None: + info.deal_date = data.deal_date + + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=info.collection.f01_01_name if info.collection else None, + collection_category=info.collection.f01_03_category if info.collection else None, + collection_version=info.collection.f02_11_version if info.collection else None, + collection_number=info.collection.f02_10_prefix_serial if info.collection else None, + ) + + +# 删除资讯 +@router.delete("/{info_id}") +def delete_information( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除资讯""" + # 允许admin删除任何人的资讯 + if current_user.role == "admin": + info = db.query(Information).filter(Information.id == info_id).first() + else: + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权删除") + + db.delete(info) + db.commit() + + return {"message": "删除成功"} + + +# 寻配号 - 自动匹配推荐藏品 +@router.get("/seek/match") +def get_seek_match( + info_id: str, + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取符合条件的我的藏品推荐""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 更新用户配号(寻号)次数 + current_user.f99_96_search_count = (current_user.f99_96_search_count or 0) + 1 + db.commit() + + # 获取用户所有藏品 + collections = db.query(Collection).filter( + Collection.f99_91_user_id == current_user.f99_90_id, + Collection.f01_04_status == "in_collection" + ).all() + + # 去掉版别筛选,因为藏品分类和发布需求的版别不同 + # if info.expect_category: + # collections = [c for c in collections if c.f01_03_category == info.expect_category] + + # 按号码特征模式匹配 + matched = [] + if info.expect_number and len(info.expect_number) == 10: + pattern = info.expect_number[2:] # 后8位 + for c in collections: + number = c.f02_10_prefix_serial or '' + # 去掉J0前缀后取前8位 + if len(number) >= 10 and number.startswith('J0'): + col_pattern = number[2:10] # 取J0后面的8位 + if match_pattern(col_pattern, pattern): + matched.append(c) + elif len(number) >= 8: + col_pattern = number[:8] # 取前8位 + if match_pattern(col_pattern, pattern): + matched.append(c) + else: + matched = collections + + return { + "info_id": info_id, + "matched_count": len(matched), + "collections": [ + { + "id": c.f99_90_id, + "code": c.f01_02_code or '', + "name": c.f01_01_name, + "number": c.f02_10_prefix_serial, + "status": c.f01_04_status, + "category": c.f01_03_category, + "version": c.f02_11_version, + "packaging": c.f02_12_packaging, + "cost_price": c.f05_40_cost_price, + } + for c in matched + ] + } + + +# 我的寻号列表 +@router.get("/my-seeks") +def get_my_seeks( + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户发布的所有寻号信息""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + items = db.query(Information).filter( + Information.user_id == current_user.f99_90_id, + Information.info_type == "seek", + Information.status == "active" + ).order_by(Information.created_at.desc()).all() + + result = [] + for item in items: + # 计算匹配数量 + matched_count = 0 + if item.expect_number: + matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number) + + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + matched_count=matched_count, + )) + + return result + + +# 成交数据统计 +@router.get("/deal/stats") +def get_deal_stats( + days: int = Query(7, ge=1, le=90, description="统计天数"), + db: Session = Depends(get_db) +): + """获取成交数据统计""" + from sqlalchemy import func + from datetime import timedelta + + start_date = datetime.now() - timedelta(days=days) + + # 按版别统计 + by_version = db.query( + Information.expect_version, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price"), + func.max(Information.deal_price).label("max_price"), + func.min(Information.deal_price).label("min_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_version).all() + + # 按包装统计 + by_packaging = db.query( + Information.expect_packaging, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_packaging).all() + + # 按号码分类统计 + by_number = db.query( + Information.expect_number, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.expect_number.isnot(None), + Information.created_at >= start_date + ).group_by(Information.expect_number).all() + + return { + "days": days, + "by_version": [ + {"version": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0), "max_price": float(r[3] or 0), "min_price": float(r[4] or 0)} + for r in by_version if r[0] + ], + "by_packaging": [ + {"packaging": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_packaging if r[0] + ], + "by_number": [ + {"number": r[0], "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_number + ] + } + + +# 获取我的发布列表 +@router.get("/my/list", response_model=List[InformationResponse]) +def get_my_information_list( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取我的发布列表""" + items = db.query(Information).options( + joinedload(Information.collection) + ).filter( + Information.user_id == current_user.f99_90_id + ).order_by(Information.created_at.desc()).offset((page-1)*page_size).limit(page_size).all() + + result = [] + for item in items: + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + )) + + return result + +# ============ 获取当前用户发布的列表 ============ +@router.get("/my") +def get_my_information( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户发布的信息列表""" + total = db.query(Information).filter(Information.author == current_user.f01_01_name).count() + infos = db.query(Information).filter( + Information.author == current_user.f01_01_name + ).order_by(Information.created_at.desc()).offset((page-1)*limit).limit(limit).all() + + return { + "total": total, + "list": [{ + "id": i.id, + "title": i.title, + "content": i.content, + "info_type": i.info_type, + "author": i.author, + "created_at": i.created_at.isoformat() if i.created_at else None + } for i in infos] + } + + +# ============ 匹配寻号 ============ +class MatchSeekRequest(BaseModel): + info_id: str + collection_id: Optional[str] = None + contact: Optional[str] = None + + +@router.post("/seek/match-confirm") +def match_seek( + request: MatchSeekRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """确认匹配寻号 - 用户愿意交换联系方式给发布者""" + info = db.query(Information).filter( + Information.id == request.info_id, + Information.info_type == "seek", + Information.status == "active" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 检查是否已被匹配(一个寻号只能被一个用户匹配) + if info.is_matched == "matched": + raise HTTPException(status_code=400, detail="该寻号已被其他用户匹配") + + # 更新匹配状态 + info.is_matched = "matched" + info.matched_user_id = current_user.f99_90_id + # 保存匹配者的联系方式 + info.matched_contact = request.contact or '' + + # 更新发布寻号者的内容,显示有藏品被匹配 + original_content = info.content or "" + # 添加匹配信息:藏品被XX藏友匹配,联系方式为:xxx + match_info = f"\n藏品被{current_user.f01_01_name}匹配成功!联系方式:{request.collection_id or '已交换'}" + info.content = original_content + match_info + + db.commit() + + return {"message": "匹配成功,已通知发布者", "is_matched": "matched"} + + +# ============ 添加留言 ============ +class CommentRequest(BaseModel): + information_id: str + content: str + + +@router.post("/comment") +def add_comment( + request: CommentRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """添加留言""" + info = db.query(Information).filter( + Information.id == request.information_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在") + + # 创建留言 + from app.models.models import InformationComment + comment = InformationComment( + information_id=request.information_id, + user_id=current_user.f99_90_id, + content=request.content + ) + db.add(comment) + db.commit() + + return { + "message": "留言成功", + "comment": { + "id": comment.id, + "content": comment.content, + "user_name": current_user.f01_01_name, + "user_avatar": current_user.avatar, + "created_at": comment.created_at + } + } + + +# ============ 获取评论列表 ============ +@router.get("/comments/{information_id}") +def get_comments( + information_id: str, + db: Session = Depends(get_db) +): + """获取资讯的评论列表""" + from app.models.models import InformationComment + comments = db.query(InformationComment).filter( + InformationComment.information_id == information_id + ).order_by(InformationComment.created_at.desc()).all() + + return [ + { + "id": c.id, + "content": c.content, + "user_name": c.user.f01_01_name if c.user else '匿名用户', + "user_avatar": c.user.avatar if c.user else None, + "created_at": c.created_at + } + for c in comments + ] + + +# ============ 获取匹配者信息 ============ +@router.get("/seek/matched-user/{info_id}") +def get_matched_user( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的匹配者信息(仅发布者可见)""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有发布者可以看到匹配者信息 + if info.user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + if not info.matched_user_id: + return {"message": "暂无匹配者"} + + # 获取匹配者信息 + matched_user = db.query(User).filter(User.f99_90_id == info.matched_user_id).first() + if not matched_user: + return {"message": "匹配者不存在"} + + return { + "matched_user_id": info.matched_user_id, + "user_name": matched_user.f01_01_name, + "phone": matched_user.phone, + "matched_contact": info.matched_contact, + "matched_at": info.updated_at.isoformat() if info.updated_at else None + } + + +# ============ 获取发布者信息 ============ +@router.get("/seek/publisher/{info_id}") +def get_publisher_info( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的发布者信息(仅匹配者可见)""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有匹配者可以看到发布者信息 + if not info.matched_user_id or info.matched_user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + # 获取发布者信息 + publisher = db.query(User).filter(User.f99_90_id == info.user_id).first() + if not publisher: + return {"message": "发布者不存在"} + + # 从content中解析联系方式 + contact = '' + if info.content: + import re + match = re.search(r'联系方式[:\s]*(.+?)(?:\n|$)', info.content) + if match: + contact = match.group(1).strip() + + return { + "user_id": info.user_id, + "user_name": publisher.f01_01_name, + "phone": publisher.phone, + "contact": contact, + "created_at": info.created_at.isoformat() if info.created_at else None + } + + +# 获取网络数据匹配列表 +@router.get("/seek/network-match/{info_id}") +def get_network_match( + info_id: str, + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """获取一尘数据库中匹配的藏品列表""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + if not info.expect_number: + return {"matched_count": 0, "collections": []} + + matched = match_collections_list_from_coolbot(info.expect_number, limit=limit) + + return { + "matched_count": len(matched), + "collections": matched + } + + +# 获取所有seek列表(包含网络匹配数量) +@router.get("/seek/list") +def get_seek_list_all( + status: str = Query("active"), + user_id: Optional[str] = None, + user_only: bool = Query(False), + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻配号列表(包含网络匹配数量)""" + query = db.query(Information).filter( + Information.info_type == "seek", + Information.status == status + ) + + if user_only and current_user: + query = query.filter(Information.user_id == current_user.f99_90_id) + + if user_id: + query = query.filter(Information.user_id == user_id) + + items = query.order_by(Information.created_at.desc()).all() + + result = [] + for item in items: + # 计算自有匹配数量 + matched_count = 0 + if item.expect_number: + matched_count = match_collections_count(db, current_user.f99_90_id if current_user else "", item.expect_number) + + # 计算网络匹配数量 + network_matched_count = 0 + if item.expect_number: + network_matched_count = match_collections_count_from_coolbot(item.expect_number) + + result.append({ + "id": item.id, + "user_id": item.user_id, + "title": item.title, + "content": item.content, + "expect_category": item.expect_category, + "expect_version": item.expect_version, + "expect_packaging": item.expect_packaging, + "expect_number": item.expect_number, + "expect_price_min": item.expect_price_min, + "expect_price_max": item.expect_price_max, + "status": item.status, + "is_matched": item.is_matched, + "matched_user_id": item.matched_user_id, + "matched_contact": item.matched_contact, + "view_count": item.view_count, + "contact_count": item.contact_count, + "created_at": item.created_at.isoformat() if item.created_at else None, + "user_name": item.user.f01_01_name if item.user else None, + "matched_count": matched_count, + "network_matched_count": network_matched_count + }) + + return result diff --git a/backend/app/routers/information.py.bak b/backend/app/routers/information.py.bak new file mode 100644 index 0000000..46ed3b8 --- /dev/null +++ b/backend/app/routers/information.py.bak @@ -0,0 +1,1065 @@ +# 资讯API路由 +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session, joinedload +from typing import List, Optional +from pydantic import BaseModel +from datetime import datetime, date + +from app.core.database import get_db +from app.core.auth import get_current_user +from app.core.coolbot_db import coolbot_engine +from sqlalchemy import text +from app.models.models import User, Information, Collection + +router = APIRouter(prefix="/api/information", tags=["资讯"]) + + +# Schema +class InformationCreate(BaseModel): + info_type: str # seek-寻配号, deal-成交数据, publish-发布 + title: str + content: Optional[str] = None + collection_id: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + status: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + deal_price: Optional[float] = None + deal_date: Optional[date] = None + + +class InformationResponse(BaseModel): + id: str + user_id: str + info_type: str + title: str + content: Optional[str] + collection_id: Optional[str] + expect_category: Optional[str] + expect_version: Optional[str] + expect_packaging: Optional[str] + expect_number: Optional[str] + expect_price_min: Optional[float] + expect_price_max: Optional[float] + deal_price: Optional[float] + deal_date: Optional[date] + status: str + is_matched: Optional[str] = "pending" + matched_user_id: Optional[str] = None + matched_contact: Optional[str] = None + view_count: int + contact_count: int + created_at: datetime + # 用户信息 + user_name: Optional[str] = None + user_avatar: Optional[str] = None + # 关联藏品信息 + collection_name: Optional[str] = None + collection_category: Optional[str] = None + collection_version: Optional[str] = None + collection_number: Optional[str] = None + # 匹配数量(我的藏品中满足条件的数量) + matched_count: Optional[int] = 0 + + class Config: + from_attributes = True + + +# 资讯列表 +@router.get("/list", response_model=List[InformationResponse]) +def get_information_list( + info_type: Optional[str] = Query(None, description="类型: seek/deal/publish"), + status: str = Query("active", description="状态: active/closed/expired"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取资讯列表(公开,无需登录)""" + query = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.status == status) + + if info_type: + query = query.filter(Information.info_type == info_type) + + # 按创建时间倒序 + query = query.order_by(Information.created_at.desc()) + + # 分页 + offset = (page - 1) * page_size + items = query.offset(offset).limit(page_size).all() + + # 转换结果 + result = [] + for item in items: + # 计算匹配数量(仅对seek类型,且用户登录时) + matched_count = 0 + if item.info_type == 'seek' and item.expect_number and current_user: + matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number) + + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + matched_count=matched_count, + )) + + return result + + +def match_collections_count(db: Session, user_id: str, expect_number: str) -> int: + """根据号码特征计算匹配藏品数量""" + if not expect_number or len(expect_number) != 10: + return 0 + + # 固定前缀 + if not expect_number.startswith('J0'): + return 0 + + pattern = expect_number[2:] # 后8位 + if not pattern: + return 0 + + # 获取用户所有藏品 + collections = db.query(Collection).filter( + Collection.f99_91_user_id == user_id, + Collection.f01_04_status == "in_collection" + ).all() + + count = 0 + for c in collections: + number = c.f02_10_prefix_serial or '' + # 去掉J0前缀后取前8位 + if len(number) >= 10 and number.startswith('J0'): + col_pattern = number[2:10] + if match_pattern(col_pattern, pattern): + count += 1 + elif len(number) >= 8: + col_pattern = number[:8] + if match_pattern(col_pattern, pattern): + count += 1 + + return count + + +def match_collections_count_from_coolbot(expect_number: str) -> int: + """根据号码特征计算匹配藏品数量(从coolbot_data数据库)""" + if not expect_number or len(expect_number) != 10: + return 0 + if not expect_number.startswith('J0'): + return 0 + pattern = expect_number[2:] + if not pattern: + return 0 + + query = text(""" + SELECT id, crown_code FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 10 + AND crown_code LIKE 'J0%' + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + match_count = 0 + for row in result: + crown_code = row[1] + if crown_code and len(crown_code) >= 10: + col_pattern = crown_code[2:10] + if match_pattern(col_pattern, pattern): + match_count += 1 + return match_count + except Exception as e: + print(f"Error querying coolbot_data: {e}") + return 0 + + +def match_collections_list_from_coolbot(expect_number: str, limit: int = 20) -> List[dict]: + """获取匹配的藏品列表(从coolbot_data数据库)""" + if not expect_number or len(expect_number) != 10: + return [] + if not expect_number.startswith('J0'): + return [] + pattern = expect_number[2:] + if not pattern: + return [] + + query = text(""" + SELECT id, name, category, crown_code, price, post_title, post_url, author, post_crawled_at + FROM collections + WHERE crown_code IS NOT NULL + AND crown_code != '' + AND LENGTH(crown_code) >= 10 + AND crown_code LIKE 'J0%' + """) + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query) + matched = [] + for row in result: + crown_code = row[3] + if crown_code and len(crown_code) >= 10: + col_pattern = crown_code[2:10] + if match_pattern(col_pattern, pattern): + matched.append({ + "id": row[0], + "name": row[1], + "category": row[2], + "crown_code": crown_code, + "price": float(row[4]) if row[4] else None, + "post_title": row[5], + "post_url": row[6], + "author": row[7], + "post_crawled_at": str(row[8]) if row[8] else None + }) + if len(matched) >= limit: + break + return matched + except Exception as e: + print(f"Error querying coolbot_data: {e}") + return [] + + + +def match_pattern(col_number: str, pattern: str) -> bool: + """匹配号码特征模式""" + # X = 任意数字 + # A = 非4 + # B = 非47 + # C = 非347 + # D = 非247 + # E = 非2347 + # F = 非23457 + # G = 非123457 + + # 注意:col_number已经是去掉J0前缀后的8位号码,不需要再处理 + col_num = col_number + + for i, p in enumerate(pattern): + if i >= len(col_num): + return False + + c = col_num[i] + + if p == 'X': + if not c.isdigit(): + return False + elif p == 'A': + if c == '4': + return False + elif p == 'B': + if c in '47': + return False + elif p == 'C': + if c in '347': + return False + elif p == 'D': + if c in '247': + return False + elif p == 'E': + if c in '2347': + return False + elif p == 'F': + if c in '23457': + return False + elif p == 'G': + if c in '123457': + return False + else: + # 数字或字母必须完全匹配 + if p != c: + return False + + return True + + +# 获取单条资讯 +@router.get("/{info_id}", response_model=InformationResponse) +def get_information( + info_id: str, + db: Session = Depends(get_db) +): + """获取资讯详情""" + item = db.query(Information).options( + joinedload(Information.user), + joinedload(Information.collection) + ).filter(Information.id == info_id).first() + + if not item: + raise HTTPException(status_code=404, detail="资讯不存在") + + # 增加浏览次数 + item.view_count += 1 + db.commit() + + return InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + ) + + +# 发布资讯 +@router.post("/", response_model=InformationResponse) +def create_information( + data: InformationCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """发布资讯""" + info = Information( + user_id=current_user.f99_90_id, + info_type=data.info_type, + title=data.title, + content=data.content, + collection_id=data.collection_id, + expect_category=data.expect_category, + expect_version=data.expect_version, + expect_packaging=data.expect_packaging, + expect_number=data.expect_number, + expect_price_min=data.expect_price_min, + expect_price_max=data.expect_price_max, + deal_price=data.deal_price, + deal_date=data.deal_date, + status="active" + ) + db.add(info) + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=None, + collection_category=None, + collection_version=None, + collection_number=None, + ) + + +# 更新资讯 +@router.put("/{info_id}", response_model=InformationResponse) +def update_information( + info_id: str, + data: InformationUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新资讯""" + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权修改") + + # 更新字段 + if data.title is not None: + info.title = data.title + if data.content is not None: + info.content = data.content + if data.status is not None: + info.status = data.status + if data.expect_category is not None: + info.expect_category = data.expect_category + if data.expect_version is not None: + info.expect_version = data.expect_version + if data.expect_packaging is not None: + info.expect_packaging = data.expect_packaging + if data.expect_number is not None: + info.expect_number = data.expect_number + if data.expect_price_min is not None: + info.expect_price_min = data.expect_price_min + if data.expect_price_max is not None: + info.expect_price_max = data.expect_price_max + if data.deal_price is not None: + info.deal_price = data.deal_price + if data.deal_date is not None: + info.deal_date = data.deal_date + + db.commit() + db.refresh(info) + + return InformationResponse( + id=info.id, + user_id=info.user_id, + info_type=info.info_type, + title=info.title, + content=info.content, + collection_id=info.collection_id, + expect_category=info.expect_category, + expect_version=info.expect_version, + expect_packaging=info.expect_packaging, + expect_number=info.expect_number, + expect_price_min=info.expect_price_min, + expect_price_max=info.expect_price_max, + deal_price=info.deal_price, + deal_date=info.deal_date, + status=info.status, + view_count=info.view_count, + contact_count=info.contact_count, + created_at=info.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=info.collection.f01_01_name if info.collection else None, + collection_category=info.collection.f01_03_category if info.collection else None, + collection_version=info.collection.f02_11_version if info.collection else None, + collection_number=info.collection.f02_10_prefix_serial if info.collection else None, + ) + + +# 删除资讯 +@router.delete("/{info_id}") +def delete_information( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除资讯""" + # 允许admin删除任何人的资讯 + if current_user.role == "admin": + info = db.query(Information).filter(Information.id == info_id).first() + else: + info = db.query(Information).filter( + Information.id == info_id, + Information.user_id == current_user.f99_90_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在或无权删除") + + db.delete(info) + db.commit() + + return {"message": "删除成功"} + + +# 寻配号 - 自动匹配推荐藏品 +@router.get("/seek/match") +def get_seek_match( + info_id: str, + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取符合条件的我的藏品推荐""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 更新用户配号(寻号)次数 + current_user.f99_96_search_count = (current_user.f99_96_search_count or 0) + 1 + db.commit() + + # 获取用户所有藏品 + collections = db.query(Collection).filter( + Collection.f99_91_user_id == current_user.f99_90_id, + Collection.f01_04_status == "in_collection" + ).all() + + # 去掉版别筛选,因为藏品分类和发布需求的版别不同 + # if info.expect_category: + # collections = [c for c in collections if c.f01_03_category == info.expect_category] + + # 按号码特征模式匹配 + matched = [] + if info.expect_number and len(info.expect_number) == 10: + pattern = info.expect_number[2:] # 后8位 + for c in collections: + number = c.f02_10_prefix_serial or '' + # 去掉J0前缀后取前8位 + if len(number) >= 10 and number.startswith('J0'): + col_pattern = number[2:10] # 取J0后面的8位 + if match_pattern(col_pattern, pattern): + matched.append(c) + elif len(number) >= 8: + col_pattern = number[:8] # 取前8位 + if match_pattern(col_pattern, pattern): + matched.append(c) + else: + matched = collections + + return { + "info_id": info_id, + "matched_count": len(matched), + "collections": [ + { + "id": c.f99_90_id, + "code": c.f01_02_code or '', + "name": c.f01_01_name, + "number": c.f02_10_prefix_serial, + "status": c.f01_04_status, + "category": c.f01_03_category, + "version": c.f02_11_version, + "packaging": c.f02_12_packaging, + "cost_price": c.f05_40_cost_price, + } + for c in matched + ] + } + + +# 我的寻号列表 +@router.get("/my-seeks") +def get_my_seeks( + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户发布的所有寻号信息""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + items = db.query(Information).filter( + Information.user_id == current_user.f99_90_id, + Information.info_type == "seek", + Information.status == "active" + ).order_by(Information.created_at.desc()).all() + + result = [] + for item in items: + # 计算匹配数量 + matched_count = 0 + if item.expect_number: + matched_count = match_collections_count(db, current_user.f99_90_id, item.expect_number) + + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=item.user.f01_01_name if item.user else None, + user_avatar=item.user.avatar if item.user else None, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + matched_count=matched_count, + )) + + return result + + +# 成交数据统计 +@router.get("/deal/stats") +def get_deal_stats( + days: int = Query(7, ge=1, le=90, description="统计天数"), + db: Session = Depends(get_db) +): + """获取成交数据统计""" + from sqlalchemy import func + from datetime import timedelta + + start_date = datetime.now() - timedelta(days=days) + + # 按版别统计 + by_version = db.query( + Information.expect_version, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price"), + func.max(Information.deal_price).label("max_price"), + func.min(Information.deal_price).label("min_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_version).all() + + # 按包装统计 + by_packaging = db.query( + Information.expect_packaging, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.created_at >= start_date + ).group_by(Information.expect_packaging).all() + + # 按号码分类统计 + by_number = db.query( + Information.expect_number, + func.count(Information.id).label("count"), + func.avg(Information.deal_price).label("avg_price") + ).filter( + Information.info_type == "deal", + Information.status == "active", + Information.expect_number.isnot(None), + Information.created_at >= start_date + ).group_by(Information.expect_number).all() + + return { + "days": days, + "by_version": [ + {"version": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0), "max_price": float(r[3] or 0), "min_price": float(r[4] or 0)} + for r in by_version if r[0] + ], + "by_packaging": [ + {"packaging": r[0] or "未知", "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_packaging if r[0] + ], + "by_number": [ + {"number": r[0], "count": r[1], "avg_price": float(r[2] or 0)} + for r in by_number + ] + } + + +# 获取我的发布列表 +@router.get("/my/list", response_model=List[InformationResponse]) +def get_my_information_list( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取我的发布列表""" + items = db.query(Information).options( + joinedload(Information.collection) + ).filter( + Information.user_id == current_user.f99_90_id + ).order_by(Information.created_at.desc()).offset((page-1)*page_size).limit(page_size).all() + + result = [] + for item in items: + result.append(InformationResponse( + id=item.id, + user_id=item.user_id, + info_type=item.info_type, + title=item.title, + content=item.content, + collection_id=item.collection_id, + expect_category=item.expect_category, + expect_version=item.expect_version, + expect_packaging=item.expect_packaging, + expect_number=item.expect_number, + expect_price_min=item.expect_price_min, + expect_price_max=item.expect_price_max, + deal_price=item.deal_price, + deal_date=item.deal_date, + status=item.status, + is_matched=item.is_matched, + matched_user_id=item.matched_user_id, + matched_contact=item.matched_contact, + view_count=item.view_count, + contact_count=item.contact_count, + created_at=item.created_at, + user_name=current_user.f01_01_name, + user_avatar=current_user.avatar, + collection_name=item.collection.f01_01_name if item.collection else None, + collection_category=item.collection.f01_03_category if item.collection else None, + collection_version=item.collection.f02_11_version if item.collection else None, + collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + )) + + return result + +# ============ 获取当前用户发布的列表 ============ +@router.get("/my") +def get_my_information( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取当前用户发布的信息列表""" + total = db.query(Information).filter(Information.author == current_user.f01_01_name).count() + infos = db.query(Information).filter( + Information.author == current_user.f01_01_name + ).order_by(Information.created_at.desc()).offset((page-1)*limit).limit(limit).all() + + return { + "total": total, + "list": [{ + "id": i.id, + "title": i.title, + "content": i.content, + "info_type": i.info_type, + "author": i.author, + "created_at": i.created_at.isoformat() if i.created_at else None + } for i in infos] + } + + +# ============ 匹配寻号 ============ +class MatchSeekRequest(BaseModel): + info_id: str + collection_id: Optional[str] = None + contact: Optional[str] = None + + +@router.post("/seek/match-confirm") +def match_seek( + request: MatchSeekRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """确认匹配寻号 - 用户愿意交换联系方式给发布者""" + info = db.query(Information).filter( + Information.id == request.info_id, + Information.info_type == "seek", + Information.status == "active" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 检查是否已被匹配(一个寻号只能被一个用户匹配) + if info.is_matched == "matched": + raise HTTPException(status_code=400, detail="该寻号已被其他用户匹配") + + # 更新匹配状态 + info.is_matched = "matched" + info.matched_user_id = current_user.f99_90_id + # 保存匹配者的联系方式 + info.matched_contact = request.contact or '' + + # 更新发布寻号者的内容,显示有藏品被匹配 + original_content = info.content or "" + # 添加匹配信息:藏品被XX藏友匹配,联系方式为:xxx + match_info = f"\n藏品被{current_user.f01_01_name}匹配成功!联系方式:{request.collection_id or '已交换'}" + info.content = original_content + match_info + + db.commit() + + return {"message": "匹配成功,已通知发布者", "is_matched": "matched"} + + +# ============ 添加留言 ============ +class CommentRequest(BaseModel): + information_id: str + content: str + + +@router.post("/comment") +def add_comment( + request: CommentRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """添加留言""" + info = db.query(Information).filter( + Information.id == request.information_id + ).first() + + if not info: + raise HTTPException(status_code=404, detail="资讯不存在") + + # 创建留言 + from app.models.models import InformationComment + comment = InformationComment( + information_id=request.information_id, + user_id=current_user.f99_90_id, + content=request.content + ) + db.add(comment) + db.commit() + + return { + "message": "留言成功", + "comment": { + "id": comment.id, + "content": comment.content, + "user_name": current_user.f01_01_name, + "user_avatar": current_user.avatar, + "created_at": comment.created_at + } + } + + +# ============ 获取评论列表 ============ +@router.get("/comments/{information_id}") +def get_comments( + information_id: str, + db: Session = Depends(get_db) +): + """获取资讯的评论列表""" + from app.models.models import InformationComment + comments = db.query(InformationComment).filter( + InformationComment.information_id == information_id + ).order_by(InformationComment.created_at.desc()).all() + + return [ + { + "id": c.id, + "content": c.content, + "user_name": c.user.f01_01_name if c.user else '匿名用户', + "user_avatar": c.user.avatar if c.user else None, + "created_at": c.created_at + } + for c in comments + ] + + +# ============ 获取匹配者信息 ============ +@router.get("/seek/matched-user/{info_id}") +def get_matched_user( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的匹配者信息(仅发布者可见)""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有发布者可以看到匹配者信息 + if info.user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + if not info.matched_user_id: + return {"message": "暂无匹配者"} + + # 获取匹配者信息 + matched_user = db.query(User).filter(User.f99_90_id == info.matched_user_id).first() + if not matched_user: + return {"message": "匹配者不存在"} + + return { + "matched_user_id": info.matched_user_id, + "user_name": matched_user.f01_01_name, + "phone": matched_user.phone, + "matched_contact": info.matched_contact, + "matched_at": info.updated_at.isoformat() if info.updated_at else None + } + + +# ============ 获取发布者信息 ============ +@router.get("/seek/publisher/{info_id}") +def get_publisher_info( + info_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻号的发布者信息(仅匹配者可见)""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + # 只有匹配者可以看到发布者信息 + if not info.matched_user_id or info.matched_user_id != current_user.f99_90_id: + raise HTTPException(status_code=403, detail="无权访问") + + # 获取发布者信息 + publisher = db.query(User).filter(User.f99_90_id == info.user_id).first() + if not publisher: + return {"message": "发布者不存在"} + + # 从content中解析联系方式 + contact = '' + if info.content: + import re + match = re.search(r'联系方式[:\s]*(.+?)(?:\n|$)', info.content) + if match: + contact = match.group(1).strip() + + return { + "user_id": info.user_id, + "user_name": publisher.f01_01_name, + "phone": publisher.phone, + "contact": contact, + "created_at": info.created_at.isoformat() if info.created_at else None + } + + +# 获取网络数据匹配列表 +@router.get("/seek/network-match/{info_id}") +def get_network_match( + info_id: str, + limit: int = Query(20, ge=1, le=100), + db: Session = Depends(get_db) +): + """获取一尘数据库中匹配的藏品列表""" + info = db.query(Information).filter( + Information.id == info_id, + Information.info_type == "seek" + ).first() + + if not info: + raise HTTPException(status_code=404, detail="寻配号信息不存在") + + if not info.expect_number: + return {"matched_count": 0, "collections": []} + + matched = match_collections_list_from_coolbot(info.expect_number, limit=limit) + + return { + "matched_count": len(matched), + "collections": matched + } + + +# 获取所有seek列表(包含网络匹配数量) +@router.get("/seek/list") +def get_seek_list_all( + status: str = Query("active"), + user_id: Optional[str] = None, + user_only: bool = Query(False), + current_user: Optional[User] = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻配号列表(包含网络匹配数量)""" + query = db.query(Information).filter( + Information.info_type == "seek", + Information.status == status + ) + + if user_only and current_user: + query = query.filter(Information.user_id == current_user.f99_90_id) + + if user_id: + query = query.filter(Information.user_id == user_id) + + items = query.order_by(Information.created_at.desc()).all() + + result = [] + for item in items: + # 计算自有匹配数量 + matched_count = 0 + if item.expect_number: + matched_count = match_collections_count(db, current_user.f99_90_id if current_user else "", item.expect_number) + + # 计算网络匹配数量 + network_matched_count = 0 + if item.expect_number: + network_matched_count = match_collections_count_from_coolbot(item.expect_number) + + result.append({ + "id": item.id, + "user_id": item.user_id, + "title": item.title, + "content": item.content, + "expect_category": item.expect_category, + "expect_version": item.expect_version, + "expect_packaging": item.expect_packaging, + "expect_number": item.expect_number, + "expect_price_min": item.expect_price_min, + "expect_price_max": item.expect_price_max, + "status": item.status, + "is_matched": item.is_matched, + "matched_user_id": item.matched_user_id, + "matched_contact": item.matched_contact, + "view_count": item.view_count, + "contact_count": item.contact_count, + "created_at": item.created_at.isoformat() if item.created_at else None, + "user_name": item.user.f01_01_name if item.user else None, + "matched_count": matched_count, + "network_matched_count": network_matched_count + }) + + return result diff --git a/backend/app/routers/news.py b/backend/app/routers/news.py new file mode 100644 index 0000000..c969522 --- /dev/null +++ b/backend/app/routers/news.py @@ -0,0 +1,128 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import Table, MetaData +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, date +from app.core.database import get_db, engine +from app.models.models import User +from app.routers.auth import get_current_user + +router = APIRouter(prefix="/api/news", tags=["资讯"]) +metadata = MetaData() + +# 分类表 +categories_table = Table('news_categories', metadata, autoload_with=engine) +news_table = Table('news', metadata, autoload_with=engine) +user_posts_table = Table('user_posts', metadata, autoload_with=engine) +users_table = Table('users', metadata, autoload_with=engine) +deals_table = Table('deals', metadata, autoload_with=engine) +notifications_table = Table('notifications', metadata, autoload_with=engine) + +# ============ 获取分类 ============ +@router.get("/categories") +def get_categories(db: Session = Depends(get_db)): + results = db.query(categories_table).order_by(categories_table.c.sort_order).all() + return [dict(r._mapping) for r in results] + +# ============ 获取资讯 ============ +@router.get("") +def get_news( + category_id: Optional[int] = None, + page: int = 1, + limit: int = 20, + db: Session = Depends(get_db) +): + query = db.query(news_table) + if category_id: + query = query.filter(news_table.c.category_id == category_id) + offset = (page - 1) * limit + results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all() + return [dict(r._mapping) for r in results] + +# ============ 获取用户发布 ============ +@router.get("/posts") +def get_posts( + post_type: Optional[str] = None, + status: str = "active", + page: int = 1, + limit: int = 20, + db: Session = Depends(get_db) +): + query = db.query(user_posts_table).filter(user_posts_table.c.status == status) + if post_type: + query = query.filter(user_posts_table.c.post_type == post_type) + offset = (page - 1) * limit + results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all() + return [dict(r._mapping) for r in results] + +# ============ 创建发布 ============ +class PostCreate(BaseModel): + post_type: str + title: str + content: Optional[str] = None + zodiac_type: Optional[str] = None + packaging: Optional[str] = None + +@router.post("/posts") +def create_post( + post: PostCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + result = db.execute(user_posts_table.insert().values( + user_id=current_user.f99_90_id, + post_type=post.post_type, + title=post.title, + content=post.content, + zodiac_type=post.zodiac_type, + packaging=post.packaging, + status="pending" + )) + db.commit() + return {"success": True, "id": result.inserted_primary_key[0]} + +# ============ 成交数据 ============ +@router.get("/deals") +def get_deals( + zodiac_type: Optional[str] = None, + limit: int = 20, + db: Session = Depends(get_db) +): + query = db.query(deals_table) + if zodiac_type: + query = query.filter(deals_table.c.zodiac_type == zodiac_type) + results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all() + return [dict(r._mapping) for r in results] + +# ============ 通知 ============ +@router.get("/notifications") +def get_notifications(limit: int = 10, db: Session = Depends(get_db)): + results = db.query(notifications_table).filter( + notifications_table.c.is_published == True + ).order_by(notifications_table.c.created_at.desc()).limit(limit).all() + return [dict(r._mapping) for r in results] + +# ============ 首页数据 ============ +@router.get("/home") +def get_home(db: Session = Depends(get_db)): + # 推荐发布 + posts = db.query(user_posts_table).filter( + user_posts_table.c.status == "active" + ).order_by(user_posts_table.c.created_at.desc()).limit(10).all() + + # 成交 + deals = db.query(deals_table).order_by( + deals_table.c.deal_date.desc() + ).limit(10).all() + + # 通知 + notices = db.query(notifications_table).filter( + notifications_table.c.is_published == True + ).order_by(notifications_table.c.created_at.desc()).limit(5).all() + + return { + "posts": [dict(p._mapping) for p in posts], + "deals": [dict(d._mapping) for d in deals], + "notices": [dict(n._mapping) for n in notices] + } diff --git a/backend/app/routers/ocr.py b/backend/app/routers/ocr.py new file mode 100644 index 0000000..e9dffc1 --- /dev/null +++ b/backend/app/routers/ocr.py @@ -0,0 +1,384 @@ +# OCR 识别路由 - 专业人民币生肖纪念钞鉴定 +import os +import uuid +import base64 +import httpx +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User + +router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"]) + +# 阿里云 DashScope API 配置 +DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f") + +# 阿里云 OSS 配置 +OSS_CONFIG = { + "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), + "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), + "bucket_name": "jiachenlong-oss", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com" +} + +# 临时上传目录(用于OCR识别本地备选) +UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp") +os.makedirs(UPLOAD_DIR, exist_ok=True) + + +def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None): + """生成OSS路径 - 按年/月/日分类""" + from datetime import datetime + now = datetime.now() + year = now.strftime("%Y") + month = now.strftime("%m") + day = now.strftime("%d") + + if file_type == "temp": + # 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext} + import uuid + unique_id = str(uuid.uuid4()) + ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' + return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id + + elif file_type == "collection": + # 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename} + if not user_id or not collection_id: + raise ValueError("user_id and collection_id required for collection") + return f"collections/{user_id}/{year}/{collection_id}/{filename}" + + elif file_type == "avatar": + # 头像: avatars/{user_id}/avatar.{ext} + if not user_id: + raise ValueError("user_id required for avatar") + ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg' + return f"avatars/{user_id}/avatar.{ext}" + + return None + + +# 上传图片到OSS - 使用服务层(带压缩) +from app.services.oss import upload_to_oss as oss_upload + +def upload_to_oss(file_data, oss_key): + """上传文件到阿里云OSS(带自动压缩)""" + return oss_upload(file_data, oss_key) + +# 专业提示词 +PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。 + +【识别流程】 +1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签) +2. 验证纪念钞特征:对照生肖纪念钞特征进行确认 +3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张 +4. 提取信息:仔细阅读标签上的所有文字内容 + +【版别格式要求】 +只需要:年份 + 属相,例如: +- 2024 龙 +- 2025 蛇 +- 2026 马 + +【输出要求】 +严格按照以下格式输出,每个字段必须填写具体值: +✅ 1 发行机构:中国人民银行 +✅ 2 发行版别:2024 龙 +✅ 3 面额:贰拾圆 +✅ 4 是否评级:是/否 +✅ 5 封装类型:裸钞/单张/标十/标百 +✅ 6 冠字序号:J0xxxxxxxx +✅ 7 评级机构:ACG/PCGS/PMG +✅ 8 评级分数:67/68/69 +✅ 9 是否三星:是/否 +✅ 10 特殊标识:金山标/天马标/红绳版等 +✅ 11 号码特征:金山号 2 张,天马号 3 张等 + +现在请仔细分析提供的图片,按上述格式输出结果。""" + + +@router.post("/recognize") +async def recognize_image( + image: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """OCR 图片识别 - 识别后自动保存图片到OSS临时目录""" + try: + # 读取图片数据 + image_data = await image.read() + image_base64 = base64.b64encode(image_data).decode('utf-8') + + # 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext} + oss_key, temp_id = get_oss_path("temp", filename=image.filename) + + # 初始化temp_path为空 + temp_path = None + + # 上传到OSS + try: + image_url = upload_to_oss(image_data, oss_key) + except Exception as oss_err: + # OSS失败时保存到本地作为备选 + temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1]) + os.makedirs(os.path.dirname(temp_path), exist_ok=True) + with open(temp_path, 'wb') as f: + f.write(image_data) + image_url = f"/uploads/temp/{oss_key.split('/')[-1]}" + + headers = { + "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "Content-Type": "application/json" + } + + # 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型) + payload = { + "model": "qwen-vl-plus", + "input": { + "messages": [{ + "role": "user", + "content": [ + { + "image": f"data:{image.content_type};base64,{image_base64}" + }, + { + "text": PROFESSIONAL_PROMPT + } + ] + }] + }, + "parameters": { + "max_tokens": 1000 + } + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + json=payload, + headers=headers + ) + + if response.status_code != 200: + # 识别失败,删除临时文件 + if temp_path and os.path.exists(temp_path): + os.remove(temp_path) + raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}") + + ocr_result = response.json() + text_content = "" + # 新版API返回格式 + if "output" in ocr_result and "choices" in ocr_result["output"]: + choices = ocr_result["output"]["choices"] + if choices and len(choices) > 0: + content = choices[0].get("message", {}).get("content", []) + if content and len(content) > 0: + text_content = content[0].get("text", "") + + fields = extract_fields(text_content) + + # 更新用户AI识别次数 + current_user.f99_95_ai_count = (current_user.f99_95_ai_count or 0) + 1 + db.commit() + + # 返回识别结果和临时图片路径 + return { + "success": True, + "text": text_content, + "fields": fields, + "aiCount": current_user.f99_95_ai_count, + "temp_image": { + "id": temp_id, + "filename": oss_key.split('/')[-1], + "path": image_url, + "original_name": image.filename, + "is_oss": image_url.startswith("https://") + } + } + + except Exception as e: + import traceback + error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}" + raise HTTPException(status_code=500, detail=error_detail) + + +def extract_fields(text: str) -> dict: + """从 OCR 文本中提取字段 - 直接返回 AI 识别结果""" + import re + fields = {} + + # 解析结构化输出 + patterns = { + 'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)', + 'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)', + 'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)', + 'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)', + 'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)', + 'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)', + 'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)', + 'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)', + 'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)', + 'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)', + 'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)' + } + + for field, pattern in patterns.items(): + match = re.search(pattern, text, re.IGNORECASE | re.DOTALL) + if match: + value = match.group(1).strip() + # 保留所有值,包括"无"和"未识别",让前端处理 + fields[field] = value + + # 处理是否评级 + if 'is_graded_text' in fields: + fields['is_graded'] = '是' in fields.pop('is_graded_text') + + # 处理是否三星 + if 'three_star_text' in fields: + fields['three_star'] = '是' in fields.pop('three_star_text') + + # 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙) + if 'version' in fields: + version = fields['version'] + # 提取年份和生肖 + year_match = re.search(r'(20\d{2})', version) + animal = '' + if '龙' in version: + animal = '龙' + elif '蛇' in version: + animal = '蛇' + elif '马' in version: + animal = '马' + elif '羊' in version: + animal = '羊' + elif '猴' in version: + animal = '猴' + elif '鸡' in version: + animal = '鸡' + elif '狗' in version: + animal = '狗' + elif '猪' in version: + animal = '猪' + elif '鼠' in version: + animal = '鼠' + elif '牛' in version: + animal = '牛' + elif '虎' in version: + animal = '虎' + elif '兔' in version: + animal = '兔' + + if year_match and animal: + fields['version'] = f"{year_match.group(1)}{animal}" + + return fields + + +@router.post("/claim-temp-image") +async def claim_temp_image( + temp_id: str, + collection_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类""" + from app.models.models import Collection, CollectionImage + from datetime import datetime + + # 验证藏品是否存在 + collection = db.query(Collection).filter( + Collection.f99_90_id == collection_id, + Collection.f99_91_user_id == current_user.f99_90_id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="藏品不存在") + + # 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename} + code = collection.f01_02_code or "0000" + prefix = collection.f02_10_prefix_serial or "" + username = current_user.f01_01_name + import time + final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg" + oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename) + + # 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径 + temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF'] + temp_content = None + found_key = None + + # 尝试最近7天的路径 + from datetime import timedelta + for i in range(7): + date = datetime.now() - timedelta(days=i) + year = date.strftime("%Y") + month = date.strftime("%m") + day = date.strftime("%d") + + for ext in temp_extensions: + try: + temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}" + import oss2 + auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"]) + bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"]) + temp_content = bucket.get_object(temp_oss_key).read() + found_key = temp_oss_key + break + except: + continue + if temp_content: + break + + if temp_content: + # 上传到正式目录 + bucket.put_object(oss_key, temp_content) + + # 删除临时图片 + try: + bucket.delete_object(found_key) + except: + pass + + # OSS URL + image_path = f"{OSS_CONFIG['public_url']}/{oss_key}" + + else: + # OSS失败,使用本地文件 + temp_path = None + for ext in temp_extensions: + temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}") + if os.path.exists(temp_path): + break + + if not temp_path or not os.path.exists(temp_path): + raise HTTPException(status_code=404, detail="临时图片不存在或已过期") + + # 保存到本地 + collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections") + os.makedirs(collection_dir, exist_ok=True) + + new_path = os.path.join(collection_dir, final_filename) + import shutil + shutil.move(temp_path, new_path) + image_path = f"uploads/collections/{final_filename}" + + # 创建图片记录 + image_record = CollectionImage( + id=str(uuid.uuid4()), + collection_id=collection.f99_90_id, + filename=final_filename, + original_name=temp_id, + path=image_path + ) + db.add(image_record) + db.commit() + + return { + "success": True, + "image": { + "id": image_record.id, + "filename": image_record.filename, + "path": image_record.path + } + } diff --git a/backend/app/routers/operations.py b/backend/app/routers/operations.py new file mode 100644 index 0000000..0881599 --- /dev/null +++ b/backend/app/routers/operations.py @@ -0,0 +1,104 @@ +# 操作路由 +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User, Collection, Operation +from app.schemas.schemas import OperationCreate, OperationResponse + +router = APIRouter(prefix="/api", tags=["操作"]) + + +@router.get("/operations", response_model=List[OperationResponse]) +def get_operations( + collection_id: Optional[str] = None, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取操作历史""" + query = db.query(Operation).filter(Operation.user_id == current_user.id) + + if collection_id: + query = query.filter(Operation.collection_id == collection_id) + + operations = query.order_by(Operation.created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + return operations + + +@router.get("/operations/history") +def get_operation_history( + collection_id: Optional[str] = None, + type: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取操作历史(带统计)""" + query = db.query(Operation).filter(Operation.user_id == current_user.id) + + if collection_id: + query = query.filter(Operation.collection_id == collection_id) + if type: + query = query.filter(Operation.type == type) + if start_date: + query = query.filter(Operation.created_at >= start_date) + if end_date: + query = query.filter(Operation.created_at <= end_date) + + total = query.count() + + data = query.order_by(Operation.created_at.desc()) \ + .offset((page - 1) * limit) \ + .limit(limit) \ + .all() + + return { + "data": data, + "pagination": { + "page": page, + "limit": limit, + "total": total, + "pages": (total + limit - 1) // limit + } + } + + +@router.post("/operations", response_model=OperationResponse) +def create_operation( + operation_data: OperationCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建操作记录""" + # 验证藏品存在 + collection = db.query(Collection).filter( + Collection.id == operation_data.collection_id, + Collection.user_id == current_user.id + ).first() + + if not collection: + raise HTTPException(status_code=404, detail="藏品不存在") + + operation = Operation( + collection_id=operation_data.collection_id, + user_id=current_user.id, + type=operation_data.type, + price=operation_data.price, + note=operation_data.note + ) + + db.add(operation) + db.commit() + db.refresh(operation) + + return operation diff --git a/backend/app/routers/seek.py b/backend/app/routers/seek.py new file mode 100644 index 0000000..76ff298 --- /dev/null +++ b/backend/app/routers/seek.py @@ -0,0 +1,189 @@ +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional +from datetime import datetime +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.seek_info import SeekInfo + +router = APIRouter(prefix="/api/seek", tags=["寻配号"]) + +# ============ Schema ============ +class SeekInfoCreate(BaseModel): + title: str + content: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + +class SeekInfoUpdate(BaseModel): + title: Optional[str] = None + content: Optional[str] = None + expect_category: Optional[str] = None + expect_version: Optional[str] = None + expect_packaging: Optional[str] = None + expect_number: Optional[str] = None + expect_price_min: Optional[float] = None + expect_price_max: Optional[float] = None + status: Optional[str] = None + +class SeekInfoResponse(BaseModel): + id: str + user_id: str + title: str + content: Optional[str] + expect_category: Optional[str] + expect_version: Optional[str] + expect_packaging: Optional[str] + expect_number: Optional[str] + expect_price_min: Optional[float] + expect_price_max: Optional[float] + status: str + is_matched: Optional[str] + matched_user_id: Optional[str] + matched_contact: Optional[str] + view_count: int + contact_count: int + created_at: Optional[datetime] + updated_at: Optional[datetime] + + class Config: + from_attributes = True + +# ============ API ============ +@router.get("/list", response_model=list[SeekInfoResponse]) +def get_seek_list( + status: str = Query("active"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=1000), + user_only: bool = Query(False), + current_user: Optional = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻配号列表""" + query = db.query(SeekInfo).filter(SeekInfo.status == status) + + # 我的寻配号:只查看自己的 + if user_only and current_user: + query = query.filter(SeekInfo.user_id == current_user.f99_90_id) + + # 排序 + query = query.order_by(SeekInfo.created_at.desc()) + + # 分页 + offset = (page - 1) * page_size + items = query.offset(offset).limit(page_size).all() + + return items + +@router.get("/stats") +def get_seek_stats( + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻配号统计""" + total = db.query(SeekInfo).filter(SeekInfo.status == "active").count() + matched = db.query(SeekInfo).filter(SeekInfo.is_matched == "true").count() + + return { + "total": total, + "matched": matched, + "unmatched": total - matched + } + +@router.post("", response_model=SeekInfoResponse) +def create_seek( + data: SeekInfoCreate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """创建寻配号""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + seek = SeekInfo( + user_id=current_user.f99_90_id, + title=data.title, + content=data.content, + expect_category=data.expect_category, + expect_version=data.expect_version, + expect_packaging=data.expect_packaging, + expect_number=data.expect_number, + expect_price_min=data.expect_price_min, + expect_price_max=data.expect_price_max, + status="active" + ) + db.add(seek) + db.commit() + db.refresh(seek) + return seek + +@router.get("/{seek_id}", response_model=SeekInfoResponse) +def get_seek( + seek_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取寻配号详情""" + seek = db.query(SeekInfo).filter(SeekInfo.id == seek_id).first() + if not seek: + raise HTTPException(status_code=404, detail="寻配号不存在") + + # 增加浏览数 + seek.view_count += 1 + db.commit() + + return seek + +@router.put("/{seek_id}", response_model=SeekInfoResponse) +def update_seek( + seek_id: str, + data: SeekInfoUpdate, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新寻配号""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + seek = db.query(SeekInfo).filter( + SeekInfo.id == seek_id, + SeekInfo.user_id == current_user.f99_90_id + ).first() + + if not seek: + raise HTTPException(status_code=404, detail="寻配号不存在") + + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(seek, key, value) + + db.commit() + db.refresh(seek) + return seek + +@router.delete("/{seek_id}") +def delete_seek( + seek_id: str, + current_user = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除寻配号""" + if not current_user: + raise HTTPException(status_code=401, detail="请先登录") + + seek = db.query(SeekInfo).filter( + SeekInfo.id == seek_id, + SeekInfo.user_id == current_user.f99_90_id + ).first() + + if not seek: + raise HTTPException(status_code=404, detail="寻配号不存在") + + seek.status = "deleted" + db.commit() + + return {"message": "删除成功"} \ No newline at end of file diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py new file mode 100644 index 0000000..c8cf2da --- /dev/null +++ b/backend/app/routers/users.py @@ -0,0 +1,305 @@ +# 用户管理路由 +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query, Body +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.core.auth import get_current_user +from app.models.models import User, Collection +from app.schemas.schemas import UserResponse, UserUpdate + +router = APIRouter(prefix="/api", tags=["用户"]) + +# ============ 当前用户接口 ============ + +@router.get("/users/me") # 无 response_model,避免 Pydantic 序列化问题 +def get_current_user_info( + current_user: User = Depends(get_current_user) +): + """获取当前登录用户信息""" + return { + "id": current_user.f99_90_id, + "username": current_user.f01_01_name, + "f99_90_id": current_user.f99_90_id, + "f01_01_name": current_user.f01_01_name, + "email": current_user.email, + "phone": current_user.phone, + "avatar": current_user.avatar, + "address": current_user.address, + "bio": current_user.bio, + "role": current_user.role, + "level": current_user.f99_94_level, + "aiCount": current_user.f99_95_ai_count or 0, + "searchCount": current_user.f99_96_search_count or 0, + "collectionCount": current_user.f99_97_collection_count or 0, + "phoneVerified": current_user.f01_06_phone_verified or False, + "loginCount": current_user.f99_98_login_count or 0, + "lastLogin": current_user.f99_99_last_login.isoformat() if current_user.f99_99_last_login else None, + "gender": current_user.f01_07_gender, + "birthday": current_user.f01_08_birthday.isoformat() if current_user.f01_08_birthday else None, + "region": current_user.f01_09_region, + "realnameVerified": current_user.f01_10_realname_verified or False, + "points": current_user.f99_100_points or 0, + "balance": float(current_user.f01_11_balance) if current_user.f01_11_balance else 0, + "totalAmount": float(current_user.f01_12_total_amount) if current_user.f01_12_total_amount else 0, + "inviteCode": current_user.f01_13_invite_code, + "user_code": current_user.user_code, + "f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None, + "f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None + } + +@router.put("/users/me") # 无 response_model,避免 Pydantic 序列化问题 +def update_current_user( + user_update: UserUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新当前用户信息""" + import logging + logger = logging.getLogger(__name__) + + # 获取用户ID + user_id = current_user.f99_90_id + logger.info(f"Updating user {user_id}, data={user_update.model_dump()}") + + # 在当前session中重新查询用户 + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 更新字段 + update_data = user_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + if field == 'f01_01_name': + user.f01_01_name = value + elif field == 'username': + pass # skip, already handled as f01_01_name + elif hasattr(user, field): + setattr(user, field, value) + + # 强制刷新以确保更新被提交 + db.flush() + db.commit() + db.refresh(user) + logger.info(f"After commit, user email={user.email}") + + return user + +# ============ 管理员用户管理 ============ + +admin_router = APIRouter(prefix="/api/admin/users", tags=["用户管理"]) + + +@admin_router.get("") +def get_users( + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取用户列表(仅管理员)""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="无权访问") + + total = db.query(User).count() + from sqlalchemy import case + users = db.query(User).order_by( + case( + (User.user_code == None, 1), + else_=0 + ), + User.user_code.asc() + ).offset((page-1)*limit).limit(limit).all() + + user_list = [] + for u in users: + # 统计每个用户的藏品数量 + count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count() + user_list.append({ + "id": u.f99_90_id, + "username": u.f01_01_name, + "email": u.email, + "phone": u.phone, + "role": u.role, + "user_code": u.user_code, + "created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None, + "collectionCount": count, + "level": u.f99_94_level, + "aiCount": u.f99_95_ai_count, + "searchCount": u.f99_96_search_count, + "loginCount": u.f99_98_login_count, + "points": u.f99_100_points, + "balance": float(u.f01_11_balance) if u.f01_11_balance else 0, + "totalAmount": float(u.f01_12_total_amount) if u.f01_12_total_amount else 0, + "phoneVerified": u.f01_06_phone_verified, + }) + + return user_list + + +@admin_router.get("/{user_id}") +def get_user( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取单个用户信息""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="无权访问") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + return { + "id": user.id, + "username": user.username, + "email": user.email, + "phone": user.phone, + "role": user.role, + "created_at": user.created_at.isoformat() if user.created_at else None + } + + +@admin_router.get("/{user_id}/collections") +def get_user_collections( + user_id: str, + limit: int = Query(100, ge=1, le=100), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定用户的藏品列表""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="无权访问") + + collections = db.query(Collection).filter( + Collection.user_id == user_id + ).limit(limit).all() + + return [c.code for c in collections] + + +@admin_router.get("/{user_id}/count") +def get_user_collection_count( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """获取指定用户的藏品数量""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + count = db.query(Collection).filter(Collection.user_id == user_id).count() + return {"count": count} + + +@admin_router.put("/{user_id}") +def update_user( + user_id: str, + username: Optional[str] = Body(None), + email: Optional[str] = Body(None), + role: Optional[str] = Body(None), + password: Optional[str] = Body(None), + user_code: Optional[str] = Body(None), + level: Optional[str] = Body(None), + points: Optional[int] = Body(None), + balance: Optional[float] = Body(None), + totalAmount: Optional[float] = Body(None), + aiCount: Optional[int] = Body(None), + searchCount: Optional[int] = Body(None), + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """更新用户信息(仅管理员)""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="E00051: 用户不存在") + + # 更新基本信息 + if username: + user.f01_01_name = username + if email: + user.email = email + if role is not None: + user.role = role + + # 更新会员等级 + if level is not None: + user.f99_94_level = level + + # 更新积分 + if points is not None: + user.f99_100_points = points + + # 更新余额 + if balance is not None: + user.f01_11_balance = balance + + # 更新累计金额 + if totalAmount is not None: + user.f01_12_total_amount = totalAmount + + # 更新AI识别次数 + if aiCount is not None: + user.f99_95_ai_count = aiCount + + # 更新寻号次数 + if searchCount is not None: + user.f99_96_search_count = searchCount + + # 更新用户编码 + if user_code is not None: + # 只有非空字符串才检查唯一性 + user_code_str = user_code.strip() if user_code else '' + if user_code_str: + existing = db.query(User).filter( + User.user_code == user_code_str, + User.f99_90_id != user_id + ).first() + if existing: + raise HTTPException(status_code=400, detail="E00052: 该用户编码已被其他用户使用") + user.user_code = user_code_str + else: + user.user_code = None + + # 更新密码 + if password and password.strip(): + from app.core.auth import get_password_hash + user.password = get_password_hash(password) + + db.commit() + db.refresh(user) + + return { + "id": user.f99_90_id, + "username": user.f01_01_name, + "email": user.email, + "role": user.role, + "message": "更新成功" + } + + +@admin_router.delete("/{user_id}") +def delete_user( + user_id: str, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + """删除用户(仅管理员)""" + if current_user.role not in ["admin", "editor"]: + raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问") + + # 不能删除自己 + if user_id == str(current_user.f99_90_id): + raise HTTPException(status_code=400, detail="E00052: 不能删除自己") + + user = db.query(User).filter(User.f99_90_id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="E00051: 用户不存在") + + db.delete(user) + db.commit() + + return {"message": "删除成功"} diff --git a/backend/app/routers/yichens.py b/backend/app/routers/yichens.py new file mode 100644 index 0000000..de8e152 --- /dev/null +++ b/backend/app/routers/yichens.py @@ -0,0 +1,377 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, text +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime, date +from app.core.coolbot_db import get_coolbot_db + +router = APIRouter(prefix="/api/yichens", tags=["一尘看板"]) + +# ============ 数据模型 ============ +class YichensPostStats(BaseModel): + total_posts: int + total_deals: int # 出售 + total_wants: int # 求购 + total_replies: int + total_views: int + avg_price: Optional[float] + +class CategoryStat(BaseModel): + category: str + count: int + +class PostItem(BaseModel): + post_id: str + title: str + category: Optional[str] + post_type: str + price: Optional[float] + author_username: str + post_time: str + reply_count: int + view_count: int + url: Optional[str] + content: Optional[str] + +class UserStat(BaseModel): + total_users: int + new_users_today: int + sellers: int + +class UserItem(BaseModel): + user_id: str + username: str + avatar_url: Optional[str] + content: Optional[str] + credit_level: Optional[str] + credit_score: Optional[int] + post_count: int + is_seller: bool + registration_date: Optional[str] + +# ============ 统计接口 ============ +@router.get("/stats/posts", response_model=YichensPostStats) +def get_post_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)): + """获取帖子统计""" + result = db.execute(text(""" + SELECT + COUNT(*) as total_posts, + COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals, + COUNT(*) FILTER (WHERE post_type = 'want') as total_wants, + COALESCE(SUM(reply_count), 0) as total_replies, + COALESCE(SUM(view_count), 0) as total_views, + AVG(price) as avg_price + FROM yichens_posts + WHERE post_time >= NOW() - INTERVAL '1 day' * :days + """), {"days": days}).fetchone() + + return YichensPostStats( + total_posts=result[0] or 0, + total_deals=result[1] or 0, + total_wants=result[2] or 0, + total_replies=result[3] or 0, + total_views=result[4] or 0, + avg_price=float(result[5]) if result[5] else None + ) + +@router.get("/stats/categories", response_model=List[CategoryStat]) +def get_category_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)): + """按分类统计帖子数量""" + results = db.execute(text(""" + SELECT category, COUNT(*) as count + FROM yichens_posts + WHERE post_time >= NOW() - INTERVAL '1 day' * :days + GROUP BY category + ORDER BY count DESC + """), {"days": days}).fetchall() + + return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results] + +@router.get("/stats/users", response_model=UserStat) +def get_user_stats(db: Session = Depends(get_coolbot_db)): + """获取用户统计""" + result = db.execute(text(""" + SELECT + COUNT(*) as total_users, + COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today, + COUNT(*) FILTER (WHERE is_seller = true) as sellers + FROM yichens_users + """)).fetchone() + + return UserStat( + total_users=result[0] or 0, + new_users_today=result[1] or 0, + sellers=result[2] or 0 + ) + +@router.get("/posts") +def get_posts( + limit: int = Query(20, ge=1, le=500), + offset: int = Query(0, ge=0), + category: Optional[str] = None, + post_type: Optional[str] = None, + keyword: Optional[str] = None, + db: Session = Depends(get_coolbot_db) +): + """获取帖子列表 - 支持全局搜索,返回总数和分页信息""" + # 构建WHERE条件 + where_clauses = ["1=1"] + params = {"limit": limit, "offset": offset} + + if category: + where_clauses.append("category = :category") + params["category"] = category + + if post_type: + where_clauses.append("post_type = :post_type") + params["post_type"] = post_type + + # 全局搜索 + if keyword: + where_clauses.append("(title ILIKE :keyword OR content ILIKE :keyword OR category ILIKE :keyword OR author_username ILIKE :keyword)") + params["keyword"] = f"%{keyword}%" + + where_sql = " AND ".join(where_clauses) + + # 查询总数 + count_query = f"SELECT COUNT(*) FROM yichens_posts WHERE {where_sql}" + total_count = db.execute(text(count_query), {k: v for k, v in params.items() if k not in ['limit', 'offset']}).scalar() or 0 + + # 查询数据 + data_query = f""" + SELECT post_id, title, content, category, post_type, price, + author_username, post_time, reply_count, view_count, url + FROM yichens_posts + WHERE {where_sql} + ORDER BY post_time DESC LIMIT :limit OFFSET :offset + """ + results = db.execute(text(data_query), params).fetchall() + + posts = [PostItem( + post_id=r[0], + title=r[1] or "", + content=r[2] or "", + category=r[3], + post_type=r[4] or "", + price=float(r[5]) if r[5] else None, + author_username=r[6] or "", + post_time=str(r[7]) if r[7] else "", + reply_count=r[8] or 0, + view_count=r[9] or 0, + url=r[10] + ) for r in results] + + return { + "posts": posts, + "total": total_count, + "page": offset // limit + 1, + "page_size": limit + } + +@router.get("/users", response_model=List[UserItem]) +def get_users( + limit: int = Query(20, ge=1, le=500), + offset: int = Query(0, ge=0), + is_seller: Optional[bool] = None, + db: Session = Depends(get_coolbot_db) +): + """获取用户列表""" + query = """ + SELECT user_id, username, avatar_url, credit_level, credit_score, + post_count, is_seller, registration_date + FROM yichens_users + WHERE 1=1 + """ + params = {"limit": limit, "offset": offset} + + if is_seller is not None: + query += " AND is_seller = :is_seller" + params["is_seller"] = is_seller + + query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset" + + results = db.execute(text(query), params).fetchall() + + return [UserItem( + user_id=r[0], + username=r[1] or "", + avatar_url=r[2], + credit_level=r[3], + credit_score=r[4], + post_count=r[5] or 0, + is_seller=r[6] or False, + registration_date=str(r[7]) if r[7] else None + ) for r in results] + + +@router.get("/stats/today") +async def get_today_stats(db: Session = Depends(get_coolbot_db)): + """获取今日新增帖子统计""" + query = """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants, + SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others, + SUM(CASE WHEN category LIKE '%龙%' THEN 1 ELSE 0 END) as dragons, + SUM(CASE WHEN category LIKE '%马%' THEN 1 ELSE 0 END) as horses, + SUM(CASE WHEN category LIKE '%蛇%' THEN 1 ELSE 0 END) as snakes, + SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + """ + result = db.execute(text(query)).fetchone() + return { + "total": result[0] or 0, + "deals": result[1] or 0, + "wants": result[2] or 0, + "others": result[3] or 0, + "dragons": result[4] or 0, + "horses": result[5] or 0, + "snakes": result[6] or 0, + "tianma": result[7] or 0 + } + +@router.get("/stats/hour") +async def get_hour_stats(db: Session = Depends(get_coolbot_db)): + """获取近一个小时新增帖子统计""" + query = """ + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants, + SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others, + SUM(CASE WHEN category LIKE '%龙%' THEN 1 ELSE 0 END) as dragons, + SUM(CASE WHEN category LIKE '%马%' THEN 1 ELSE 0 END) as horses, + SUM(CASE WHEN category LIKE '%蛇%' THEN 1 ELSE 0 END) as snakes + FROM yichens_posts + WHERE post_time >= NOW() - INTERVAL '1 hour' + """ + result = db.execute(text(query)).fetchone() + return { + "total": result[0] or 0, + "deals": result[1] or 0, + "wants": result[2] or 0, + "others": result[3] or 0, + "dragons": result[3] or 0, + "horses": result[4] or 0, + "snakes": result[5] or 0 + } + +@router.get("/stats/today-category") +async def get_today_category_stats(db: Session = Depends(get_coolbot_db)): + """获取今日帖子分类统计""" + query = """ + SELECT category, COUNT(*) as count + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + GROUP BY category + ORDER BY count DESC + """ + results = db.execute(text(query)).fetchall() + return [{"category": r[0] or "未分类", "count": r[1]} for r in results] + + +@router.get("/stats/dragons-today") +def get_dragons_stats_today( + db: Session = Depends(get_coolbot_db) +): + """获取今日龙钞详细统计数据(按号码分类)- 就高不就低""" + from sqlalchemy import text + + # 1. 带4:包含"带4"、"带四"、"通货" + dai4 = db.execute(text(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + AND category LIKE '%龙%' + AND ( + content LIKE '%带4%' OR title LIKE '%带4%' + OR content LIKE '%带四%' OR title LIKE '%带四%' + OR content LIKE '%通货%' OR title LIKE '%通货%' + ) + """)).fetchone() + + # 2. 无4:包含"无4"、"无四",排除"无47"、"无247"、"无四七"、"永恒" + wu4 = db.execute(text(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + AND category LIKE '%龙%' + AND ( + content LIKE '%无4%' OR title LIKE '%无4%' + OR content LIKE '%无四%' OR title LIKE '%无四%' + ) + AND content NOT LIKE '%无47%' AND title NOT LIKE '%无47%' + AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%' + AND content NOT LIKE '%永恒%' AND title NOT LIKE '%永恒%' + AND content NOT LIKE '%无四七%' AND title NOT LIKE '%无四七%' + """)).fetchone() + + # 3. 无47:包含"无47"、"永恒"、"无四七",排除"无247" + wu47 = db.execute(text(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + AND category LIKE '%龙%' + AND ( + content LIKE '%无47%' OR title LIKE '%无47%' + OR content LIKE '%永恒%' OR title LIKE '%永恒%' + OR content LIKE '%无四七%' OR title LIKE '%无四七%' + ) + AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%' + """)).fetchone() + + # 4. 无247:包含"无247"、"天马"、"金山",排除"无347" + wu247 = db.execute(text(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + AND category LIKE '%龙%' + AND ( + content LIKE '%无247%' OR title LIKE '%无247%' + OR content LIKE '%天马%' OR title LIKE '%天马%' + OR content LIKE '%金山%' OR title LIKE '%金山%' + ) + AND content NOT LIKE '%无347%' AND title NOT LIKE '%无347%' + """)).fetchone() + + # 5. 无347:包含"无347"、"钻石"、"金马"、"魅力"、"朦胧" + wu347 = db.execute(text(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals, + SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants + FROM yichens_posts + WHERE post_time >= CURRENT_DATE + AND category LIKE '%龙%' + AND ( + content LIKE '%无347%' OR title LIKE '%无347%' + OR content LIKE '%钻石%' OR title LIKE '%钻石%' + OR content LIKE '%金马%' OR title LIKE '%金马%' + OR content LIKE '%魅力%' OR title LIKE '%魅力%' + OR content LIKE '%朦胧%' OR title LIKE '%朦胧%' + ) + """)).fetchone() + + return { + "dai4": {"total": dai4[0] or 0, "deals": dai4[1] or 0, "wants": dai4[2] or 0}, + "wu4": {"total": wu4[0] or 0, "deals": wu4[1] or 0, "wants": wu4[2] or 0}, + "wu47": {"total": wu47[0] or 0, "deals": wu47[1] or 0, "wants": wu47[2] or 0}, + "wu247": {"total": wu247[0] or 0, "deals": wu247[1] or 0, "wants": wu247[2] or 0}, + "wu347": {"total": wu347[0] or 0, "deals": wu347[1] or 0, "wants": wu347[2] or 0} + } + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..a8497b5 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -0,0 +1,231 @@ +# Pydantic Schema - 使用字段编码并支持 camelCase +from typing import Optional, List +from pydantic import BaseModel, EmailStr, Field, ConfigDict +from datetime import datetime + + +# ============ 用户相关 ============ + +class UserBase(BaseModel): + f01_01_name: str = Field(..., min_length=3, max_length=255, alias="username") + email: Optional[EmailStr] = None + phone: Optional[str] = None + avatar: Optional[str] = None + address: Optional[str] = None + bio: Optional[str] = None + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class UserCreate(UserBase): + password: str = Field(..., min_length=6) + invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填) + + +class UserUpdate(BaseModel): + f01_01_name: Optional[str] = Field(None, alias="username") + email: Optional[EmailStr] = None + phone: Optional[str] = None + avatar: Optional[str] = None + address: Optional[str] = None + bio: Optional[str] = None + password: Optional[str] = None + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class UserResponse(UserBase): + # 新增字段 + f99_94_level: Optional[str] = Field(None, alias="level") + f99_95_ai_count: Optional[int] = Field(0, alias="aiCount") + f99_96_search_count: Optional[int] = Field(0, alias="searchCount") + f99_97_collection_count: Optional[int] = Field(0, alias="collectionCount") + f01_06_phone_verified: Optional[bool] = Field(False, alias="phoneVerified") + f99_98_login_count: Optional[int] = Field(0, alias="loginCount") + f99_99_last_login: Optional[datetime] = Field(None, alias="lastLogin") + f01_07_gender: Optional[str] = Field(None, alias="gender") + f01_08_birthday: Optional[datetime] = Field(None, alias="birthday") + f01_09_region: Optional[str] = Field(None, alias="region") + f01_10_realname_verified: Optional[bool] = Field(False, alias="realnameVerified") + f99_100_points: Optional[int] = Field(0, alias="points") + f01_11_balance: Optional[float] = Field(0, alias="balance") + f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount") + f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode") + f99_101_invited_count: Optional[int] = Field(0, alias="invitedCount") + f99_90_id: str = Field(..., alias="id") + f01_01_name: str = Field(..., alias="username") + role: str + user_code: Optional[str] = None + f99_92_created_at: Optional[datetime] = Field(None, alias="created_at") + f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +# ============ 藏品相关 ============ + +class CollectionBase(BaseModel): + # f01 基本信息 + f01_01_name: str = Field(..., min_length=2, max_length=255, alias="name") + f01_02_code: Optional[str] = Field(None, max_length=50, alias="code") + f01_03_category: str = Field(..., max_length=100, alias="category") + f01_04_status: Optional[str] = Field("in_collection", alias="status") + f01_05_remark: Optional[str] = Field(None, alias="remark") + + # f02 详细字段 + f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial") + f02_11_version: Optional[str] = Field(None, alias="version") + f02_12_packaging: Optional[str] = Field(None, alias="packaging") + f02_13_rarity: Optional[str] = Field(None, alias="rarity") + f02_14_number_category: Optional[str] = Field(None, alias="numberCategory") + + # f03 评级信息 + f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded") + f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany") + f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore") + f03_23_three_star: Optional[bool] = Field(False, alias="threeStar") + + # f04 特殊信息 + f04_30_special_mark: Optional[str] = Field(None, alias="specialMark") + f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature") + f04_32_issuer: Optional[str] = Field(None, alias="issuer") + f04_33_issue_year: Optional[str] = Field(None, alias="issueYear") + f04_34_material: Optional[str] = Field(None, alias="material") + f04_35_denomination: Optional[str] = Field(None, alias="denomination") + f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity") + + # f05 价格信息 + f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice") + f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice") + f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice") + f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee") + f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee") + + # f06 其他信息 + f06_50_purpose: Optional[str] = Field(None, alias="purpose") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionCreate(CollectionBase): + pass + + +class CollectionUpdate(BaseModel): + # f01 基本信息 + f01_01_name: Optional[str] = Field(None, alias="name") + f01_02_code: Optional[str] = Field(None, alias="code") + f01_03_category: Optional[str] = Field(None, alias="category") + f01_04_status: Optional[str] = Field(None, alias="status") + f01_05_remark: Optional[str] = Field(None, alias="remark") + + # f02 详细字段 + f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial") + f02_11_version: Optional[str] = Field(None, alias="version") + f02_12_packaging: Optional[str] = Field(None, alias="packaging") + f02_13_rarity: Optional[str] = Field(None, alias="rarity") + f02_14_number_category: Optional[str] = Field(None, alias="numberCategory") + + # f03 评级信息 + f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded") + f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany") + f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore") + f03_23_three_star: Optional[bool] = Field(None, alias="threeStar") + + # f04 特殊信息 + f04_30_special_mark: Optional[str] = Field(None, alias="specialMark") + f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature") + f04_32_issuer: Optional[str] = Field(None, alias="issuer") + f04_33_issue_year: Optional[str] = Field(None, alias="issueYear") + f04_34_material: Optional[str] = Field(None, alias="material") + f04_35_denomination: Optional[str] = Field(None, alias="denomination") + f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity") + + # f05 价格信息 + f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice") + f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice") + f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice") + f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee") + f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee") + + # f06 其他信息 + f06_50_purpose: Optional[str] = Field(None, alias="purpose") + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionImageResponse(BaseModel): + f99_90_id: str + filename: str + original_name: Optional[str] = None + path: Optional[str] = None + f99_92_created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class CollectionResponse(CollectionBase): + f99_90_id: str = Field(..., alias="id") + f99_91_user_id: str = Field(..., alias="userId") + f99_92_created_at: Optional[datetime] = Field(None, alias="createdAt") + f99_93_updated_at: Optional[datetime] = Field(None, alias="updatedAt") + images: List[CollectionImageResponse] = [] + + model_config = ConfigDict(from_attributes=True, populate_by_name=True) + + +class CollectionListResponse(BaseModel): + data: List[CollectionResponse] + pagination: dict + + +# ============ 操作日志相关 ============ + +class OperationBase(BaseModel): + type: str = Field(..., max_length=50) + price: Optional[float] = None + note: Optional[str] = None + + +class OperationCreate(OperationBase): + f99_91_user_id: str + + +class OperationResponse(OperationBase): + f99_90_id: str + f99_91_user_id: str + f99_93_created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +# ============ OCR 相关 ============ + +class OCRRequest(BaseModel): + image: str + ocr_provider: Optional[str] = "aliyun" + + +class OCRResponse(BaseModel): + text: str + confidence: float + fields: Optional[dict] = None + + +# ============ 通用响应 ============ + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + + +class TokenData(BaseModel): + f99_90_user_id: Optional[str] = None + + +class MessageResponse(BaseModel): + message: str + + +class ErrorResponse(BaseModel): + detail: str diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py new file mode 100644 index 0000000..6c52c87 --- /dev/null +++ b/backend/app/services/oss.py @@ -0,0 +1,126 @@ +# 阿里云OSS服务 +import os +import uuid +import datetime +from typing import Optional +import oss2 +from PIL import Image +import io + +# OSS配置 +OSS_CONFIG = { + "access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"), + "access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"), + "bucket_name": "jiachenlong-oss", + "endpoint": "oss-cn-hangzhou.aliyuncs.com", + "public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com" +} + +# 图片压缩配置 +IMAGE_CONFIG = { + "max_size": 1024 * 1024, # 1MB + "max_width": 2048, + "max_height": 2048, + "quality": 85, + "format": "JPEG" +} + +# 初始化OSS +auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"]) +bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"]) + + +def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str: + """生成OSS存储路径""" + now = datetime.datetime.now() + year = now.strftime("%Y") + month = now.strftime("%m") + day = now.strftime("%d") + + if filename: + ext = filename.split('.')[-1] if '.' in filename else 'jpg' + unique_name = f"{uuid.uuid4().hex}.{ext}" + else: + unique_name = f"{uuid.uuid4().hex}.jpg" + + if user_id: + path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}" + else: + path = f"{prefix}/{year}/{month}/{day}/{unique_name}" + + return path, unique_name + + +def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str: + """上传文件到OSS,返回公网URL""" + try: + # 如果是图片,进行压缩 + if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']): + file_data = compress_image(file_data) + + # 上传文件 + result = bucket.put_object(oss_key, file_data) + + if result.status == 200: + # 返回公网URL + return f"{OSS_CONFIG['public_url']}/{oss_key}" + else: + raise Exception(f"OSS上传失败: {result.status}") + + except Exception as e: + raise Exception(f"OSS上传失败: {str(e)}") + + +def delete_from_oss(oss_key: str) -> bool: + """从OSS删除文件""" + try: + result = bucket.delete_object(oss_key) + return result.status == 204 + except Exception as e: + print(f"OSS删除失败: {str(e)}") + return False + + +def get_public_url(oss_key: str) -> str: + """获取公网URL""" + return f"{OSS_CONFIG['public_url']}/{oss_key}" + + +def compress_image(image_data: bytes, max_size: int = None) -> bytes: + """压缩图片到指定大小以内""" + if max_size is None: + max_size = IMAGE_CONFIG["max_size"] + + # 如果已经小于限制,直接返回 + if len(image_data) <= max_size: + return image_data + + # 打开图片 + img = Image.open(io.BytesIO(image_data)) + + # 如果是PNG且有透明通道,转换为RGB + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + + # 逐步降低质量直到达到目标大小 + quality = 95 + compressed_data = image_data + + while quality > 30 and len(compressed_data) > max_size: + output = io.BytesIO() + img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True) + compressed_data = output.getvalue() + quality -= 10 + + # 如果还是太大,缩小尺寸 + if len(compressed_data) > max_size: + width, height = img.size + while len(compressed_data) > max_size and width > 400: + width = int(width * 0.8) + height = int(height * 0.8) + img_resized = img.resize((width, height), Image.Resampling.LANCZOS) + output = io.BytesIO() + img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True) + compressed_data = output.getvalue() + + return compressed_data diff --git a/backend/app/services/sms.py b/backend/app/services/sms.py new file mode 100644 index 0000000..f067142 --- /dev/null +++ b/backend/app/services/sms.py @@ -0,0 +1,106 @@ +# 阿里云短信服务 +import os +import random +import string +import time +from datetime import datetime, timedelta +from typing import Optional + +# 阿里云短信配置 +SMS_CONFIG = { + "access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"), + "access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"), + "sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"), + "template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"), +} + +# 验证码缓存(生产环境建议用Redis) +# 格式: { phone: { code: "123456", expire: 1234567890 } } +VERIFICATION_CODES = {} + + +def generate_code(length: int = 6) -> str: + """生成6位数字验证码""" + return ''.join(random.choices(string.digits, k=length)) + + +def send_verification_code(phone: str) -> dict: + """发送短信验证码""" + from alibabacloud_dysmsapi20170525 import models + from alibabacloud_dysmsapi20170525.client import Client + from alibabacloud_tea_openapi import models as open_models + + try: + # 生成验证码 + code = generate_code(6) + + # 配置客户端 + config = open_models.Config( + access_key_id=SMS_CONFIG["access_key_id"], + access_key_secret=SMS_CONFIG["access_key_secret"], + ) + config.endpoint = "dysmsapi.aliyuncs.com" + config.region_id = "cn-hangzhou" + + client = Client(config) + + # 构造请求 + request = models.SendSmsRequest( + phone_numbers=phone, + sign_name=SMS_CONFIG["sign_name"], + template_code=SMS_CONFIG["template_code"], + template_param=f'{{"code":"{code}"}}' + ) + + # 发送 + response = client.send_sms(request) + + # 检查结果 + if response.body.code == "OK": + # 保存验证码 + VERIFICATION_CODES[phone] = { + "code": code, + "expire": int(time.time()) + 300 # 5分钟有效 + } + return { + "success": True, + "message": "验证码已发送", + "expire": 300 + } + else: + return { + "success": False, + "message": f"发送失败: {response.body.message}" + } + + except Exception as e: + return { + "success": False, + "message": f"发送失败: {str(e)}" + } + + +def verify_code(phone: str, code: str) -> bool: + """验证验证码""" + if phone not in VERIFICATION_CODES: + return False + + stored = VERIFICATION_CODES[phone] + + # 检查是否过期 + if int(time.time()) > stored["expire"]: + del VERIFICATION_CODES[phone] + return False + + # 验证码匹配 + if stored["code"] == code: + # 验证成功,删除验证码 + del VERIFICATION_CODES[phone] + return True + + return False + + +def check_code_exists(phone: str) -> bool: + """检查是否已发送过验证码""" + return phone in VERIFICATION_CODES diff --git a/backend/app/utils/number_category.py b/backend/app/utils/number_category.py new file mode 100644 index 0000000..340e9f2 --- /dev/null +++ b/backend/app/utils/number_category.py @@ -0,0 +1,132 @@ +# 号码分类工具 +# 按MEMORY.md最新规则 (2026-04-02) +# 优先级:数字越小越有价值 + +CATEGORIES = [ + # 1. 圆圆号:无.123457,只能用0689 + {"name": "圆圆号", "cannot_use": ".123457", "must_have": ""}, + # 2. 倒置号:无23457,可用01689必须有1 + {"name": "倒置号", "cannot_use": "23457", "must_have": "1"}, + # 3. 金马王:无12347,可用05689必须有5 + {"name": "金马王", "cannot_use": "12347", "must_have": "5"}, + # 4. 金马号:无2347,可用015689必须有1和5 + {"name": "金马号", "cannot_use": "2347", "must_have": "15"}, + # 5. 金山王:无12457,可用03689必须有3 + {"name": "金山王", "cannot_use": "12457", "must_have": "3"}, + # 6. 天马王:无1247,可用035689必须有3和5 + {"name": "天马王", "cannot_use": "1247", "must_have": "35"}, + # 7. 金山号:无2457,可用013689必须有1和3 + {"name": "金山号", "cannot_use": "2457", "must_have": "13"}, + # 8. 天马号:无247,可用0135689必须有1、3和5 + {"name": "天马号", "cannot_use": "247", "must_have": "135"}, + # 9. 朦胧王:无13457 + {"name": "朦胧王", "cannot_use": "13457", "must_have": ""}, + # 10. 朦胧号:无3457 + {"name": "朦胧号", "cannot_use": "3457", "must_have": ""}, + # 11. 如意号:无1347 + {"name": "如意号", "cannot_use": "1347", "must_have": ""}, + # 12. 钻石号:无347 + {"name": "钻石号", "cannot_use": "347", "must_have": ""}, + # 13. 永恒号:无47 + {"name": "永恒号", "cannot_use": "47", "must_have": ""}, + # 14. 无4号:不包含4 + {"name": "无4号", "cannot_use": "4", "must_have": ""}, + # 15. 通货:含4 + {"name": "通货", "cannot_use": "", "must_have": "4"}, +] + + +def extract_digits(serial: str) -> dict: + """提取冠字号中的数字部分""" + if not serial: + return {"digits": "", "type": "single"} + + nums = serial.replace("J", "").replace(",", "").replace(".", "").strip() + nums = "".join(c for c in nums if c.isdigit()) + + if nums.endswith("01"): + return {"digits": nums[:-2], "type": "hundred"} + elif nums.endswith("1"): + return {"digits": nums[:-1], "type": "ten"} + else: + return {"digits": nums, "type": "single"} + + +def matches_category(digits: str, category: dict) -> bool: + cannot_use = category.get("cannot_use", "") + must_have = category.get("must_have", "") + + # 检查不能用的数字 + for n in cannot_use: + if n in digits: + return False + + # 检查必须有的数字 + if must_have: + for n in must_have: + if n not in digits: + return False + + return True + + +def get_number_category(serial: str) -> str: + """号码分类函数""" + if not serial: + return "" + + # 提取数字 + info = extract_digits(serial) + digits = info["digits"] + + if not digits: + return "" + + # 根据类型取对应位数 + digits_type = info["type"] + if digits_type == "hundred": + # 标百看后6位 + check_digits = digits[-6:] if len(digits) >= 6 else digits + elif digits_type == "ten": + # 标十看后7位 + check_digits = digits[-7:] if len(digits) >= 7 else digits + else: + # 散钞看全部 + check_digits = digits + + # 按优先级匹配分类 + for category in CATEGORIES: + if matches_category(check_digits, category): + return category["name"] + + return "通货" + + +def get_number_category_color(category: str) -> str: + """获取分类颜色""" + colors = { + "圆圆号": "#ef4444", # 红 + "倒置号": "#f97316", # 橙 + "金马王": "#eab308", # 黄 + "金马号": "#84cc16", # 绿 + "金山王": "#22c55e", # 深绿 + "天马王": "#14b8a6", # 青 + "金山号": "#06b6d4", # 蓝 + "天马号": "#0ea5e9", # 浅蓝 + "朦胧王": "#6366f1", # 靛蓝 + "朦胧号": "#8b5cf6", # 紫 + "如意号": "#a855f7", # 深紫 + "钻石号": "#d946ef", # 品红 + "永恒号": "#ec4899", # 粉红 + "无4号": "#64748b", # 灰 + "通货": "#9ca3af", # 浅灰 + } + return colors.get(category, "#9ca3af") + + +def get_category_priority(category: str) -> int: + """获取分类优先级(数字越小越高级)""" + for i, cat in enumerate(CATEGORIES, 1): + if cat["name"] == category: + return i + return 999 \ No newline at end of file diff --git a/backend/logs/app.log b/backend/logs/app.log new file mode 100644 index 0000000..f254449 --- /dev/null +++ b/backend/logs/app.log @@ -0,0 +1,47 @@ +{"timestamp": "2026-04-15T04:06:34.997704Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "979705e7-6942-4347-af1b-886297594d66", "user_id": null, "request_id": "857409fa-6f64-424a-9cbd-8c2c7cb4cd15", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:07:02.330013Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "4cec75a0-0ae3-4a30-86fe-b2a3bca6943e", "user_id": null, "request_id": "c96d816b-309a-447e-9241-fe005a769d31", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:07:33.485146Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "f3762a9d-0c17-4bc7-be60-70c1b7b579d2", "user_id": null, "request_id": "799e1335-ab10-4aa4-aeaa-b6f77a6c7eb2", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:07:55.082694Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: type object 'Information' has no attribute 'deal_no'", "trace_id": "aa0f7d18-0428-47f0-92d3-13698ad6f8ce", "user_id": null, "request_id": "c68777ae-fd05-4192-bb5f-c105ba038bfc", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:07:55.086656Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/information/list", "trace_id": "2895acdc-f404-415b-b8bb-65cc76367aab", "user_id": null, "request_id": "ada08c80-2231-445b-b4f3-0c67239a1f8e", "ip_address": "127.0.0.1", "duration_ms": 21.480321884155273, "data": {"method": "GET", "path": "/api/information/list", "query": "info_type=yichen&page=1&page_size=2", "error": "type object 'Information' has no attribute 'deal_no'"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 135, in get_information_list\n query = query.order_by(Information.deal_date.desc().nullslast(), Information.deal_no.desc().nullslast())\n ^^^^^^^^^^^^^^^^^^^\nAttributeError: type object 'Information' has no attribute 'deal_no'"} +{"timestamp": "2026-04-15T04:07:55.093581Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:type object 'Information' has no attribute 'deal_no'\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n | raise e\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n | raw_response = await run_endpoint_function(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n | return await run_in_threadpool(dependant.call, **values)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n | return await anyio.to_thread.run_sync(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n | return await get_async_backend().run_sync_in_worker_thread(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n | return await future\n | ^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n | result = context.run(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 135, in get_information_list\n | query = query.order_by(Information.deal_date.desc().nullslast(), Information.deal_no.desc().nullslast())\n | ^^^^^^^^^^^^^^^^^^^\n | AttributeError: type object 'Information' has no attribute 'deal_no'\n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 135, in get_information_list\n query = query.order_by(Information.deal_date.desc().nullslast(), Information.deal_no.desc().nullslast())\n ^^^^^^^^^^^^^^^^^^^\nAttributeError: type object 'Information' has no attribute 'deal_no'\n", "trace_id": "18a49976-49f4-45a5-a927-190bcb2f4b86", "user_id": null, "request_id": "0eb02dbe-30c6-4693-a456-36e9aa54abbe", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:09:13.628237Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "ba877311-e544-42b4-8b25-aa7058128c64", "user_id": null, "request_id": "33a35503-64c1-4359-8ed0-151527e274cb", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:09:17.533102Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: (psycopg2.errors.UndefinedColumn) column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n[SQL: SELECT information.id AS information_id, information.user_id AS information_user_id, information.info_type AS information_info_type, information.title AS information_title, information.content AS information_content, information.collection_id AS information_collection_id, information.expect_category AS information_expect_category, information.expect_version AS information_expect_version, information.expect_packaging AS information_expect_packaging, information.expect_number AS information_expect_number, information.expect_price_min AS information_expect_price_min, information.expect_price_max AS information_expect_price_max, information.deal_price AS information_deal_price, information.deal_date AS information_deal_date, information.status AS information_status, information.is_matched AS information_is_matched, information.matched_user_id AS information_matched_user_id, information.matched_contact AS information_matched_contact, information.view_count AS information_view_count, information.contact_count AS information_contact_count, information.created_at AS information_created_at, information.updated_at AS information_updated_at, information.author AS information_author, users_1.f99_90_id AS users_1_f99_90_id, users_1.user_code AS users_1_user_code, users_1.f99_91_user_id AS users_1_f99_91_user_id, users_1.f01_01_name AS users_1_f01_01_name, users_1.email AS users_1_email, users_1.phone AS users_1_phone, users_1.avatar AS users_1_avatar, users_1.address AS users_1_address, users_1.bio AS users_1_bio, users_1.password AS users_1_password, users_1.role AS users_1_role, users_1.f99_92_created_at AS users_1_f99_92_created_at, users_1.f99_93_updated_at AS users_1_f99_93_updated_at, users_1.f99_94_level AS users_1_f99_94_level, users_1.f99_95_ai_count AS users_1_f99_95_ai_count, users_1.f99_96_search_count AS users_1_f99_96_search_count, users_1.f99_97_collection_count AS users_1_f99_97_collection_count, users_1.f01_06_phone_verified AS users_1_f01_06_phone_verified, users_1.f99_98_login_count AS users_1_f99_98_login_count, users_1.f99_99_last_login AS users_1_f99_99_last_login, users_1.f01_07_gender AS users_1_f01_07_gender, users_1.f01_08_birthday AS users_1_f01_08_birthday, users_1.f01_09_region AS users_1_f01_09_region, users_1.f01_10_realname_verified AS users_1_f01_10_realname_verified, users_1.f99_100_points AS users_1_f99_100_points, users_1.f01_11_balance AS users_1_f01_11_balance, users_1.f01_12_total_amount AS users_1_f01_12_total_amount, users_1.f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f99_101_invited_count AS users_1_f99_101_invited_count, users_1.f99_101_information_count AS users_1_f99_101_information_count, collections_1.f99_90_id AS collections_1_f99_90_id, collections_1.f99_91_user_id AS collections_1_f99_91_user_id, collections_1.f99_92_created_at AS collections_1_f99_92_created_at, collections_1.f99_93_updated_at AS collections_1_f99_93_updated_at, collections_1.f01_01_name AS collections_1_f01_01_name, collections_1.f01_02_code AS collections_1_f01_02_code, collections_1.f01_03_category AS collections_1_f01_03_category, collections_1.f01_04_status AS collections_1_f01_04_status, collections_1.f01_05_remark AS collections_1_f01_05_remark, collections_1.f02_10_prefix_serial AS collections_1_f02_10_prefix_serial, collections_1.f02_11_version AS collections_1_f02_11_version, collections_1.f02_12_packaging AS collections_1_f02_12_packaging, collections_1.f02_13_rarity AS collections_1_f02_13_rarity, collections_1.f02_14_number_category AS collections_1_f02_14_number_category, collections_1.f03_20_is_graded AS collections_1_f03_20_is_graded, collections_1.f03_21_grading_company AS collections_1_f03_21_grading_company, collections_1.f03_22_grading_score AS collections_1_f03_22_grading_score, collections_1.f03_23_three_star AS collections_1_f03_23_three_star, collections_1.f04_30_special_mark AS collections_1_f04_30_special_mark, collections_1.f04_31_serial_feature AS collections_1_f04_31_serial_feature, collections_1.f04_32_issuer AS collections_1_f04_32_issuer, collections_1.f04_33_issue_year AS collections_1_f04_33_issue_year, collections_1.f04_34_material AS collections_1_f04_34_material, collections_1.f04_35_denomination AS collections_1_f04_35_denomination, collections_1.f04_36_issue_quantity AS collections_1_f04_36_issue_quantity, collections_1.f05_40_cost_price AS collections_1_f05_40_cost_price, collections_1.f05_41_target_price AS collections_1_f05_41_target_price, collections_1.f05_42_goal_price AS collections_1_f05_42_goal_price, collections_1.f05_43_repair_fee AS collections_1_f05_43_repair_fee, collections_1.f05_44_grading_fee AS collections_1_f05_44_grading_fee, collections_1.f06_50_purpose AS collections_1_f06_50_purpose \nFROM information LEFT OUTER JOIN users AS users_1 ON users_1.f99_90_id = information.user_id LEFT OUTER JOIN collections AS collections_1 ON collections_1.f99_90_id = information.collection_id \nWHERE information.status = %(status_1)s AND information.info_type = %(info_type_1)s ORDER BY information.deal_date DESC NULLS LAST \n LIMIT %(param_1)s OFFSET %(param_2)s]\n[parameters: {'status_1': 'active', 'info_type_1': 'yichen', 'param_1': 2, 'param_2': 0}]\n(Background on this error at: https://sqlalche.me/e/20/f405)", "trace_id": "1527cf36-195d-49ae-aa16-0487f464ad72", "user_id": null, "request_id": "b5f2c384-c275-4339-9155-ec8dfdb7b482", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:09:17.539802Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/information/list", "trace_id": "f24c915b-4e68-45a0-b01c-1f887ed5a8f9", "user_id": null, "request_id": "ab15af30-5ddb-464f-96de-56c91b85e0a1", "ip_address": "127.0.0.1", "duration_ms": 32.13644027709961, "data": {"method": "GET", "path": "/api/information/list", "query": "info_type=yichen&page=1&page_size=2", "error": "(psycopg2.errors.UndefinedColumn) column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n[SQL: SELECT information.id AS information_id, information.user_id AS information_user_id, information.info_type AS information_info_type, information.title AS information_title, information.content AS information_content, information.collection_id AS information_collection_id, information.expect_category AS information_expect_category, information.expect_version AS information_expect_version, information.expect_packaging AS information_expect_packaging, information.expect_number AS information_expect_number, information.expect_price_min AS information_expect_price_min, information.expect_price_max AS information_expect_price_max, information.deal_price AS information_deal_price, information.deal_date AS information_deal_date, information.status AS information_status, information.is_matched AS information_is_matched, information.matched_user_id AS information_matched_user_id, information.matched_contact AS information_matched_contact, information.view_count AS information_view_count, information.contact_count AS information_contact_count, information.created_at AS information_created_at, information.updated_at AS information_updated_at, information.author AS information_author, users_1.f99_90_id AS users_1_f99_90_id, users_1.user_code AS users_1_user_code, users_1.f99_91_user_id AS users_1_f99_91_user_id, users_1.f01_01_name AS users_1_f01_01_name, users_1.email AS users_1_email, users_1.phone AS users_1_phone, users_1.avatar AS users_1_avatar, users_1.address AS users_1_address, users_1.bio AS users_1_bio, users_1.password AS users_1_password, users_1.role AS users_1_role, users_1.f99_92_created_at AS users_1_f99_92_created_at, users_1.f99_93_updated_at AS users_1_f99_93_updated_at, users_1.f99_94_level AS users_1_f99_94_level, users_1.f99_95_ai_count AS users_1_f99_95_ai_count, users_1.f99_96_search_count AS users_1_f99_96_search_count, users_1.f99_97_collection_count AS users_1_f99_97_collection_count, users_1.f01_06_phone_verified AS users_1_f01_06_phone_verified, users_1.f99_98_login_count AS users_1_f99_98_login_count, users_1.f99_99_last_login AS users_1_f99_99_last_login, users_1.f01_07_gender AS users_1_f01_07_gender, users_1.f01_08_birthday AS users_1_f01_08_birthday, users_1.f01_09_region AS users_1_f01_09_region, users_1.f01_10_realname_verified AS users_1_f01_10_realname_verified, users_1.f99_100_points AS users_1_f99_100_points, users_1.f01_11_balance AS users_1_f01_11_balance, users_1.f01_12_total_amount AS users_1_f01_12_total_amount, users_1.f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f99_101_invited_count AS users_1_f99_101_invited_count, users_1.f99_101_information_count AS users_1_f99_101_information_count, collections_1.f99_90_id AS collections_1_f99_90_id, collections_1.f99_91_user_id AS collections_1_f99_91_user_id, collections_1.f99_92_created_at AS collections_1_f99_92_created_at, collections_1.f99_93_updated_at AS collections_1_f99_93_updated_at, collections_1.f01_01_name AS collections_1_f01_01_name, collections_1.f01_02_code AS collections_1_f01_02_code, collections_1.f01_03_category AS collections_1_f01_03_category, collections_1.f01_04_status AS collections_1_f01_04_status, collections_1.f01_05_remark AS collections_1_f01_05_remark, collections_1.f02_10_prefix_serial AS collections_1_f02_10_prefix_serial, collections_1.f02_11_version AS collections_1_f02_11_version, collections_1.f02_12_packaging AS collections_1_f02_12_packaging, collections_1.f02_13_rarity AS collections_1_f02_13_rarity, collections_1.f02_14_number_category AS collections_1_f02_14_number_category, collections_1.f03_20_is_graded AS collections_1_f03_20_is_graded, collections_1.f03_21_grading_company AS collections_1_f03_21_grading_company, collections_1.f03_22_grading_score AS collections_1_f03_22_grading_score, collections_1.f03_23_three_star AS collections_1_f03_23_three_star, collections_1.f04_30_special_mark AS collections_1_f04_30_special_mark, collections_1.f04_31_serial_feature AS collections_1_f04_31_serial_feature, collections_1.f04_32_issuer AS collections_1_f04_32_issuer, collections_1.f04_33_issue_year AS collections_1_f04_33_issue_year, collections_1.f04_34_material AS collections_1_f04_34_material, collections_1.f04_35_denomination AS collections_1_f04_35_denomination, collections_1.f04_36_issue_quantity AS collections_1_f04_36_issue_quantity, collections_1.f05_40_cost_price AS collections_1_f05_40_cost_price, collections_1.f05_41_target_price AS collections_1_f05_41_target_price, collections_1.f05_42_goal_price AS collections_1_f05_42_goal_price, collections_1.f05_43_repair_fee AS collections_1_f05_43_repair_fee, collections_1.f05_44_grading_fee AS collections_1_f05_44_grading_fee, collections_1.f06_50_purpose AS collections_1_f06_50_purpose \nFROM information LEFT OUTER JOIN users AS users_1 ON users_1.f99_90_id = information.user_id LEFT OUTER JOIN collections AS collections_1 ON collections_1.f99_90_id = information.collection_id \nWHERE information.status = %(status_1)s AND information.info_type = %(info_type_1)s ORDER BY information.deal_date DESC NULLS LAST \n LIMIT %(param_1)s OFFSET %(param_2)s]\n[parameters: {'status_1': 'active', 'info_type_1': 'yichen', 'param_1': 2, 'param_2': 0}]\n(Background on this error at: https://sqlalche.me/e/20/f405)"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1969, in _exec_single_context\n self.dialect.do_execute(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/default.py\", line 922, in do_execute\n cursor.execute(statement, parameters)\npsycopg2.errors.UndefinedColumn: column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 129, in get_information_list\n items = query.offset(offset).limit(page_size).all()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/query.py\", line 2693, in all\n return self._iter().all() # type: ignore\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/query.py\", line 2847, in _iter\n result: Union[ScalarResult[_T], Result[_T]] = self.session.execute(\n ^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/session.py\", line 2308, in execute\n return self._execute_internal(\n ^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/session.py\", line 2190, in _execute_internal\n result: Result[Any] = compile_state_cls.orm_execute_statement(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/context.py\", line 293, in orm_execute_statement\n result = conn.execute(\n ^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1416, in execute\n return meth(\n ^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/sql/elements.py\", line 517, in _execute_on_connection\n return connection._execute_clauseelement(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1639, in _execute_clauseelement\n ret = self._execute_context(\n ^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1848, in _execute_context\n return self._exec_single_context(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1988, in _exec_single_context\n self._handle_dbapi_exception(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 2344, in _handle_dbapi_exception\n raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1969, in _exec_single_context\n self.dialect.do_execute(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/default.py\", line 922, in do_execute\n cursor.execute(statement, parameters)\nsqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n[SQL: SELECT information.id AS information_id, information.user_id AS information_user_id, information.info_type AS information_info_type, information.title AS information_title, information.content AS information_content, information.collection_id AS information_collection_id, information.expect_category AS information_expect_category, information.expect_version AS information_expect_version, information.expect_packaging AS information_expect_packaging, information.expect_number AS information_expect_number, information.expect_price_min AS information_expect_price_min, information.expect_price_max AS information_expect_price_max, information.deal_price AS information_deal_price, information.deal_date AS information_deal_date, information.status AS information_status, information.is_matched AS information_is_matched, information.matched_user_id AS information_matched_user_id, information.matched_contact AS information_matched_contact, information.view_count AS information_view_count, information.contact_count AS information_contact_count, information.created_at AS information_created_at, information.updated_at AS information_updated_at, information.author AS information_author, users_1.f99_90_id AS users_1_f99_90_id, users_1.user_code AS users_1_user_code, users_1.f99_91_user_id AS users_1_f99_91_user_id, users_1.f01_01_name AS users_1_f01_01_name, users_1.email AS users_1_email, users_1.phone AS users_1_phone, users_1.avatar AS users_1_avatar, users_1.address AS users_1_address, users_1.bio AS users_1_bio, users_1.password AS users_1_password, users_1.role AS users_1_role, users_1.f99_92_created_at AS users_1_f99_92_created_at, users_1.f99_93_updated_at AS users_1_f99_93_updated_at, users_1.f99_94_level AS users_1_f99_94_level, users_1.f99_95_ai_count AS users_1_f99_95_ai_count, users_1.f99_96_search_count AS users_1_f99_96_search_count, users_1.f99_97_collection_count AS users_1_f99_97_collection_count, users_1.f01_06_phone_verified AS users_1_f01_06_phone_verified, users_1.f99_98_login_count AS users_1_f99_98_login_count, users_1.f99_99_last_login AS users_1_f99_99_last_login, users_1.f01_07_gender AS users_1_f01_07_gender, users_1.f01_08_birthday AS users_1_f01_08_birthday, users_1.f01_09_region AS users_1_f01_09_region, users_1.f01_10_realname_verified AS users_1_f01_10_realname_verified, users_1.f99_100_points AS users_1_f99_100_points, users_1.f01_11_balance AS users_1_f01_11_balance, users_1.f01_12_total_amount AS users_1_f01_12_total_amount, users_1.f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f99_101_invited_count AS users_1_f99_101_invited_count, users_1.f99_101_information_count AS users_1_f99_101_information_count, collections_1.f99_90_id AS collections_1_f99_90_id, collections_1.f99_91_user_id AS collections_1_f99_91_user_id, collections_1.f99_92_created_at AS collections_1_f99_92_created_at, collections_1.f99_93_updated_at AS collections_1_f99_93_updated_at, collections_1.f01_01_name AS collections_1_f01_01_name, collections_1.f01_02_code AS collections_1_f01_02_code, collections_1.f01_03_category AS collections_1_f01_03_category, collections_1.f01_04_status AS collections_1_f01_04_status, collections_1.f01_05_remark AS collections_1_f01_05_remark, collections_1.f02_10_prefix_serial AS collections_1_f02_10_prefix_serial, collections_1.f02_11_version AS collections_1_f02_11_version, collections_1.f02_12_packaging AS collections_1_f02_12_packaging, collections_1.f02_13_rarity AS collections_1_f02_13_rarity, collections_1.f02_14_number_category AS collections_1_f02_14_number_category, collections_1.f03_20_is_graded AS collections_1_f03_20_is_graded, collections_1.f03_21_grading_company AS collections_1_f03_21_grading_company, collections_1.f03_22_grading_score AS collections_1_f03_22_grading_score, collections_1.f03_23_three_star AS collections_1_f03_23_three_star, collections_1.f04_30_special_mark AS collections_1_f04_30_special_mark, collections_1.f04_31_serial_feature AS collections_1_f04_31_serial_feature, collections_1.f04_32_issuer AS collections_1_f04_32_issuer, collections_1.f04_33_issue_year AS collections_1_f04_33_issue_year, collections_1.f04_34_material AS collections_1_f04_34_material, collections_1.f04_35_denomination AS collections_1_f04_35_denomination, collections_1.f04_36_issue_quantity AS collections_1_f04_36_issue_quantity, collections_1.f05_40_cost_price AS collections_1_f05_40_cost_price, collections_1.f05_41_target_price AS collections_1_f05_41_target_price, collections_1.f05_42_goal_price AS collections_1_f05_42_goal_price, collections_1.f05_43_repair_fee AS collections_1_f05_43_repair_fee, collections_1.f05_44_grading_fee AS collections_1_f05_44_grading_fee, collections_1.f06_50_purpose AS collections_1_f06_50_purpose \nFROM information LEFT OUTER JOIN users AS users_1 ON users_1.f99_90_id = information.user_id LEFT OUTER JOIN collections AS collections_1 ON collections_1.f99_90_id = information.collection_id \nWHERE information.status = %(status_1)s AND information.info_type = %(info_type_1)s ORDER BY information.deal_date DESC NULLS LAST \n LIMIT %(param_1)s OFFSET %(param_2)s]\n[parameters: {'status_1': 'active', 'info_type_1': 'yichen', 'param_1': 2, 'param_2': 0}]\n(Background on this error at: https://sqlalche.me/e/20/f405)"} +{"timestamp": "2026-04-15T04:09:17.545679Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:(psycopg2.errors.UndefinedColumn) column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n[SQL: SELECT information.id AS information_id, information.user_id AS information_user_id, information.info_type AS information_info_type, information.title AS information_title, information.content AS information_content, information.collection_id AS information_collection_id, information.expect_category AS information_expect_category, information.expect_version AS information_expect_version, information.expect_packaging AS information_expect_packaging, information.expect_number AS information_expect_number, information.expect_price_min AS information_expect_price_min, information.expect_price_max AS information_expect_price_max, information.deal_price AS information_deal_price, information.deal_date AS information_deal_date, information.status AS information_status, information.is_matched AS information_is_matched, information.matched_user_id AS information_matched_user_id, information.matched_contact AS information_matched_contact, information.view_count AS information_view_count, information.contact_count AS information_contact_count, information.created_at AS information_created_at, information.updated_at AS information_updated_at, information.author AS information_author, users_1.f99_90_id AS users_1_f99_90_id, users_1.user_code AS users_1_user_code, users_1.f99_91_user_id AS users_1_f99_91_user_id, users_1.f01_01_name AS users_1_f01_01_name, users_1.email AS users_1_email, users_1.phone AS users_1_phone, users_1.avatar AS users_1_avatar, users_1.address AS users_1_address, users_1.bio AS users_1_bio, users_1.password AS users_1_password, users_1.role AS users_1_role, users_1.f99_92_created_at AS users_1_f99_92_created_at, users_1.f99_93_updated_at AS users_1_f99_93_updated_at, users_1.f99_94_level AS users_1_f99_94_level, users_1.f99_95_ai_count AS users_1_f99_95_ai_count, users_1.f99_96_search_count AS users_1_f99_96_search_count, users_1.f99_97_collection_count AS users_1_f99_97_collection_count, users_1.f01_06_phone_verified AS users_1_f01_06_phone_verified, users_1.f99_98_login_count AS users_1_f99_98_login_count, users_1.f99_99_last_login AS users_1_f99_99_last_login, users_1.f01_07_gender AS users_1_f01_07_gender, users_1.f01_08_birthday AS users_1_f01_08_birthday, users_1.f01_09_region AS users_1_f01_09_region, users_1.f01_10_realname_verified AS users_1_f01_10_realname_verified, users_1.f99_100_points AS users_1_f99_100_points, users_1.f01_11_balance AS users_1_f01_11_balance, users_1.f01_12_total_amount AS users_1_f01_12_total_amount, users_1.f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f99_101_invited_count AS users_1_f99_101_invited_count, users_1.f99_101_information_count AS users_1_f99_101_information_count, collections_1.f99_90_id AS collections_1_f99_90_id, collections_1.f99_91_user_id AS collections_1_f99_91_user_id, collections_1.f99_92_created_at AS collections_1_f99_92_created_at, collections_1.f99_93_updated_at AS collections_1_f99_93_updated_at, collections_1.f01_01_name AS collections_1_f01_01_name, collections_1.f01_02_code AS collections_1_f01_02_code, collections_1.f01_03_category AS collections_1_f01_03_category, collections_1.f01_04_status AS collections_1_f01_04_status, collections_1.f01_05_remark AS collections_1_f01_05_remark, collections_1.f02_10_prefix_serial AS collections_1_f02_10_prefix_serial, collections_1.f02_11_version AS collections_1_f02_11_version, collections_1.f02_12_packaging AS collections_1_f02_12_packaging, collections_1.f02_13_rarity AS collections_1_f02_13_rarity, collections_1.f02_14_number_category AS collections_1_f02_14_number_category, collections_1.f03_20_is_graded AS collections_1_f03_20_is_graded, collections_1.f03_21_grading_company AS collections_1_f03_21_grading_company, collections_1.f03_22_grading_score AS collections_1_f03_22_grading_score, collections_1.f03_23_three_star AS collections_1_f03_23_three_star, collections_1.f04_30_special_mark AS collections_1_f04_30_special_mark, collections_1.f04_31_serial_feature AS collections_1_f04_31_serial_feature, collections_1.f04_32_issuer AS collections_1_f04_32_issuer, collections_1.f04_33_issue_year AS collections_1_f04_33_issue_year, collections_1.f04_34_material AS collections_1_f04_34_material, collections_1.f04_35_denomination AS collections_1_f04_35_denomination, collections_1.f04_36_issue_quantity AS collections_1_f04_36_issue_quantity, collections_1.f05_40_cost_price AS collections_1_f05_40_cost_price, collections_1.f05_41_target_price AS collections_1_f05_41_target_price, collections_1.f05_42_goal_price AS collections_1_f05_42_goal_price, collections_1.f05_43_repair_fee AS collections_1_f05_43_repair_fee, collections_1.f05_44_grading_fee AS collections_1_f05_44_grading_fee, collections_1.f06_50_purpose AS collections_1_f06_50_purpose \nFROM information LEFT OUTER JOIN users AS users_1 ON users_1.f99_90_id = information.user_id LEFT OUTER JOIN collections AS collections_1 ON collections_1.f99_90_id = information.collection_id \nWHERE information.status = %(status_1)s AND information.info_type = %(info_type_1)s ORDER BY information.deal_date DESC NULLS LAST \n LIMIT %(param_1)s OFFSET %(param_2)s]\n[parameters: {'status_1': 'active', 'info_type_1': 'yichen', 'param_1': 2, 'param_2': 0}]\n(Background on this error at: https://sqlalche.me/e/20/f405)\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1969, in _exec_single_context\n self.dialect.do_execute(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/default.py\", line 922, in do_execute\n cursor.execute(statement, parameters)\npsycopg2.errors.UndefinedColumn: column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 129, in get_information_list\n items = query.offset(offset).limit(page_size).all()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/query.py\", line 2693, in all\n return self._iter().all() # type: ignore\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/query.py\", line 2847, in _iter\n result: Union[ScalarResult[_T], Result[_T]] = self.session.execute(\n ^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/session.py\", line 2308, in execute\n return self._execute_internal(\n ^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/session.py\", line 2190, in _execute_internal\n result: Result[Any] = compile_state_cls.orm_execute_statement(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/orm/context.py\", line 293, in orm_execute_statement\n result = conn.execute(\n ^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1416, in execute\n return meth(\n ^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/sql/elements.py\", line 517, in _execute_on_connection\n return connection._execute_clauseelement(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1639, in _execute_clauseelement\n ret = self._execute_context(\n ^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1848, in _execute_context\n return self._exec_single_context(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1988, in _exec_single_context\n self._handle_dbapi_exception(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 2344, in _handle_dbapi_exception\n raise sqlalchemy_exception.with_traceback(exc_info[2]) from e\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/base.py\", line 1969, in _exec_single_context\n self.dialect.do_execute(\n File \"/usr/local/lib/python3.12/dist-packages/sqlalchemy/engine/default.py\", line 922, in do_execute\n cursor.execute(statement, parameters)\nsqlalchemy.exc.ProgrammingError: (psycopg2.errors.UndefinedColumn) column users_1.f99_101_invited_count does not exist\nLINE 1: ...f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f9...\n ^\n\n[SQL: SELECT information.id AS information_id, information.user_id AS information_user_id, information.info_type AS information_info_type, information.title AS information_title, information.content AS information_content, information.collection_id AS information_collection_id, information.expect_category AS information_expect_category, information.expect_version AS information_expect_version, information.expect_packaging AS information_expect_packaging, information.expect_number AS information_expect_number, information.expect_price_min AS information_expect_price_min, information.expect_price_max AS information_expect_price_max, information.deal_price AS information_deal_price, information.deal_date AS information_deal_date, information.status AS information_status, information.is_matched AS information_is_matched, information.matched_user_id AS information_matched_user_id, information.matched_contact AS information_matched_contact, information.view_count AS information_view_count, information.contact_count AS information_contact_count, information.created_at AS information_created_at, information.updated_at AS information_updated_at, information.author AS information_author, users_1.f99_90_id AS users_1_f99_90_id, users_1.user_code AS users_1_user_code, users_1.f99_91_user_id AS users_1_f99_91_user_id, users_1.f01_01_name AS users_1_f01_01_name, users_1.email AS users_1_email, users_1.phone AS users_1_phone, users_1.avatar AS users_1_avatar, users_1.address AS users_1_address, users_1.bio AS users_1_bio, users_1.password AS users_1_password, users_1.role AS users_1_role, users_1.f99_92_created_at AS users_1_f99_92_created_at, users_1.f99_93_updated_at AS users_1_f99_93_updated_at, users_1.f99_94_level AS users_1_f99_94_level, users_1.f99_95_ai_count AS users_1_f99_95_ai_count, users_1.f99_96_search_count AS users_1_f99_96_search_count, users_1.f99_97_collection_count AS users_1_f99_97_collection_count, users_1.f01_06_phone_verified AS users_1_f01_06_phone_verified, users_1.f99_98_login_count AS users_1_f99_98_login_count, users_1.f99_99_last_login AS users_1_f99_99_last_login, users_1.f01_07_gender AS users_1_f01_07_gender, users_1.f01_08_birthday AS users_1_f01_08_birthday, users_1.f01_09_region AS users_1_f01_09_region, users_1.f01_10_realname_verified AS users_1_f01_10_realname_verified, users_1.f99_100_points AS users_1_f99_100_points, users_1.f01_11_balance AS users_1_f01_11_balance, users_1.f01_12_total_amount AS users_1_f01_12_total_amount, users_1.f01_13_invite_code AS users_1_f01_13_invite_code, users_1.f99_101_invited_count AS users_1_f99_101_invited_count, users_1.f99_101_information_count AS users_1_f99_101_information_count, collections_1.f99_90_id AS collections_1_f99_90_id, collections_1.f99_91_user_id AS collections_1_f99_91_user_id, collections_1.f99_92_created_at AS collections_1_f99_92_created_at, collections_1.f99_93_updated_at AS collections_1_f99_93_updated_at, collections_1.f01_01_name AS collections_1_f01_01_name, collections_1.f01_02_code AS collections_1_f01_02_code, collections_1.f01_03_category AS collections_1_f01_03_category, collections_1.f01_04_status AS collections_1_f01_04_status, collections_1.f01_05_remark AS collections_1_f01_05_remark, collections_1.f02_10_prefix_serial AS collections_1_f02_10_prefix_serial, collections_1.f02_11_version AS collections_1_f02_11_version, collections_1.f02_12_packaging AS collections_1_f02_12_packaging, collections_1.f02_13_rarity AS collections_1_f02_13_rarity, collections_1.f02_14_number_category AS collections_1_f02_14_number_category, collections_1.f03_20_is_graded AS collections_1_f03_20_is_graded, collections_1.f03_21_grading_company AS collections_1_f03_21_grading_company, collections_1.f03_22_grading_score AS collections_1_f03_22_grading_score, collections_1.f03_23_three_star AS collections_1_f03_23_three_star, collections_1.f04_30_special_mark AS collections_1_f04_30_special_mark, collections_1.f04_31_serial_feature AS collections_1_f04_31_serial_feature, collections_1.f04_32_issuer AS collections_1_f04_32_issuer, collections_1.f04_33_issue_year AS collections_1_f04_33_issue_year, collections_1.f04_34_material AS collections_1_f04_34_material, collections_1.f04_35_denomination AS collections_1_f04_35_denomination, collections_1.f04_36_issue_quantity AS collections_1_f04_36_issue_quantity, collections_1.f05_40_cost_price AS collections_1_f05_40_cost_price, collections_1.f05_41_target_price AS collections_1_f05_41_target_price, collections_1.f05_42_goal_price AS collections_1_f05_42_goal_price, collections_1.f05_43_repair_fee AS collections_1_f05_43_repair_fee, collections_1.f05_44_grading_fee AS collections_1_f05_44_grading_fee, collections_1.f06_50_purpose AS collections_1_f06_50_purpose \nFROM information LEFT OUTER JOIN users AS users_1 ON users_1.f99_90_id = information.user_id LEFT OUTER JOIN collections AS collections_1 ON collections_1.f99_90_id = information.collection_id \nWHERE information.status = %(status_1)s AND information.info_type = %(info_type_1)s ORDER BY information.deal_date DESC NULLS LAST \n LIMIT %(param_1)s OFFSET %(param_2)s]\n[parameters: {'status_1': 'active', 'info_type_1': 'yichen', 'param_1': 2, 'param_2': 0}]\n(Background on this error at: https://sqlalche.me/e/20/f405)\n", "trace_id": "fd653117-7066-4645-b23d-4395e1453cb5", "user_id": null, "request_id": "b36fd8b6-3f0f-4291-95e6-903b48b96e0a", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:10:21.921961Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "c0c8c9fb-02ac-4f1c-8d97-caa806e6d54a", "user_id": null, "request_id": "64d0ed38-3b51-4a50-9161-2a9750d4479c", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:10:55.614704Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "994f8d7d-61fb-496b-8204-441cb9ead315", "user_id": null, "request_id": "72434856-457c-422d-8881-d12aac04a97a", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:11:54.743982Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "bfd9d19c-30aa-4863-8fdd-65552e601a53", "user_id": null, "request_id": "60c1dfe9-dce6-4fd6-ac45-5890871cbb61", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:12:18.350083Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "c27c03bb-e733-4967-80ab-bbc1c75221a5", "user_id": null, "request_id": "e1b1ce77-1bc9-469b-8e89-62dfcc6da361", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:32:54.455912Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 401: E00011: 用户名或密码错误", "trace_id": "bee519b9-6cfc-41bb-ab79-63e9bcdb7ca0", "user_id": null, "request_id": "07418ddc-394d-447f-8c9f-3b988827e210", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:33:46.727754Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type", "trace_id": "4d9f265f-dd65-404f-a352-4209a53da3e2", "user_id": null, "request_id": "50d8f2a6-f3e9-40d9-8c69-ab18965f534a", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:33:46.731371Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:POST /api/information/", "trace_id": "0b8b5cbf-e872-4d30-8447-588447273e4a", "user_id": "authenticated", "request_id": "19f0ce46-1030-4724-97b6-e198883564a1", "ip_address": "127.0.0.1", "duration_ms": 19.598007202148438, "data": {"method": "POST", "path": "/api/information/", "query": "", "error": "1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 470, in create_information\n return InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"} +{"timestamp": "2026-04-15T04:33:46.736348Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n | raise e\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n | raw_response = await run_endpoint_function(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n | return await run_in_threadpool(dependant.call, **values)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n | return await anyio.to_thread.run_sync(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n | return await get_async_backend().run_sync_in_worker_thread(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n | return await future\n | ^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n | result = context.run(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 470, in create_information\n | return InformationResponse(\n | ^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\n | pydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\n | created_at\n | Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n | For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 470, in create_information\n return InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n", "trace_id": "918c81da-4a49-4bf6-a5c4-6af79b9126c7", "user_id": null, "request_id": "05ea208b-2aca-41c5-b06c-dd32372aaefa", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:35:27.776534Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 404: 资讯不存在", "trace_id": "fe5e3bf9-3864-45c2-b44c-ad150aa45fc4", "user_id": null, "request_id": "b4558b5a-0685-4ed9-85de-cc62f04d6599", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:39:37.783033Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "88e5f2ea-5660-431f-8a1e-3b9764f559aa", "user_id": null, "request_id": "6d2c816a-e153-484c-8dd6-43f74fd9d8f4", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:39:42.958240Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 404: 资讯不存在", "trace_id": "1b25d3fd-3a7e-47ed-81bd-62aeecb62fa1", "user_id": null, "request_id": "c9993123-65b4-4891-9271-3641d0cedd2d", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:41:51.486935Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type", "trace_id": "40594474-d1ba-4548-9d2f-3cf318e77363", "user_id": null, "request_id": "de7d80e8-aa27-484a-aa4e-50d31c589482", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:41:51.490603Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/information/list", "trace_id": "5710b554-8725-4d24-9d6a-c720970694e9", "user_id": "authenticated", "request_id": "f9a6185c-a73b-46cb-bd33-9ceb05d1b198", "ip_address": "127.0.0.1", "duration_ms": 21.33631706237793, "data": {"method": "GET", "path": "/api/information/list", "query": "info_type=seek", "error": "1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 148, in get_information_list\n result.append(InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"} +{"timestamp": "2026-04-15T04:41:51.495550Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n | raise e\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n | raw_response = await run_endpoint_function(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n | return await run_in_threadpool(dependant.call, **values)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n | return await anyio.to_thread.run_sync(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n | return await get_async_backend().run_sync_in_worker_thread(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n | return await future\n | ^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n | result = context.run(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 148, in get_information_list\n | result.append(InformationResponse(\n | ^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\n | pydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\n | created_at\n | Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n | For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 148, in get_information_list\n result.append(InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n", "trace_id": "9086b2b8-98df-41fe-bacf-3ba62a92f0b5", "user_id": null, "request_id": "f4c8cc51-606e-4ac4-93e5-fcaf50d570d1", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:42:58.846192Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type", "trace_id": "26b91125-790c-4d3f-9b4d-6b416b5b2c33", "user_id": null, "request_id": "d51b4b7a-78f6-4db8-9339-7698778b05a6", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:42:58.848163Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/information/my/list", "trace_id": "1f653a8b-0aa4-4103-bce0-fa46d6a2264a", "user_id": "authenticated", "request_id": "b82e34b3-50d1-463f-aebe-d8b884b501ef", "ip_address": "127.0.0.1", "duration_ms": 18.507719039916992, "data": {"method": "GET", "path": "/api/information/my/list", "query": "", "error": "1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 865, in get_my_information_list\n result.append(InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type"} +{"timestamp": "2026-04-15T04:42:58.853579Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n | raise e\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n | raw_response = await run_endpoint_function(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n | return await run_in_threadpool(dependant.call, **values)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n | return await anyio.to_thread.run_sync(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n | return await get_async_backend().run_sync_in_worker_thread(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n | return await future\n | ^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n | result = context.run(func, *args)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 865, in get_my_information_list\n | result.append(InformationResponse(\n | ^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n | __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\n | pydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\n | created_at\n | Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n | For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 299, in app\n raise e\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 294, in app\n raw_response = await run_endpoint_function(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 193, in run_endpoint_function\n return await run_in_threadpool(dependant.call, **values)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/concurrency.py\", line 40, in run_in_threadpool\n return await anyio.to_thread.run_sync(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/to_thread.py\", line 63, in run_sync\n return await get_async_backend().run_sync_in_worker_thread(\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 2502, in run_sync_in_worker_thread\n return await future\n ^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 986, in run\n result = context.run(func, *args)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/routers/information.py\", line 865, in get_my_information_list\n result.append(InformationResponse(\n ^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/pydantic/main.py\", line 164, in __init__\n __pydantic_self__.__pydantic_validator__.validate_python(data, self_instance=__pydantic_self__)\npydantic_core._pydantic_core.ValidationError: 1 validation error for InformationResponse\ncreated_at\n Input should be a valid datetime [type=datetime_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.5/v/datetime_type\n", "trace_id": "9ad0efb7-00bf-4486-9a8b-fb92580ff08a", "user_id": null, "request_id": "b36c422b-8d83-4c07-8eb1-1b364a4d84e8", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:48:07.281241Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "6f6cd185-cd65-4400-ba50-4a875a3752b9", "user_id": null, "request_id": "59082f37-bf42-445b-a989-3ec7a8f32405", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:48:34.642478Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n", "trace_id": "5a24478f-193a-41ff-8f03-278094ed2e83", "user_id": null, "request_id": "f5593d8a-f8df-4b6e-8b0a-91ef7a1a0917", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:48:34.644427Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/deal/list", "trace_id": "2c627391-bde1-47de-b4ff-5731a023c409", "user_id": "authenticated", "request_id": "b8c85965-fb12-4dad-bfae-065cf67484bc", "ip_address": "127.0.0.1", "duration_ms": 5.033731460571289, "data": {"method": "GET", "path": "/api/deal/list", "query": "", "error": "2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"} +{"timestamp": "2026-04-15T04:48:34.647748Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n | content = await serialize_response(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n | raise ResponseValidationError(\n | fastapi.exceptions.ResponseValidationError: 2 validation errors:\n | {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | \n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n", "trace_id": "bec50163-3a4c-4acd-afce-89ae53cc9ada", "user_id": null, "request_id": "7aec923e-dcdb-4fc2-9925-6c58522eca2a", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:49:16.266011Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "48a1e525-2343-48eb-808f-0251c0412ca7", "user_id": null, "request_id": "10a19f52-44e1-40b0-a106-921ffe00f6fd", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:49:58.006677Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "1caf2b84-32e8-4666-91a0-777cba8cd10f", "user_id": null, "request_id": "6ec3b15d-c285-4fca-96ad-446fdef4ce7f", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:51:32.869462Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "4ed7e61c-82f3-4b62-8736-79da1aaa7e6f", "user_id": null, "request_id": "436515bb-30db-4ddf-9a73-cda22ada49ce", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:52:07.316981Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "56d0f6a9-e671-4e09-a1bd-e6b78660e77a", "user_id": null, "request_id": "b4abaa99-f024-4542-b7c4-01b34734e7e9", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:52:44.266934Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "e5116cd9-26d7-47d7-b739-f35b5977e43a", "user_id": null, "request_id": "9f22ae8c-098e-4533-ada6-83ee6c9b9788", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:53:55.198568Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "6ed5bb9a-f6e7-4b05-94c5-b862ba5636f4", "user_id": null, "request_id": "9b554653-6013-45b6-a122-d6a8f22e26e8", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:55:00.396074Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "a0c907b0-5b4b-4108-a5a0-ef02d52a6a0d", "user_id": null, "request_id": "748da3dc-2349-4d3b-bb4d-07ce2f8b2619", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:55:42.128833Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n", "trace_id": "718e585c-9383-44c8-913c-5e2e3027c3fb", "user_id": null, "request_id": "5eb46231-5418-4929-ab6b-792319cdffb7", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:55:42.131315Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/deal/list", "trace_id": "929f3ffd-19cf-4348-baf3-ad9fa80379a6", "user_id": "authenticated", "request_id": "06f3d2cd-1662-46cb-95c6-72f960a5fe21", "ip_address": "127.0.0.1", "duration_ms": 14.678478240966797, "data": {"method": "GET", "path": "/api/deal/list", "query": "", "error": "2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"} +{"timestamp": "2026-04-15T04:55:42.135299Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n | content = await serialize_response(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n | raise ResponseValidationError(\n | fastapi.exceptions.ResponseValidationError: 2 validation errors:\n | {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | \n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n", "trace_id": "1717aeb9-909f-4141-b400-f21f2da118ee", "user_id": null, "request_id": "9380897d-def5-48d7-9d8d-7af62fd23ca8", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:56:32.065975Z", "level": "ERROR", "logger": "app.core.database", "message": "数据库会话错误: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n", "trace_id": "82ffeeac-0f40-4dff-b5d4-a1234d9ff83a", "user_id": null, "request_id": "af57ea21-9ea3-48c0-adb3-ba3e1db499ec", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:56:32.067664Z", "level": "ERROR", "logger": "root", "message": "API 请求异常:GET /api/deal/list", "trace_id": "5ec0a5fa-31f7-4a1a-a16c-2edc58264970", "user_id": "authenticated", "request_id": "21cbfdcd-d11b-4d31-a5de-b1d521abab09", "ip_address": "127.0.0.1", "duration_ms": 14.132261276245117, "data": {"method": "GET", "path": "/api/deal/list", "query": "", "error": "2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"}, "exception": "Traceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 159, in call_next\n message = await recv_stream.receive()\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/anyio/streams/memory.py\", line 132, in receive\n raise EndOfStream from None\nanyio.EndOfStream\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n"} +{"timestamp": "2026-04-15T04:56:32.071101Z", "level": "ERROR", "logger": "root", "message": "未捕获异常:2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n + Exception Group Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 85, in collapse_excgroups\n | yield\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 190, in __call__\n | async with anyio.create_task_group() as task_group:\n | File \"/usr/local/lib/python3.12/dist-packages/anyio/_backends/_asyncio.py\", line 783, in __aexit__\n | raise BaseExceptionGroup(\n | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)\n +-+---------------- 1 ----------------\n | Traceback (most recent call last):\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n | await self.app(scope, receive, _send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n | with collapse_excgroups():\n | File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n | self.gen.throw(value)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n | response = await self.dispatch_func(request, call_next)\n | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n | response = await call_next(request)\n | ^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n | raise app_exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n | await self.app(scope, receive_or_disconnect, send_no_error)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n | await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n | await self.middleware_stack(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n | await route.handle(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n | await self.app(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n | await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n | raise exc\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n | await app(scope, receive, sender)\n | File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n | response = await func(request)\n | ^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n | content = await serialize_response(\n | ^^^^^^^^^^^^^^^^^^^^^^^^^\n | File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n | raise ResponseValidationError(\n | fastapi.exceptions.ResponseValidationError: 2 validation errors:\n | {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n | \n +------------------------------------\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/errors.py\", line 164, in __call__\n await self.app(scope, receive, _send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 189, in __call__\n with collapse_excgroups():\n File \"/usr/lib/python3.12/contextlib.py\", line 158, in __exit__\n self.gen.throw(value)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_utils.py\", line 91, in collapse_excgroups\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 191, in __call__\n response = await self.dispatch_func(request, call_next)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/root/.openclaw/workspace/jiachenlong/backend/app/middleware/logging.py\", line 28, in logging_middleware\n response = await call_next(request)\n ^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 165, in call_next\n raise app_exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/base.py\", line 151, in coro\n await self.app(scope, receive_or_disconnect, send_no_error)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/cors.py\", line 83, in __call__\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/middleware/exceptions.py\", line 62, in __call__\n await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 762, in __call__\n await self.middleware_stack(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 782, in app\n await route.handle(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 297, in handle\n await self.app(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 77, in app\n await wrap_app_handling_exceptions(app, request)(scope, receive, send)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 64, in wrapped_app\n raise exc\n File \"/usr/local/lib/python3.12/dist-packages/starlette/_exception_handler.py\", line 53, in wrapped_app\n await app(scope, receive, sender)\n File \"/usr/local/lib/python3.12/dist-packages/starlette/routing.py\", line 72, in app\n response = await func(request)\n ^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 315, in app\n content = await serialize_response(\n ^^^^^^^^^^^^^^^^^^^^^^^^^\n File \"/usr/local/lib/python3.12/dist-packages/fastapi/routing.py\", line 155, in serialize_response\n raise ResponseValidationError(\nfastapi.exceptions.ResponseValidationError: 2 validation errors:\n {'type': 'int_type', 'loc': ('response', 0, 'view_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n {'type': 'int_type', 'loc': ('response', 0, 'contact_count'), 'msg': 'Input should be a valid integer', 'input': None, 'url': 'https://errors.pydantic.dev/2.5/v/int_type'}\n\n", "trace_id": "a05bdaf9-6aab-417a-84ef-83d1f80ae150", "user_id": null, "request_id": "292b0d05-6ef3-4df3-ae75-45702b48f1fb", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:57:52.248276Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "171b9f58-06d4-46b9-8739-77aaa801e177", "user_id": null, "request_id": "ff662c1c-a1ed-4432-bfc1-2e91f1a066df", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:59:01.357100Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "f1f16bce-a725-4684-afe8-bfd5fccb0588", "user_id": null, "request_id": "59bbc981-3c1a-4cd6-b553-5db880c79a75", "ip_address": null, "duration_ms": null} +{"timestamp": "2026-04-15T04:59:50.412138Z", "level": "INFO", "logger": "root", "message": "甲辰收藏系统 FastAPI 后端 v0.0.0 启动成功", "trace_id": "d884e124-b403-485a-8ba8-8d961ec357aa", "user_id": null, "request_id": "ca3cbcf1-034c-42f9-8727-d919fb26a1e3", "ip_address": null, "duration_ms": null} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bda1a26 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +# 甲辰藏品管理系统后端 - Python 依赖 +# 版本:v1.0.0 + +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +pydantic==2.5.3 +python-jose[cryptography]==3.3.0 +bcrypt==4.1.2 +python-multipart==0.0.6 +pillow==10.2.0 +dashscope==1.14.1 diff --git a/backend/uploads/.gitkeep b/backend/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..2fe6c95 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,47 @@ +# 前端 - React + Vite + +## 启动方式 + +### 开发环境 + +```bash +# 安装依赖 +npm install + +# 开发模式(热重载) +npm run dev + +# 访问 http://localhost:5173 +``` + +### 生产构建 + +```bash +# 构建 +npm run build + +# 部署 dist/ 目录到服务器 +``` + +## 目录结构 + +``` +frontend/ +├── src/ +│ ├── pages/ # 页面组件 +│ ├── utils/ # 工具函数 +│ └── config/ # 配置文件 +├── public/ # 静态资源 +└── dist/ # 构建产物 +``` + +## 页面列表 + +- `/` - 首页 +- `/login` - 登录 +- `/stats` - 统计 +- `/list` - 藏品列表 +- `/add` - 添加藏品 +- `/detail` - 藏品详情 +- `/edit` - 编辑藏品 +- `/admin` - 用户管理(仅管理员) diff --git a/frontend/VERSION b/frontend/VERSION new file mode 100644 index 0000000..ce6248b --- /dev/null +++ b/frontend/VERSION @@ -0,0 +1 @@ +VERSION=1.2.97 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..72cb774 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + + 甲辰收藏 v=1.2.97 + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f22a1fc --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2132 @@ +{ + "name": "jiachenlong-frontend", + "version": "1.2.82", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jiachenlong-frontend", + "version": "1.2.82", + "dependencies": { + "axios": "^1.7.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", + "integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1826e55 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "jiachenlong-frontend", + "version": "1.2.82", + "private": true, + "description": "甲辰藏品管理系统 - 移动端前端", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.4.2" + } +} diff --git a/frontend/package.txt b/frontend/package.txt new file mode 100644 index 0000000..1d01bc1 --- /dev/null +++ b/frontend/package.txt @@ -0,0 +1 @@ +v=1.2.80 diff --git a/frontend/postbuild.js b/frontend/postbuild.js new file mode 100644 index 0000000..0962aea --- /dev/null +++ b/frontend/postbuild.js @@ -0,0 +1,20 @@ +const fs = require('fs'); +const path = require('path'); + +const src = path.join(__dirname, 'static', 'images'); +const dst = path.join(__dirname, 'dist', 'static', 'images'); + +if (!fs.existsSync(dst)) { + fs.mkdirSync(dst, { recursive: true }); +} + +if (fs.existsSync(src)) { + fs.readdirSync(src).forEach(f => { + const srcFile = path.join(src, f); + const dstFile = path.join(dst, f); + fs.copyFileSync(srcFile, dstFile); + console.log('Copied:', f); + }); +} + +console.log('Logo复制完成'); \ No newline at end of file diff --git a/frontend/public/images/jiachenlong-logo.png b/frontend/public/images/jiachenlong-logo.png new file mode 100644 index 0000000..247598e Binary files /dev/null and b/frontend/public/images/jiachenlong-logo.png differ diff --git a/frontend/public/images/title_logo.svg b/frontend/public/images/title_logo.svg new file mode 100644 index 0000000..acd9b87 --- /dev/null +++ b/frontend/public/images/title_logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + 甲辰收藏 + + + 生肖纪念钞管理系统 + diff --git a/frontend/public/static/icons/apple-touch-icon.png b/frontend/public/static/icons/apple-touch-icon.png new file mode 100644 index 0000000..247598e Binary files /dev/null and b/frontend/public/static/icons/apple-touch-icon.png differ diff --git a/frontend/public/static/icons/favicon.ico b/frontend/public/static/icons/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/static/icons/favicon.png b/frontend/public/static/icons/favicon.png new file mode 100644 index 0000000..247598e Binary files /dev/null and b/frontend/public/static/icons/favicon.png differ diff --git a/frontend/public/static/images/jiachenlong-logo.png b/frontend/public/static/images/jiachenlong-logo.png new file mode 100644 index 0000000..247598e Binary files /dev/null and b/frontend/public/static/images/jiachenlong-logo.png differ diff --git a/frontend/public/user_agreement.html b/frontend/public/user_agreement.html new file mode 100644 index 0000000..f06cf22 --- /dev/null +++ b/frontend/public/user_agreement.html @@ -0,0 +1,115 @@ + + + + + + 用户协议 - 甲辰收藏 + + + +
+

甲辰收藏平台用户协议

+
版本生效日期:自注册之日起
+ +

特别提示:本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。

+ +

一、协议主体与适用范围

+
    +
  1. 本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。
  2. +
  3. 平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。
  4. +
  5. 您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。
  6. +
+ +

二、个人信息收集与使用

+

(一)收集原则

+

平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。

+ +

(二)收集范围与目的

+
    +
  1. 注册与身份信息:用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。
  2. +
  3. 藏品与行为信息:藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。
  4. +
  5. 设备与网络信息:设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。
  6. +
  7. 位置信息:仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。
  8. +
+ +

(三)信息使用规则

+
    +
  1. 平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。
  2. +
  3. 向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。
  4. +
  5. 您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。
  6. +
  7. 平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。
  8. +
+ +

三、公开信息使用与免责条款

+
    +
  1. 公开信息定义:您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。
  2. +
  3. 授权使用:您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。
  4. +
  5. 法定免责依据:根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。
  6. +
  7. 第三方行为免责: +
      +
    • 公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。
    • +
    • 因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。
    • +
    +
  8. +
  9. 内容责任自负: +
      +
    • 您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。
    • +
    • 如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。
    • +
    +
  10. +
  11. 平台管理边界:平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。
  12. +
+ +

四、用户权利与义务

+
    +
  1. 您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。
  2. +
  3. 不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。
  4. +
  5. 不得利用平台从事洗钱、诈骗、非法交易等违法活动。
  6. +
  7. 您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。
  8. +
+ +

五、知识产权条款

+
    +
  1. 平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。
  2. +
  3. 您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。
  4. +
  5. 未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。
  6. +
+ +

六、服务变更、中断与终止

+
    +
  1. 平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。
  2. +
  3. 平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。
  4. +
  5. 您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。
  6. +
+ +

七、免责声明(法律允许范围内)

+
    +
  1. 平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。
  2. +
  3. 平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。
  4. +
  5. 法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。
  6. +
  7. 因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。
  8. +
+ +

八、协议修改与争议解决

+
    +
  1. 平台修改协议将提前7日公示,您继续使用视为接受修订;如不同意,可停止使用并注销账号。
  2. +
  3. 因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。
  4. +
  5. 本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。
  6. +
+ +

九、联系与通知

+

平台联系方式:aicoolbot@163.com;您可通过客服渠道咨询协议、隐私、投诉等相关事宜。

+
+ + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..34e17ce --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import Home from './pages/Home' +import Settings from './pages/Settings' +import List from './pages/List' +import Add from './pages/Add' +import Stats from './pages/Stats' +import News from './pages/News' +import Login from './pages/Login' +import Detail from './pages/Detail' +import Edit from './pages/Edit' +import Admin from './pages/Admin' + +export default function App() { + const [path, setPath] = useState(window.location.hash.slice(1) || '/') + + useEffect(() => { + const handleHashChange = () => { + setPath(window.location.hash.slice(1) || '/') + } + window.addEventListener('hashchange', handleHashChange) + return () => window.removeEventListener('hashchange', handleHashChange) + }, []) + + const handleNavigate = (newPath) => { + window.location.hash = '#' + newPath + setPath(newPath) + } + + const getComponent = () => { + const basePath = path.split('?')[0] + if (basePath === '/') return + if (basePath === '/news') return + if (basePath === '/stats') return + if (basePath === '/list') return + if (basePath === '/add') return + if (basePath === '/login') return + if (basePath === '/settings') return + if (basePath === '/admin') return + if (basePath.startsWith('/edit')) return + if (basePath.startsWith('/detail')) return + return + } + + const token = localStorage.getItem('token') + const userStr = localStorage.getItem('user') + let user = null + try { + user = userStr ? JSON.parse(userStr) : null + } catch (e) { + console.error('Parse user error:', e) + } + const isAdmin = user && user.role === 'admin' + const isEditor = user && user.role === 'editor' + + // 登录页面独立渲染,不显示底部导航 + if (!token) { + // 强制刷新页面,确保状态同步 + if (path !== '/login') { + window.location.hash = '#/login' + } + return + } + + // 如果已登录且在登录页,强制跳转到首页 + if (path === '/login') { + // 使用 href 强制刷新页面 + window.location.href = window.location.origin + window.location.pathname + '#/' + return null + } + + return ( +
+ {getComponent()} + +
+ {[ + { path: '/', icon: '🏠', label: '首页' }, + { path: '/news', icon: '📰', label: '行情' }, + { path: '/stats', icon: '📊', label: '统计' }, + { path: '/list', icon: '📚', label: '我的' }, + { path: '/add', icon: '🎯', label: '录入' }, + ...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : []) + ].map(tab => ( +
handleNavigate(tab.path)} + style={{ + flex: 1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + color: path === tab.path ? '#fbbf24' : '#94a3b8', + cursor: 'pointer' + }} + > +
{tab.icon}
+
{tab.label}
+
+ ))} +
+
+ ) +} +// Fri Apr 10 03:44:24 PM CST 2026 diff --git a/frontend/src/config/version.js b/frontend/src/config/version.js new file mode 100644 index 0000000..4810618 --- /dev/null +++ b/frontend/src/config/version.js @@ -0,0 +1 @@ +VERSION=1.2.100 diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5c1e0df --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +/* 全局样式 */ diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..ef33be1 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,23 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { HashRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +// 渲染应用 +const root = document.getElementById('root') +try { + ReactDOM.createRoot(root).render( + + + + + + ) + console.log('App rendered successfully') +} catch (e) { + console.error('Render error:', e) + root.innerHTML = '
Error: ' + e.message + '
' +} +// v2.7.2 build +// Force rebuild v2.7.2 - 1773392274 diff --git a/frontend/src/pages/Add.jsx b/frontend/src/pages/Add.jsx new file mode 100644 index 0000000..09a9208 --- /dev/null +++ b/frontend/src/pages/Add.jsx @@ -0,0 +1,1083 @@ +// 添加藏品页面 - 支持 AI 识别/手工录入 +import React, { useState, useRef } from 'react' +import { APP_VERSION } from '../config/version' +const API_BASE = localStorage.getItem('API_BASE') || '' +const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}]; + +// 字段转换函数 +const convertField = (obj) => { + const map = { + id: 'f99_90_id', userId: 'f99_91_user_id', + name: 'f01_01_name', code: 'f01_02_code', category: 'f01_03_category', + status: 'f01_04_status', remark: 'f01_05_remark', + prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version', + packaging: 'f02_12_packaging', rarity: 'f02_13_rarity', + isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company', + gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star', + specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category', + issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year', + material: 'f04_34_material', denomination: 'f04_35_denomination', + issueQuantity: 'f04_36_issue_quantity', + costPrice: 'f05_40_cost_price', targetPrice: 'f05_41_target_price', + goalPrice: 'f05_42_goal_price', repairFee: 'f05_43_repair_fee', + gradingFee: 'f05_44_grading_fee', purpose: 'f06_50_purpose' + } + const result = {} + const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee'] + for (const key in obj) { + let value = obj[key] + if (value === '' || value === null) value = null + else if (numberFields.includes(key)) { + value = parseFloat(value) + if (isNaN(value)) value = null + } + result[map[key] || key] = value + } + return result +} + +// 通用 Input 组件 +const Input = ({ form, handleChange, label, field, type = 'text', options = null }) => { + const onChange = (e) => { + const value = e.target.value + if (type === 'number' && value !== '') { + const num = parseFloat(value) + handleChange(field, isNaN(num) ? '' : num) + } else handleChange(field, value) + } + return ( +
+
{label}
+ {options ? ( + + ) : ( + + )} +
+ ) +} + +const getDefaultForm = () => ({ + name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '', + version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张', + material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false, + gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '', + serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024', + costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: '' +}) + +export default function Add() { + // 行情录入表单 + const [dealForm, setDealForm] = useState({ + serial: '', + category: '', + packaging: '标十', + price: '', + platform: '抖音', + seller: '', + buyer: '', + date: new Date().toISOString().split('T')[0], + isGraded: false, + gradingCompany: '爱藏', + gradingScore: '67+' + }) + const [batchDefaultPackaging, setBatchDefaultPackaging] = useState('') + const [batchDefaultDate, setBatchDefaultDate] = useState('') + const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('抖音') + const [dealMode, setDealMode] = useState('single') // single-单条录入, batch-批量录入 + const [batchText, setBatchText] = useState('') + const [batchResult, setBatchResult] = useState([]) + const [parsing, setParsing] = useState(false) + const [savingDeal, setSavingDeal] = useState(false) + + // 号码分类函数 + const autoCategory = (serial) => { + const digits = serial.replace('J', '').replace(/[^0-9]/g, '').slice(0, 9) + if (!digits) return '' + const d = digits + if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '圆圆号' + if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '倒置号' + if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马王' + if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马号' + if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山王' + if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马王' + if (!d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山号' + if (!d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马号' + if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧王' + if (!d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧号' + if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '如意号' + if (!d.includes('3') && !d.includes('4') && !d.includes('7')) return '钻石号' + if (!d.includes('4') && !d.includes('7')) return '永恒号' + if (!d.includes('4')) return '带7号' + return '带4号' + } + + // 根据 URL 参数确定默认标签 + const params = new URLSearchParams(window.location.hash.split('?')[1] || '') + const modeParam = params.get('mode') + const defaultTab = modeParam === 'manual' ? 'manual' : 'ai' + + const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal + const [form, setForm] = useState(getDefaultForm()) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [recognizing, setRecognizing] = useState(false) + const [selectedImage, setSelectedImage] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [recognizedImage, setRecognizedImage] = useState(null) + const fileInputRef = useRef(null) + const imageFileInputRef = useRef(null) + const [uploadImages, setUploadImages] = useState([]) + const [tempImage, setTempImage] = useState(null) // OCR识别后的临时图片 + const maxImages = 1 + + const handleUploadImage = (e) => { + const files = Array.from(e.target.files) + if (files.length === 0) return + if (files.length + uploadImages.length > maxImages) { + alert(`最多只能上传${maxImages}张图片`) + return + } + const newImages = files.map(file => ({ + file, + preview: URL.createObjectURL(file), + name: file.name, + size: file.size + })) + setUploadImages([...uploadImages, ...newImages]) + } + + const statusOptions = [ + { value: 'in_collection', label: '收藏中' }, + { value: 'selling', label: '出售中' }, + { value: 'sold', label: '已售' }, + { value: 'grading', label: '送评中' }, + { value: 'repairing', label: '修复中' }, + { value: 'transit', label: '在途中' }, + { value: 'seeking', label: '寻号中' }, + { value: 'other', label: '其他' } + ] + + const categoryOptions = [ + { value: '自持', label: '自持' }, + { value: '寄存', label: '寄存' }, + { value: '寄售', label: '寄售' }, + { value: '共有', label: '共有' }, + { value: '其他', label: '其他' } + ] + + const rarityOptions = [ + { value: '通货', label: '通货' }, + { value: '特色', label: '特色' }, + { value: '少见', label: '少见' }, + { value: '稀有', label: '稀有' }, + { value: '珍品', label: '珍品' }, + { value: '孤品', label: '孤品' } + ] + + const packagingOptions = [ + { value: '标十', label: '标十' }, + { value: '标百', label: '标百' }, + { value: '单张', label: '单张' }, + { value: '裸钞', label: '裸钞' } + ] + + const handleChange = (key, value) => { + setForm({ ...form, [key]: value }) + // 只有设置了出售价(goalPrice)才自动设置为"已售" + if (key === 'goalPrice' && value) { + setForm(prev => ({ ...prev, status: 'sold' })) + } + // 冠字号变化时自动分类 + if (key === 'prefixSerial') { + const serial = value + let cat = '' + const match = serial.match(/J(\d{9})/) + const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) + if (digits) { + if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' + else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' + else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' + else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' + else if (digits.includes('7') && !digits.includes('4')) cat = '带7号' + else if (digits.includes('4')) cat = '带4号' + else cat = '其他' + } + setForm(prev => ({ ...prev, numberCategory: cat })) + } + // 版别变化时同步发行年份 + if (key === 'version') { + const yearMatch = value.match(/(20\d{2})/) + if (yearMatch) { + setForm(prev => ({ ...prev, issueYear: yearMatch[1] })) + } + } + } + + const handleSelectImage = (e) => { + const file = e.target.files[0] + if (!file) return + setSelectedImage(file) + const reader = new FileReader() + reader.onload = (e) => setImagePreview(e.target.result) + reader.readAsDataURL(file) + } + + const handleRecognize = async () => { + if (!selectedImage) { setError('请先选择图片'); return } + setRecognizing(true) + setError('') + const token = localStorage.getItem('token') + const formData = new FormData() + formData.append('image', selectedImage) + + // 使用 XMLHttpRequest 替代 fetch + const data = await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', '/api/ocr/recognize') + xhr.setRequestHeader('Authorization', 'Bearer ' + token) + + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const data = JSON.parse(xhr.responseText) + resolve(data) + } catch (e) { + reject(new Error('JSON解析失败: ' + xhr.responseText.substring(0, 100))) + } + } else { + try { + const data = JSON.parse(xhr.responseText) + reject(new Error(data.error?.message || data.detail || '识别失败')) + } catch (e) { + reject(new Error('请求失败: ' + xhr.status)) + } + } + } + + xhr.onerror = function() { + reject(new Error('网络错误')) + } + + xhr.send(formData) + }) + try { + if (data.fields) { + const recognizedForm = { ...getDefaultForm() } + // 直接对应 AI 返回的字段,不需要二次解析 + if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer + if (data.fields.version) recognizedForm.version = data.fields.version + if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination + if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial + if (data.fields.packaging) recognizedForm.packaging = data.fields.packaging + if (data.fields.grading_company) recognizedForm.gradingCompany = data.fields.grading_company + if (data.fields.grading_score) recognizedForm.gradingScore = data.fields.grading_score + if (data.fields.special_mark && data.fields.special_mark !== '无') recognizedForm.specialMark = data.fields.special_mark + if (data.fields.serial_feature && data.fields.serial_feature !== '无') recognizedForm.serialFeature = data.fields.serial_feature + // 布尔字段 + if (data.fields.is_graded !== undefined) recognizedForm.isGraded = data.fields.is_graded + if (data.fields.three_star !== undefined) recognizedForm.threeStar = data.fields.three_star + + // 保存识别的图片信息(包含服务器临时路径) + const newImage = { + file: selectedImage, + preview: URL.createObjectURL(selectedImage), + name: selectedImage.name, + size: selectedImage.size, + temp_image: data.temp_image // 保存临时图片信息 + } + setUploadImages([newImage]) + setTempImage(data.temp_image) // 保存临时图片信息 + + setForm(recognizedForm) + setActiveTab('manual') + console.log('AI 识别结果:', recognizedForm) + console.log('识别图片:', newImage) + } else setError('识别结果为空') + } catch (e) { setError('识别失败:' + e.message) } + finally { setRecognizing(false) } + } + + const handleSave = async () => { + if (!form.name || !form.version) { setError('名称和版别为必填项'); return } + setSaving(true) + setError('') + const token = localStorage.getItem('token') + // 保存前自动根据冠字号设置号码分类 + const formWithCategory = { ...form } + if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) { + const serial = formWithCategory.prefixSerial + const match = serial.match(/J(\d{9})/) + const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9) + if (digits) { + let cat = '' + if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347' + else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347' + else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247' + else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47' + else if (digits.includes('7') && !digits.includes('4')) cat = '带7号' + else if (digits.includes('4')) cat = '带4号' + else cat = '其他' + formWithCategory.numberCategory = cat + } + } + + const formData = convertField(formWithCategory) + try { + // 1. 保存藏品信息(先检查是否重复) + const res = await fetch('/api/collections', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify(formData) + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.error?.message || data.detail || '保存失败') + } + const result = await res.json() + let collectionId = result.f99_90_id || result.id + + // 检查是否有错误(重复编号等) + if (result.error) { + throw new Error(result.error.message || '保存失败') + } + + // 检查是否有重复警告 + if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') { + const { warning } = result + const confirmed = window.confirm( + `⚠️ 发现重复冠字号!\n\n` + + `冠字号:${warning.existing_collection.prefix_serial}\n` + + `已存在于:${warning.existing_collection.name} (编号:${warning.existing_collection.code})\n\n` + + `是否继续保存?` + ) + + if (!confirmed) { + setSaving(false) + return + } + + // 用户确认继续,再次调用 API(添加 force=true 参数) + const forceRes = await fetch('/api/collections?force=true', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }, + body: JSON.stringify(formData) + }) + + if (!forceRes.ok) { + const forceData = await forceRes.json() + throw new Error(forceData.error?.message || '保存失败') + } + + const forceResult = await forceRes.json() + collectionId = forceResult.f99_90_id || forceResult.id + console.log('藏品保存成功(确认重复),ID:', collectionId) + } else if (collectionId) { + console.log('藏品保存成功,ID:', collectionId) + } else { + throw new Error('保存失败:未返回藏品 ID') + } + + // 上传图片(无论是否重复都执行) + let totalUploadCount = 0 + + // 2. 处理识别的图片(如果有临时图片,直接认领;否则上传) + if (uploadImages.length > 0 && collectionId) { + console.log('处理图片,数量:', uploadImages.length) + + for (const img of uploadImages) { + // 检查是否有临时图片(OCR识别后保存的) + if (img.temp_image && img.temp_image.id) { + // 认领临时图片 + try { + const claimRes = await fetch(`/api/ocr/claim-temp-image?temp_id=${img.temp_image.id}&collection_id=${collectionId}`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token } + }) + if (claimRes.ok) { + totalUploadCount++ + console.log('✅ 临时图片认领成功') + } else { + console.error('临时图片认领失败:', await claimRes.text()) + } + } catch (claimErr) { + console.error('临时图片认领异常:', claimErr) + } + } else { + // 普通上传 + const imgFormData = new FormData() + imgFormData.append('file', img.file) + try { + const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${collectionId}`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: imgFormData + }) + if (uploadRes.ok) { + totalUploadCount++ + console.log(`图片上传成功`) + } else { + console.error('图片上传失败:', await uploadRes.text()) + } + } catch (uploadErr) { + console.error('图片上传异常:', uploadErr) + } + } + } + + console.log(`✅ 共处理 ${totalUploadCount} 张图片`) + } + + alert('保存成功!' + (totalUploadCount > 0 ? `已上传${totalUploadCount}张图片` : '')) + window.location.hash = '#/list' + window.refreshList?.() + window.refreshHome?.() + } catch (e) { + console.error('保存失败:', e) + alert('保存失败:' + e.message) + } + finally { setSaving(false) } + } + + return ( +
+ {/* 顶部标签切换 */} +
+
+ + + +
+
+ + {error && ( +
⚠️ {error}
+ )} + + {/* AI 识别模式 */} + {activeTab === 'ai' && ( +
+
+ + {imagePreview ? ( +
+ 已选择图片 +
+ + +
+
+ ) : ( +
+
{ fileInputRef.current.setAttribute('capture', 'environment'); fileInputRef.current.click(); }} + style={{ padding: '24px', background: 'rgba(59, 130, 246, 0.1)', border: '2px dashed rgba(59, 130, 246, 0.5)', borderRadius: '12px', cursor: 'pointer', marginBottom: '16px', textAlign: 'center' }}> +
📷
+
拍照识别
+
使用相机拍照并识别
+
+
{ fileInputRef.current.removeAttribute('capture'); fileInputRef.current.click(); }} + style={{ padding: '24px', background: 'rgba(34, 197, 94, 0.1)', border: '2px dashed rgba(34, 197, 94, 0.5)', borderRadius: '12px', cursor: 'pointer', textAlign: 'center' }}> +
🖼️
+
从相册选择
+
从相册选择已有图片
+
+
+ )} +
+
+
💡 识别说明
+
    +
  • 支持拍照或从相册选择图片
  • +
  • 自动识别名称、版别、冠字序号等字段
  • +
  • 识别结果可手动修改完善
  • +
  • 建议拍摄清晰、光线充足的正面照片
  • +
+
+
+ )} + + {/* 手工录入模式 - 完整表单 */} + {activeTab === 'manual' && ( +
+ {/* 图片上传区域 */} +
+
+
藏品图片 ({uploadImages.length}/1)
+
+ + + {uploadImages.length > 0 ? ( +
+ {uploadImages.map((img, index) => ( +
+ {img.name} +
{img.name}
+ +
{index + 1}/{uploadImages.length}
+
+ ))} + {uploadImages.length < 1 && ( +
imageFileInputRef.current?.click()} style={{ + aspectRatio: '1.5', + background: 'rgba(255,255,255,0.05)', + border: '2px dashed rgba(255,255,255,0.3)', + borderRadius: '12px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: '#94a3b8', + fontSize: '13px' + }}> +
📷
+
添加图片
+
最多可上传 1 张
+
+ )} +
+ ) : ( +
imageFileInputRef.current?.click()} style={{ + aspectRatio: '1.5', + background: 'rgba(255,255,255,0.05)', + border: '2px dashed #fbbf24', + borderRadius: '12px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + color: '#fbbf24', + fontSize: '13px' + }}> +
📷
+
点击上传图片
+
最多可上传 1 张
+
+ )} +
+ + {/* 基本信息 */} +
+
基本信息
+
+ + + + + + + + + +
+
+ + {/* 评级信息 */} +
+
评级信息
+
+
+ handleChange('isGraded', e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} /> + +
+
+ handleChange('threeStar', e.target.checked)} + style={{ width: '18px', height: '18px', cursor: 'pointer', accentColor: '#22c55e' }} /> + +
+
+
+ + +
+
+ + {/* 特殊信息 */} +
+
特殊信息
+
+ + + + + + + +
+
+ + {/* 价格信息 */} +
+
价格信息
+
+ + + + + + +
+
+ + {/* 备注 */} +
+
备注
+