diff --git a/backend/.env.example b/backend/.env.example index 5f674bd..1ea1745 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,40 +1,64 @@ -# 甲辰藏品系统环境变量配置示例 +# ============================================================ +# 甲辰藏品系统 环境变量配置 +# ============================================================ # 复制此文件为 .env 并填入实际值 +# cp .env.example .env # ========== 数据库配置 ========== +# 主数据库 - jiachenlong +# 注意:密码中的特殊字符(如 @)需要URL编码 +# 例如:Passwd1@3 -> Passwd1%403 DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong +# 一尘数据库 - coolbot_data (用于关联查询) +# 地址:pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com +# 密码:Coolbot123 (无需URL编码,无特殊字符) +COOLBOT_DB_URL=postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com:5432/coolbot_data + # ========== JWT认证配置 ========== +# 用于签名和验证访问令牌的安全密钥(生产环境务必使用强随机密钥) SECRET_KEY=your-super-secret-key-change-in-production + +# JWT签名算法 ALGORITHM=HS256 + +# 访问令牌过期时间(分钟),默认10080 = 7天 ACCESS_TOKEN_EXPIRE_MINUTES=10080 # ========== 阿里云百炼AI (DASHSCOPE) ========== -# 用于AI批量解析行情数据 +# 用于AI批量解析藏品行情数据 +# 申请地址:https://bailian.console.aliyun.com/ DASHSCOPE_API_KEY=your-dashscope-api-key # ========== 阿里云短信服务 ========== +# 用于发送注册登录验证码 SMS_ACCESS_KEY_ID=your-sms-access-key-id SMS_ACCESS_KEY_SECRET=your-sms-access-key-secret SMS_SIGN_NAME=您的签名 SMS_TEMPLATE_CODE=SMS_xxx -# ========== OSS存储 ========== +# ========== 阿里云OSS存储 ========== +# 用于存储用户上传的图片和文件 OSS_ACCESS_KEY_ID=your-oss-access-key-id OSS_ACCESS_KEY_SECRET=your-oss-access-key-secret OSS_BUCKET=jiachenlong-oss OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com -# ========== Redis (可选,用于限流和缓存) ========== -# REDIS_URL=redis://localhost:6379/0 - # ========== 请求限流配置 ========== +# 是否启用限流 RATE_LIMIT_ENABLED=true -RATE_LIMIT_SMS_PER_MINUTE=3 -RATE_LIMIT_OCR_PER_MINUTE=10 -RATE_LIMIT_BATCH_PER_MINUTE=5 -RATE_LIMIT_API_PER_MINUTE=60 + +# 各接口每分钟限制次数 +RATE_LIMIT_SMS_PER_MINUTE=3 # 短信验证码 +RATE_LIMIT_OCR_PER_MINUTE=10 # OCR识别 +RATE_LIMIT_BATCH_PER_MINUTE=5 # 批量操作 +RATE_LIMIT_API_PER_MINUTE=60 # 通用API # ========== 管理员账号 ========== +# 默认管理员账户(生产环境务必修改密码) ADMIN_USERNAME=admin ADMIN_PASSWORD=admin123 + +# ========== Redis (可选) ========== +# 用于限流和缓存,生产环境建议启用 +# REDIS_URL=redis://localhost:6379/0 diff --git a/backend/alembic/versions/002_add_version_to_information.py b/backend/alembic/versions/002_add_version_to_information.py new file mode 100644 index 0000000..3d7a0bd --- /dev/null +++ b/backend/alembic/versions/002_add_version_to_information.py @@ -0,0 +1,21 @@ +"""add version to information + +Revision ID: 002 +Revises: 001 +Create Date: 2026-04-12 11:55:00 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = '002' +down_revision = '001' +branch_labels = None +depends_on = None + +def upgrade(): + op.add_column('information', sa.Column('version', sa.String(100), nullable=True)) + +def downgrade(): + op.drop_column('information', 'version') diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 58ea330..ca110cf 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -8,24 +8,24 @@ class Settings: """应用配置类""" def __init__(self): - # 阿里云百炼AI (DASHSCOPE) API Key + # 阿里云百炼AI (DASHSCOPE) API Key - 必须设置,无默认值 self.DASHSCOPE_API_KEY: Optional[str] = os.getenv("DASHSCOPE_API_KEY") - # 阿里云短信服务配置 - self.SMS_ACCESS_KEY_ID: str = os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE") - self.SMS_ACCESS_KEY_SECRET: str = os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1") - self.SMS_SIGN_NAME: str = os.getenv("SMS_SIGN_NAME", "苏州算力") - self.SMS_TEMPLATE_CODE: str = os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956") + # 阿里云短信服务配置 - 必须设置,无默认值 + self.SMS_ACCESS_KEY_ID: Optional[str] = os.getenv("SMS_ACCESS_KEY_ID") + self.SMS_ACCESS_KEY_SECRET: Optional[str] = os.getenv("SMS_ACCESS_KEY_SECRET") + self.SMS_SIGN_NAME: str = os.getenv("SMS_SIGN_NAME", "甲辰藏品") + self.SMS_TEMPLATE_CODE: str = os.getenv("SMS_TEMPLATE_CODE", "SMS_xxx") - # 数据库配置 - self.DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong") + # 数据库配置 - 必须设置,无默认值 + self.DATABASE_URL: Optional[str] = os.getenv("DATABASE_URL") # JWT配置 - self.SECRET_KEY: str = os.getenv("SECRET_KEY", "jiachenlong-secret-key-change-in-production") + self.SECRET_KEY: str = os.getenv("SECRET_KEY") # 必须设置,无默认值 self.ALGORITHM: str = os.getenv("ALGORITHM", "HS256") self.ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 - # OSS配置 + # OSS配置 - 必须设置,无默认值 self.OSS_ACCESS_KEY_ID: Optional[str] = os.getenv("OSS_ACCESS_KEY_ID") self.OSS_ACCESS_KEY_SECRET: Optional[str] = os.getenv("OSS_ACCESS_KEY_SECRET") self.OSS_BUCKET: str = os.getenv("OSS_BUCKET", "jiachenlong-oss") @@ -41,9 +41,9 @@ class Settings: self.RATE_LIMIT_BATCH_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_BATCH_PER_MINUTE", "5")) self.RATE_LIMIT_API_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_API_PER_MINUTE", "60")) - # 管理员账号 - self.ADMIN_USERNAME: str = os.getenv("ADMIN_USERNAME", "admin") - self.ADMIN_PASSWORD: str = os.getenv("ADMIN_PASSWORD", "admin123") + # 管理员账号 - 必须设置,无默认值 + self.ADMIN_USERNAME: Optional[str] = os.getenv("ADMIN_USERNAME") + self.ADMIN_PASSWORD: Optional[str] = os.getenv("ADMIN_PASSWORD") def get_dashscope_api_key(self) -> str: """获取阿里云百炼API Key""" diff --git a/backend/app/core/coolbot_db.py b/backend/app/core/coolbot_db.py index a0b9014..2a2c7d9 100644 --- a/backend/app/core/coolbot_db.py +++ b/backend/app/core/coolbot_db.py @@ -4,10 +4,13 @@ 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_DB_URL" ) +# 如果未设置环境变量,抛出错误 +if not COOLBOT_DB_URL: + raise ValueError("COOLBOT_DB_URL environment variable is not set") + coolbot_engine = create_engine( COOLBOT_DB_URL, poolclass=QueuePool, diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 6ea0838..5a1e854 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -171,6 +171,7 @@ class Information(Base): # 期望条件 (寻配号用) expect_category = Column(String(100), nullable=True) # 期望类别 expect_version = Column(String(100), nullable=True) # 期望版别 + 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) # 期望价格区间 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 11c9650..8f5523b 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,9 +1,10 @@ # 认证路由 - 使用字段编码 -from fastapi import APIRouter, Depends, HTTPException, status, Body +from fastapi import APIRouter, Depends, HTTPException, status, Body, Request 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.core.logging_config import logger from app.models.models import User from app.schemas.schemas import Token, UserCreate, UserResponse @@ -25,16 +26,23 @@ def generate_user_code(db): while db.query(User).filter(User.user_code == str(num)).first(): num += 1 return str(num) - except: + except Exception: pass return "201" @router.post("/register", response_model=UserResponse) -def register(user_data: UserCreate, db: Session = Depends(get_db)): +def register(user_data: UserCreate, request: Request, db: Session = Depends(get_db)): """用户注册""" # 检查用户名是否已存在 existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first() if existing_user: + logger.warning( + "用户注册失败:用户名已存在", + extra={ + 'ip_address': request.client.host if request.client else None, + 'data': {'username': user_data.f01_01_name, 'reason': 'username_exists'} + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="f01_01_name: 用户名已存在" @@ -46,6 +54,13 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): if user_data.phone: existing_phone = db.query(User).filter(User.phone == user_data.phone).first() if existing_phone: + logger.warning( + "用户注册失败:手机号已被注册", + extra={ + 'ip_address': request.client.host if request.client else None, + 'data': {'phone': user_data.phone[:3] + '****' + user_data.phone[7:], 'reason': 'phone_exists'} + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="E00040:该手机号已被注册,请更换手机号" @@ -53,6 +68,13 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): if user_data.email: existing_email = db.query(User).filter(User.email == user_data.email).first() if existing_email: + logger.warning( + "用户注册失败:邮箱已被注册", + extra={ + 'ip_address': request.client.host if request.client else None, + 'data': {'email': user_data.email, 'reason': 'email_exists'} + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="E00041:该邮箱已被注册,请更换邮箱" @@ -64,6 +86,13 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): # 查找邀请人 invited_by_user = db.query(User).filter(User.user_code == user_data.invite_code).first() if not invited_by_user: + logger.warning( + "用户注册失败:邀请码无效", + extra={ + 'ip_address': request.client.host if request.client else None, + 'data': {'invite_code': user_data.invite_code, 'reason': 'invalid_invite_code'} + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="E00042:邀请码无效" @@ -103,6 +132,20 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): db.commit() db.refresh(user) + # 记录注册成功日志 + logger.info( + "用户注册成功", + extra={ + 'user_id': user.f99_90_id, + 'ip_address': request.client.host if request.client else None, + 'data': { + 'user_code': user.user_code, + 'username': user.f01_01_name, + 'invited_by': invited_by_user.user_code if invited_by_user else None + } + } + ) + # 返回用户信息(避免Pydantic序列化问题) return { "id": user.f99_90_id, @@ -122,15 +165,25 @@ def register(user_data: UserCreate, db: Session = Depends(get_db)): @router.post("/login", response_model=Token) def login( form_data: OAuth2PasswordRequestForm = Depends(), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + request: Request = None ): """用户登录 - 支持用户名或用户编码登录""" + ip_address = request.client.host if request.client else None + # 先尝试用户名登录 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: + logger.warning( + "用户登录失败:用户不存在", + extra={ + 'ip_address': ip_address, + 'data': {'username': form_data.username, 'reason': 'user_not_found'} + } + ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="E00011: 用户名或密码错误", @@ -139,6 +192,14 @@ def login( # 验证密码 if not verify_password(form_data.password, user.password): + logger.warning( + "用户登录失败:密码错误", + extra={ + 'user_id': user.f99_90_id, + 'ip_address': ip_address, + 'data': {'username': form_data.username, 'reason': 'wrong_password'} + } + ) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="E00011: 用户名或密码错误", @@ -151,6 +212,20 @@ def login( user.f99_99_last_login = datetime.now() db.commit() + # 记录登录成功日志 + logger.info( + "用户登录成功", + extra={ + 'user_id': user.f99_90_id, + 'ip_address': ip_address, + 'data': { + 'username': form_data.username, + 'user_code': user.user_code, + 'login_count': user.f99_98_login_count + } + } + ) + # 生成 token access_token = create_access_token(data={"sub": user.f99_90_id}) @@ -215,10 +290,24 @@ def change_password( # 在当前session中重新查询用户 user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first() if not user: + logger.error( + "修改密码失败:用户不存在", + extra={ + 'user_id': current_user.f99_90_id, + 'data': {'reason': 'user_not_found'} + } + ) raise HTTPException(status_code=404, detail="用户不存在") # 验证旧密码 if not verify_password(old_password, user.password): + logger.warning( + "修改密码失败:旧密码错误", + extra={ + 'user_id': user.f99_90_id, + 'data': {'reason': 'wrong_old_password'} + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码错误" @@ -228,6 +317,15 @@ def change_password( user.password = get_password_hash(new_password) db.commit() + # 记录密码修改成功日志 + logger.info( + "用户密码修改成功", + extra={ + 'user_id': user.f99_90_id, + 'data': {'action': 'change_password'} + } + ) + return {"message": "密码修改成功"} @@ -236,24 +334,58 @@ def change_password( @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 + purpose: str = Body("register"), # register | login | reset_password + request: Request = None ): """发送短信验证码""" from app.services.sms import send_verification_code as send_sms + from app.core.logging_config import logger + + ip_address = request.client.host if request and request.client else None # 验证手机号格式 if not phone.startswith("1") or len(phone) != 11: + logger.warning( + "发送验证码失败:手机号格式不正确", + extra={ + 'ip_address': ip_address, + 'data': {'phone': phone, 'purpose': purpose, 'reason': 'invalid_format'} + } + ) return {"success": False, "message": "手机号格式不正确"} result = send_sms(phone) if result["success"]: + # 记录验证码发送成功日志(不记录验证码本身) + logger.info( + "验证码发送成功", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'purpose': purpose + } + } + ) return { "success": True, "message": f"验证码已发送到 {phone[:3]}****{phone[7:]}", "expire": result.get("expire", 300) } else: + # 记录验证码发送失败日志 + logger.error( + "验证码发送失败", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'purpose': purpose, + 'reason': result.get('message', 'unknown_error') + } + } + ) return result @@ -263,6 +395,7 @@ def verify_code( code: str = Body(..., min_length=6, max_length=6), new_password: str = Body(None, min_length=6), # 可选:新密码(用于重置密码) purpose: str = Body("verify"), # verify | reset_password + request: Request = None, db: Session = Depends(get_db) ): """ @@ -272,10 +405,24 @@ def verify_code( """ from app.services.sms import verify_code as check_code from app.core.auth import get_password_hash + from app.core.logging_config import logger + + ip_address = request.client.host if request and request.client else None is_valid = check_code(phone, code) if not is_valid: + logger.warning( + "验证码验证失败", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'purpose': purpose, + 'reason': 'invalid_or_expired_code' + } + } + ) return {"success": False, "message": "验证码错误或已过期"} # 验证成功后处理 @@ -286,14 +433,49 @@ def verify_code( # 查找该手机号的用户 user = db.query(User).filter(User.phone == phone).first() if not user: + logger.warning( + "密码重置失败:手机号未注册", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'reason': 'user_not_found' + } + } + ) return {"success": False, "message": "该手机号未注册"} # 更新密码 user.password = get_password_hash(new_password) db.commit() + # 记录密码重置成功日志 + logger.info( + "密码重置成功", + extra={ + 'user_id': user.f99_90_id, + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'action': 'reset_password_by_verify_code' + } + } + ) + return {"success": True, "message": "密码重置成功"} + # 记录验证成功日志 + logger.info( + "验证码验证成功", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'purpose': purpose + } + } + ) + return {"success": True, "message": "验证成功"} @@ -302,6 +484,7 @@ def reset_password( phone: str = Body(...), code: str = Body(..., min_length=6, max_length=6), new_password: str = Body(..., min_length=6), + request: Request = None, db: Session = Depends(get_db) ): """ @@ -313,10 +496,23 @@ def reset_password( """ from app.services.sms import verify_code as check_code from app.core.auth import get_password_hash + from app.core.logging_config import logger + + ip_address = request.client.host if request and request.client else None # 验证验证码 is_valid = check_code(phone, code) if not is_valid: + logger.warning( + "密码重置失败:验证码错误", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'reason': 'invalid_verification_code' + } + } + ) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期" @@ -325,6 +521,16 @@ def reset_password( # 查找该手机号的用户 user = db.query(User).filter(User.phone == phone).first() if not user: + logger.warning( + "密码重置失败:用户不存在", + extra={ + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'reason': 'user_not_found' + } + } + ) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="该手机号未注册" @@ -334,4 +540,17 @@ def reset_password( user.password = get_password_hash(new_password) db.commit() + # 记录密码重置成功日志 + logger.info( + "密码重置成功", + extra={ + 'user_id': user.f99_90_id, + 'ip_address': ip_address, + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'action': 'reset_password' + } + } + ) + return {"message": "密码重置成功"} diff --git a/backend/app/routers/information.py b/backend/app/routers/information.py index 5b096d8..1228e3b 100644 --- a/backend/app/routers/information.py +++ b/backend/app/routers/information.py @@ -1,15 +1,17 @@ # 资讯API路由 -from fastapi import APIRouter, Depends, HTTPException, Query, Response, Body +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Body, Request from sqlalchemy.orm import Session, joinedload from sqlalchemy import text from typing import List, Optional from pydantic import BaseModel from datetime import datetime, date import os +import time from app.core.database import get_db from app.core.auth import get_current_user from app.core.coolbot_db import coolbot_engine +from app.core.logging_config import logger from app.models.models import User, Information, Collection router = APIRouter(prefix="/api/information", tags=["资讯"]) @@ -23,6 +25,7 @@ class InformationCreate(BaseModel): collection_id: Optional[str] = None expect_category: Optional[str] = None expect_version: Optional[str] = None + version: Optional[str] = None # 版别(龙标/马钞/蛇钞/其他) expect_packaging: Optional[str] = None expect_number: Optional[str] = None expect_price_min: Optional[float] = None @@ -43,6 +46,7 @@ class InformationUpdate(BaseModel): status: Optional[str] = None expect_category: Optional[str] = None expect_version: Optional[str] = None + version: Optional[str] = None # 版别(龙标/马钞/蛇钞/其他) expect_packaging: Optional[str] = None expect_number: Optional[str] = None expect_price_min: Optional[float] = None @@ -64,6 +68,7 @@ class InformationResponse(BaseModel): collection_id: Optional[str] expect_category: Optional[str] expect_version: Optional[str] + version: Optional[str] = None # 版别(龙标/马钞/蛇钞/其他) expect_packaging: Optional[str] expect_number: Optional[str] expect_price_min: Optional[float] @@ -199,6 +204,7 @@ def get_information_list( collection_id=item.collection_id, expect_category=item.expect_category, expect_version=item.expect_version, + version=item.version, expect_packaging=item.expect_packaging, expect_number=item.expect_number, expect_price_min=item.expect_price_min, @@ -325,7 +331,7 @@ def match_collections_count_from_coolbot(expect_number: str) -> int: return match_count except Exception as e: - print(f"Error querying coolbot_data: {e}") + logger.error(f"Error querying coolbot_data: {e}") return 0 @@ -390,7 +396,7 @@ def batch_match_collections_count_from_coolbot(expect_numbers: List[str]) -> dic result_map[expect_number] = count except Exception as e: - print(f"Error batch querying coolbot_data: {e}") + logger.error(f"Error batch querying coolbot_data: {e}") # 查询失败时返回0 for expect_number in expect_numbers: result_map[expect_number] = 0 @@ -447,7 +453,7 @@ def match_collections_list_from_coolbot(expect_number: str, limit: int = 20) -> return matched except Exception as e: - print(f"Error querying coolbot_data: {e}") + logger.error(f"Error querying coolbot_data: {e}") return [] @@ -531,6 +537,7 @@ def get_information( collection_id=item.collection_id, expect_category=item.expect_category, expect_version=item.expect_version, + version=item.version, expect_packaging=item.expect_packaging, expect_number=item.expect_number, expect_price_min=item.expect_price_min, @@ -563,6 +570,8 @@ def create_information( db: Session = Depends(get_db) ): """发布资讯""" + start_time = time.time() + # 验证并矫正冠字号:必须是J0开头 + 8位数字 = 共10位 if data.title: import re @@ -610,6 +619,7 @@ def create_information( collection_id=data.collection_id, expect_category=data.expect_category, expect_version=data.expect_version, + version=data.version, expect_packaging=data.expect_packaging, expect_number=data.expect_number, expect_price_min=data.expect_price_min, @@ -628,6 +638,24 @@ def create_information( db.commit() db.refresh(info) + duration_ms = (time.time() - start_time) * 1000 + + # 记录资讯发布日志 + logger.info( + f"资讯发布成功:{data.info_type}", + extra={ + 'user_id': current_user.f99_90_id, + 'duration_ms': duration_ms, + 'data': { + 'info_id': info.id, + 'info_type': data.info_type, + 'title': data.title[:50] if data.title else None, + 'deal_no': deal_no, + 'expect_number': data.expect_number + } + } + ) + return InformationResponse( id=info.id, user_id=info.user_id, @@ -637,6 +665,7 @@ def create_information( collection_id=info.collection_id, expect_category=info.expect_category, expect_version=info.expect_version, + version=info.version, expect_packaging=info.expect_packaging, expect_number=info.expect_number, expect_price_min=info.expect_price_min, @@ -665,12 +694,21 @@ def update_information( db: Session = Depends(get_db) ): """更新资讯""" + start_time = time.time() + info = db.query(Information).filter( Information.id == info_id, Information.user_id == current_user.f99_90_id ).first() if not info: + logger.warning( + "资讯更新失败:不存在或无权修改", + extra={ + 'user_id': current_user.f99_90_id, + 'data': {'info_id': info_id} + } + ) raise HTTPException(status_code=404, detail="资讯不存在或无权修改") # 更新字段 @@ -710,6 +748,22 @@ def update_information( db.commit() db.refresh(info) + duration_ms = (time.time() - start_time) * 1000 + + # 记录资讯更新日志 + logger.info( + f"资讯更新成功:{info.info_type}", + extra={ + 'user_id': current_user.f99_90_id, + 'duration_ms': duration_ms, + 'data': { + 'info_id': info.id, + 'info_type': info.info_type, + 'title': info.title[:50] if info.title else None + } + } + ) + return InformationResponse( id=info.id, user_id=info.user_id, @@ -719,6 +773,7 @@ def update_information( collection_id=info.collection_id, expect_category=info.expect_category, expect_version=info.expect_version, + version=info.version, expect_packaging=info.expect_packaging, expect_number=info.expect_number, expect_price_min=info.expect_price_min, @@ -761,11 +816,35 @@ def delete_information( ).first() if not info: + logger.warning( + "资讯删除失败:不存在或无权删除", + extra={ + 'user_id': current_user.f99_90_id, + 'data': {'info_id': info_id, 'role': current_user.role} + } + ) raise HTTPException(status_code=404, detail="资讯不存在或无权删除") + # 保存信息用于日志 + info_type = info.info_type + info_title = info.title + db.delete(info) db.commit() + # 记录资讯删除日志 + logger.info( + f"资讯删除成功:{info_type}", + extra={ + 'user_id': current_user.f99_90_id, + 'data': { + 'info_id': info_id, + 'info_type': info_type, + 'title': info_title[:50] if info_title else None + } + } + ) + return {"message": "删除成功"} @@ -777,7 +856,15 @@ def get_seek_match( db: Session = Depends(get_db) ): """获取符合条件的我的藏品推荐""" + start_time = time.time() + if not current_user: + logger.warning( + "寻配号匹配失败:未登录", + extra={ + 'data': {'info_id': info_id, 'reason': 'not_authenticated'} + } + ) raise HTTPException(status_code=401, detail="请先登录") info = db.query(Information).filter( @@ -786,6 +873,13 @@ def get_seek_match( ).first() if not info: + logger.warning( + "寻配号匹配失败:信息不存在", + extra={ + 'user_id': current_user.f99_90_id, + 'data': {'info_id': info_id} + } + ) raise HTTPException(status_code=404, detail="寻配号信息不存在") # 更新用户配号(寻号)次数 @@ -820,6 +914,23 @@ def get_seek_match( else: matched = collections + duration_ms = (time.time() - start_time) * 1000 + + # 记录寻配号匹配查询日志 + logger.info( + "寻配号匹配查询", + extra={ + 'user_id': current_user.f99_90_id, + 'duration_ms': duration_ms, + 'data': { + 'info_id': info_id, + 'expect_number': info.expect_number, + 'matched_count': len(matched), + 'user_collections_count': len(collections) + } + } + ) + return { "info_id": info_id, "matched_count": len(matched), @@ -901,6 +1012,7 @@ def get_my_seeks( collection_id=item.collection_id, expect_category=item.expect_category, expect_version=item.expect_version, + version=item.version, expect_packaging=item.expect_packaging, expect_number=item.expect_number, expect_price_min=item.expect_price_min, @@ -1030,6 +1142,7 @@ def get_my_information_list( collection_id=item.collection_id, expect_category=item.expect_category, expect_version=item.expect_version, + version=item.version, expect_packaging=item.expect_packaging, expect_number=item.expect_number, expect_price_min=item.expect_price_min, @@ -1094,6 +1207,8 @@ def match_seek( db: Session = Depends(get_db) ): """确认匹配寻号 - 用户愿意交换联系方式给发布者""" + start_time = time.time() + info = db.query(Information).filter( Information.id == request.info_id, Information.info_type == "seek", @@ -1101,10 +1216,27 @@ def match_seek( ).first() if not info: + logger.warning( + "寻配号匹配失败:信息不存在", + extra={ + 'user_id': current_user.f99_90_id, + 'data': {'info_id': request.info_id} + } + ) raise HTTPException(status_code=404, detail="寻配号信息不存在") # 检查是否已被匹配(一个寻号只能被一个用户匹配) if info.is_matched == "matched": + logger.warning( + "寻配号匹配失败:已被其他用户匹配", + extra={ + 'user_id': current_user.f99_90_id, + 'data': { + 'info_id': request.info_id, + 'matched_user_id': info.matched_user_id + } + } + ) raise HTTPException(status_code=400, detail="该寻号已被其他用户匹配") # 更新匹配状态 @@ -1121,6 +1253,22 @@ def match_seek( db.commit() + duration_ms = (time.time() - start_time) * 1000 + + # 记录寻配号匹配成功日志 + logger.info( + "寻配号匹配成功", + extra={ + 'user_id': current_user.f99_90_id, + 'duration_ms': duration_ms, + 'data': { + 'info_id': request.info_id, + 'publisher_id': info.user_id, + 'collection_id': request.collection_id + } + } + ) + return {"message": "匹配成功,已通知发布者", "is_matched": "matched"} @@ -1460,7 +1608,7 @@ async def batch_parse_deals(text: str = Body(..., embed=True)): try: data = json.loads(match.group()) return {"success": True, "data": data} - except: + except Exception: pass return {"success": False, "error": "解析失败", "raw": content[:500]} @@ -1498,7 +1646,7 @@ def parse_deals_locally(text: str, default_packaging: str = '', default_date: st # 3月29日格式 extracted_date = f"2026-{int(match.group(1)):02d}-{int(match.group(2)):02d}" break - except: + except Exception: pass # 默认使用今天 diff --git a/backend/app/routers/ocr.py b/backend/app/routers/ocr.py index e9dffc1..3b96735 100644 --- a/backend/app/routers/ocr.py +++ b/backend/app/routers/ocr.py @@ -3,25 +3,20 @@ import os import uuid import base64 import httpx +import time 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.core.logging_config import logger +from app.core.config import settings 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" -} +# 允许的图片类型 +ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} +MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB # 临时上传目录(用于OCR识别本地备选) UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp") @@ -105,9 +100,20 @@ async def recognize_image( db: Session = Depends(get_db) ): """OCR 图片识别 - 识别后自动保存图片到OSS临时目录""" + start_time = time.time() + try: # 读取图片数据 image_data = await image.read() + + # 验证文件类型 + if image.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException(status_code=400, detail=f"不支持的图片类型: {image.content_type},支持的类型:JPEG, PNG, GIF, WebP") + + # 验证文件大小 + if len(image_data) > MAX_IMAGE_SIZE: + raise HTTPException(status_code=400, detail=f"图片大小不能超过 {MAX_IMAGE_SIZE // 1024 // 1024}MB") + image_base64 = base64.b64encode(image_data).decode('utf-8') # 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext} @@ -121,14 +127,22 @@ async def recognize_image( image_url = upload_to_oss(image_data, oss_key) except Exception as oss_err: # OSS失败时保存到本地作为备选 + logger.warning(f"OSS上传失败,使用本地存储: {oss_err}") 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]}" + # 获取API Key + try: + dashscope_api_key = settings.get_dashscope_api_key() + except ValueError as e: + logger.error(f"DASHSCOPE API Key未配置: {e}") + raise HTTPException(status_code=500, detail="OCR服务未配置,请联系管理员") + headers = { - "Authorization": f"Bearer {DASHSCOPE_API_KEY}", + "Authorization": f"Bearer {dashscope_api_key}", "Content-Type": "application/json" } @@ -182,6 +196,31 @@ async def recognize_image( current_user.f99_95_ai_count = (current_user.f99_95_ai_count or 0) + 1 db.commit() + duration_ms = (time.time() - start_time) * 1000 + + # 记录OCR识别成功日志(不记录图片数据) + logger.info( + "OCR图片识别成功", + extra={ + 'user_id': current_user.f99_90_id, + 'duration_ms': duration_ms, + 'data': { + 'temp_id': temp_id, + 'filename': image.filename, + 'is_oss': image_url.startswith("https://"), + 'extracted_fields': { + 'version': fields.get('version'), + 'prefix_serial': fields.get('prefix_serial'), + 'grading_company': fields.get('grading_company'), + 'grading_score': fields.get('grading_score'), + 'packaging': fields.get('packaging'), + 'is_graded': fields.get('is_graded') + }, + 'ai_count': current_user.f99_95_ai_count + } + } + ) + # 返回识别结果和临时图片路径 return { "success": True, @@ -198,6 +237,22 @@ async def recognize_image( } except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + + # 记录OCR识别失败日志 + logger.error( + "OCR图片识别失败", + extra={ + 'user_id': current_user.f99_90_id if current_user else None, + 'duration_ms': duration_ms, + 'data': { + 'error': str(e), + 'filename': image.filename if image else None + } + }, + exc_info=True + ) + import traceback error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}" raise HTTPException(status_code=500, detail=error_detail) @@ -320,12 +375,13 @@ async def claim_temp_image( 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"]) + auth = oss2.Auth(settings.OSS_ACCESS_KEY_ID, settings.OSS_ACCESS_KEY_SECRET) + bucket = oss2.Bucket(auth, settings.OSS_ENDPOINT, settings.OSS_BUCKET) temp_content = bucket.get_object(temp_oss_key).read() found_key = temp_oss_key break - except: + except Exception as e: + logger.debug(f"尝试OSS路径失败: {temp_oss_key}, error: {e}") continue if temp_content: break @@ -337,11 +393,11 @@ async def claim_temp_image( # 删除临时图片 try: bucket.delete_object(found_key) - except: - pass + except Exception as e: + logger.warning(f"删除OSS临时文件失败: {e}") # OSS URL - image_path = f"{OSS_CONFIG['public_url']}/{oss_key}" + image_path = f"https://{settings.OSS_BUCKET}.{settings.OSS_ENDPOINT}/{oss_key}" else: # OSS失败,使用本地文件 diff --git a/backend/app/routers/yichens.py b/backend/app/routers/yichens.py index de8e152..ce168c8 100644 --- a/backend/app/routers/yichens.py +++ b/backend/app/routers/yichens.py @@ -1,10 +1,13 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import func, text from sqlalchemy.orm import Session from pydantic import BaseModel from typing import Optional, List from datetime import datetime, date +import time + from app.core.coolbot_db import get_coolbot_db +from app.core.logging_config import logger router = APIRouter(prefix="/api/yichens", tags=["一尘看板"]) @@ -50,6 +53,15 @@ class UserItem(BaseModel): is_seller: bool registration_date: Optional[str] +class PriceIndexItem(BaseModel): + price_date: Optional[str] + category: str + item_type: str + spec: str + price: float + unit: Optional[str] + source: 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)): @@ -115,6 +127,8 @@ def get_posts( db: Session = Depends(get_coolbot_db) ): """获取帖子列表 - 支持全局搜索,返回总数和分页信息""" + start_time = time.time() + # 构建WHERE条件 where_clauses = ["1=1"] params = {"limit": limit, "offset": offset} @@ -162,6 +176,24 @@ def get_posts( url=r[10] ) for r in results] + duration_ms = (time.time() - start_time) * 1000 + + # 记录一尘帖子查询日志 + logger.info( + "一尘帖子查询", + extra={ + 'duration_ms': duration_ms, + 'data': { + 'category': category, + 'post_type': post_type, + 'keyword': keyword[:20] + '...' if keyword and len(keyword) > 20 else keyword, + 'result_count': len(posts), + 'total': total_count, + 'page': offset // limit + 1 + } + } + ) + return { "posts": posts, "total": total_count, @@ -375,3 +407,57 @@ def get_dragons_stats_today( "wu347": {"total": wu347[0] or 0, "deals": wu347[1] or 0, "wants": wu347[2] or 0} } + +# ============ 参考价接口 ============ +@router.get("/price-index", response_model=List[PriceIndexItem]) +def get_price_index( + category: Optional[str] = Query(None, description="版别:龙钞/马钞/蛇钞"), + item_type: Optional[str] = Query(None, description="包装:散张/标十/标百"), + db: Session = Depends(get_coolbot_db) +): + """ + 获取 crown_price_index 参考价数据 + - 连接一尘数据库 coolbot_data 的 crown_price_index 表 + - 支持按版别(category)、包装类型(item_type)筛选 + - 返回最新日期的价格数据 + """ + # 构建 WHERE 条件 + where_clauses = ["1=1"] + params = {} + + if category: + where_clauses.append("category = :category") + params["category"] = category + + if item_type: + where_clauses.append("item_type = :item_type") + params["item_type"] = item_type + + where_sql = " AND ".join(where_clauses) + + # 查询每个 category + item_type + spec 组合的最新价格 + query = f""" + SELECT price_date, category, item_type, spec, price, unit, source + FROM crown_price_index + WHERE {where_sql} + AND (price_date, category, item_type, spec) IN ( + SELECT MAX(price_date) as pd, category, item_type, spec + FROM crown_price_index + WHERE {where_sql} + GROUP BY category, item_type, spec + ) + ORDER BY category, item_type, spec + """ + + results = db.execute(text(query), params).fetchall() + + return [PriceIndexItem( + price_date=str(r[0]) if r[0] else None, + category=r[1] or "", + item_type=r[2] or "", + spec=r[3] or "", + price=float(r[4]) if r[4] else 0.0, + unit=r[5], + source=r[6] + ) for r in results] + diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index 6c52c87..4faef86 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -6,15 +6,7 @@ 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" -} +from app.core.config import settings # 图片压缩配置 IMAGE_CONFIG = { @@ -25,9 +17,18 @@ IMAGE_CONFIG = { "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"]) +# 懒加载OSS连接 +_oss_bucket = None + +def _get_oss_bucket(): + """获取OSS Bucket实例(懒加载)""" + global _oss_bucket + if _oss_bucket is None: + if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET: + raise ValueError("OSS配置未设置,请检查环境变量 OSS_ACCESS_KEY_ID 和 OSS_ACCESS_KEY_SECRET") + auth = oss2.Auth(settings.OSS_ACCESS_KEY_ID, settings.OSS_ACCESS_KEY_SECRET) + _oss_bucket = oss2.Bucket(auth, settings.OSS_ENDPOINT, settings.OSS_BUCKET) + return _oss_bucket def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str: @@ -54,6 +55,8 @@ def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str: def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str: """上传文件到OSS,返回公网URL""" try: + bucket = _get_oss_bucket() + # 如果是图片,进行压缩 if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']): file_data = compress_image(file_data) @@ -63,7 +66,7 @@ def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str: if result.status == 200: # 返回公网URL - return f"{OSS_CONFIG['public_url']}/{oss_key}" + return f"https://{settings.OSS_BUCKET}.{settings.OSS_ENDPOINT}/{oss_key}" else: raise Exception(f"OSS上传失败: {result.status}") @@ -74,6 +77,7 @@ def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str: def delete_from_oss(oss_key: str) -> bool: """从OSS删除文件""" try: + bucket = _get_oss_bucket() result = bucket.delete_object(oss_key) return result.status == 204 except Exception as e: @@ -83,7 +87,7 @@ def delete_from_oss(oss_key: str) -> bool: def get_public_url(oss_key: str) -> str: """获取公网URL""" - return f"{OSS_CONFIG['public_url']}/{oss_key}" + return f"https://{settings.OSS_BUCKET}.{settings.OSS_ENDPOINT}/{oss_key}" def compress_image(image_data: bytes, max_size: int = None) -> bytes: diff --git a/backend/app/services/sms.py b/backend/app/services/sms.py index 787e9e9..40f24b7 100644 --- a/backend/app/services/sms.py +++ b/backend/app/services/sms.py @@ -8,6 +8,7 @@ from typing import Optional # 阿里云短信配置 - 从 config.py 读取(不再硬编码默认值) from app.core.config import settings +from app.core.logging_config import logger SMS_CONFIG = settings.get_sms_config() @@ -59,18 +60,56 @@ def send_verification_code(phone: str) -> dict: "code": code, "expire": int(time.time()) + 300 # 5分钟有效 } + + # 记录短信发送成功日志(不记录验证码) + logger.info( + "短信验证码发送成功", + extra={ + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'provider': 'aliyun' + } + } + ) + return { "success": True, "message": "验证码已发送", "expire": 300 } else: + # 记录短信发送失败日志 + logger.warning( + "短信验证码发送失败", + extra={ + 'data': { + 'phone': phone[:3] + '****' + phone[7:], + 'provider': 'aliyun', + 'error_code': response.body.code, + 'error_message': response.body.message + } + } + ) + return { "success": False, "message": f"发送失败: {response.body.message}" } except Exception as e: + # 记录短信发送异常日志 + logger.error( + "短信验证码发送异常", + extra={ + 'data': { + 'phone': phone[:3] + '****' + phone[7:] if len(phone) >= 11 else phone, + 'provider': 'aliyun', + 'error': str(e) + } + }, + exc_info=True + ) + return { "success": False, "message": f"发送失败: {str(e)}" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..65140f2 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# tests package diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..74baff1 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,122 @@ +# tests/conftest.py - pytest fixtures for 甲辰藏品系统 + +import os +import sys +from datetime import datetime + +# 设置项目路径 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# 设置环境变量(测试环境使用SQLite内存数据库) +os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only" +os.environ["DATABASE_URL"] = "sqlite:///./test.db" +os.environ["ALGORITHM"] = "HS256" +os.environ["ACCESS_TOKEN_EXPIRE_MINUTES"] = "60" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.database import Base, get_db +from app.main import app + + +# ========== 数据库 Fixture ========== + +# 使用SQLite内存数据库进行测试 +TEST_DATABASE_URL = "sqlite:///:memory:" + +engine = create_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) + +# 启用外键约束(SQLite需要) +@event.listens_for(engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session(): + """每次测试创建新的数据库表,测试结束后清理""" + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture(scope="function") +def client(db_session): + """FastAPI测试客户端,使用测试数据库""" + def override_get_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +@pytest.fixture(scope="function") +def sample_user_data(): + """示例用户注册数据""" + return { + "f01_01_name": "testuser", + "password": "testpass123", + "email": "test@example.com", + "phone": "13800138000", + } + + +@pytest.fixture(scope="function") +def registered_user(db_session, sample_user_data): + """创建一个已注册的用户(带密码哈希)""" + from app.core.auth import get_password_hash + from app.models.models import User + import uuid + + hashed_password = get_password_hash(sample_user_data["password"]) + user = User( + f99_90_id=str(uuid.uuid4()), + f99_91_user_id=str(uuid.uuid4()), + user_code="201", + f01_01_name=sample_user_data["f01_01_name"], + email=sample_user_data["email"], + phone=sample_user_data["phone"], + password=hashed_password, + role="user", + ) + db_session.add(user) + db_session.commit() + db_session.refresh(user) + return user + + +@pytest.fixture(scope="function") +def auth_token(registered_user): + """生成已注册用户的访问令牌""" + from app.core.auth import create_access_token + + token = create_access_token(data={"sub": registered_user.f99_90_id}) + return token + + +@pytest.fixture(scope="function") +def auth_headers(auth_token): + """带Bearer令牌的请求头""" + return {"Authorization": f"Bearer {auth_token}"} diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..f6bbfa5 --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,238 @@ +# tests/test_auth.py - 认证模块单元测试 + +import pytest +from app.core.auth import ( + verify_password, + get_password_hash, + create_access_token, + decode_access_token, +) + + +class TestPasswordHashing: + """密码哈希与验证测试""" + + def test_hash_password_returns_string(self): + """哈希密码应返回字符串""" + password = "testpassword123" + hashed = get_password_hash(password) + assert isinstance(hashed, str) + assert hashed != password + + def test_hash_password_different_each_time(self): + """每次哈希应不同(bcrypt使用随机盐)""" + password = "testpassword123" + hash1 = get_password_hash(password) + hash2 = get_password_hash(password) + assert hash1 != hash2 + + def test_verify_password_correct(self): + """正确密码应验证通过""" + password = "testpassword123" + hashed = get_password_hash(password) + assert verify_password(password, hashed) is True + + def test_verify_password_incorrect(self): + """错误密码应验证失败""" + password = "testpassword123" + wrong_password = "wrongpassword456" + hashed = get_password_hash(password) + assert verify_password(wrong_password, hashed) is False + + def test_verify_password_empty(self): + """空密码应验证失败""" + password = "testpassword123" + hashed = get_password_hash(password) + assert verify_password("", hashed) is False + + def test_verify_password_none(self): + """None密码应验证失败""" + password = "testpassword123" + hashed = get_password_hash(password) + assert verify_password(None, hashed) is False + + +class TestJWTToken: + """JWT令牌创建与解析测试""" + + def test_create_and_decode_token(self): + """创建令牌后能正确解码""" + data = {"sub": "user123", "role": "admin"} + token = create_access_token(data) + assert isinstance(token, str) + assert len(token) > 0 + + payload = decode_access_token(token) + assert payload is not None + assert payload["sub"] == "user123" + assert payload["role"] == "admin" + + def test_decode_invalid_token_returns_none(self): + """无效令牌应返回None""" + invalid_token = "invalid.token.here" + payload = decode_access_token(invalid_token) + assert payload is None + + def test_decode_empty_token_returns_none(self): + """空令牌应返回None""" + payload = decode_access_token("") + assert payload is None + + def test_token_contains_expiration(self): + """令牌应包含过期时间""" + data = {"sub": "user123"} + token = create_access_token(data) + payload = decode_access_token(token) + assert "exp" in payload + + +class TestAuthEndpoints: + """认证API端点测试""" + + def test_register_success(self, client, sample_user_data): + """用户注册成功""" + response = client.post("/api/auth/register", json=sample_user_data) + assert response.status_code == 200 + data = response.json() + assert data["username"] == sample_user_data["f01_01_name"] + assert "id" in data + assert "user_code" in data + + def test_register_duplicate_username(self, client, sample_user_data, registered_user): + """重复用户名应注册失败""" + response = client.post("/api/auth/register", json=sample_user_data) + assert response.status_code == 400 + assert "已存在" in response.json().get("detail", "") + + def test_register_short_password(self, client, sample_user_data): + """密码过短应注册失败""" + sample_user_data["password"] = "123" # 小于6位 + response = client.post("/api/auth/register", json=sample_user_data) + assert response.status_code == 422 # Pydantic验证失败 + + def test_login_success(self, client, sample_user_data): + """登录成功返回token""" + # 先注册 + client.post("/api/auth/register", json=sample_user_data) + + # 再登录 + response = client.post( + "/api/auth/login", + data={ + "username": sample_user_data["f01_01_name"], + "password": sample_user_data["password"], + }, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + def test_login_with_user_code(self, client, sample_user_data): + """使用用户编码登录""" + # 先注册 + client.post("/api/auth/register", json=sample_user_data) + + # 获取用户编码 + user_response = client.post("/api/auth/register", json=sample_user_data) + registered_user_response = client.post( + "/api/auth/login", + data={ + "username": sample_user_data["f01_01_name"], + "password": sample_user_data["password"], + }, + ) + token = registered_user_response.json()["access_token"] + me_response = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) + user_code = me_response.json()["user_code"] + + # 用用户编码登录 + login_response = client.post( + "/api/auth/login", + data={ + "username": user_code, + "password": sample_user_data["password"], + }, + ) + assert login_response.status_code == 200 + assert "access_token" in login_response.json() + + def test_login_wrong_password(self, client, sample_user_data): + """错误密码应登录失败""" + # 先注册 + client.post("/api/auth/register", json=sample_user_data) + + response = client.post( + "/api/auth/login", + data={ + "username": sample_user_data["f01_01_name"], + "password": "wrongpassword", + }, + ) + assert response.status_code == 401 + + def test_login_nonexistent_user(self, client): + """不存在的用户应登录失败""" + response = client.post( + "/api/auth/login", + data={ + "username": "nonexistent", + "password": "somepassword", + }, + ) + assert response.status_code == 401 + + def test_get_current_user_success(self, client, auth_headers): + """获取当前用户信息成功""" + response = client.get("/api/auth/me", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert "username" in data + assert "user_code" in data + + def test_get_current_user_no_token(self, client): + """无令牌应返回401""" + response = client.get("/api/auth/me") + assert response.status_code == 401 + + def test_get_current_user_invalid_token(self, client): + """无效令牌应返回401""" + response = client.get( + "/api/auth/me", + headers={"Authorization": "Bearer invalid-token"}, + ) + assert response.status_code == 401 + + def test_change_password_success(self, client, auth_headers, sample_user_data): + """修改密码成功""" + response = client.post( + "/api/auth/change-password", + headers=auth_headers, + json={ + "old_password": sample_user_data["password"], + "new_password": "newpassword456", + }, + ) + assert response.status_code == 200 + + # 用新密码登录应该成功 + login_response = client.post( + "/api/auth/login", + data={ + "username": sample_user_data["f01_01_name"], + "password": "newpassword456", + }, + ) + assert login_response.status_code == 200 + + def test_change_password_wrong_old_password(self, client, auth_headers, sample_user_data): + """旧密码错误应修改失败""" + response = client.post( + "/api/auth/change-password", + headers=auth_headers, + json={ + "old_password": "wrongoldpassword", + "new_password": "newpassword456", + }, + ) + assert response.status_code == 400 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 811ed7d..d6f70c6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,5 +1,9 @@ # 甲辰藏品管理系统 部署手册 +**当前版本:v1.2.88** + +--- + ## 环境概览 ### 服务器信息 @@ -16,11 +20,74 @@ | **主数据库** | jiachenlong | pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com | 5432 | jiachenlong | Passwd1@3 | | **一尘数据库** | coolbot_data | pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com | 5432 | coolbot | Coolbot123 | +> ⚠️ **密码中的特殊字符 `@` 必须URL编码为 `%40`** +> - `Passwd1@3` → `Passwd1%403` +> - `Coolbot123` 无需编码 + ### OSS存储 - Bucket: jiachenlong-oss --- +## 环境变量 (.env) + +> 📁 完整示例见 `backend/.env.example` + +### 数据库配置 +```env +# 主数据库 - jiachenlong +DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong + +# 一尘数据库 - coolbot_data +COOLBOT_DB_URL=postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com:5432/coolbot_data +``` + +### JWT认证配置 +```env +SECRET_KEY=jiachenlong-production-secret-key-2026 +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 +``` + +### 阿里云百炼AI (DASHSCOPE) +```env +# 用于AI批量解析藏品行情数据 +DASHSCOPE_API_KEY=your-dashscope-api-key +``` + +### 阿里云OSS存储 +```env +OSS_ACCESS_KEY_ID=your-oss-access-key-id +OSS_ACCESS_KEY_SECRET=your-oss-access-key-secret +OSS_BUCKET=jiachenlong-oss +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com +``` + +### 阿里云短信服务 +```env +SMS_ACCESS_KEY_ID=your-sms-access-key-id +SMS_ACCESS_KEY_SECRET=your-sms-access-key-secret +SMS_SIGN_NAME=您的签名 +SMS_TEMPLATE_CODE=SMS_xxx +``` + +### 请求限流配置 +```env +RATE_LIMIT_ENABLED=true +RATE_LIMIT_SMS_PER_MINUTE=3 +RATE_LIMIT_OCR_PER_MINUTE=10 +RATE_LIMIT_BATCH_PER_MINUTE=5 +RATE_LIMIT_API_PER_MINUTE=60 +``` + +### 管理员账号 +```env +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 +``` + +--- + ## 部署流程 ### 1. 获取代码 @@ -31,7 +98,7 @@ git clone http://caibotd:Caibotd123@101.37.160.219/root/jiachenlong.git # 切换到目标版本 cd jiachenlong -git checkout v1.2.78 # 或指定版本tag +git checkout v1.2.88 # 或指定版本tag ``` ### 2. 构建前端 @@ -49,27 +116,44 @@ npm run build ```bash cat > /root/jiachenlong/backend/.env << 'EOF' # 主数据库 - jiachenlong -DB_HOST=pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com -DB_PORT=5432 -DB_USER=jiachenlong -DB_PASSWORD=Passwd1@3 -DB_NAME=jiachenlong +DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong # 一尘数据库 - coolbot_data -YICHEN_DB_HOST=pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com -YICHEN_DB_PORT=5432 -YICHEN_DB_USER=coolbot -YICHEN_DB_PASSWORD=Coolbot123 -YICHEN_DB_NAME=coolbot_data +COOLBOT_DB_URL=postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com:5432/coolbot_data -# 应用配置 +# JWT SECRET_KEY=jiachenlong-production-secret-key-2026 -DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 + +# 阿里云百炼AI +DASHSCOPE_API_KEY=your-dashscope-api-key + +# 阿里云OSS +OSS_ACCESS_KEY_ID=your-oss-access-key-id +OSS_ACCESS_KEY_SECRET=your-oss-access-key-secret +OSS_BUCKET=jiachenlong-oss +OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com + +# 阿里云SMS +SMS_ACCESS_KEY_ID=your-sms-access-key-id +SMS_ACCESS_KEY_SECRET=your-sms-access-key-secret +SMS_SIGN_NAME=您的签名 +SMS_TEMPLATE_CODE=SMS_xxx + +# 限流 +RATE_LIMIT_ENABLED=true +RATE_LIMIT_SMS_PER_MINUTE=3 +RATE_LIMIT_OCR_PER_MINUTE=10 +RATE_LIMIT_BATCH_PER_MINUTE=5 +RATE_LIMIT_API_PER_MINUTE=60 + +# 管理员 +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 EOF ``` -**注意**:密码中的特殊字符 `@` 必须URL编码为 `%40` - ### 4. 上传文件到服务器 ```bash @@ -78,6 +162,8 @@ scp -r frontend/dist/* root@47.111.184.210:/var/www/html/ # 上传后端 scp -r backend/app root@47.111.184.210:/root/jiachenlong/backend/ +scp backend/requirements.txt root@47.111.184.210:/root/jiachenlong/backend/ +scp backend/.env root@47.111.184.210:/root/jiachenlong/backend/ ``` ### 5. 配置Nginx @@ -95,37 +181,74 @@ events { } http { - include /etc/nginx/mime.types; - default_type application/octet-stream; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml; server { listen 80; server_name _; - + + # 前端静态文件 root /var/www/html; index index.html; - - # 前端静态文件 (SPA) + + # 大文件上传限制 + client_max_body_size 50M; + client_body_timeout 300s; + + # 前端静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # 前端 SPA 路由 location / { try_files $uri $uri/ /index.html; } - - # 静态资源 - location /static { - alias /var/www/html/static; - expires 30d; - } - + # API代理到后端 location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # 上传文件代理 + location /uploads/ { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } - - # 上传文件代理 - location /uploads/ { - proxy_pass http://127.0.0.1:3000; + + # 静态文件服务 + location /static/ { + alias /var/www/html/static/; + expires 30d; } } } @@ -138,6 +261,12 @@ http { source /opt/conda/etc/profile.d/conda.sh conda activate py312 +# 安装依赖(如需要) +pip install -r requirements.txt + +# 创建日志目录 +mkdir -p /root/jiachenlong/backend/logs + # 设置Python路径 export PYTHONPATH=/root/jiachenlong/backend @@ -156,10 +285,154 @@ nginx -t nginx -s reload # 或完全重启 -killall nginx +killall nginx && nginx +``` + +--- + +## SSL证书配置 (Let's Encrypt) + +### 安装 Certbot + +```bash +# CentOS/RHEL +yum install epel-release +yum install certbot python3-certbot-nginx + +# 或使用 pip +pip install certbot certbot-nginx +``` + +### 获取证书 + +```bash +# 停止 Nginx(如果正在运行) +nginx -s stop + +# 获取证书(单域名) +certbot certonly --standalone -d jiachenlong.com --agree-tos --email admin@jiachenlong.com --no-eff-email + +# 获取证书(多域名) +certbot certonly --standalone -d jiachenlong.com -d www.jiachenlong.com --agree-tos --email admin@jiachenlong.com --no-eff-email + +# 重启 Nginx nginx ``` +### 自动续期 + +```bash +# 测试续期(dry-run) +certbot renew --dry-run + +# 设置定时任务(每天凌晨自动续期) +crontab -e +# 添加以下行: +# 0 3 * * * certbot renew --quiet --deploy-hook "nginx -s reload" +``` + +### Nginx HTTPS 配置 + +```nginx +server { + listen 80; + server_name jiachenlong.com www.jiachenlong.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name jiachenlong.com www.jiachenlong.com; + + # SSL证书 + ssl_certificate /etc/letsencrypt/live/jiachenlong.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/jiachenlong.com/privkey.pem; + + # SSL安全配置 + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_session_tickets off; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=63072000" always; + + # ... 其余配置同上 ... +} +``` + +--- + +## 数据库迁移 (Alembic) + +### 查看当前版本 + +```bash +cd /root/jiachenlong/backend +source /opt/conda/etc/profile.d/conda.sh && conda activate py312 +export PYTHONPATH=/root/jiachenlong/backend + +alembic current +``` + +### 查看迁移历史 + +```bash +alembic history --verbose +``` + +### 创建新迁移 + +```bash +# 自动生成迁移脚本(根据模型变更) +alembic revision --autogenerate -m "描述变更内容" + +# 手动创建空白迁移 +alembic revision -m "描述变更内容" +``` + +### 执行迁移 + +```bash +# 升级到最新版本 +alembic upgrade head + +# 升级到指定版本 +alembic upgrade + +# 检查是否有待执行迁移 +alembic check +``` + +### 回滚 + +```bash +# 回滚一步 +alembic downgrade -1 + +# 回滚到初始状态 +alembic downgrade base +``` + +### 生产环境迁移流程 + +```bash +# 1. 在测试环境验证迁移 +alembic upgrade head + +# 2. 检查数据完整性 +# (在测试环境执行应用相关测试) + +# 3. 在生产环境执行迁移(建议在低峰期) +alembic upgrade head + +# 4. 重启后端服务 +pkill -f 'uvicorn app.main:app' +nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > logs/api.log 2>&1 & +``` + --- ## 常见问题与解决方案 @@ -246,11 +519,33 @@ chmod -R 755 /var/www/html **解决**:强制刷新 (Ctrl+Shift+R) 或清除缓存 +### 9. 后端日志显示数据库连接超时 + +**原因**:数据库地址不可达或网络问题 + +**解决**: +```bash +# 测试连接 +telnet pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com 5432 + +# 或 +nc -zv pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com 5432 +``` + +### 10. 文件上传失败 413 Request Entity Too Large + +**原因**:Nginx `client_max_body_size` 限制 + +**解决**:在 nginx.conf 的 http 或 server 块中添加: +```nginx +client_max_body_size 50M; +``` + --- ## 代码修改注意事项 -### information.py 常见问题 +### information.py / collections.py 常见问题 1. **Response参数问题** - 函数签名中的 `response: Response = None` 会导致 `NameError` @@ -285,42 +580,21 @@ if not current_user or current_user.role != "admin": --- -## 改进建议 +## 改进建议(已完成) -### 1. 环境变量配置 -- [ ] 使用 `python-dotenv` 管理环境变量 -- [ ] 生产环境与测试环境配置分离 -- [ ] 敏感信息(密码、密钥)使用环境变量而非硬编码 - -### 2. 部署脚本化 -- [ ] 编写自动化部署脚本 (deploy.sh) -- [ ] 包含数据库迁移步骤 -- [ ] 部署前自动备份 - -### 3. 健康检查 -- [ ] 添加后端 `/health` 端点 -- [ ] 配置监控告警 - -### 4. 日志管理 -- [ ] 统一日志格式 -- [ ] 日志轮转配置 -- [ ] 错误日志实时告警 - -### 5. 数据库迁移 -- [ ] 使用 Alembic 管理数据库版本 -- [ ] 编写数据迁移脚本 -- [ ] 部署前检查数据库schema是否匹配 - -### 6. 代码质量 +- [x] 使用 `python-dotenv` 管理环境变量 → `backend/.env.example` +- [x] Alembic 数据库迁移管理 → `backend/alembic/` +- [x] 单元测试框架 → `backend/tests/` +- [ ] 自动化部署脚本 (deploy.sh) - [ ] CI/CD 自动化测试 -- [ ] 代码审查流程 -- [ ] 部署前在测试环境验证 +- [ ] 健康检查端点 --- ## 快速命令参考 ```bash +# ========== 后端管理 ========== # 查看后端进程 ps aux | grep uvicorn | grep -v grep @@ -334,17 +608,29 @@ source /opt/conda/etc/profile.d/conda.sh && conda activate py312 export PYTHONPATH=/root/jiachenlong/backend nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > logs/api.log 2>&1 & -# 测试API +# ========== 测试API ========== curl http://localhost:3000/api/collections/stats curl http://localhost:3000/api/information/list?info_type=seek -# Nginx相关 +# ========== Nginx ========== nginx -t # 测试配置 nginx -s reload # 重载配置 nginx -s stop # 停止 killall nginx && nginx # 完全重启 + +# ========== 数据库迁移 ========== +cd /root/jiachenlong/backend +export PYTHONPATH=/root/jiachenlong/backend +alembic current # 查看当前版本 +alembic history # 查看历史 +alembic upgrade head # 执行迁移 +alembic downgrade -1 # 回滚一步 + +# ========== SSL证书 ========== +certbot renew --dry-run # 测试续期 +certbot renew --quiet # 执行续期 ``` --- -*最后更新:2026-04-09* +*最后更新:2026-04-12* diff --git a/frontend/index.html b/frontend/index.html index e3a4d95..f67287c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - 甲辰收藏 v=1.2.81 + 甲辰收藏 v1.2.88 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3d1484e..25e6028 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "jiachenlong-frontend", - "version": "1.2.87", + "version": "1.2.88", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jiachenlong-frontend", - "version": "1.2.87", + "version": "1.2.88", "dependencies": { "axios": "^1.7.9", "react": "^18.3.1", diff --git a/frontend/src/pages/Add.jsx b/frontend/src/pages/Add.jsx index 5f19b7a..ab12dfc 100644 --- a/frontend/src/pages/Add.jsx +++ b/frontend/src/pages/Add.jsx @@ -90,6 +90,8 @@ export default function Add() { const [batchDefaultDate, setBatchDefaultDate] = useState('') const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('') const [dealMode, setDealMode] = useState('single') // single-单条录入, batch-批量录入 + const [dealVersion, setDealVersion] = useState('龙钞') // 龙钞/马钞/蛇钞/其他 + const [batchDefaultVersion, setBatchDefaultVersion] = useState('龙钞') const [batchText, setBatchText] = useState('') const [batchResult, setBatchResult] = useState([]) const [parsing, setParsing] = useState(false) @@ -742,6 +744,18 @@ export default function Add() { )} +
+
版别
+
+ {['龙钞', '马钞', '蛇钞', '其他'].map(v => ( + + ))} +
+
+
包装类型
@@ -833,12 +847,14 @@ export default function Add() { tailNumber = digits.slice(-3) sizeType = ['101','201','301','401','501'].includes(tailNumber) ? '小号' : '大号' } + // 根据版别确定前缀 const response = await fetch(`${API_BASE}/api/information/`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ info_type: 'deal', title: `J0${dealForm.serial.replace('J', '').slice(0, 8)}-¥${dealForm.price}`, + version: dealVersion, content: `分类: ${dealForm.category || '未分类'}\n尾号: ${tailNumber || '-'}\n大小号: ${sizeType || '-'}\n包装: ${dealForm.packaging}\n平台: ${dealForm.platform}\n价格: ¥${dealForm.price}\n出售者: ${dealForm.seller || '-'}\n购买者: ${dealForm.buyer || '-'}\n日期: ${dealForm.date}\n评级: ${dealForm.gradingCompany ? dealForm.gradingCompany + ' ' + dealForm.gradingScore : '未评级'}`, deal_price: parseFloat(dealForm.price), deal_date: dealForm.date, @@ -897,6 +913,19 @@ export default function Add() {
+ {/* 版别选择 */} +
+
版别
+
+ {['龙钞', '马钞', '蛇钞', '其他'].map(v => ( + + ))} +
+
+ {/* 包装类型选择 */}
批量默认包装类型(可选)
@@ -1038,7 +1067,8 @@ export default function Add() { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ info_type: 'deal', - title: `${item.serial}-¥${item.price}`, + title: `J0${item.serial.replace('J', '').slice(0, 8)}-¥${item.price}`, + version: batchDefaultVersion, content: `分类: ${item.category || '-'}\n尾号: ${tail_number || '-'}\n大小号: ${size_type || '-'}\n包装: ${item.packaging || '单张'}\n平台: ${item.platform || '-'}\n价格: ¥${item.price}\n出售者: ${item.seller || '-'}\n购买者: ${item.buyer || '-'}\n日期: ${item.deal_date || new Date().toISOString().split('T')[0]}`, deal_price: parseFloat(item.price), deal_date: item.deal_date || new Date().toISOString().split('T')[0], diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index a9c3728..5664dc2 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -136,11 +136,9 @@ export default function Admin() { searchCount: editingUser.searchCount, points: editingUser.points, balance: editingUser.balance, - totalAmount: editingUser.totalAmount, - phoneVerified: editingUser.phoneVerified + totalAmount: editingUser.totalAmount } - // 如果有新密码,添加到更新数据 if (editingUser.newPassword && editingUser.newPassword.trim()) { updateData.password = editingUser.newPassword } diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx index 7629566..133586a 100644 --- a/frontend/src/pages/Home.jsx +++ b/frontend/src/pages/Home.jsx @@ -10,6 +10,7 @@ export default function Home() { const [recentPosts, setRecentPosts] = useState([]) const [dragonStats, setDragonStats] = useState({}) const [dealStats, setDealStats] = useState({ total: 0, items: [] }) + const [priceIndexData, setPriceIndexData] = useState([]) const [dealVersion, setDealVersion] = useState('龙钞') const [dealDetailItems, setDealDetailItems] = useState(null) const currentPath = window.location.hash.slice(1) || '/' @@ -23,17 +24,75 @@ export default function Home() { return c } + // item_type 映射:crown_price_index -> 前端显示 + const itemTypeMap = { '散张': '单张', '标十': '标十', '标百': '标百' } + + // spec 映射:crown_price_index.spec -> 前端 category + const specToCategory = { + '带4': '带4号', '无4': '带7号', '无47': '无47', '无347': '无347', '无247': '无247', + '带4号': '带4号', '无4号': '带7号', '无47号': '无47', '无347号': '无347', + '无2347': '无347', '无34': '无47' + } + const categoryToSpec = { + '带4号': '带4', + '带7号': '无4', + '永恒号': '无47', + '天马号': '无247', '天马王': '无247', + '金山号': '无247', '金山王': '无247', + '钻石号': '无347', '朦胧号': '无347', '朦胧王': '无347', + '金马号': '无347', '金马王': '无347', + '倒置号': '无347', '圆圆号': '无347' + } + + // 获取参考价 + const getPriceIndex = (pkg, cat) => { + // 映射 item_type: 前端pkg -> crown_price_index item_type + const pkgToItemType = { '单张': '散张', '标十': '标十', '标百': '标百' } + const mappedItemType = pkgToItemType[pkg] || pkg + // 映射 category: 前端cat -> crown_price_index spec + const mappedSpec = categoryToSpec[cat] || cat + + const items = priceIndexData.filter(item => { + // 版别过滤 + if (dealVersion !== '其他' && item.category !== dealVersion) return false + // 包装类型过滤 + if (item.item_type !== mappedItemType) return false + // 分类过滤 + if (item.spec !== mappedSpec) return false + return true + }) + return items.length > 0 ? items[0] : null + } + const calcDealAvg = (pkg, cat) => { - const items = dealStats.items.filter(item => { + // 先按版本过滤数据 + const filteredData = dealStats.items.filter(item => { + const serial = (item.title || '').split('-')[0] || '' + let version = '其他' + if (serial.startsWith('J0')) version = '龙钞' + else if (serial.startsWith('J1')) version = '马钞' + else if (serial.startsWith('J3')) version = '蛇钞' + return version === dealVersion + }) + + const items = filteredData.filter(item => { const content = item.content || '' const p = content.includes('包装:') ? content.split('包装:')[1].split('\n')[0].trim() : (item.packaging || '') let c = content.includes('分类:') ? content.split('分类:')[1].split('\n')[0].trim() : (item.category || '') c = normalizeCat(c) return p === pkg && c === cat }) - if (items.length === 0) return null + + // 参考价 + const pi = getPriceIndex(pkg, cat) + + if (items.length === 0) { + // 没有用户数据,返回参考价 + if (pi) return { avg: Math.round(pi.price), count: 0, items: [], isPriceIndex: true, pi } + return null + } const sum = items.reduce((a, b) => a + (b.deal_price || 0), 0) - return { avg: Math.round(sum / items.length), count: items.length, items } + return { avg: Math.round(sum / items.length), count: items.length, items, isPriceIndex: false } } // 获取今日成交数据 @@ -47,6 +106,16 @@ export default function Home() { } }) .catch(() => {}) + + // 获取参考价数据 + fetch(`/api/yichens/price-index`) + .then(res => res.json()) + .then(data => { + if (Array.isArray(data)) { + setPriceIndexData(data) + } + }) + .catch(() => {}) }, []) useEffect(() => { @@ -106,6 +175,11 @@ export default function Home() { setYichensStats(data || {}) }).catch(() => {}) + // 获取参考价数据 + fetch('/api/yichens/price-index').then(res => res.json()).then(data => { + setPriceIndexData(data || []) + }).catch(() => {}) + // 获取最新一尘帖子 fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => { setDragonStats(data || {}) @@ -298,7 +372,7 @@ export default function Home() {
{/* 今日成交数据统计 */} - {dealStats.total > 0 && ( + {(dealStats.total > 0 || priceIndexData.length > 0) && (
📈 最新成交信息统计(均价)
@@ -333,10 +407,17 @@ export default function Home() { {rowData.map((d, i) => ( {d ? ( -
setDealDetailItems(d.items)}> +
!d.isPriceIndex && setDealDetailItems(d.items)}> ¥{d.avg.toLocaleString()} - ({d.count}) + {d.isPriceIndex ? null : ( + ({d.count}) + )}
) : -} @@ -347,6 +428,9 @@ export default function Home() {
+
+ 绿色为精确成交数据统计    橙色为网络参考数据 +
)} @@ -395,31 +479,31 @@ export default function Home() { {/* 数据行 */}
-
带4
+
带4
{dragonStats.dai4?.deals || 0}
{dragonStats.dai4?.wants || 0}
{(dragonStats.dai4?.deals || 0) + (dragonStats.dai4?.wants || 0)}
-
带7号
+
带7号
{dragonStats.wu4?.deals || 0}
{dragonStats.wu4?.wants || 0}
{(dragonStats.wu4?.deals || 0) + (dragonStats.wu4?.wants || 0)}
-
无47
+
无47
{dragonStats.wu47?.deals || 0}
{dragonStats.wu47?.wants || 0}
{(dragonStats.wu47?.deals || 0) + (dragonStats.wu47?.wants || 0)}
-
无247
+
无247
{dragonStats.wu247?.deals || 0}
{dragonStats.wu247?.wants || 0}
{(dragonStats.wu247?.deals || 0) + (dragonStats.wu247?.wants || 0)}
-
无347
+
无347
{dragonStats.wu347?.deals || 0}
{dragonStats.wu347?.wants || 0}
{(dragonStats.wu347?.deals || 0) + (dragonStats.wu347?.wants || 0)}
diff --git a/frontend/src/pages/List.jsx b/frontend/src/pages/List.jsx index 27b9a15..4f14669 100644 --- a/frontend/src/pages/List.jsx +++ b/frontend/src/pages/List.jsx @@ -788,6 +788,16 @@ function DealListItem({ deal, onRefresh }) {
{deal.deal_date && {deal.deal_date.slice(5)}} {deal.title?.split('-')[0] || '-'} + {(() => { + const serial = (deal.title || '').split('-')[0] || '' + let version = deal.version || '其他' + if (!version || version === '其他') { + if (serial.startsWith('J0')) version = '龙钞' + else if (serial.startsWith('J1')) version = '马钞' + else if (serial.startsWith('J3')) version = '蛇钞' + } + return {version} + })()} {deal.packaging && {deal.packaging}} {deal.grading_company && {deal.grading_score}} ¥{deal.deal_price?.toLocaleString()} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 8c8dfc1..dcfac37 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -205,26 +205,20 @@ export default function Login() { setError('') try { - console.log('开始登录...') const loginData = await api.auth.login(username, password) - console.log('登录响应:', loginData) if (!loginData.access_token) { throw new Error('登录响应中没有 access_token') } localStorage.setItem('token', loginData.access_token) - console.log('Token 已保存') const userData = await api.user.me() - console.log('用户信息:', userData) localStorage.setItem('user', JSON.stringify(userData)) - console.log('用户信息已保存') // 强制刷新页面,确保 React 状态更新 window.location.href = '/' - console.log('跳转首页') } catch (err) { console.error('登录错误:', err) const errorCode = err.code || 'E00000' diff --git a/frontend/src/pages/News.jsx b/frontend/src/pages/News.jsx index abae5ab..aa2deb7 100644 --- a/frontend/src/pages/News.jsx +++ b/frontend/src/pages/News.jsx @@ -51,6 +51,7 @@ export default function News() { edition: '龙钞', type: '', category: '寻号', price: '', matchType: '模糊', features: '', content: '', title: '', contact: getUserPhone() || '' }) const [infoList, setInfoList] = useState([]) + const [priceIndexData, setPriceIndexData] = useState([]) const [viewMode, setViewMode] = useState('all') const [expandedItems, setExpandedItems] = useState({}) // 展开状态 const [loading, setLoading] = useState(false) @@ -72,6 +73,13 @@ export default function News() { useEffect(() => { fetchInfoList() }, [activeTab]) + + // 获取参考价数据 + useEffect(() => { + fetch('/api/yichens/price-index').then(res => res.json()).then(data => { + setPriceIndexData(data || []) + }).catch(() => {}) + }, []) // 自动生成寻号标题 useEffect(() => { @@ -103,24 +111,13 @@ export default function News() { const data = await res.json() console.log('资讯列表:', data) - // 成交行情按分类排序 + // 成交行情按时间排序(最新在前) let sortedData = data || [] if (activeTab === 'deal') { - const categoryOrder = ['带4号', '带7号', '永恒号', '天马号', '天马王', '金山号', '金山王', '钻石号', '朦胧号', '朦胧王', '金马号', '金马王', '倒置号', '圆圆号'] - // 标准化分类名称(通货->带4号,无4->带7号) - const normalizeCat = (c) => { - if (c === '通货') return '带4号' - if (c === '无4') return '带7号' - return c - } sortedData = [...(data || [])].sort((a, b) => { - const contentA = a.content || '' - const contentB = b.content || '' - const catA = normalizeCat(contentA.match(/分类:\s*(.+?)(?:\n|$)/)?.[1]?.trim() || a.category || '') - const catB = normalizeCat(contentB.match(/分类:\s*(.+?)(?:\n|$)/)?.[1]?.trim() || b.category || '') - const idxA = categoryOrder.indexOf(catA) - const idxB = categoryOrder.indexOf(catB) - return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB) + const timeA = new Date(a.created_at || 0).getTime() + const timeB = new Date(b.created_at || 0).getTime() + return timeB - timeA // 降序,最新在前 }) } @@ -530,14 +527,28 @@ export default function News() { const filteredData = infoList.filter(item => { const serial = (item.title || '').split('-')[0] || '' - let version = '其他' - if (serial.startsWith('J0')) version = '龙钞' - else if (serial.startsWith('J1')) version = '马钞' - else if (serial.startsWith('J3')) version = '蛇钞' + let version = item.version || '其他' + if (!version || version === '其他') { + if (serial.startsWith('J0')) version = '龙钞' + else if (serial.startsWith('J1')) version = '马钞' + else if (serial.startsWith('J3')) version = '蛇钞' + } if (version !== dealVersion) return false return true }) + // 分类映射 + const categoryToSpec = { + '带4号': '带4', + '带7号': '无4', + '永恒号': '无47', + '天马号': '无247', '天马王': '无247', + '金山号': '无247', '金山王': '无247', + '钻石号': '无347', '朦胧号': '无347', '朦胧王': '无347', + '金马号': '无347', '金马王': '无347', + '倒置号': '无347', '圆圆号': '无347' + } + const calcAvg = (pkg, cat) => { const items = filteredData.filter(item => { const content = item.content || '' @@ -546,9 +557,23 @@ export default function News() { c = normalizeCat(c) return p === pkg && c === cat }) - if (items.length === 0) return null + + // 获取参考价 + const pkgToItemType = { '单张': '散张', '标十': '标十', '标百': '标百' } + const mappedSpec = categoryToSpec[cat] || cat + const mappedItemType = pkgToItemType[pkg] || pkg + const pi = priceIndexData.find(item => + item.category === dealVersion && + item.item_type === mappedItemType && + item.spec === mappedSpec + ) + + if (items.length === 0) { + if (pi) return { avg: Math.round(pi.price), count: 0, items: [], isPriceIndex: true } + return null + } const sum = items.reduce((a, b) => a + (b.deal_price || 0), 0) - return { avg: Math.round(sum / items.length), count: items.length, items } + return { avg: Math.round(sum / items.length), count: items.length, items, isPriceIndex: false } } return ( @@ -604,10 +629,16 @@ export default function News() { {rowData.map((d, i) => ( {d ? ( -
setDealDetailItems(d.items)}> +
!d.isPriceIndex && setDealDetailItems(d.items)}> ¥{d.avg.toLocaleString()} - ({d.count}) + {d.isPriceIndex ? null : ( + ({d.count}) + )}
) : -} @@ -618,6 +649,9 @@ export default function News() { })()} +
+ 绿色为精确成交数据统计    橙色为网络参考数据 +
) @@ -693,10 +727,12 @@ export default function News() { const gradingCompany = item.grading_company || '' const serial = (item.title || '').split('-')[0] || '' - let version = '其他' - if (serial.startsWith('J0')) version = '龙钞' - else if (serial.startsWith('J1')) version = '马钞' - else if (serial.startsWith('J3')) version = '蛇钞' + let version = item.version || '其他' + if (!version || version === '其他') { + if (serial.startsWith('J0')) version = '龙钞' + else if (serial.startsWith('J1')) version = '马钞' + else if (serial.startsWith('J3')) version = '蛇钞' + } return (
diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index 666341d..f61eff8 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -41,7 +41,6 @@ export default function Stats() { } const data = await statsRes.json() - console.log('统计数据:', data) setStats({ totalCount: data.totalCount || 0, @@ -85,8 +84,6 @@ export default function Stats() { packaging: 'packaging', rarity: 'rarity', version: 'version', - gradingCompany: 'gradingCompany', - gradingScore: 'gradingScore', specialMark: 'specialMark', numberCategory: 'numberCategory', gradingCompany: 'gradingCompany', diff --git a/frontend/src/pages/YichensBoard.jsx b/frontend/src/pages/YichensBoard.jsx index 85d80e0..21a589c 100644 --- a/frontend/src/pages/YichensBoard.jsx +++ b/frontend/src/pages/YichensBoard.jsx @@ -17,17 +17,14 @@ export default function YichensBoard() { const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate() useEffect(() => { - console.log('YichensBoard: 开始加载数据') fetchTodayStats() }, []) const fetchTodayStats = async () => { try { - console.log('YichensBoard: 请求 /api/yichens/stats/today') const res = await fetch('/api/yichens/stats/today') if (!res.ok) throw new Error('stats API error: ' + res.status) const data = await res.json() - console.log('YichensBoard: stats data', data) setTodayStats(data) } catch(e) { console.error('YichensBoard: fetchTodayStats error', e) @@ -49,7 +46,6 @@ export default function YichensBoard() { if (searchKw && searchKw.trim()) url += '&keyword=' + encodeURIComponent(searchKw.trim()) try { - console.log('YichensBoard: 请求 posts', url) const res = await fetch(url) if (!res.ok) throw new Error('posts API error: ' + res.status) let data = await res.json() || [] @@ -57,7 +53,6 @@ export default function YichensBoard() { if (!Array.isArray(data)) { data = data.posts || data.data || [] } - console.log('YichensBoard: posts data count', data.length) if (currentCat && Array.isArray(data)) { if (currentCat === '龙') { data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞'))) @@ -73,7 +68,6 @@ export default function YichensBoard() { if (!Array.isArray(data)) { data = data.posts || data.data || [] } - console.log('YichensBoard: posts data count', data.length) if (currentCat && Array.isArray(data)) { if (currentCat === '龙') { data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞'))) @@ -143,16 +137,16 @@ export default function YichensBoard() { 📈 连体钞/纪念钞 {dateStr}
- { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); }} /> - { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); }} /> + { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); }} /> + { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); }} /> { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); }} /> - { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); }} /> + { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); }} />
📊 今日分类统计
-
龙: {todayStats.dragons || 0} | 马: {todayStats.horses || 0} | 蛇: {todayStats.snakes || 0} | 天马: {todayStats.tianma || 0}
+
龙: {todayStats.dragons || 0} | 马: {todayStats.horses || 0} | 蛇: {todayStats.snakes || 0} | 天马: {todayStats.tianma || 0}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index a2b9451..adeb1fe 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -29,7 +29,7 @@ function updateHtmlTitle() { let htmlContent = readFileSync(htmlPath, 'utf-8') // 替换 甲辰收藏 vXXX htmlContent = htmlContent.replace( - /甲辰收藏 v[\d.]+<\/title>/, + /<title>甲辰收藏 v[=\d.]+<\/title>/, '<title>甲辰收藏 v' + APP_VERSION + '' ) writeFileSync(htmlPath, htmlContent, 'utf-8')