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