diff --git a/backend/.env.example b/backend/.env.example index 9f5443e..5f674bd 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,26 +1,40 @@ -# 数据库配置 -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac +# 甲辰藏品系统环境变量配置示例 +# 复制此文件为 .env 并填入实际值 -# JWT配置 -SECRET_KEY=your-production-secret-key-change-this -ACCESS_TOKEN_EXPIRE_MINUTES=60 +# ========== 数据库配置 ========== +DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong -# 阿里云 DashScope OCR API(必须配置!) -DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f +# ========== JWT认证配置 ========== +SECRET_KEY=your-super-secret-key-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=10080 -# 阿里云 OSS配置 -OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG -OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1 -OSS_BUCKET_NAME=jiachenlong-oss +# ========== 阿里云百炼AI (DASHSCOPE) ========== +# 用于AI批量解析行情数据 +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_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 -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 +# ========== Redis (可选,用于限流和缓存) ========== +# REDIS_URL=redis://localhost:6379/0 -# 服务配置 -PORT=3000 -HOST=0.0.0.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 + +# ========== 管理员账号 ========== +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 diff --git a/backend/VERSION b/backend/VERSION index b621ce2..830fe90 100644 --- a/backend/VERSION +++ b/backend/VERSION @@ -1 +1 @@ -1.2.87 +1.2.88 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..2ddb286 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,103 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..30bca5c --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,78 @@ +# Alembic Environment Configuration +from logging.config import fileConfig +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sqlalchemy import engine_from_config +from sqlalchemy import pool +from alembic import context + +# Import your models' Base +from app.core.database import Base +from app.models.models import User, Collection, CollectionImage, Operation, CustomField, Information, InformationComment, InformationContactView, InformationLike + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/versions/001_add_information_fields.py b/backend/alembic/versions/001_add_information_fields.py new file mode 100644 index 0000000..498d0a3 --- /dev/null +++ b/backend/alembic/versions/001_add_information_fields.py @@ -0,0 +1,79 @@ +"""001_add_information_fields + +添加资讯表新字段:包装、评级信息、分类、行情编号 + +Revision ID: 001 +Revises: +Create Date: 2026-04-12 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """添加information表的新字段""" + + # 检查字段是否存在,如果不存在则添加 + # packaging 包装类型 + if not _column_exists('information', 'packaging'): + op.add_column('information', sa.Column('packaging', sa.String(50), nullable=True)) + + # is_graded 是否评级 + if not _column_exists('information', 'is_graded'): + op.add_column('information', sa.Column('is_graded', sa.Boolean(), server_default='false', nullable=True)) + + # grading_company 评级公司 + if not _column_exists('information', 'grading_company'): + op.add_column('information', sa.Column('grading_company', sa.String(100), nullable=True)) + + # grading_score 评级分数 + if not _column_exists('information', 'grading_score'): + op.add_column('information', sa.Column('grading_score', sa.String(50), nullable=True)) + + # category 分类 + if not _column_exists('information', 'category'): + op.add_column('information', sa.Column('category', sa.String(100), nullable=True)) + + # deal_no 行情编号 + if not _column_exists('information', 'deal_no'): + op.add_column('information', sa.Column('deal_no', sa.String(50), nullable=True)) + op.create_index('ix_information_deal_no', 'information', ['deal_no']) + + +def downgrade() -> None: + """删除information表的新字段""" + if _column_exists('information', 'deal_no'): + op.drop_index('ix_information_deal_no', 'information') + op.drop_column('information', 'deal_no') + + if _column_exists('information', 'category'): + op.drop_column('information', 'category') + + if _column_exists('information', 'grading_score'): + op.drop_column('information', 'grading_score') + + if _column_exists('information', 'grading_company'): + op.drop_column('information', 'grading_company') + + if _column_exists('information', 'is_graded'): + op.drop_column('information', 'is_graded') + + if _column_exists('information', 'packaging'): + op.drop_column('information', 'packaging') + + +def _column_exists(table: str, column: str) -> bool: + """检查列是否存在""" + from sqlalchemy import inspect + from app.core.database import engine + + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns(table)] + return column in columns diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..58ea330 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,71 @@ +# 配置管理 - 集中管理所有环境变量和敏感配置 +import os +from functools import lru_cache +from typing import Optional + + +class Settings: + """应用配置类""" + + def __init__(self): + # 阿里云百炼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.DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong") + + # JWT配置 + self.SECRET_KEY: str = os.getenv("SECRET_KEY", "jiachenlong-secret-key-change-in-production") + self.ALGORITHM: str = os.getenv("ALGORITHM", "HS256") + self.ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天 + + # 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") + self.OSS_ENDPOINT: str = os.getenv("OSS_ENDPOINT", "oss-cn-hangzhou.aliyuncs.com") + + # Redis配置 (可选,用于限流和缓存) + self.REDIS_URL: Optional[str] = os.getenv("REDIS_URL") + + # API Rate Limiting + self.RATE_LIMIT_ENABLED: bool = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true" + self.RATE_LIMIT_SMS_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_SMS_PER_MINUTE", "3")) + self.RATE_LIMIT_OCR_PER_MINUTE: int = int(os.getenv("RATE_LIMIT_OCR_PER_MINUTE", "10")) + 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") + + def get_dashscope_api_key(self) -> str: + """获取阿里云百炼API Key""" + if not self.DASHSCOPE_API_KEY: + raise ValueError("DASHSCOPE_API_KEY 环境变量未设置") + return self.DASHSCOPE_API_KEY + + def get_sms_config(self) -> dict: + """获取短信配置""" + return { + "access_key_id": self.SMS_ACCESS_KEY_ID, + "access_key_secret": self.SMS_ACCESS_KEY_SECRET, + "sign_name": self.SMS_SIGN_NAME, + "template_code": self.SMS_TEMPLATE_CODE, + } + + +@lru_cache() +def get_settings() -> Settings: + """获取单例配置实例""" + return Settings() + + +# 导出便捷访问 +settings = get_settings() diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py new file mode 100644 index 0000000..2e3e2a4 --- /dev/null +++ b/backend/app/core/rate_limit.py @@ -0,0 +1,165 @@ +# 请求限流机制 - 简单内存限流器 +import time +from collections import defaultdict +from functools import wraps +from typing import Callable, Optional +from fastapi import HTTPException, Request +from app.core.config import settings + + +class RateLimiter: + """简单内存限流器""" + + def __init__(self): + self._requests = defaultdict(list) + self._enabled = settings.RATE_LIMIT_ENABLED + + def _cleanup(self, key: str, window: int): + """清理过期的请求记录""" + now = time.time() + self._requests[key] = [ + ts for ts in self._requests[key] + if now - ts < window + ] + + def is_allowed(self, key: str, max_requests: int, window: int = 60) -> tuple[bool, int]: + """ + 检查是否允许请求 + + Args: + key: 限流键 (如 IP、用户ID、手机号等) + max_requests: 时间窗口内最大请求数 + window: 时间窗口秒数 + + Returns: + (是否允许, 剩余请求数) + """ + if not self._enabled: + return True, max_requests + + now = time.time() + self._cleanup(key, window) + + current_count = len(self._requests[key]) + remaining = max(0, max_requests - current_count) + + if current_count >= max_requests: + return False, 0 + + self._requests[key].append(now) + return True, remaining - 1 + + def get_retry_after(self, key: str, window: int = 60) -> int: + """获取重试前需要等待的秒数""" + if not self._requests[key]: + return 0 + + oldest = min(self._requests[key]) + now = time.time() + elapsed = now - oldest + remaining = window - elapsed + + return max(0, int(remaining)) + + +# 全局限流器实例 +_rate_limiter = RateLimiter() + + +def get_rate_limiter() -> RateLimiter: + """获取限流器实例""" + return _rate_limiter + + +def rate_limit(key_func: Callable[[Request], str], max_requests: int, window: int = 60): + """ + 限流装饰器 + + Args: + key_func: 从请求中提取限流键的函数 + max_requests: 最大请求数 + window: 时间窗口(秒) + + Example: + @rate_limit(lambda r: r.client.host, 10, 60) + async def my_endpoint(): + ... + """ + def decorator(func): + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + limiter = get_rate_limiter() + key = key_func(request) + + allowed, remaining = limiter.is_allowed(key, max_requests, window) + + if not allowed: + retry_after = limiter.get_retry_after(key, window) + raise HTTPException( + status_code=429, + detail=f"请求过于频繁,请 {retry_after} 秒后重试", + headers={"Retry-After": str(retry_after)} + ) + + response = await func(request, *args, **kwargs) + + # 如果返回的是 Response 对象,添加限流头 + if hasattr(response, 'headers'): + response.headers['X-RateLimit-Remaining'] = str(remaining) + response.headers['X-RateLimit-Limit'] = str(max_requests) + + return response + + # 对于非 async 函数 + if not hasattr(wrapper, '__wrapped__'): + @wraps(func) + def sync_wrapper(*args, **kwargs): + return func(*args, **kwargs) + return sync_wrapper + + return wrapper + return decorator + + +def rate_limit_by_ip(max_requests: int = 60, window: int = 60): + """按IP限流的装饰器""" + return rate_limit(lambda r: r.client.host if r.client else "unknown", max_requests, window) + + +def rate_limit_by_phone(phone: str, max_requests: int, window: int = 60) -> bool: + """ + 按手机号限流(用于短信发送等场景) + + Returns: + 是否允许发送 + """ + limiter = get_rate_limiter() + allowed, _ = limiter.is_allowed(f"phone:{phone}", max_requests, window) + return allowed + + +def rate_limit_sms(): + """短信限流装饰器工厂""" + def key_func(request: Request) -> str: + # 尝试从body获取手机号 + import json + try: + body = json.loads(request.body.decode()) + phone = body.get("phone", "") + except: + phone = "" + return f"phone:{phone}" if phone else request.client.host + + return rate_limit(key_func, settings.RATE_LIMIT_SMS_PER_MINUTE, 60) + + +def rate_limit_ocr(): + """OCR接口限流装饰器""" + return rate_limit(lambda r: r.client.host if r.client else "unknown", + settings.RATE_LIMIT_OCR_PER_MINUTE, 60) + + +def rate_limit_batch(): + """批量解析接口限流装饰器""" + return rate_limit(lambda r: r.client.host if r.client else "unknown", + settings.RATE_LIMIT_BATCH_PER_MINUTE, 60) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 0c6fe73..11c9650 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -162,12 +162,43 @@ def login( @router.get("/me", response_model=UserResponse) def get_current_user_info( - current_user: User = Depends(lambda: None) + current_user: User = Depends(get_current_user) ): """获取当前用户信息""" - raise HTTPException( - status_code=status.HTTP_501_NOT_IMPLEMENTED, - detail="请使用正确的依赖注入" + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未登录" + ) + + return UserResponse( + f99_90_id=current_user.f99_90_id, + f01_01_name=current_user.f01_01_name, + user_code=current_user.user_code, + 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, + gender=current_user.f01_07_gender, + birthday=current_user.f01_08_birthday, + region=current_user.f01_09_region, + realnameVerified=current_user.f01_10_realname_verified or False, + points=current_user.f99_100_points or 0, + balance=current_user.f01_11_balance or 0, + totalAmount=current_user.f01_12_total_amount or 0, + inviteCode=current_user.f01_13_invite_code, + invitedCount=current_user.f99_101_invited_count or 0, + created_at=current_user.f99_92_created_at, + updated_at=current_user.f99_93_updated_at, ) @@ -229,14 +260,78 @@ def send_verification_code( @router.post("/verify-code") def verify_code( phone: str = Body(...), - code: str = Body(..., min_length=6, max_length=6) + code: str = Body(..., min_length=6, max_length=6), + new_password: str = Body(None, min_length=6), # 可选:新密码(用于重置密码) + purpose: str = Body("verify"), # verify | reset_password + db: Session = Depends(get_db) ): - """验证短信验证码(仅验证,不执行后续操作)""" + """ + 验证短信验证码 + - purpose=verify: 仅验证验证码有效性 + - purpose=reset_password: 验证并重置密码(需提供new_password) + """ from app.services.sms import verify_code as check_code + from app.core.auth import get_password_hash is_valid = check_code(phone, code) - if is_valid: - return {"success": True, "message": "验证成功"} - else: + if not is_valid: return {"success": False, "message": "验证码错误或已过期"} + + # 验证成功后处理 + if purpose == "reset_password": + if not new_password: + return {"success": False, "message": "重置密码需要提供新密码"} + + # 查找该手机号的用户 + user = db.query(User).filter(User.phone == phone).first() + if not user: + return {"success": False, "message": "该手机号未注册"} + + # 更新密码 + user.password = get_password_hash(new_password) + db.commit() + + return {"success": True, "message": "密码重置成功"} + + return {"success": True, "message": "验证成功"} + + +@router.post("/reset-password") +def reset_password( + phone: str = Body(...), + code: str = Body(..., min_length=6, max_length=6), + new_password: str = Body(..., min_length=6), + db: Session = Depends(get_db) +): + """ + 重置密码(通过短信验证码) + + 流程: + 1. 调用 /send-verification-code 发送验证码(purpose=reset_password) + 2. 调用 /reset-password 重置密码 + """ + from app.services.sms import verify_code as check_code + from app.core.auth import get_password_hash + + # 验证验证码 + is_valid = check_code(phone, code) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="验证码错误或已过期" + ) + + # 查找该手机号的用户 + user = db.query(User).filter(User.phone == phone).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="该手机号未注册" + ) + + # 更新密码 + user.password = get_password_hash(new_password) + db.commit() + + return {"message": "密码重置成功"} diff --git a/backend/app/routers/collections.py b/backend/app/routers/collections.py index d04bb8f..9b779d8 100644 --- a/backend/app/routers/collections.py +++ b/backend/app/routers/collections.py @@ -63,35 +63,60 @@ def to_camel_case(data: dict) -> dict: # 编码生成函数 -def generate_code(version: str, user_id: str, db: Session) -> str: - """自动生成藏品编号 - 按用户独立编码""" +def generate_code(version: str, user_id: str, db: Session, max_retries: int = 5) -> str: + """ + 自动生成藏品编号 - 按用户独立编码 + 支持并发安全:使用唯一约束 + 重试机制 + """ import re + from sqlalchemy.exc import IntegrityError - # 查询当前用户的非空编码(不与其他用户混算)- 使用行锁防止并发 - 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() + for attempt in range(max_retries): + # 查询当前用户的非空编码(不与其他用户混算)- 使用行锁防止并发 + 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: + candidate_code = str(next_num).zfill(5) + else: + candidate_code = str(next_num).zfill(4) + + # 检查该编码是否已存在(双重检查) + existing = db.query(Collection).filter( + Collection.f01_02_code == candidate_code, + Collection.f99_91_user_id == user_id + ).first() + + if not existing: + # 编码可用 + return candidate_code + + # 如果已存在,继续循环尝试下一个编号 + # (理论上不应该走到这里,因为with_for_update锁住了现有记录) + logger.warning(f"编码 {candidate_code} 已被占用,尝试下一个编号 (attempt {attempt + 1})") - 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) + # 所有重试都失败了,返回一个带时间戳的唯一编码 + import time + fallback_code = f"{int(time.time()) % 100000:05d}" + logger.error(f"编码生成重试次数耗尽,使用fallback编码: {fallback_code}") + return fallback_code @router.get("/next-code") diff --git a/backend/app/routers/information.py b/backend/app/routers/information.py index b4d71cd..5b096d8 100644 --- a/backend/app/routers/information.py +++ b/backend/app/routers/information.py @@ -114,7 +114,7 @@ def get_information_list( db: Session = Depends(get_db), response: Response = None ): - """获取资讯列表(公开,无需登录)""" + """获取资讯列表(公开,无需登录)- 优化版,解决N+1查询问题""" query = db.query(Information).options( joinedload(Information.user), joinedload(Information.collection) @@ -140,13 +140,55 @@ def get_information_list( offset = (page - 1) * page_size items = query.offset(offset).limit(page_size).all() + # ========== N+1优化:提前批量获取数据 ========== + + # 1. 收集所有需要匹配的expect_number + seek_items = [item for item in items if item.info_type == 'seek' and item.expect_number] + expect_numbers = [item.expect_number for item in seek_items] + + # 2. 批量获取用户藏品(只查一次) + user_collections_map = {} # {expect_number: count} + if current_user and expect_numbers: + # 预获取用户所有在库藏品 + user_collections = db.query(Collection.f02_10_prefix_serial).filter( + Collection.f99_91_user_id == current_user.f99_90_id, + Collection.f01_04_status == "in_collection" + ).all() + user_serial_list = [c[0] for c in user_collections if c[0]] + + # 为每个expect_number计算匹配数量(内存中计算) + for expect_number in expect_numbers: + if not expect_number or len(expect_number) != 10 or not expect_number.startswith('J0'): + user_collections_map[expect_number] = 0 + continue + pattern = expect_number[2:] + count = 0 + for serial in user_serial_list: + if not serial or len(serial) < 8: + continue + if serial.startswith('J0'): + col_pattern = serial[2:10] if len(serial) >= 10 else serial[2:] + else: + col_pattern = serial[:8] + if match_pattern(col_pattern, pattern): + count += 1 + user_collections_map[expect_number] = count + + # 3. 批量获取coolbot数据(只查一次) + coolbot_collections_map = {} # {expect_number: count} + if expect_numbers: + coolbot_collections_map = batch_match_collections_count_from_coolbot(expect_numbers) + # 转换结果 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) + network_matched_count = 0 + if item.info_type == 'seek' and item.expect_number: + if current_user: + matched_count = user_collections_map.get(item.expect_number, 0) + network_matched_count = coolbot_collections_map.get(item.expect_number, 0) result.append(InformationResponse( id=item.id, @@ -183,7 +225,7 @@ def get_information_list( category=item.category, deal_no=item.deal_no, matched_count=matched_count, - network_matched_count=match_collections_count_from_coolbot(item.expect_number) if item.info_type == 'seek' and item.expect_number else 0, + network_matched_count=network_matched_count, )) # 获取总数并设置响应头 @@ -287,6 +329,75 @@ def match_collections_count_from_coolbot(expect_number: str) -> int: return 0 +def batch_match_collections_count_from_coolbot(expect_numbers: List[str]) -> dict: + """ + 批量计算匹配藏品数量(从coolbot_data数据库) + + Args: + expect_numbers: expect_number列表 + + Returns: + {expect_number: match_count} 字典 + """ + if not expect_numbers: + return {} + + # 过滤有效的expect_number并提取pattern + valid_patterns = {} # {expect_number: pattern} + for expect_number in expect_numbers: + if not expect_number or len(expect_number) != 10 or not expect_number.startswith('J0'): + valid_patterns[expect_number] = None + continue + pattern = expect_number[2:] + if not pattern: + valid_patterns[expect_number] = None + else: + valid_patterns[expect_number] = pattern + + # 查询所有J0开头的藏品(只查一次) + query_all = 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%' + """) + + result_map = {} # {expect_number: count} + + try: + with coolbot_engine.connect() as conn: + result = conn.execute(query_all) + rows = result.fetchall() + + # 预提取所有藏品的pattern + crown_codes = [] + for row in rows: + crown_code = row[1] if len(row) > 1 else None + if crown_code and len(crown_code) >= 10: + crown_codes.append(crown_code[2:10]) # 去掉J0前缀后的8位 + + # 为每个expect_number计算匹配数量 + for expect_number, pattern in valid_patterns.items(): + if pattern is None: + result_map[expect_number] = 0 + continue + + count = 0 + for col_pattern in crown_codes: + if match_pattern(col_pattern, pattern): + count += 1 + result_map[expect_number] = count + + except Exception as e: + print(f"Error batch querying coolbot_data: {e}") + # 查询失败时返回0 + for expect_number in expect_numbers: + result_map[expect_number] = 0 + + return result_map + + 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: @@ -1234,8 +1345,9 @@ async def batch_parse_deals(text: str = Body(..., embed=True)): import json import re - # 使用阿里云百炼Coding Plan API - api_key = "sk-sp-d5ce68bb203e48ca857c2aea25255b26" + # 使用阿里云百炼Coding Plan API - 从配置读取 + from app.core.config import settings + api_key = settings.get_dashscope_api_key() base_url = "https://coding.dashscope.aliyuncs.com/v1" # 更详细的解析提示词 diff --git a/backend/app/services/sms.py b/backend/app/services/sms.py index f067142..787e9e9 100644 --- a/backend/app/services/sms.py +++ b/backend/app/services/sms.py @@ -6,13 +6,10 @@ 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"), -} +# 阿里云短信配置 - 从 config.py 读取(不再硬编码默认值) +from app.core.config import settings + +SMS_CONFIG = settings.get_sms_config() # 验证码缓存(生产环境建议用Redis) # 格式: { phone: { code: "123456", expire: 1234567890 } } diff --git a/config/VERSION b/config/VERSION index b621ce2..830fe90 100644 --- a/config/VERSION +++ b/config/VERSION @@ -1 +1 @@ -1.2.87 +1.2.88 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..811ed7d --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,350 @@ +# 甲辰藏品管理系统 部署手册 + +## 环境概览 + +### 服务器信息 + +| 环境 | 前端 | 后端 | SSH密码 | +|------|------|------|--------| +| **D测试环境** | 114.55.137.168, 172.26.30.32 (内网) | 47.111.184.210, 172.26.30.33 (内网) | Jiachend123 | +| **101生产环境** | 47.98.171.101 (80端口) | 47.98.171.101:8080 | Coolbot123 | + +### 数据库信息 + +| 用途 | 数据库 | 地址 | 端口 | 用户名 | 密码 | +|------|--------|------|------|--------|------| +| **主数据库** | jiachenlong | pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com | 5432 | jiachenlong | Passwd1@3 | +| **一尘数据库** | coolbot_data | pgm-bp1t1008h019ez6c.pg.rds.aliyuncs.com | 5432 | coolbot | Coolbot123 | + +### OSS存储 +- Bucket: jiachenlong-oss + +--- + +## 部署流程 + +### 1. 获取代码 + +```bash +# 克隆仓库 +git clone http://caibotd:Caibotd123@101.37.160.219/root/jiachenlong.git + +# 切换到目标版本 +cd jiachenlong +git checkout v1.2.78 # 或指定版本tag +``` + +### 2. 构建前端 + +```bash +cd frontend +npm install +npm run build +``` + +### 3. 配置后端环境变量 + +在后端服务器创建 `.env` 文件: + +```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 + +# 一尘数据库 - 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 + +# 应用配置 +SECRET_KEY=jiachenlong-production-secret-key-2026 +DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong +EOF +``` + +**注意**:密码中的特殊字符 `@` 必须URL编码为 `%40` + +### 4. 上传文件到服务器 + +```bash +# 上传前端 +scp -r frontend/dist/* root@47.111.184.210:/var/www/html/ + +# 上传后端 +scp -r backend/app root@47.111.184.210:/root/jiachenlong/backend/ +``` + +### 5. 配置Nginx + +D测试环境配置示例 (`/etc/nginx/nginx.conf`): + +```nginx +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 80; + server_name _; + + root /var/www/html; + index index.html; + + # 前端静态文件 (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_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # 上传文件代理 + location /uploads/ { + proxy_pass http://127.0.0.1:3000; + } + } +} +``` + +### 6. 启动后端服务 + +```bash +# 激活conda环境 +source /opt/conda/etc/profile.d/conda.sh +conda activate py312 + +# 设置Python路径 +export PYTHONPATH=/root/jiachenlong/backend + +# 启动后端 +cd /root/jiachenlong/backend +nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > logs/api.log 2>&1 & +``` + +### 7. 重启Nginx + +```bash +# 测试配置 +nginx -t + +# 重载配置 +nginx -s reload + +# 或完全重启 +killall nginx +nginx +``` + +--- + +## 常见问题与解决方案 + +### 1. 后端启动失败:`address already in use` + +**原因**:端口被占用 + +**解决**: +```bash +# 查看占用进程 +fuser 3000/tcp + +# 杀死占用进程 +fuser -k 3000/tcp +``` + +### 2. 后端启动失败:`ModuleNotFoundError: No module named 'xxx'` + +**原因**:Python依赖缺失 + +**解决**: +```bash +pip install -r requirements.txt +# 或安装特定依赖 +pip install bcrypt passlib python-jose python-multipart email-validator oss2 Pillow +``` + +### 3. API返回500错误:`name 'response' is not defined` + +**原因**:FastAPI函数参数中使用了`response: Response = None`,但该参数已被废弃或删除 + +**解决**:检查并修复 `app/routers/*.py` 文件中的 `response.headers` 使用 + +```python +# 找到类似代码 +response.headers['X-Total-Pages'] = str(total_pages) + +# 删除这些行(它们不是必需的) +``` + +### 4. 数据库连接失败:`could not translate host name` + +**原因**:主机名未正确解析 + +**解决**:确保 `.env` 中的数据库地址使用完整域名 +```bash +# 错误 +DB_HOST=pgm-bp1t5w248t7s1pvr + +# 正确 +DB_HOST=pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com +``` + +### 5. 数据库连接失败:密码中特殊字符问题 + +**原因**:密码中的 `@` 符号导致URL解析错误 + +**解决**:URL编码密码 +```bash +# 密码 Passwd1@3 编码为 Passwd1%403 +DATABASE_URL=postgresql://jiachenlong:Passwd1%403@... +``` + +### 6. 前端403 Forbidden + +**原因**:文件权限问题 + +**解决**: +```bash +chown -R nginx:nginx /var/www/html +chmod -R 755 /var/www/html +``` + +### 7. Nginx配置错误:`server directive is not allowed here` + +**原因**:nginx配置文件放在HTTP块外面 + +**解决**:确保server块在http块内部 + +### 8. 前端显示旧版本 + +**原因**:浏览器缓存 + +**解决**:强制刷新 (Ctrl+Shift+R) 或清除缓存 + +--- + +## 代码修改注意事项 + +### information.py 常见问题 + +1. **Response参数问题** + - 函数签名中的 `response: Response = None` 会导致 `NameError` + - 如果不需要修改响应头,应该直接删除这个参数 + - 同时删除函数体内使用 `response.headers` 的代码 + +2. **处理方法**: +```python +# 错误写法 +def get_list(response: Response = None): + response.headers['X-Total'] = '100' # 会报错 + +# 正确写法 +def get_list(): + # 不使用response.headers,或使用Response参数的正确方式 + pass +``` + +### collections.py 统计接口 + +- `get_current_user` 可能返回 `None` +- 访问 `current_user.role` 前必须检查 `current_user` 是否为 `None` + +```python +# 错误 +if current_user.role == "admin": + +# 正确 +if not current_user or current_user.role != "admin": + return {"totalCount": 0} +``` + +--- + +## 改进建议 + +### 1. 环境变量配置 +- [ ] 使用 `python-dotenv` 管理环境变量 +- [ ] 生产环境与测试环境配置分离 +- [ ] 敏感信息(密码、密钥)使用环境变量而非硬编码 + +### 2. 部署脚本化 +- [ ] 编写自动化部署脚本 (deploy.sh) +- [ ] 包含数据库迁移步骤 +- [ ] 部署前自动备份 + +### 3. 健康检查 +- [ ] 添加后端 `/health` 端点 +- [ ] 配置监控告警 + +### 4. 日志管理 +- [ ] 统一日志格式 +- [ ] 日志轮转配置 +- [ ] 错误日志实时告警 + +### 5. 数据库迁移 +- [ ] 使用 Alembic 管理数据库版本 +- [ ] 编写数据迁移脚本 +- [ ] 部署前检查数据库schema是否匹配 + +### 6. 代码质量 +- [ ] CI/CD 自动化测试 +- [ ] 代码审查流程 +- [ ] 部署前在测试环境验证 + +--- + +## 快速命令参考 + +```bash +# 查看后端进程 +ps aux | grep uvicorn | grep -v grep + +# 查看后端日志 +tail -f /root/jiachenlong/backend/logs/api.log + +# 重启后端 +pkill -f 'uvicorn app.main:app' +cd /root/jiachenlong/backend +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 +curl http://localhost:3000/api/collections/stats +curl http://localhost:3000/api/information/list?info_type=seek + +# Nginx相关 +nginx -t # 测试配置 +nginx -s reload # 重载配置 +nginx -s stop # 停止 +killall nginx && nginx # 完全重启 +``` + +--- + +*最后更新:2026-04-09* diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 79e65c8..3d1484e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "jiachenlong-frontend", - "version": "1.2.81", + "version": "1.2.87", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jiachenlong-frontend", - "version": "1.2.81", + "version": "1.2.87", "dependencies": { "axios": "^1.7.9", "react": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 7ce7840..7502abc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jiachenlong-frontend", - "version": "1.2.87", + "version": "1.2.88", "private": true, "description": "甲辰藏品管理系统 - 移动端前端", "scripts": {