v1.2.88 optimize and fix batch
This commit is contained in:
parent
3ae188da93
commit
1975a683d4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.2.87
|
||||
1.2.88
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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": "密码重置成功"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
# 当前用户最大号 +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)
|
||||
# 如果超过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})")
|
||||
|
||||
# 所有重试都失败了,返回一个带时间戳的唯一编码
|
||||
import time
|
||||
fallback_code = f"{int(time.time()) % 100000:05d}"
|
||||
logger.error(f"编码生成重试次数耗尽,使用fallback编码: {fallback_code}")
|
||||
return fallback_code
|
||||
|
||||
|
||||
@router.get("/next-code")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
# 更详细的解析提示词
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1.2.87
|
||||
1.2.88
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "jiachenlong-frontend",
|
||||
"version": "1.2.87",
|
||||
"version": "1.2.88",
|
||||
"private": true,
|
||||
"description": "甲辰藏品管理系统 - 移动端前端",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue