v1.2.89: 完善参考价功能 - 首页/行情页统计卡片整合网络参考价,分类映射优化,数据源说明图例

This commit is contained in:
socool 2026-04-12 14:24:48 +08:00
parent 1975a683d4
commit 31c859323f
26 changed files with 1591 additions and 200 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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) # 期望价格区间

View File

@ -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": "密码重置成功"}

View File

@ -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
# 默认使用今天

View File

@ -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失败使用本地文件

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
# tests package

122
backend/tests/conftest.py Normal file
View File

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

238
backend/tests/test_auth.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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> 绿色为精确成交数据统计 &nbsp;&nbsp; <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>

View File

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

View File

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

View File

@ -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号', '永恒号', '天马号', '天马王', '金山号', '金山王', '钻石号', '朦胧号', '朦胧王', '金马号', '金马王', '倒置号', '圆圆号']
// ->44->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> 绿色为精确成交数据统计 &nbsp;&nbsp; <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' }}>

View File

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

View File

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

View File

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