新增用户管理字段: 会员等级/积分/余额/登录次数等
This commit is contained in:
parent
19e2ae6b0e
commit
84cd53143b
|
|
@ -1,5 +1,5 @@
|
|||
# 数据库模型 - 使用字段编码
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
|
|
@ -26,9 +26,25 @@ class User(Base):
|
|||
bio = Column(Text, nullable=True)
|
||||
password = Column(String(255), nullable=False)
|
||||
role = Column(String(50), default="user")
|
||||
# last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
# 时间字段
|
||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
# 新增字段
|
||||
f99_94_level = Column(String(20), default="青铜") # 会员等级
|
||||
f99_95_ai_count = Column(Integer, default=0) # ai识别次数
|
||||
f99_96_search_count = Column(Integer, default=0) # 寻号使用次数
|
||||
f99_97_collection_count = Column(Integer, default=0) # 藏品数量
|
||||
f01_06_phone_verified = Column(Boolean, default=False) # 手机号已核验
|
||||
f99_98_login_count = Column(Integer, default=0) # 登录次数
|
||||
f99_99_last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
|
||||
f01_07_gender = Column(String(10), nullable=True) # 性别
|
||||
f01_08_birthday = Column(Date, nullable=True) # 生日
|
||||
f01_09_region = Column(String(100), nullable=True) # 地区
|
||||
f01_10_realname_verified = Column(Boolean, default=False) # 实名认证
|
||||
f99_100_points = Column(Integer, default=0) # 积分
|
||||
f01_11_balance = Column(Float, default=0) # 余额
|
||||
f01_12_total_amount = Column(Float, default=0) # 累计金额
|
||||
f01_13_invite_code = Column(String(20), nullable=True) # 邀请码
|
||||
|
||||
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
||||
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ def generate_user_code(db):
|
|||
return f"{num:04d}"
|
||||
except:
|
||||
pass
|
||||
return "0001"
|
||||
return "000501"
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
|
|
@ -36,6 +36,15 @@ 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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该手机号已被注册"
|
||||
)
|
||||
if user_data.email:
|
||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_email:
|
||||
|
|
|
|||
|
|
@ -34,6 +34,22 @@ class UserUpdate(BaseModel):
|
|||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
# 新增字段
|
||||
f99_94_level: Optional[str] = Field(None, alias="level")
|
||||
f99_95_ai_count: Optional[int] = Field(0, alias="aiCount")
|
||||
f99_96_search_count: Optional[int] = Field(0, alias="searchCount")
|
||||
f99_97_collection_count: Optional[int] = Field(0, alias="collectionCount")
|
||||
f01_06_phone_verified: Optional[bool] = Field(False, alias="phoneVerified")
|
||||
f99_98_login_count: Optional[int] = Field(0, alias="loginCount")
|
||||
f99_99_last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
||||
f01_07_gender: Optional[str] = Field(None, alias="gender")
|
||||
f01_08_birthday: Optional[datetime] = Field(None, alias="birthday")
|
||||
f01_09_region: Optional[str] = Field(None, alias="region")
|
||||
f01_10_realname_verified: Optional[bool] = Field(False, alias="realnameVerified")
|
||||
f99_100_points: Optional[int] = Field(0, alias="points")
|
||||
f01_11_balance: Optional[float] = Field(0, alias="balance")
|
||||
f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount")
|
||||
f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode")
|
||||
f99_90_id: str = Field(..., alias="id")
|
||||
f01_01_name: str = Field(..., alias="username")
|
||||
role: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.git
|
||||
.env
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
logs/*
|
||||
*.log
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 数据库配置
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY=your-production-secret-key-change-this
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
|
||||
# 阿里云 DashScope OCR API(必须配置!)
|
||||
DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
|
||||
|
||||
# 阿里云 OSS配置
|
||||
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
||||
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
||||
OSS_BUCKET_NAME=jiachenlong-oss
|
||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
# 阿里云短信配置(必须配置!)
|
||||
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
||||
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
||||
SMS_SIGN_NAME=苏州算力
|
||||
SMS_TEMPLATE_CODE=SMS_501590956
|
||||
|
||||
# 服务配置
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# FastAPI 后端 Docker 镜像
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 Python 依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建上传目录
|
||||
RUN mkdir -p uploads
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 3000
|
||||
|
||||
# 启动命令
|
||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# 后端服务 - FastAPI
|
||||
|
||||
## 启动方式
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 启动服务
|
||||
python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 --reload
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
|
||||
```bash
|
||||
# 后台运行
|
||||
nohup python -m uvicorn app.main:app --port 3000 --host 0.0.0.0 > /tmp/uvicorn.log 2>&1 &
|
||||
|
||||
# 或使用 systemd
|
||||
sudo systemctl start zodiac-backend
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```ini
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://postgres:密码@localhost:5432/zodiac
|
||||
|
||||
# JWT
|
||||
SECRET_KEY=你的密钥
|
||||
|
||||
# OCR API
|
||||
DASHSCOPE_API_KEY=sk-你的密钥
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
启动后访问:http://localhost:3000/docs
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
# 认证模块
|
||||
import os
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.models import User
|
||||
|
||||
# 配置
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
||||
|
||||
# HTTP Bearer 认证
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
try:
|
||||
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""生成密码哈希"""
|
||||
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建访问令牌"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""解码访问令牌"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
db: Session = Depends(lambda: SessionLocal())
|
||||
) -> User:
|
||||
"""获取当前用户"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="未提供认证信息",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id: str = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# 支持 MySQL, PostgreSQL
|
||||
DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
"postgresql://postgres:postgres@localhost:5432/zodiac"
|
||||
)
|
||||
|
||||
# 数据库引擎配置
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
echo=False
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话"""
|
||||
db = SessionLocal(expire_on_commit=False)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
# 统一错误处理
|
||||
from fastapi import FastAPI, Request, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from pydantic import ValidationError
|
||||
from app.core.logging_config import logger
|
||||
|
||||
# 错误码定义
|
||||
ERROR_CODES = {
|
||||
# 认证错误 (10-19)
|
||||
401: "E00010", # 未授权
|
||||
403: "E00014", # 禁止访问
|
||||
|
||||
# 验证错误 (20-29)
|
||||
400: "E00000", # 请求错误
|
||||
422: "E00000", # 验证错误
|
||||
|
||||
# 资源错误 (30-39)
|
||||
404: "E00033", # 资源不存在
|
||||
|
||||
# 服务器错误 (50-59)
|
||||
500: "E00003", # 服务器内部错误
|
||||
}
|
||||
|
||||
# 错误信息映射
|
||||
ERROR_MESSAGES = {
|
||||
"E00010": "未登录或登录已过期",
|
||||
"E00011": "用户名或密码错误",
|
||||
"E00012": "验证码错误",
|
||||
"E00014": "无权访问此资源",
|
||||
"E00015": "令牌无效或已过期",
|
||||
"E00020": "请输入用户名和密码",
|
||||
"E00021": "用户名至少 3 个字符",
|
||||
"E00022": "密码至少 6 个字符",
|
||||
"E00023": "用户名已存在",
|
||||
"E00024": "邮箱已被注册",
|
||||
"E00030": "藏品名称不能为空",
|
||||
"E00031": "藏品名称至少 2 个字符",
|
||||
"E00032": "藏品分类不能为空",
|
||||
"E00033": "藏品不存在",
|
||||
"E00034": "禁止重复:此冠字号已存在",
|
||||
"E00035": "成本价格必须>=0",
|
||||
"E00036": "目标价格必须>=0",
|
||||
"E00037": "发行年份必须是 4 位数字",
|
||||
"E00040": "请选择图片文件",
|
||||
"E00041": "图片尺寸太小,无法识别",
|
||||
"E00042": "OCR 识别失败,请重试",
|
||||
"E00050": "仅管理员可访问",
|
||||
"E00051": "用户不存在",
|
||||
"E00000": "请求失败",
|
||||
"E00001": "网络连接失败",
|
||||
"E00003": "服务器内部错误",
|
||||
}
|
||||
|
||||
def setup_error_handlers(app: FastAPI):
|
||||
"""设置全局错误处理器"""
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request: Request, exc: HTTPException):
|
||||
"""处理 HTTP 异常"""
|
||||
# 从 detail 中提取错误码
|
||||
detail = exc.detail
|
||||
error_code = ERROR_CODES.get(exc.status_code, "E00000")
|
||||
|
||||
# 如果 detail 已经包含错误码,直接使用
|
||||
if isinstance(detail, str) and detail.startswith("E"):
|
||||
parts = detail.split(":", 1)
|
||||
error_code = parts[0]
|
||||
message = parts[1].strip() if len(parts) > 1 else ERROR_MESSAGES.get(error_code, detail)
|
||||
else:
|
||||
message = ERROR_MESSAGES.get(error_code, detail if isinstance(detail, str) else "请求失败")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": error_code,
|
||||
"message": message,
|
||||
"status": exc.status_code
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
"""处理请求验证错误"""
|
||||
errors = exc.errors()
|
||||
if errors:
|
||||
error = errors[0]
|
||||
field = ".".join(str(x) for x in error.get("loc", []))
|
||||
msg = error.get("msg", "验证失败")
|
||||
|
||||
# 根据字段和消息匹配错误码
|
||||
# 先检查请求路径,区分用户接口和藏品接口
|
||||
path = request.url.path
|
||||
|
||||
if "cost_price" in field or "价格" in msg:
|
||||
error_code = "E00035"
|
||||
message = "成本价格必须>=0"
|
||||
elif "target_price" in field:
|
||||
error_code = "E00036"
|
||||
message = "目标价格必须>=0"
|
||||
elif "issue_year" in field or "年份" in msg:
|
||||
error_code = "E00037"
|
||||
message = "发行年份必须是 4 位数字"
|
||||
elif "name" in field:
|
||||
# 根据路径区分用户 name 和藏品 name
|
||||
if "/auth/" in path or "/users/" in path or "/admin/users/" in path:
|
||||
error_code = "E00021"
|
||||
message = "用户名至少 3 个字符"
|
||||
else:
|
||||
error_code = "E00031"
|
||||
message = "藏品名称至少 2 个字符"
|
||||
elif "category" in field:
|
||||
error_code = "E00032"
|
||||
message = "藏品分类不能为空"
|
||||
else:
|
||||
error_code = "E00000"
|
||||
message = f"{field}: {msg}"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": error_code,
|
||||
"message": message,
|
||||
"status": 422,
|
||||
"field": field
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content={
|
||||
"error": {
|
||||
"code": "E00000",
|
||||
"message": "验证失败",
|
||||
"status": 422
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc: Exception):
|
||||
"""处理 404 错误"""
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": {
|
||||
"code": "E00033",
|
||||
"message": "接口不存在",
|
||||
"status": 404
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request: Request, exc: Exception):
|
||||
"""处理未捕获的异常"""
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(f"未捕获异常:{str(exc)}\n{error_trace}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "E00003",
|
||||
"message": "服务器内部错误",
|
||||
"status": 500
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
# 日志系统配置
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
"""JSON 格式日志处理器"""
|
||||
|
||||
def format(self, record):
|
||||
log_data = {
|
||||
'timestamp': datetime.utcnow().isoformat() + 'Z',
|
||||
'level': record.levelname,
|
||||
'logger': record.name,
|
||||
'message': record.getMessage(),
|
||||
'trace_id': getattr(record, 'trace_id', str(uuid.uuid4())),
|
||||
'user_id': getattr(record, 'user_id', None),
|
||||
'request_id': getattr(record, 'request_id', str(uuid.uuid4())),
|
||||
'ip_address': getattr(record, 'ip_address', None),
|
||||
'duration_ms': getattr(record, 'duration_ms', None),
|
||||
}
|
||||
|
||||
# 添加额外字段
|
||||
if hasattr(record, 'data'):
|
||||
log_data['data'] = record.data
|
||||
|
||||
# 添加异常信息
|
||||
if record.exc_info:
|
||||
log_data['exception'] = self.formatException(record.exc_info)
|
||||
|
||||
return json.dumps(log_data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def setup_logging(
|
||||
log_file: str = 'logs/app.log',
|
||||
level: str = 'INFO',
|
||||
max_bytes: int = 10*1024*1024, # 10MB
|
||||
backup_count: int = 5
|
||||
):
|
||||
"""配置日志系统"""
|
||||
|
||||
# 创建日志目录
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
|
||||
# 根日志器
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(level)
|
||||
|
||||
# 清空现有处理器
|
||||
logger.handlers.clear()
|
||||
|
||||
# 文件处理器(带轮转)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setFormatter(JSONFormatter())
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 控制台处理器(仅开发环境)
|
||||
if os.getenv('ENV', 'development') == 'development':
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(JSONFormatter())
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
# 创建日志器实例
|
||||
logger = setup_logging()
|
||||
|
||||
|
||||
# 日志装饰器
|
||||
def log_operation(operation_name: str):
|
||||
"""记录操作日志的装饰器"""
|
||||
def decorator(func):
|
||||
import functools
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
duration = (time.time() - start_time) * 1000
|
||||
|
||||
logger.info(
|
||||
f"{operation_name} 成功",
|
||||
extra={
|
||||
'duration_ms': duration,
|
||||
'data': {'function': func.__name__}
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
logger.error(
|
||||
f"{operation_name} 失败:{str(e)}",
|
||||
extra={
|
||||
'duration_ms': duration,
|
||||
'data': {'function': func.__name__, 'error': str(e)}
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
raise
|
||||
return wrapper
|
||||
return decorator
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
# FastAPI 应用入口
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.database import engine, Base
|
||||
from app.core.logging_config import logger, setup_logging
|
||||
from app.core.error_handler import setup_error_handlers
|
||||
from app.middleware.logging import logging_middleware
|
||||
from app.routers import auth, collections, operations
|
||||
from app.routers import ocr as ocr_router
|
||||
from app.routers import users as users_router
|
||||
|
||||
# 版本信息 - 从 config/VERSION 文件读取
|
||||
def get_version():
|
||||
"""从 config/VERSION 文件读取版本号"""
|
||||
try:
|
||||
version_file = Path(__file__).parent.parent.parent / "config" / "VERSION"
|
||||
if version_file.exists():
|
||||
with open(version_file, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.startswith('VERSION='):
|
||||
return line.strip().split('=', 1)[1]
|
||||
except Exception as e:
|
||||
logger.error(f"读取 VERSION 文件失败:{e}")
|
||||
return "0.0.0" # 默认版本号
|
||||
|
||||
__version__ = get_version()
|
||||
__app_name__ = "甲辰收藏系统 FastAPI 后端"
|
||||
|
||||
# 启动时创建数据库表
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger.info(f"{__app_name__} v{__version__} 启动成功")
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title=__app_name__,
|
||||
version=__version__,
|
||||
description="生肖纪念钞收藏管理系统后端 API"
|
||||
)
|
||||
|
||||
# 设置全局错误处理器
|
||||
setup_error_handlers(app)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 生产环境应该限制域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 挂载静态文件目录(图片上传和项目静态资源)
|
||||
uploads_dir = "uploads"
|
||||
os.makedirs(uploads_dir, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx)
|
||||
# static_dir = Path(__file__).parent.parent.parent / "static"
|
||||
# if static_dir.exists():
|
||||
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||
|
||||
# 添加日志中间件
|
||||
app.middleware("http")(logging_middleware)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(auth.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(operations.router)
|
||||
app.include_router(ocr_router.router) # OCR 识别
|
||||
app.include_router(users_router.router) # 当前用户接口
|
||||
app.include_router(users_router.admin_router) # 管理员用户管理
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"name": __app_name__,
|
||||
"version": __version__,
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = int(os.getenv("PORT", "3000"))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
# 请求日志中间件 - 优化版
|
||||
import time
|
||||
import uuid
|
||||
from fastapi import Request, Response
|
||||
from app.core.logging_config import logger
|
||||
|
||||
|
||||
async def logging_middleware(request: Request, call_next):
|
||||
"""记录所有 API 请求的日志 - 优化版"""
|
||||
|
||||
# 生成请求 ID
|
||||
request_id = str(uuid.uuid4())
|
||||
start_time = time.time()
|
||||
|
||||
# 获取用户信息(如果已登录)
|
||||
user_id = None
|
||||
try:
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
user_id = "authenticated"
|
||||
except Exception as e:
|
||||
# 静默失败,不影响主流程
|
||||
pass
|
||||
|
||||
# 执行请求
|
||||
response_status = 500
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response_status = response.status_code
|
||||
except Exception as e:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
try:
|
||||
logger.error(
|
||||
f"API 请求异常:{request.method} {request.url.path}",
|
||||
extra={
|
||||
'request_id': request_id,
|
||||
'user_id': user_id,
|
||||
'ip_address': request.client.host if request.client else None,
|
||||
'duration_ms': duration,
|
||||
'data': {
|
||||
'method': request.method,
|
||||
'path': request.url.path,
|
||||
'query': str(request.query_params),
|
||||
'error': str(e)
|
||||
}
|
||||
},
|
||||
exc_info=True
|
||||
)
|
||||
except:
|
||||
pass # 日志记录失败不影响主流程
|
||||
raise
|
||||
|
||||
# 记录响应
|
||||
duration = (time.time() - start_time) * 1000
|
||||
|
||||
try:
|
||||
log_level = 'INFO'
|
||||
if response_status >= 500:
|
||||
log_level = 'ERROR'
|
||||
elif response_status >= 400:
|
||||
log_level = 'WARNING'
|
||||
|
||||
getattr(logger, log_level)(
|
||||
f"API 请求完成:{request.method} {request.url.path}",
|
||||
extra={
|
||||
'request_id': request_id,
|
||||
'user_id': user_id,
|
||||
'ip_address': request.client.host if request.client else None,
|
||||
'duration_ms': duration,
|
||||
'data': {
|
||||
'method': request.method,
|
||||
'path': request.url.path,
|
||||
'query': str(request.query_params),
|
||||
'status_code': response_status
|
||||
}
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
# 日志记录失败不影响主流程
|
||||
pass
|
||||
|
||||
# 在响应头中添加请求 ID
|
||||
try:
|
||||
response.headers['X-Request-ID'] = request_id
|
||||
except:
|
||||
pass
|
||||
|
||||
return response
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
# 数据库模型 - 使用字段编码
|
||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from app.core.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
def generate_uuid():
|
||||
"""生成 UUID 字符串"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
# f99 系统字段
|
||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
|
||||
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
|
||||
email = Column(String(255), unique=True, nullable=True, index=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
avatar = Column(String(500), nullable=True)
|
||||
address = Column(String(500), nullable=True)
|
||||
bio = Column(Text, nullable=True)
|
||||
password = Column(String(255), nullable=False)
|
||||
role = Column(String(50), default="user")
|
||||
# last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
||||
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
|
||||
custom_fields = relationship("CustomField", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Collection(Base):
|
||||
__tablename__ = "collections"
|
||||
|
||||
# f99 系统字段
|
||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
f99_91_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# f01 基本信息
|
||||
f01_01_name = Column(String(255), nullable=False)
|
||||
f01_02_code = Column(String(50), nullable=True, index=True)
|
||||
f01_03_category = Column(String(100), nullable=False, index=True)
|
||||
f01_04_status = Column(String(50), default="in_collection", index=True)
|
||||
f01_05_remark = Column(Text, nullable=True)
|
||||
|
||||
# f02 详细字段
|
||||
f02_10_prefix_serial = Column(String(50), nullable=True, index=True)
|
||||
f02_11_version = Column(String(100), nullable=True, index=True)
|
||||
f02_12_packaging = Column(String(100), nullable=True, index=True)
|
||||
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
|
||||
f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类
|
||||
|
||||
# f03 评级信息
|
||||
f03_20_is_graded = Column(Boolean, default=False, index=True)
|
||||
f03_21_grading_company = Column(String(100), nullable=True, index=True)
|
||||
f03_22_grading_score = Column(String(20), nullable=True, index=True)
|
||||
f03_23_three_star = Column(Boolean, default=False, index=True)
|
||||
|
||||
# f04 特殊信息
|
||||
f04_30_special_mark = Column(String(200), nullable=True, index=True)
|
||||
f04_31_serial_feature = Column(String(100), nullable=True, index=True)
|
||||
f04_32_issuer = Column(String(100), nullable=True, index=True)
|
||||
f04_33_issue_year = Column(String(20), nullable=True, index=True)
|
||||
f04_34_material = Column(String(50), nullable=True)
|
||||
f04_35_denomination = Column(String(20), nullable=True)
|
||||
f04_36_issue_quantity = Column(String(50), nullable=True)
|
||||
|
||||
# f05 价格信息
|
||||
f05_40_cost_price = Column(Float, nullable=True, index=True)
|
||||
f05_41_target_price = Column(Float, nullable=True, index=True)
|
||||
f05_42_goal_price = Column(Float, nullable=True)
|
||||
f05_43_repair_fee = Column(Float, nullable=True)
|
||||
f05_44_grading_fee = Column(Float, nullable=True)
|
||||
|
||||
# f06 其他信息
|
||||
f06_50_purpose = Column(String(100), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="collections")
|
||||
images = relationship("CollectionImage", back_populates="collection", cascade="all, delete-orphan")
|
||||
operations = relationship("Operation", back_populates="collection", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CollectionImage(Base):
|
||||
__tablename__ = "collection_images"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_name = Column(String(255), nullable=True)
|
||||
path = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
collection = relationship("Collection", back_populates="images")
|
||||
|
||||
|
||||
class Operation(Base):
|
||||
__tablename__ = "operations"
|
||||
|
||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
f99_91_user_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
f99_92_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
type = Column(String(50), nullable=False, index=True)
|
||||
price = Column(Float, nullable=True)
|
||||
note = Column(Text, nullable=True)
|
||||
f99_93_created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
collection = relationship("Collection", back_populates="operations")
|
||||
user = relationship("User", back_populates="operations")
|
||||
|
||||
|
||||
class CustomField(Base):
|
||||
__tablename__ = "custom_fields"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
field_type = Column(String(50), default="text")
|
||||
options = Column(Text, nullable=True)
|
||||
required = Column(Boolean, default=False)
|
||||
visible = Column(Boolean, default=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="custom_fields")
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
# 认证路由 - 使用字段编码
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||
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.models.models import User
|
||||
from app.schemas.schemas import Token, UserCreate, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""用户注册"""
|
||||
# 检查用户名是否已存在
|
||||
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="f01_01_name: 用户名已存在"
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if user_data.email:
|
||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邮箱已被注册"
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
import uuid
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
user = User(
|
||||
f99_90_id=str(uuid.uuid4()),
|
||||
f99_91_user_id=str(uuid.uuid4()), # 生成唯一 user_id
|
||||
f01_01_name=user_data.f01_01_name,
|
||||
email=user_data.email,
|
||||
phone=user_data.phone,
|
||||
avatar=user_data.avatar,
|
||||
address=user_data.address,
|
||||
bio=user_data.bio,
|
||||
password=hashed_password,
|
||||
role="user"
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""用户登录"""
|
||||
# 查找用户
|
||||
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="E00011: 用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 验证密码
|
||||
if not verify_password(form_data.password, user.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="E00011: 用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 生成 token
|
||||
access_token = create_access_token(data={"sub": user.f99_90_id})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user_info(
|
||||
current_user: User = Depends(lambda: None)
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="请使用正确的依赖注入"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
def change_password(
|
||||
old_password: str = Body(...),
|
||||
new_password: str = Body(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""修改当前用户密码"""
|
||||
from app.core.auth import verify_password, get_password_hash
|
||||
|
||||
# 在当前session中重新查询用户
|
||||
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 验证旧密码
|
||||
if not verify_password(old_password, user.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="当前密码错误"
|
||||
)
|
||||
|
||||
# 更新密码
|
||||
user.password = get_password_hash(new_password)
|
||||
db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
# ============ 短信验证码接口 ============
|
||||
|
||||
@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
|
||||
):
|
||||
"""发送短信验证码"""
|
||||
from app.services.sms import send_verification_code as send_sms
|
||||
|
||||
# 验证手机号格式
|
||||
if not phone.startswith("1") or len(phone) != 11:
|
||||
return {"success": False, "message": "手机号格式不正确"}
|
||||
|
||||
result = send_sms(phone)
|
||||
|
||||
if result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
|
||||
"expire": result.get("expire", 300)
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/verify-code")
|
||||
def verify_code(
|
||||
phone: str = Body(...),
|
||||
code: str = Body(..., min_length=6, max_length=6)
|
||||
):
|
||||
"""验证短信验证码(仅验证,不执行后续操作)"""
|
||||
from app.services.sms import verify_code as check_code
|
||||
|
||||
is_valid = check_code(phone, code)
|
||||
|
||||
if is_valid:
|
||||
return {"success": True, "message": "验证成功"}
|
||||
else:
|
||||
return {"success": False, "message": "验证码错误或已过期"}
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
# 藏品路由 - 使用字段编码
|
||||
import os
|
||||
import uuid
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File
|
||||
from sqlalchemy import func, text
|
||||
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.models.models import User, Collection, CollectionImage, Operation
|
||||
from app.schemas.schemas import (
|
||||
CollectionCreate, CollectionUpdate, CollectionResponse,
|
||||
CollectionListResponse, CollectionImageResponse
|
||||
)
|
||||
from app.services.oss import upload_to_oss, get_oss_path, delete_from_oss, get_public_url
|
||||
|
||||
router = APIRouter(prefix="/api/collections", tags=["藏品"])
|
||||
|
||||
|
||||
def to_camel_case(data: dict) -> dict:
|
||||
"""将字段编码转换为 camelCase 格式"""
|
||||
if not data:
|
||||
return data
|
||||
|
||||
mapping = {
|
||||
'f99_90_id': 'id',
|
||||
'f99_91_user_id': 'userId',
|
||||
'f99_92_created_at': 'createdAt',
|
||||
'f99_93_updated_at': 'updatedAt',
|
||||
'f01_01_name': 'name',
|
||||
'f01_02_code': 'code',
|
||||
'f01_03_category': 'category',
|
||||
'f01_04_status': 'status',
|
||||
'f01_05_remark': 'remark',
|
||||
'f02_10_prefix_serial': 'prefixSerial',
|
||||
'f02_11_version': 'version',
|
||||
'f02_12_packaging': 'packaging',
|
||||
'f02_13_rarity': 'rarity',
|
||||
'f02_14_number_category': 'numberCategory',
|
||||
'f03_20_is_graded': 'isGraded',
|
||||
'f03_21_grading_company': 'gradingCompany',
|
||||
'f03_22_grading_score': 'gradingScore',
|
||||
'f03_23_three_star': 'threeStar',
|
||||
'f04_30_special_mark': 'specialMark',
|
||||
'f04_31_serial_feature': 'serialFeature',
|
||||
'f04_32_issuer': 'issuer',
|
||||
'f04_33_issue_year': 'issueYear',
|
||||
'f04_34_material': 'material',
|
||||
'f04_35_denomination': 'denomination',
|
||||
'f04_36_issue_quantity': 'issueQuantity',
|
||||
'f05_40_cost_price': 'costPrice',
|
||||
'f05_41_target_price': 'targetPrice',
|
||||
'f05_42_goal_price': 'goalPrice',
|
||||
'f05_43_repair_fee': 'repairFee',
|
||||
'f05_44_grading_fee': 'gradingFee',
|
||||
'f06_50_purpose': 'purpose',
|
||||
}
|
||||
|
||||
return {mapping.get(k, k): v for k, v in data.items()}
|
||||
|
||||
|
||||
# 编码生成函数
|
||||
def generate_code(version: str, user_id: str, db: Session) -> str:
|
||||
"""自动生成藏品编号 - 按用户独立编码"""
|
||||
import re
|
||||
|
||||
# 查询当前用户的非空编码(不与其他用户混算)
|
||||
user_codes = db.query(Collection.f01_02_code).filter(
|
||||
Collection.f01_02_code.isnot(None),
|
||||
Collection.f99_91_user_id == user_id
|
||||
).all()
|
||||
|
||||
max_num = 0
|
||||
for (code,) in user_codes:
|
||||
# 处理纯数字编码(支持4位和5位)
|
||||
if re.match(r'^\d{4,5}$', code):
|
||||
try:
|
||||
num = int(code)
|
||||
if num > max_num:
|
||||
max_num = num
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 当前用户最大号 +1
|
||||
next_num = max_num + 1
|
||||
|
||||
# 如果超过9999,使用5位;否则使用4位
|
||||
if next_num > 9999:
|
||||
return str(next_num).zfill(5)
|
||||
else:
|
||||
return str(next_num).zfill(4)
|
||||
|
||||
|
||||
@router.get("/next-code")
|
||||
def get_next_code(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取下一个藏品编号"""
|
||||
next_code = generate_code("2024 龙", current_user.f99_90_id, db)
|
||||
return {"code": 200, "data": {"nextCode": next_code}}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_collections(
|
||||
category: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取藏品列表"""
|
||||
# admin 用户可以看到所有藏品,普通用户只能看到自己的
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
if current_user.role == "admin":
|
||||
# 联表查询获取用户名
|
||||
query = db.query(Collection, User.f01_01_name.label('owner_name')).join(
|
||||
User, Collection.f99_91_user_id == User.f99_90_id, isouter=True
|
||||
)
|
||||
else:
|
||||
query = db.query(Collection).filter(Collection.f99_91_user_id == current_user.f99_90_id)
|
||||
|
||||
if category:
|
||||
query = query.filter(Collection.f01_03_category == category)
|
||||
if status:
|
||||
query = query.filter(Collection.f01_04_status == status)
|
||||
if search:
|
||||
query = query.filter(
|
||||
(Collection.f01_01_name.contains(search)) |
|
||||
(Collection.f01_05_remark.contains(search))
|
||||
)
|
||||
|
||||
# 总数(分开查询避免join影响count)
|
||||
if current_user.role == "admin":
|
||||
total = db.query(Collection).count()
|
||||
else:
|
||||
total = query.count()
|
||||
|
||||
# 分页
|
||||
data = query.order_by(Collection.f99_92_created_at.desc()) \
|
||||
.offset((page - 1) * limit) \
|
||||
.limit(limit) \
|
||||
.all()
|
||||
|
||||
# 转换为字典列表并转为 camelCase
|
||||
data_list = []
|
||||
for item in data:
|
||||
# 处理联表查询结果
|
||||
if current_user.role == "admin":
|
||||
collection_item, owner_name = item
|
||||
else:
|
||||
collection_item = item
|
||||
owner_name = None
|
||||
|
||||
item_dict = {
|
||||
'f99_90_id': collection_item.f99_90_id,
|
||||
'f99_91_user_id': collection_item.f99_91_user_id,
|
||||
'owner_name': owner_name, # 所属用户名(仅管理员可见)
|
||||
'f01_01_name': collection_item.f01_01_name,
|
||||
'f01_02_code': collection_item.f01_02_code,
|
||||
'f01_03_category': collection_item.f01_03_category,
|
||||
'f01_04_status': collection_item.f01_04_status,
|
||||
'f01_05_remark': collection_item.f01_05_remark,
|
||||
'f02_10_prefix_serial': collection_item.f02_10_prefix_serial,
|
||||
'f02_11_version': collection_item.f02_11_version,
|
||||
'f02_12_packaging': collection_item.f02_12_packaging,
|
||||
'f02_13_rarity': collection_item.f02_13_rarity,
|
||||
'f02_14_number_category': collection_item.f02_14_number_category,
|
||||
'f03_20_is_graded': collection_item.f03_20_is_graded,
|
||||
'f03_21_grading_company': collection_item.f03_21_grading_company,
|
||||
'f03_22_grading_score': collection_item.f03_22_grading_score,
|
||||
'f03_23_three_star': collection_item.f03_23_three_star,
|
||||
'f04_30_special_mark': collection_item.f04_30_special_mark,
|
||||
'f04_31_serial_feature': collection_item.f04_31_serial_feature,
|
||||
'f04_32_issuer': collection_item.f04_32_issuer,
|
||||
'f04_33_issue_year': collection_item.f04_33_issue_year,
|
||||
'f04_34_material': collection_item.f04_34_material,
|
||||
'f04_35_denomination': collection_item.f04_35_denomination,
|
||||
'f04_36_issue_quantity': collection_item.f04_36_issue_quantity,
|
||||
'f05_40_cost_price': float(collection_item.f05_40_cost_price) if collection_item.f05_40_cost_price else None,
|
||||
'f05_41_target_price': float(collection_item.f05_41_target_price) if collection_item.f05_41_target_price else None,
|
||||
'f05_42_goal_price': float(collection_item.f05_42_goal_price) if collection_item.f05_42_goal_price else None,
|
||||
'f05_43_repair_fee': float(collection_item.f05_43_repair_fee) if collection_item.f05_43_repair_fee else None,
|
||||
'f05_44_grading_fee': float(collection_item.f05_44_grading_fee) if collection_item.f05_44_grading_fee else None,
|
||||
'f06_50_purpose': collection_item.f06_50_purpose,
|
||||
'f99_92_created_at': collection_item.f99_92_created_at.isoformat() if collection_item.f99_92_created_at else None,
|
||||
'images': []
|
||||
}
|
||||
|
||||
# 加载图片数据
|
||||
from app.models.models import CollectionImage
|
||||
images = db.query(CollectionImage).filter(
|
||||
CollectionImage.collection_id == collection_item.f99_90_id
|
||||
).all()
|
||||
|
||||
for img in images:
|
||||
item_dict['images'].append({
|
||||
'id': img.id,
|
||||
'filename': img.filename,
|
||||
'original_name': img.original_name,
|
||||
'path': img.path,
|
||||
'created_at': img.created_at.isoformat() if img.created_at else None
|
||||
})
|
||||
|
||||
data_list.append(to_camel_case(item_dict))
|
||||
|
||||
return {
|
||||
"data": data_list,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取藏品统计"""
|
||||
# 获取所有藏品
|
||||
if current_user.role == "admin":
|
||||
all_collections = db.query(Collection).all()
|
||||
else:
|
||||
all_collections = db.query(Collection).filter(
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).all()
|
||||
|
||||
# 总数
|
||||
total_count = len(all_collections)
|
||||
|
||||
# 按分类统计
|
||||
from collections import Counter
|
||||
by_category = Counter(c.f01_03_category for c in all_collections).items()
|
||||
|
||||
# 按状态统计
|
||||
by_status = Counter(c.f01_04_status for c in all_collections).items()
|
||||
|
||||
# 按是否评级统计
|
||||
by_graded = Counter(c.f03_20_is_graded for c in all_collections).items()
|
||||
|
||||
# 新增:8 个分布统计
|
||||
by_packaging = Counter(c.f02_12_packaging for c in all_collections if c.f02_12_packaging).items()
|
||||
by_rarity = Counter(c.f02_13_rarity for c in all_collections if c.f02_13_rarity).items()
|
||||
by_version = Counter(c.f02_11_version for c in all_collections if c.f02_11_version).items()
|
||||
by_grading_company = Counter(c.f03_21_grading_company for c in all_collections if c.f03_20_is_graded and c.f03_21_grading_company).items()
|
||||
by_grading_score = Counter(c.f03_22_grading_score for c in all_collections if c.f03_20_is_graded and c.f03_22_grading_score).items()
|
||||
by_special_mark = Counter(c.f04_30_special_mark for c in all_collections if c.f04_30_special_mark).items()
|
||||
by_number_category = Counter(c.f02_14_number_category for c in all_collections if c.f02_14_number_category).items()
|
||||
|
||||
# 总成本: SUM(cost_price + repair_fee + grading_fee) for ALL collections
|
||||
total_cost = sum(
|
||||
(c.f05_40_cost_price or 0) + (c.f05_43_repair_fee or 0) + (c.f05_44_grading_fee or 0)
|
||||
for c in all_collections
|
||||
)
|
||||
|
||||
# 预期利润: SUM(target_price - cost_price) for collections with target_price > 0
|
||||
expected_profit = sum(
|
||||
(c.f05_41_target_price or 0) - (c.f05_40_cost_price or 0)
|
||||
for c in all_collections
|
||||
if c.f05_41_target_price and c.f05_41_target_price > 0
|
||||
)
|
||||
|
||||
# 已售商品:状态为 sold 且出售价 > 0
|
||||
sold_collections = [
|
||||
c for c in all_collections
|
||||
if c.f01_04_status == 'sold' and c.f05_42_goal_price and c.f05_42_goal_price > 0
|
||||
]
|
||||
|
||||
# 总收入:SUM(出售价) for 已售商品(售价>0)
|
||||
total_revenue = sum(
|
||||
c.f05_42_goal_price or 0
|
||||
for c in sold_collections
|
||||
)
|
||||
|
||||
# 总利润(已实现利润):SUM(出售价 - 成本价 - 修复费 - 评级费) for 已售商品
|
||||
# 单藏品总成本 = 成本价 + 修复费 + 评级费
|
||||
total_profit = sum(
|
||||
(c.f05_42_goal_price or 0) - (c.f05_40_cost_price or 0) - (c.f05_43_repair_fee or 0) - (c.f05_44_grading_fee or 0)
|
||||
for c in sold_collections
|
||||
)
|
||||
|
||||
return {
|
||||
"totalCount": total_count,
|
||||
"byCategory": [{"category": c, "count": n} for c, n in by_category],
|
||||
"byStatus": [{"status": s, "count": n} for s, n in by_status],
|
||||
"byGrading": [{"isGraded": g, "count": n} for g, n in by_graded],
|
||||
"byPackaging": [{"packaging": p, "count": n} for p, n in by_packaging],
|
||||
"byRarity": [{"rarity": r, "count": n} for r, n in by_rarity],
|
||||
"byVersion": [{"version": v, "count": n} for v, n in by_version],
|
||||
"byGradingCompany": [{"company": c, "count": n} for c, n in by_grading_company],
|
||||
"byGradingScore": [{"score": s, "count": n} for s, n in by_grading_score],
|
||||
"bySpecialMark": [{"mark": m, "count": n} for m, n in by_special_mark],
|
||||
"byNumberCategory": [{"numberCategory": n, "count": c} for n, c in by_number_category],
|
||||
# 盈亏统计(只统计已售且有价格的藏品)
|
||||
"byProfitLoss": [
|
||||
{"type": "profit", "label": "盈利", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price > c.f05_40_cost_price)},
|
||||
{"type": "loss", "label": "亏损", "count": sum(1 for c in sold_collections if c.f05_42_goal_price and c.f05_40_cost_price and c.f05_42_goal_price <= c.f05_40_cost_price)}
|
||||
],
|
||||
"totalCost": total_cost,
|
||||
"totalTarget": sum(c.f05_41_target_price or 0 for c in all_collections),
|
||||
"expectedProfit": expected_profit,
|
||||
"totalRevenue": total_revenue,
|
||||
"totalProfit": total_profit
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{collection_id}")
|
||||
def get_collection(
|
||||
collection_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取单个藏品详情"""
|
||||
result = db.execute(
|
||||
text("SELECT * FROM collections WHERE f99_90_id = :id"),
|
||||
{"id": collection_id}
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
||||
|
||||
collection = dict(result._mapping)
|
||||
|
||||
# 非管理员只能查看自己的藏品
|
||||
if current_user.role != "admin" and collection.get('f99_91_user_id') != current_user.f99_90_id:
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
result_dict = {
|
||||
'f99_90_id': collection.get('f99_90_id'),
|
||||
'f99_91_user_id': collection.get('f99_91_user_id'),
|
||||
'f01_01_name': collection.get('f01_01_name'),
|
||||
'f01_02_code': collection.get('f01_02_code'),
|
||||
'f01_03_category': collection.get('f01_03_category'),
|
||||
'f01_04_status': collection.get('f01_04_status'),
|
||||
'f01_05_remark': collection.get('f01_05_remark'),
|
||||
'f02_10_prefix_serial': collection.get('f02_10_prefix_serial'),
|
||||
'f02_11_version': collection.get('f02_11_version'),
|
||||
'f02_12_packaging': collection.get('f02_12_packaging'),
|
||||
'f02_13_rarity': collection.get('f02_13_rarity'),
|
||||
'f02_14_number_category': collection.get('f02_14_number_category'),
|
||||
'f03_20_is_graded': collection.get('f03_20_is_graded'),
|
||||
'f03_21_grading_company': collection.get('f03_21_grading_company'),
|
||||
'f03_22_grading_score': collection.get('f03_22_grading_score'),
|
||||
'f03_23_three_star': collection.get('f03_23_three_star'),
|
||||
'f04_30_special_mark': collection.get('f04_30_special_mark'),
|
||||
'f04_31_serial_feature': collection.get('f04_31_serial_feature'),
|
||||
'f04_32_issuer': collection.get('f04_32_issuer'),
|
||||
'f04_33_issue_year': collection.get('f04_33_issue_year'),
|
||||
'f04_34_material': collection.get('f04_34_material'),
|
||||
'f04_35_denomination': collection.get('f04_35_denomination'),
|
||||
'f04_36_issue_quantity': collection.get('f04_36_issue_quantity'),
|
||||
'f05_40_cost_price': float(collection.get('f05_40_cost_price')) if collection.get('f05_40_cost_price') else None,
|
||||
'f05_41_target_price': float(collection.get('f05_41_target_price')) if collection.get('f05_41_target_price') else None,
|
||||
'f05_42_goal_price': float(collection.get('f05_42_goal_price')) if collection.get('f05_42_goal_price') else None,
|
||||
'f05_43_repair_fee': float(collection.get('f05_43_repair_fee')) if collection.get('f05_43_repair_fee') else None,
|
||||
'f05_44_grading_fee': float(collection.get('f05_44_grading_fee')) if collection.get('f05_44_grading_fee') else None,
|
||||
'f06_50_purpose': collection.get('f06_50_purpose'),
|
||||
'f99_92_created_at': collection.get('f99_92_created_at').isoformat() if collection.get('f99_92_created_at') else None,
|
||||
'images': []
|
||||
}
|
||||
|
||||
# 加载图片数据
|
||||
images = db.query(CollectionImage).filter(
|
||||
CollectionImage.collection_id == collection_id
|
||||
).all()
|
||||
|
||||
for img in images:
|
||||
result_dict['images'].append({
|
||||
'id': img.id,
|
||||
'filename': img.filename,
|
||||
'original_name': img.original_name,
|
||||
'path': img.path,
|
||||
'created_at': img.created_at.isoformat() if img.created_at else None
|
||||
})
|
||||
|
||||
return to_camel_case(result_dict)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_collection(
|
||||
collection_data: CollectionCreate,
|
||||
force: bool = False, # 是否强制保存(忽略重复警告)
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建藏品 - 支持冠字号查重"""
|
||||
from app.core.logging_config import logger
|
||||
|
||||
# 自动生成编码
|
||||
final_code = collection_data.f01_02_code or generate_code(
|
||||
collection_data.f02_11_version or '2024 龙',
|
||||
current_user.f99_90_id,
|
||||
db
|
||||
)
|
||||
|
||||
# 编号查重(如果提供了编号且不是强制保存)
|
||||
if not force and final_code:
|
||||
existing_code = db.query(Collection).filter(
|
||||
Collection.f01_02_code == final_code,
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).first()
|
||||
|
||||
if existing_code:
|
||||
logger.warning(f"发现重复编号:{final_code}, 已存在藏品 ID: {existing_code.f99_90_id}")
|
||||
return {
|
||||
"error": {
|
||||
"code": "DUPLICATE_CODE",
|
||||
"message": f"藏品编号 {final_code} 已存在,请使用其他编号"
|
||||
}
|
||||
}
|
||||
|
||||
# 冠字号查重(如果提供了冠字号且不是强制保存)
|
||||
if not force and collection_data.f02_10_prefix_serial:
|
||||
# 查询当前用户是否有相同冠字号的藏品
|
||||
existing = db.query(Collection).filter(
|
||||
Collection.f02_10_prefix_serial == collection_data.f02_10_prefix_serial,
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
logger.warning(f"发现重复冠字号:{collection_data.f02_10_prefix_serial}, 已存在藏品 ID: {existing.f99_90_id}")
|
||||
# 返回警告信息,让前端询问用户是否继续
|
||||
return {
|
||||
"warning": {
|
||||
"code": "DUPLICATE_SERIAL",
|
||||
"message": f"发现重复冠字号:{collection_data.f02_10_prefix_serial}",
|
||||
"existing_collection": {
|
||||
"id": existing.f99_90_id,
|
||||
"name": existing.f01_01_name,
|
||||
"code": existing.f01_02_code,
|
||||
"prefix_serial": existing.f02_10_prefix_serial
|
||||
}
|
||||
},
|
||||
"data": {
|
||||
"ask_continue": True
|
||||
}
|
||||
}
|
||||
|
||||
collection = Collection(
|
||||
f99_91_user_id=current_user.f99_90_id,
|
||||
f01_01_name=collection_data.f01_01_name,
|
||||
f01_02_code=final_code,
|
||||
f01_03_category=collection_data.f01_03_category,
|
||||
f01_04_status=collection_data.f01_04_status or "in_collection",
|
||||
f01_05_remark=collection_data.f01_05_remark,
|
||||
f02_10_prefix_serial=collection_data.f02_10_prefix_serial,
|
||||
f02_11_version=collection_data.f02_11_version,
|
||||
f02_12_packaging=collection_data.f02_12_packaging,
|
||||
f02_13_rarity=collection_data.f02_13_rarity,
|
||||
f02_14_number_category=collection_data.f02_14_number_category,
|
||||
f03_20_is_graded=collection_data.f03_20_is_graded or False,
|
||||
f03_21_grading_company=collection_data.f03_21_grading_company,
|
||||
f03_22_grading_score=collection_data.f03_22_grading_score,
|
||||
f03_23_three_star=collection_data.f03_23_three_star or False,
|
||||
f04_30_special_mark=collection_data.f04_30_special_mark,
|
||||
f04_31_serial_feature=collection_data.f04_31_serial_feature,
|
||||
f04_32_issuer=collection_data.f04_32_issuer,
|
||||
f04_33_issue_year=collection_data.f04_33_issue_year,
|
||||
f04_34_material=collection_data.f04_34_material,
|
||||
f04_35_denomination=collection_data.f04_35_denomination,
|
||||
f04_36_issue_quantity=collection_data.f04_36_issue_quantity,
|
||||
f05_40_cost_price=collection_data.f05_40_cost_price,
|
||||
f05_41_target_price=collection_data.f05_41_target_price,
|
||||
f05_42_goal_price=collection_data.f05_42_goal_price,
|
||||
f05_43_repair_fee=collection_data.f05_43_repair_fee,
|
||||
f05_44_grading_fee=collection_data.f05_44_grading_fee,
|
||||
f06_50_purpose=collection_data.f06_50_purpose
|
||||
)
|
||||
|
||||
db.add(collection)
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
return {
|
||||
'f99_90_id': collection.f99_90_id,
|
||||
'f99_91_user_id': collection.f99_91_user_id,
|
||||
'f01_01_name': collection.f01_01_name,
|
||||
'f01_02_code': collection.f01_02_code,
|
||||
'f01_03_category': collection.f01_03_category,
|
||||
'f01_04_status': collection.f01_04_status,
|
||||
'f01_05_remark': collection.f01_05_remark,
|
||||
'f99_92_created_at': collection.f99_92_created_at.isoformat() if collection.f99_92_created_at else None,
|
||||
'message': '创建成功'
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{collection_id}")
|
||||
def update_collection(
|
||||
collection_id: str,
|
||||
collection_data: CollectionUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新藏品"""
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.f99_90_id == collection_id,
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
||||
|
||||
# 更新字段 - 使用 model_fields_set 检查哪些字段被设置
|
||||
for field_name in collection_data.model_fields_set:
|
||||
value = getattr(collection_data, field_name)
|
||||
if value is not None:
|
||||
setattr(collection, field_name, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
return {
|
||||
"f99_90_id": collection.f99_90_id,
|
||||
"message": "更新成功"
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{collection_id}")
|
||||
def delete_collection(
|
||||
collection_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除藏品"""
|
||||
# 验证权限并检查是否存在
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.f99_90_id == collection_id,
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
||||
|
||||
# 使用原生 SQL 删除(避免 ORM 级联查询字段不匹配问题)
|
||||
from sqlalchemy import text
|
||||
# 1. 删除关联的 operations(f99_91_user_id 关联到 collections.f99_90_id)
|
||||
db.execute(text("DELETE FROM operations WHERE f99_91_user_id = :id"), {"id": collection_id})
|
||||
# 2. 删除关联的图片
|
||||
db.execute(text("DELETE FROM collection_images WHERE collection_id = :id"), {"id": collection_id})
|
||||
# 3. 删除藏品本身
|
||||
db.execute(text("DELETE FROM collections WHERE f99_90_id = :id"), {"id": collection_id})
|
||||
db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(
|
||||
collection_id: str = None,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上传藏品图片 - 文件名格式:用户名 - 藏品编号 - 冠字号"""
|
||||
try:
|
||||
# 验证藏品是否存在
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.f99_90_id == collection_id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="E00033: 藏品不存在")
|
||||
|
||||
# 获取用户信息(用于文件名)
|
||||
owner = db.query(User).filter(User.f99_90_id == collection.f99_91_user_id).first()
|
||||
username = owner.f01_01_name if owner else "unknown"
|
||||
|
||||
# 获取藏品信息(用于文件名)
|
||||
code = collection.f01_02_code or "0000"
|
||||
prefix_serial = collection.f02_10_prefix_serial or ""
|
||||
|
||||
# 检查文件类型
|
||||
if not file.content_type.startswith('image/'):
|
||||
raise HTTPException(status_code=400, detail="E00038: 只能上传图片文件")
|
||||
|
||||
# 检查文件大小(限制 10MB)
|
||||
file_size = 0
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
if file_size > 10 * 1024 * 1024: # 10MB
|
||||
raise HTTPException(status_code=400, detail=f"E00039: 图片大小不能超过 10MB(当前{file_size // 1024 // 1024}MB)")
|
||||
|
||||
# 生成OSS存储路径
|
||||
user_id = collection.f99_91_user_id
|
||||
file_extension = file.filename.split('.')[-1] if '.' in file.filename else 'jpg'
|
||||
|
||||
# 清理特殊字符
|
||||
clean_username = re.sub(r'[^\w\u4e00-\u9fff\-]', '', username)
|
||||
clean_serial = re.sub(r'[^\w\u4e00-\u9fff\-]', '', prefix_serial)
|
||||
|
||||
# 文件名格式:用户名-藏品编号-冠字号.jpg
|
||||
if clean_serial:
|
||||
filename = f"{clean_username}-{code}-{clean_serial}.{file_extension}"
|
||||
else:
|
||||
filename = f"{clean_username}-{code}.{file_extension}"
|
||||
|
||||
# 生成OSS key
|
||||
oss_key, unique_name = get_oss_path("collections", user_id=user_id, filename=filename)
|
||||
|
||||
# 上传到OSS
|
||||
image_url = upload_to_oss(content, oss_key)
|
||||
|
||||
# 创建图片记录(保存OSS URL)
|
||||
image = CollectionImage(
|
||||
id=str(uuid.uuid4()),
|
||||
collection_id=collection_id,
|
||||
filename=unique_name,
|
||||
original_name=file.filename,
|
||||
path=image_url # 保存OSS URL
|
||||
)
|
||||
|
||||
db.add(image)
|
||||
db.commit()
|
||||
db.refresh(image)
|
||||
|
||||
logger.info(f"图片上传成功:{image_url}, collection_id={collection_id}")
|
||||
|
||||
return {
|
||||
"message": "上传成功",
|
||||
"image_id": image.id,
|
||||
"filename": unique_name,
|
||||
"url": image_url
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"图片上传失败:{str(e)}")
|
||||
raise HTTPException(status_code=500, detail="上传失败")
|
||||
|
||||
|
||||
@router.delete("/images/{image_id}")
|
||||
async def delete_image(
|
||||
image_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除藏品图片"""
|
||||
try:
|
||||
# 查找图片记录
|
||||
image = db.query(CollectionImage).filter(
|
||||
CollectionImage.id == image_id
|
||||
).first()
|
||||
|
||||
if not image:
|
||||
raise HTTPException(status_code=404, detail="E00033: 图片不存在")
|
||||
|
||||
# 检查权限
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.f99_90_id == image.collection_id
|
||||
).first()
|
||||
|
||||
if collection and current_user.role != "admin" and collection.f99_91_user_id != current_user.f99_90_id:
|
||||
raise HTTPException(status_code=403, detail="E00014: 无权删除此图片")
|
||||
|
||||
# 删除OSS文件(如果path是OSS URL)
|
||||
if image.path and image.path.startswith("https://"):
|
||||
# 从OSS URL提取key
|
||||
try:
|
||||
oss_key = image.path.replace("https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com/", "")
|
||||
delete_from_oss(oss_key)
|
||||
except Exception as e:
|
||||
logger.warning(f"OSS文件删除失败: {e}")
|
||||
elif image.path and os.path.exists(image.path):
|
||||
# 兼容旧的本地上传
|
||||
os.remove(image.path)
|
||||
|
||||
# 删除数据库记录
|
||||
db.delete(image)
|
||||
db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"图片删除失败:{str(e)}")
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
|
@ -0,0 +1,376 @@
|
|||
# OCR 识别路由 - 专业人民币生肖纪念钞鉴定
|
||||
import os
|
||||
import uuid
|
||||
import base64
|
||||
import httpx
|
||||
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.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"
|
||||
}
|
||||
|
||||
# 临时上传目录(用于OCR识别本地备选)
|
||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
|
||||
"""生成OSS路径 - 按年/月/日分类"""
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
year = now.strftime("%Y")
|
||||
month = now.strftime("%m")
|
||||
day = now.strftime("%d")
|
||||
|
||||
if file_type == "temp":
|
||||
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
|
||||
import uuid
|
||||
unique_id = str(uuid.uuid4())
|
||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
||||
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
|
||||
|
||||
elif file_type == "collection":
|
||||
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
|
||||
if not user_id or not collection_id:
|
||||
raise ValueError("user_id and collection_id required for collection")
|
||||
return f"collections/{user_id}/{year}/{collection_id}/{filename}"
|
||||
|
||||
elif file_type == "avatar":
|
||||
# 头像: avatars/{user_id}/avatar.{ext}
|
||||
if not user_id:
|
||||
raise ValueError("user_id required for avatar")
|
||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
||||
return f"avatars/{user_id}/avatar.{ext}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# 上传图片到OSS - 使用服务层(带压缩)
|
||||
from app.services.oss import upload_to_oss as oss_upload
|
||||
|
||||
def upload_to_oss(file_data, oss_key):
|
||||
"""上传文件到阿里云OSS(带自动压缩)"""
|
||||
return oss_upload(file_data, oss_key)
|
||||
|
||||
# 专业提示词
|
||||
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
|
||||
|
||||
【识别流程】
|
||||
1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签)
|
||||
2. 验证纪念钞特征:对照生肖纪念钞特征进行确认
|
||||
3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张
|
||||
4. 提取信息:仔细阅读标签上的所有文字内容
|
||||
|
||||
【版别格式要求】
|
||||
只需要:年份 + 属相,例如:
|
||||
- 2024 龙
|
||||
- 2025 蛇
|
||||
- 2026 马
|
||||
|
||||
【输出要求】
|
||||
严格按照以下格式输出,每个字段必须填写具体值:
|
||||
✅ 1 发行机构:中国人民银行
|
||||
✅ 2 发行版别:2024 龙
|
||||
✅ 3 面额:贰拾圆
|
||||
✅ 4 是否评级:是/否
|
||||
✅ 5 封装类型:裸钞/单张/标十/标百
|
||||
✅ 6 冠字序号:J0xxxxxxxx
|
||||
✅ 7 评级机构:ACG/PCGS/PMG
|
||||
✅ 8 评级分数:67/68/69
|
||||
✅ 9 是否三星:是/否
|
||||
✅ 10 特殊标识:金山标/天马标/红绳版等
|
||||
✅ 11 号码特征:金山号 2 张,天马号 3 张等
|
||||
|
||||
现在请仔细分析提供的图片,按上述格式输出结果。"""
|
||||
|
||||
|
||||
@router.post("/recognize")
|
||||
async def recognize_image(
|
||||
image: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
|
||||
try:
|
||||
# 读取图片数据
|
||||
image_data = await image.read()
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
|
||||
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
|
||||
oss_key, temp_id = get_oss_path("temp", filename=image.filename)
|
||||
|
||||
# 上传到OSS
|
||||
try:
|
||||
image_url = upload_to_oss(image_data, oss_key)
|
||||
except Exception as oss_err:
|
||||
# OSS失败时保存到本地作为备选
|
||||
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]}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
|
||||
payload = {
|
||||
"model": "qwen-vl-plus",
|
||||
"input": {
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"image": f"data:{image.content_type};base64,{image_base64}"
|
||||
},
|
||||
{
|
||||
"text": PROFESSIONAL_PROMPT
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
"parameters": {
|
||||
"max_tokens": 1000
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
||||
json=payload,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# 识别失败,删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
|
||||
|
||||
ocr_result = response.json()
|
||||
text_content = ""
|
||||
# 新版API返回格式
|
||||
if "output" in ocr_result and "choices" in ocr_result["output"]:
|
||||
choices = ocr_result["output"]["choices"]
|
||||
if choices and len(choices) > 0:
|
||||
content = choices[0].get("message", {}).get("content", [])
|
||||
if content and len(content) > 0:
|
||||
text_content = content[0].get("text", "")
|
||||
|
||||
fields = extract_fields(text_content)
|
||||
|
||||
# 返回识别结果和临时图片路径
|
||||
return {
|
||||
"success": True,
|
||||
"text": text_content,
|
||||
"fields": fields,
|
||||
"temp_image": {
|
||||
"id": temp_id,
|
||||
"filename": oss_key.split('/')[-1],
|
||||
"path": image_url,
|
||||
"original_name": image.filename,
|
||||
"is_oss": image_url.startswith("https://")
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
|
||||
raise HTTPException(status_code=500, detail=error_detail)
|
||||
|
||||
|
||||
def extract_fields(text: str) -> dict:
|
||||
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
|
||||
import re
|
||||
fields = {}
|
||||
|
||||
# 解析结构化输出
|
||||
patterns = {
|
||||
'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)',
|
||||
'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)'
|
||||
}
|
||||
|
||||
for field, pattern in patterns.items():
|
||||
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
# 保留所有值,包括"无"和"未识别",让前端处理
|
||||
fields[field] = value
|
||||
|
||||
# 处理是否评级
|
||||
if 'is_graded_text' in fields:
|
||||
fields['is_graded'] = '是' in fields.pop('is_graded_text')
|
||||
|
||||
# 处理是否三星
|
||||
if 'three_star_text' in fields:
|
||||
fields['three_star'] = '是' in fields.pop('three_star_text')
|
||||
|
||||
# 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙)
|
||||
if 'version' in fields:
|
||||
version = fields['version']
|
||||
# 提取年份和生肖
|
||||
year_match = re.search(r'(20\d{2})', version)
|
||||
animal = ''
|
||||
if '龙' in version:
|
||||
animal = '龙'
|
||||
elif '蛇' in version:
|
||||
animal = '蛇'
|
||||
elif '马' in version:
|
||||
animal = '马'
|
||||
elif '羊' in version:
|
||||
animal = '羊'
|
||||
elif '猴' in version:
|
||||
animal = '猴'
|
||||
elif '鸡' in version:
|
||||
animal = '鸡'
|
||||
elif '狗' in version:
|
||||
animal = '狗'
|
||||
elif '猪' in version:
|
||||
animal = '猪'
|
||||
elif '鼠' in version:
|
||||
animal = '鼠'
|
||||
elif '牛' in version:
|
||||
animal = '牛'
|
||||
elif '虎' in version:
|
||||
animal = '虎'
|
||||
elif '兔' in version:
|
||||
animal = '兔'
|
||||
|
||||
if year_match and animal:
|
||||
fields['version'] = f"{year_match.group(1)}{animal}"
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
@router.post("/claim-temp-image")
|
||||
async def claim_temp_image(
|
||||
temp_id: str,
|
||||
collection_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
|
||||
from app.models.models import Collection, CollectionImage
|
||||
from datetime import datetime
|
||||
|
||||
# 验证藏品是否存在
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.f99_90_id == collection_id,
|
||||
Collection.f99_91_user_id == current_user.f99_90_id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
||||
|
||||
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
|
||||
code = collection.f01_02_code or "0000"
|
||||
prefix = collection.f02_10_prefix_serial or ""
|
||||
username = current_user.f01_01_name
|
||||
import time
|
||||
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg"
|
||||
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
|
||||
|
||||
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
|
||||
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
|
||||
temp_content = None
|
||||
found_key = None
|
||||
|
||||
# 尝试最近7天的路径
|
||||
from datetime import timedelta
|
||||
for i in range(7):
|
||||
date = datetime.now() - timedelta(days=i)
|
||||
year = date.strftime("%Y")
|
||||
month = date.strftime("%m")
|
||||
day = date.strftime("%d")
|
||||
|
||||
for ext in temp_extensions:
|
||||
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"])
|
||||
temp_content = bucket.get_object(temp_oss_key).read()
|
||||
found_key = temp_oss_key
|
||||
break
|
||||
except:
|
||||
continue
|
||||
if temp_content:
|
||||
break
|
||||
|
||||
if temp_content:
|
||||
# 上传到正式目录
|
||||
bucket.put_object(oss_key, temp_content)
|
||||
|
||||
# 删除临时图片
|
||||
try:
|
||||
bucket.delete_object(found_key)
|
||||
except:
|
||||
pass
|
||||
|
||||
# OSS URL
|
||||
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
|
||||
|
||||
else:
|
||||
# OSS失败,使用本地文件
|
||||
temp_path = None
|
||||
for ext in temp_extensions:
|
||||
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
|
||||
if os.path.exists(temp_path):
|
||||
break
|
||||
|
||||
if not temp_path or not os.path.exists(temp_path):
|
||||
raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
|
||||
|
||||
# 保存到本地
|
||||
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
|
||||
os.makedirs(collection_dir, exist_ok=True)
|
||||
|
||||
new_path = os.path.join(collection_dir, final_filename)
|
||||
import shutil
|
||||
shutil.move(temp_path, new_path)
|
||||
image_path = f"uploads/collections/{final_filename}"
|
||||
|
||||
# 创建图片记录
|
||||
image_record = CollectionImage(
|
||||
id=str(uuid.uuid4()),
|
||||
collection_id=collection.f99_90_id,
|
||||
filename=final_filename,
|
||||
original_name=temp_id,
|
||||
path=image_path
|
||||
)
|
||||
db.add(image_record)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"image": {
|
||||
"id": image_record.id,
|
||||
"filename": image_record.filename,
|
||||
"path": image_record.path
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# 操作路由
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.models.models import User, Collection, Operation
|
||||
from app.schemas.schemas import OperationCreate, OperationResponse
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["操作"])
|
||||
|
||||
|
||||
@router.get("/operations", response_model=List[OperationResponse])
|
||||
def get_operations(
|
||||
collection_id: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取操作历史"""
|
||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
||||
|
||||
if collection_id:
|
||||
query = query.filter(Operation.collection_id == collection_id)
|
||||
|
||||
operations = query.order_by(Operation.created_at.desc()) \
|
||||
.offset((page - 1) * limit) \
|
||||
.limit(limit) \
|
||||
.all()
|
||||
|
||||
return operations
|
||||
|
||||
|
||||
@router.get("/operations/history")
|
||||
def get_operation_history(
|
||||
collection_id: Optional[str] = None,
|
||||
type: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取操作历史(带统计)"""
|
||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
||||
|
||||
if collection_id:
|
||||
query = query.filter(Operation.collection_id == collection_id)
|
||||
if type:
|
||||
query = query.filter(Operation.type == type)
|
||||
if start_date:
|
||||
query = query.filter(Operation.created_at >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(Operation.created_at <= end_date)
|
||||
|
||||
total = query.count()
|
||||
|
||||
data = query.order_by(Operation.created_at.desc()) \
|
||||
.offset((page - 1) * limit) \
|
||||
.limit(limit) \
|
||||
.all()
|
||||
|
||||
return {
|
||||
"data": data,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"pages": (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/operations", response_model=OperationResponse)
|
||||
def create_operation(
|
||||
operation_data: OperationCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建操作记录"""
|
||||
# 验证藏品存在
|
||||
collection = db.query(Collection).filter(
|
||||
Collection.id == operation_data.collection_id,
|
||||
Collection.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
||||
|
||||
operation = Operation(
|
||||
collection_id=operation_data.collection_id,
|
||||
user_id=current_user.id,
|
||||
type=operation_data.type,
|
||||
price=operation_data.price,
|
||||
note=operation_data.note
|
||||
)
|
||||
|
||||
db.add(operation)
|
||||
db.commit()
|
||||
db.refresh(operation)
|
||||
|
||||
return operation
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
# 用户管理路由
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.core.auth import get_current_user
|
||||
from app.models.models import User, Collection
|
||||
from app.schemas.schemas import UserResponse, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["用户"])
|
||||
|
||||
# ============ 当前用户接口 ============
|
||||
|
||||
@router.get("/users/me", response_model=UserResponse)
|
||||
def get_current_user_info(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""获取当前登录用户信息"""
|
||||
return {
|
||||
"f99_90_id": current_user.f99_90_id,
|
||||
"f01_01_name": current_user.f01_01_name,
|
||||
"email": current_user.email,
|
||||
"phone": current_user.phone,
|
||||
"avatar": current_user.avatar,
|
||||
"address": current_user.address,
|
||||
"bio": current_user.bio,
|
||||
"role": current_user.role,
|
||||
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
|
||||
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
|
||||
}
|
||||
|
||||
@router.put("/users/me", response_model=UserResponse)
|
||||
def update_current_user(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新当前用户信息"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 获取用户ID
|
||||
user_id = current_user.f99_90_id
|
||||
logger.info(f"Updating user {user_id}, data={user_update.model_dump()}")
|
||||
|
||||
# 在当前session中重新查询用户
|
||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 更新字段
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if field == 'f01_01_name':
|
||||
user.f01_01_name = value
|
||||
elif field == 'username':
|
||||
pass # skip, already handled as f01_01_name
|
||||
elif hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
# 强制刷新以确保更新被提交
|
||||
db.flush()
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info(f"After commit, user email={user.email}")
|
||||
|
||||
return user
|
||||
|
||||
# ============ 管理员用户管理 ============
|
||||
|
||||
admin_router = APIRouter(prefix="/api/admin/users", tags=["用户管理"])
|
||||
|
||||
|
||||
@admin_router.get("")
|
||||
def get_users(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户列表(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
total = db.query(User).count()
|
||||
users = db.query(User).offset((page-1)*limit).limit(limit).all()
|
||||
|
||||
user_list = []
|
||||
for u in users:
|
||||
# 统计每个用户的藏品数量
|
||||
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
|
||||
user_list.append({
|
||||
"id": u.f99_90_id,
|
||||
"username": u.f01_01_name,
|
||||
"email": u.email,
|
||||
"phone": u.phone,
|
||||
"role": u.role,
|
||||
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
|
||||
"collection_count": count
|
||||
})
|
||||
|
||||
return user_list
|
||||
|
||||
|
||||
@admin_router.get("/{user_id}")
|
||||
def get_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取单个用户信息"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get("/{user_id}/collections")
|
||||
def get_user_collections(
|
||||
user_id: str,
|
||||
limit: int = Query(100, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定用户的藏品列表"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
collections = db.query(Collection).filter(
|
||||
Collection.user_id == user_id
|
||||
).limit(limit).all()
|
||||
|
||||
return [c.code for c in collections]
|
||||
|
||||
|
||||
@admin_router.get("/{user_id}/count")
|
||||
def get_user_collection_count(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定用户的藏品数量"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||
|
||||
count = db.query(Collection).filter(Collection.user_id == user_id).count()
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@admin_router.put("/{user_id}")
|
||||
def update_user(
|
||||
user_id: str,
|
||||
username: Optional[str] = Body(None),
|
||||
email: Optional[str] = Body(None),
|
||||
role: Optional[str] = Body(None),
|
||||
password: Optional[str] = Body(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新用户信息(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||
|
||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="E00051: 用户不存在")
|
||||
|
||||
# 更新基本信息
|
||||
if username:
|
||||
user.f01_01_name = username
|
||||
if email:
|
||||
user.email = email
|
||||
if role is not None:
|
||||
user.role = role
|
||||
|
||||
# 更新密码
|
||||
if password and password.strip():
|
||||
from app.core.auth import get_password_hash
|
||||
user.password = get_password_hash(password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"id": user.f99_90_id,
|
||||
"username": user.f01_01_name,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"message": "更新成功"
|
||||
}
|
||||
|
||||
|
||||
@admin_router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除用户(仅管理员)"""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||
|
||||
# 不能删除自己
|
||||
if user_id == str(current_user.f99_90_id):
|
||||
raise HTTPException(status_code=400, detail="E00052: 不能删除自己")
|
||||
|
||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="E00051: 用户不存在")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
# Pydantic Schema - 使用字段编码并支持 camelCase
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ============ 用户相关 ============
|
||||
|
||||
class UserBase(BaseModel):
|
||||
f01_01_name: str = Field(..., min_length=3, max_length=255, alias="username")
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
f01_01_name: Optional[str] = Field(None, alias="username")
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
f99_90_id: str = Field(..., alias="id")
|
||||
f01_01_name: str = Field(..., alias="username")
|
||||
role: str
|
||||
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
|
||||
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
# ============ 藏品相关 ============
|
||||
|
||||
class CollectionBase(BaseModel):
|
||||
# f01 基本信息
|
||||
f01_01_name: str = Field(..., min_length=2, max_length=255, alias="name")
|
||||
f01_02_code: Optional[str] = Field(None, max_length=50, alias="code")
|
||||
f01_03_category: str = Field(..., max_length=100, alias="category")
|
||||
f01_04_status: Optional[str] = Field("in_collection", alias="status")
|
||||
f01_05_remark: Optional[str] = Field(None, alias="remark")
|
||||
|
||||
# f02 详细字段
|
||||
f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial")
|
||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
||||
|
||||
# f03 评级信息
|
||||
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
|
||||
f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany")
|
||||
f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore")
|
||||
f03_23_three_star: Optional[bool] = Field(False, alias="threeStar")
|
||||
|
||||
# f04 特殊信息
|
||||
f04_30_special_mark: Optional[str] = Field(None, alias="specialMark")
|
||||
f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature")
|
||||
f04_32_issuer: Optional[str] = Field(None, alias="issuer")
|
||||
f04_33_issue_year: Optional[str] = Field(None, alias="issueYear")
|
||||
f04_34_material: Optional[str] = Field(None, alias="material")
|
||||
f04_35_denomination: Optional[str] = Field(None, alias="denomination")
|
||||
f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity")
|
||||
|
||||
# f05 价格信息
|
||||
f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice")
|
||||
f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice")
|
||||
f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice")
|
||||
f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee")
|
||||
f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee")
|
||||
|
||||
# f06 其他信息
|
||||
f06_50_purpose: Optional[str] = Field(None, alias="purpose")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class CollectionCreate(CollectionBase):
|
||||
pass
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
# f01 基本信息
|
||||
f01_01_name: Optional[str] = Field(None, alias="name")
|
||||
f01_02_code: Optional[str] = Field(None, alias="code")
|
||||
f01_03_category: Optional[str] = Field(None, alias="category")
|
||||
f01_04_status: Optional[str] = Field(None, alias="status")
|
||||
f01_05_remark: Optional[str] = Field(None, alias="remark")
|
||||
|
||||
# f02 详细字段
|
||||
f02_10_prefix_serial: Optional[str] = Field(None, alias="prefixSerial")
|
||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
||||
|
||||
# f03 评级信息
|
||||
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")
|
||||
f03_21_grading_company: Optional[str] = Field(None, alias="gradingCompany")
|
||||
f03_22_grading_score: Optional[str] = Field(None, alias="gradingScore")
|
||||
f03_23_three_star: Optional[bool] = Field(None, alias="threeStar")
|
||||
|
||||
# f04 特殊信息
|
||||
f04_30_special_mark: Optional[str] = Field(None, alias="specialMark")
|
||||
f04_31_serial_feature: Optional[str] = Field(None, alias="serialFeature")
|
||||
f04_32_issuer: Optional[str] = Field(None, alias="issuer")
|
||||
f04_33_issue_year: Optional[str] = Field(None, alias="issueYear")
|
||||
f04_34_material: Optional[str] = Field(None, alias="material")
|
||||
f04_35_denomination: Optional[str] = Field(None, alias="denomination")
|
||||
f04_36_issue_quantity: Optional[str] = Field(None, alias="issueQuantity")
|
||||
|
||||
# f05 价格信息
|
||||
f05_40_cost_price: Optional[float] = Field(None, ge=0, alias="costPrice")
|
||||
f05_41_target_price: Optional[float] = Field(None, ge=0, alias="targetPrice")
|
||||
f05_42_goal_price: Optional[float] = Field(None, ge=0, alias="goalPrice")
|
||||
f05_43_repair_fee: Optional[float] = Field(None, ge=0, alias="repairFee")
|
||||
f05_44_grading_fee: Optional[float] = Field(None, ge=0, alias="gradingFee")
|
||||
|
||||
# f06 其他信息
|
||||
f06_50_purpose: Optional[str] = Field(None, alias="purpose")
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class CollectionImageResponse(BaseModel):
|
||||
f99_90_id: str
|
||||
filename: str
|
||||
original_name: Optional[str] = None
|
||||
path: Optional[str] = None
|
||||
f99_92_created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CollectionResponse(CollectionBase):
|
||||
f99_90_id: str = Field(..., alias="id")
|
||||
f99_91_user_id: str = Field(..., alias="userId")
|
||||
f99_92_created_at: Optional[datetime] = Field(None, alias="createdAt")
|
||||
f99_93_updated_at: Optional[datetime] = Field(None, alias="updatedAt")
|
||||
images: List[CollectionImageResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
|
||||
|
||||
|
||||
class CollectionListResponse(BaseModel):
|
||||
data: List[CollectionResponse]
|
||||
pagination: dict
|
||||
|
||||
|
||||
# ============ 操作日志相关 ============
|
||||
|
||||
class OperationBase(BaseModel):
|
||||
type: str = Field(..., max_length=50)
|
||||
price: Optional[float] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class OperationCreate(OperationBase):
|
||||
f99_91_user_id: str
|
||||
|
||||
|
||||
class OperationResponse(OperationBase):
|
||||
f99_90_id: str
|
||||
f99_91_user_id: str
|
||||
f99_93_created_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ============ OCR 相关 ============
|
||||
|
||||
class OCRRequest(BaseModel):
|
||||
image: str
|
||||
ocr_provider: Optional[str] = "aliyun"
|
||||
|
||||
|
||||
class OCRResponse(BaseModel):
|
||||
text: str
|
||||
confidence: float
|
||||
fields: Optional[dict] = None
|
||||
|
||||
|
||||
# ============ 通用响应 ============
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
f99_90_user_id: Optional[str] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# 阿里云OSS服务
|
||||
import os
|
||||
import uuid
|
||||
import datetime
|
||||
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"
|
||||
}
|
||||
|
||||
# 图片压缩配置
|
||||
IMAGE_CONFIG = {
|
||||
"max_size": 1024 * 1024, # 1MB
|
||||
"max_width": 2048,
|
||||
"max_height": 2048,
|
||||
"quality": 85,
|
||||
"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"])
|
||||
|
||||
|
||||
def get_oss_path(prefix: str, user_id: str = None, filename: str = None) -> str:
|
||||
"""生成OSS存储路径"""
|
||||
now = datetime.datetime.now()
|
||||
year = now.strftime("%Y")
|
||||
month = now.strftime("%m")
|
||||
day = now.strftime("%d")
|
||||
|
||||
if filename:
|
||||
ext = filename.split('.')[-1] if '.' in filename else 'jpg'
|
||||
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
else:
|
||||
unique_name = f"{uuid.uuid4().hex}.jpg"
|
||||
|
||||
if user_id:
|
||||
path = f"{prefix}/{user_id}/{year}/{month}/{unique_name}"
|
||||
else:
|
||||
path = f"{prefix}/{year}/{month}/{day}/{unique_name}"
|
||||
|
||||
return path, unique_name
|
||||
|
||||
|
||||
def upload_to_oss(file_data: bytes, oss_key: str, compress: bool = True) -> str:
|
||||
"""上传文件到OSS,返回公网URL"""
|
||||
try:
|
||||
# 如果是图片,进行压缩
|
||||
if compress and any(oss_key.lower().endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.webp']):
|
||||
file_data = compress_image(file_data)
|
||||
|
||||
# 上传文件
|
||||
result = bucket.put_object(oss_key, file_data)
|
||||
|
||||
if result.status == 200:
|
||||
# 返回公网URL
|
||||
return f"{OSS_CONFIG['public_url']}/{oss_key}"
|
||||
else:
|
||||
raise Exception(f"OSS上传失败: {result.status}")
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"OSS上传失败: {str(e)}")
|
||||
|
||||
|
||||
def delete_from_oss(oss_key: str) -> bool:
|
||||
"""从OSS删除文件"""
|
||||
try:
|
||||
result = bucket.delete_object(oss_key)
|
||||
return result.status == 204
|
||||
except Exception as e:
|
||||
print(f"OSS删除失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_public_url(oss_key: str) -> str:
|
||||
"""获取公网URL"""
|
||||
return f"{OSS_CONFIG['public_url']}/{oss_key}"
|
||||
|
||||
|
||||
def compress_image(image_data: bytes, max_size: int = None) -> bytes:
|
||||
"""压缩图片到指定大小以内"""
|
||||
if max_size is None:
|
||||
max_size = IMAGE_CONFIG["max_size"]
|
||||
|
||||
# 如果已经小于限制,直接返回
|
||||
if len(image_data) <= max_size:
|
||||
return image_data
|
||||
|
||||
# 打开图片
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# 如果是PNG且有透明通道,转换为RGB
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 逐步降低质量直到达到目标大小
|
||||
quality = 95
|
||||
compressed_data = image_data
|
||||
|
||||
while quality > 30 and len(compressed_data) > max_size:
|
||||
output = io.BytesIO()
|
||||
img.save(output, format=IMAGE_CONFIG["format"], quality=quality, optimize=True)
|
||||
compressed_data = output.getvalue()
|
||||
quality -= 10
|
||||
|
||||
# 如果还是太大,缩小尺寸
|
||||
if len(compressed_data) > max_size:
|
||||
width, height = img.size
|
||||
while len(compressed_data) > max_size and width > 400:
|
||||
width = int(width * 0.8)
|
||||
height = int(height * 0.8)
|
||||
img_resized = img.resize((width, height), Image.Resampling.LANCZOS)
|
||||
output = io.BytesIO()
|
||||
img_resized.save(output, format=IMAGE_CONFIG["format"], quality=80, optimize=True)
|
||||
compressed_data = output.getvalue()
|
||||
|
||||
return compressed_data
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
# 阿里云短信服务
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
# 阿里云短信配置
|
||||
SMS_CONFIG = {
|
||||
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
||||
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
||||
"sign_name": "阿里云",
|
||||
"template_code": "100001",
|
||||
}
|
||||
|
||||
# 验证码缓存(生产环境建议用Redis)
|
||||
# 格式: { phone: { code: "123456", expire: 1234567890 } }
|
||||
VERIFICATION_CODES = {}
|
||||
|
||||
|
||||
def generate_code(length: int = 6) -> str:
|
||||
"""生成6位数字验证码"""
|
||||
return ''.join(random.choices(string.digits, k=length))
|
||||
|
||||
|
||||
def send_verification_code(phone: str) -> dict:
|
||||
"""发送短信验证码"""
|
||||
from alibabacloud_dysmsapi20170525 import models
|
||||
from alibabacloud_dysmsapi20170525.client import Client
|
||||
from alibabacloud_tea_openapi import models as open_models
|
||||
|
||||
try:
|
||||
# 生成验证码
|
||||
code = generate_code(6)
|
||||
|
||||
# 配置客户端
|
||||
config = open_models.Config(
|
||||
access_key_id=SMS_CONFIG["access_key_id"],
|
||||
access_key_secret=SMS_CONFIG["access_key_secret"],
|
||||
)
|
||||
config.endpoint = "dysmsapi.aliyuncs.com"
|
||||
config.region_id = "cn-hangzhou"
|
||||
|
||||
client = Client(config)
|
||||
|
||||
# 构造请求
|
||||
request = models.SendSmsRequest(
|
||||
phone_numbers=phone,
|
||||
sign_name=SMS_CONFIG["sign_name"],
|
||||
template_code=SMS_CONFIG["template_code"],
|
||||
template_param=f'{{"code":"{code}"}}'
|
||||
)
|
||||
|
||||
# 发送
|
||||
response = client.send_sms(request)
|
||||
|
||||
# 检查结果
|
||||
if response.body.code == "OK":
|
||||
# 保存验证码
|
||||
VERIFICATION_CODES[phone] = {
|
||||
"code": code,
|
||||
"expire": int(time.time()) + 300 # 5分钟有效
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"message": "验证码已发送",
|
||||
"expire": 300
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"发送失败: {response.body.message}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"发送失败: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
def verify_code(phone: str, code: str) -> bool:
|
||||
"""验证验证码"""
|
||||
if phone not in VERIFICATION_CODES:
|
||||
return False
|
||||
|
||||
stored = VERIFICATION_CODES[phone]
|
||||
|
||||
# 检查是否过期
|
||||
if int(time.time()) > stored["expire"]:
|
||||
del VERIFICATION_CODES[phone]
|
||||
return False
|
||||
|
||||
# 验证码匹配
|
||||
if stored["code"] == code:
|
||||
# 验证成功,删除验证码
|
||||
del VERIFICATION_CODES[phone]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_code_exists(phone: str) -> bool:
|
||||
"""检查是否已发送过验证码"""
|
||||
return phone in VERIFICATION_CODES
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# 甲辰藏品管理系统后端 - Python 依赖
|
||||
# 版本:v1.0.0
|
||||
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.3
|
||||
python-jose[cryptography]==3.3.0
|
||||
bcrypt==4.1.2
|
||||
python-multipart==0.0.6
|
||||
pillow==10.2.0
|
||||
dashscope==1.14.1
|
||||
Loading…
Reference in New Issue