v1.2.88 optimize and fix batch

This commit is contained in:
socool 2026-04-12 09:49:18 +08:00
parent 3ae188da93
commit 1975a683d4
15 changed files with 1163 additions and 74 deletions

View File

@ -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

View File

@ -1 +1 @@
1.2.87
1.2.88

103
backend/alembic.ini Normal file
View File

@ -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

78
backend/alembic/env.py Normal file
View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)
):
"""获取当前用户信息"""
if not current_user:
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="请使用正确的依赖注入"
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": "密码重置成功"}

View File

@ -63,10 +63,15 @@ 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
for attempt in range(max_retries):
# 查询当前用户的非空编码(不与其他用户混算)- 使用行锁防止并发
user_codes = db.query(Collection.f01_02_code).filter(
Collection.f01_02_code.isnot(None),
@ -89,9 +94,29 @@ def generate_code(version: str, user_id: str, db: Session) -> str:
# 如果超过9999使用5位否则使用4位
if next_num > 9999:
return str(next_num).zfill(5)
candidate_code = str(next_num).zfill(5)
else:
return str(next_num).zfill(4)
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})")
# 所有重试都失败了,返回一个带时间戳的唯一编码
import time
fallback_code = f"{int(time.time()) % 100000:05d}"
logger.error(f"编码生成重试次数耗尽使用fallback编码: {fallback_code}")
return fallback_code
@router.get("/next-code")

View File

@ -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"
# 更详细的解析提示词

View File

@ -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 } }

View File

@ -1 +1 @@
1.2.87
1.2.88

350
docs/DEPLOYMENT.md Normal file
View File

@ -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*

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "jiachenlong-frontend",
"version": "1.2.87",
"version": "1.2.88",
"private": true,
"description": "甲辰藏品管理系统 - 移动端前端",
"scripts": {