feat: AI套图分层方案 + Gemini集成 - 4种图案类型处理 + 正片叠底 + 宽高比 + 模型选择
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import shutil
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status, Form
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status, Form, Header
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import get_db
|
||||
from app.models.group import PluginGroup as DBPluginGroup
|
||||
@@ -8,21 +8,21 @@ from app.models.user import User
|
||||
from app.schemas.group import PluginGroupCreate, PluginGroupUpdate, PluginGroup
|
||||
from app.schemas.admin import UserInfo
|
||||
from app.core.config import settings
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Hardcoded admin token for simplicity as per requirements
|
||||
ADMIN_TOKEN = "admin-secret-token"
|
||||
# 从配置读取管理员令牌
|
||||
ADMIN_TOKEN = settings.ADMIN_TOKEN
|
||||
|
||||
def verify_admin(token: str = Form(...)):
|
||||
if token != ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Admin permission required")
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
def get_admin_dep(x_admin_token: str = None):
|
||||
# Alternative using header
|
||||
def verify_admin_header(x_admin_token: Optional[str] = Header(None, alias="X-Admin-Token")):
|
||||
"""通过 Header 验证管理员身份"""
|
||||
if x_admin_token != ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Admin permission required")
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
# Ensure archives directory exists
|
||||
ARCHIVES_DIR = "archives"
|
||||
@@ -31,11 +31,11 @@ os.makedirs(ARCHIVES_DIR, exist_ok=True)
|
||||
@router.post("/upload_version")
|
||||
async def upload_version(
|
||||
file: UploadFile = File(...),
|
||||
# token: str = Form(...), # Simple auth
|
||||
token: str = Form(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# if token != ADMIN_TOKEN:
|
||||
# raise HTTPException(status_code=403, detail="Invalid admin token")
|
||||
if token != ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="Invalid admin token")
|
||||
|
||||
file_location = os.path.join(ARCHIVES_DIR, file.filename)
|
||||
with open(file_location, "wb+") as file_object:
|
||||
@@ -44,7 +44,7 @@ async def upload_version(
|
||||
return {"code": 200, "message": f"File '{file.filename}' uploaded successfully", "filename": file.filename}
|
||||
|
||||
@router.get("/archives")
|
||||
async def list_archives():
|
||||
async def list_archives(_: None = Depends(verify_admin_header)):
|
||||
if not os.path.exists(ARCHIVES_DIR):
|
||||
return []
|
||||
files = os.listdir(ARCHIVES_DIR)
|
||||
@@ -53,7 +53,7 @@ async def list_archives():
|
||||
return files
|
||||
|
||||
@router.post("/groups", response_model=PluginGroup)
|
||||
async def create_group(group: PluginGroupCreate, db: Session = Depends(get_db)):
|
||||
async def create_group(group: PluginGroupCreate, db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
db_group = DBPluginGroup(**group.model_dump())
|
||||
db.add(db_group)
|
||||
db.commit()
|
||||
@@ -61,11 +61,11 @@ async def create_group(group: PluginGroupCreate, db: Session = Depends(get_db)):
|
||||
return db_group
|
||||
|
||||
@router.get("/groups", response_model=List[PluginGroup])
|
||||
async def list_groups(db: Session = Depends(get_db)):
|
||||
async def list_groups(db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
return db.query(DBPluginGroup).all()
|
||||
|
||||
@router.put("/groups/{group_id}", response_model=PluginGroup)
|
||||
async def update_group(group_id: int, group_update: PluginGroupUpdate, db: Session = Depends(get_db)):
|
||||
async def update_group(group_id: int, group_update: PluginGroupUpdate, db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
db_group = db.query(DBPluginGroup).filter(DBPluginGroup.id == group_id).first()
|
||||
if not db_group:
|
||||
raise HTTPException(status_code=404, detail="Group not found")
|
||||
@@ -79,11 +79,11 @@ async def update_group(group_id: int, group_update: PluginGroupUpdate, db: Sessi
|
||||
return db_group
|
||||
|
||||
@router.get("/users", response_model=List[UserInfo])
|
||||
async def list_users(db: Session = Depends(get_db)):
|
||||
async def list_users(db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
return db.query(User).all()
|
||||
|
||||
@router.put("/users/{user_id}/group")
|
||||
async def update_user_group(user_id: int, group_id: int, db: Session = Depends(get_db)):
|
||||
async def update_user_group(user_id: int, group_id: int, db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
@@ -97,7 +97,7 @@ async def update_user_group(user_id: int, group_id: int, db: Session = Depends(g
|
||||
return {"code": 200, "message": "User group updated"}
|
||||
|
||||
@router.put("/users/{user_id}/permissions")
|
||||
async def update_user_permissions(user_id: int, permissions: str = Form(...), db: Session = Depends(get_db)):
|
||||
async def update_user_permissions(user_id: int, permissions: str = Form(...), db: Session = Depends(get_db), _: None = Depends(verify_admin_header)):
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db import get_db
|
||||
from app.models.business import FeatureConfig, VipConfig, CheckInConfig
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -51,8 +52,7 @@ class CheckInConfigUpdate(BaseModel):
|
||||
|
||||
def verify_admin_token(token: str):
|
||||
"""验证管理员Token"""
|
||||
expected_token = "admin-secret-token"
|
||||
if token != expected_token:
|
||||
if token != settings.ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="管理员Token无效")
|
||||
|
||||
# ==================== 功能配置管理 ====================
|
||||
|
||||
1270
Server/app/api/v1/ai_chat.py
Normal file
1270
Server/app/api/v1/ai_chat.py
Normal file
File diff suppressed because it is too large
Load Diff
239
Server/app/api/v1/ai_tools.py
Normal file
239
Server/app/api/v1/ai_tools.py
Normal file
@@ -0,0 +1,239 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
AI 工具定义
|
||||
将 PS 操作注册为 function calling 的 tool schema
|
||||
前端收到 tool_calls 后执行 JSX,把结果回传
|
||||
"""
|
||||
|
||||
# ==================== 工具 Schema(OpenAI function calling 格式)====================
|
||||
|
||||
PS_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_document_info",
|
||||
"description": "获取当前 Photoshop 文档信息(名称、尺寸、分辨率)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_layer_structure",
|
||||
"description": "获取当前文档的图层结构树(所有顶层组和子图层)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_layer",
|
||||
"description": "在 Photoshop 中创建新图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "新图层的名称"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "rename_layer",
|
||||
"description": "重命名当前选中的图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "新名称"
|
||||
}
|
||||
},
|
||||
"required": ["new_name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_layer",
|
||||
"description": "删除当前选中的图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "duplicate_layer",
|
||||
"description": "复制当前选中的图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "align_layers",
|
||||
"description": "对齐当前选中的图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["left", "right", "centerH", "top", "bottom", "centerV"],
|
||||
"description": "对齐方式:left=左对齐, right=右对齐, centerH=水平居中, top=顶对齐, bottom=底对齐, centerV=垂直居中"
|
||||
}
|
||||
},
|
||||
"required": ["type"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "merge_layers",
|
||||
"description": "合并当前选中的图层",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_layer_group",
|
||||
"description": "创建图层组(如果不存在)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "图层组名称"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "move_layer_to_group",
|
||||
"description": "将当前图层移入指定图层组",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_name": {
|
||||
"type": "string",
|
||||
"description": "目标图层组名称"
|
||||
}
|
||||
},
|
||||
"required": ["group_name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
# ==================== 套图工具 ====================
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "identify_pieces",
|
||||
"description": "识别裁片部位。截取当前PS画布并分析每个裁片图层的形状和位置,用视觉AI识别哪个图层是前片、后片、袖子等。应在套图前先调用,以建立图层名到裁片部位的对应关系。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_garment_preview",
|
||||
"description": "【阶段1-生成预览】将用户上传的成衣照片中的花样,填充到 PS 文档的裁片轮廓上,生成一张带轮廓的花样预览图。预览图会显示在聊天中,供用户确认效果。需要用户已上传成衣图片。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "extract_and_apply_all_pieces",
|
||||
"description": "【阶段2-提取套图】用户确认预览 OK 后调用。根据 identify_pieces 的分析结果,对每个裁片执行不同操作:solid=PS纯色填充;fill_pattern=AI提取花型铺满;theme_pattern=底层纯色填充+上层AI提取主题图案(白底+正片叠底);mixed_pattern=底层AI提取花型+上层AI提取主题图案(白底+正片叠底)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pieces": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "裁片图层名称"},
|
||||
"type": {"type": "string", "enum": ["solid", "fill_pattern", "theme_pattern", "mixed_pattern"], "description": "图案类型:solid=纯色;fill_pattern=花型铺满;theme_pattern=主题图案+纯色底;mixed_pattern=主题图案+花型底纹"},
|
||||
"color": {"type": "string", "description": "颜色 hex 值。solid 和 theme_pattern 必须提供(theme_pattern 的 color 是底色)"},
|
||||
"description": {"type": "string", "description": "图案内容描述"}
|
||||
},
|
||||
"required": ["name", "type"]
|
||||
},
|
||||
"description": "裁片列表,type 和 color 来自 identify_pieces 的分析结果"
|
||||
}
|
||||
},
|
||||
"required": ["pieces"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "verify_pattern_result",
|
||||
"description": "验证套图效果。自动截取当前 PS 画布与原始成衣对比,由视觉 AI 评分(1-10)并给出改进建议。应在套图完成后调用。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
# 工具名称映射(中文显示用)
|
||||
TOOL_DISPLAY_NAMES = {
|
||||
"get_document_info": "查看文档信息",
|
||||
"get_layer_structure": "查看图层结构",
|
||||
"create_layer": "新建图层",
|
||||
"rename_layer": "重命名图层",
|
||||
"delete_layer": "删除图层",
|
||||
"duplicate_layer": "复制图层",
|
||||
"align_layers": "对齐图层",
|
||||
"merge_layers": "合并图层",
|
||||
"create_layer_group": "创建图层组",
|
||||
"move_layer_to_group": "移入图层组",
|
||||
"identify_pieces": "识别裁片部位",
|
||||
"generate_garment_preview": "生成花样预览",
|
||||
"extract_and_apply_all_pieces": "提取并套图",
|
||||
"verify_pattern_result": "验证套图效果",
|
||||
}
|
||||
506
Server/app/api/v1/algorithm.py
Normal file
506
Server/app/api/v1/algorithm.py
Normal file
@@ -0,0 +1,506 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PLT 裁片处理接口 (Photoshop 插件专用)
|
||||
功能:解析 PLT 文件,生成裁片图片和坐标信息
|
||||
优化版:提升匹配计算速度 + 并行图片生成
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import base64
|
||||
import json
|
||||
import numpy as np
|
||||
import cv2
|
||||
from typing import List, Optional, Dict, Tuple
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from PIL import Image
|
||||
from shapely import affinity
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import asyncio
|
||||
|
||||
# 导入自定义模块
|
||||
import sys
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))))
|
||||
from pltreader import PltReader
|
||||
|
||||
from app.db import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.core.qiniu_storage import qiniu_storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class PieceInfo(BaseModel):
|
||||
"""单个裁片信息"""
|
||||
size: str
|
||||
image_base64: Optional[str] = None # base64 图片(未启用云存储时使用)
|
||||
image_url: Optional[str] = None # 云存储 URL(启用七牛云时使用)
|
||||
width_px: int
|
||||
height_px: int
|
||||
width_cm: float
|
||||
height_cm: float
|
||||
center_x_cm: float
|
||||
center_y_cm: float
|
||||
left_cm: float
|
||||
top_cm: float
|
||||
|
||||
class GroupInfo(BaseModel):
|
||||
"""裁片分组信息"""
|
||||
group_id: int
|
||||
pieces: List[PieceInfo]
|
||||
|
||||
class MatchInfo(BaseModel):
|
||||
"""双文件匹配结果"""
|
||||
standard_id: int
|
||||
rotated_id: int
|
||||
distance: float
|
||||
angle: float
|
||||
|
||||
class SizeMatchInfo(BaseModel):
|
||||
"""每个尺码的匹配结果"""
|
||||
size: str
|
||||
matches: List[MatchInfo]
|
||||
|
||||
class ProcessPltResponse(BaseModel):
|
||||
"""API 响应"""
|
||||
success: bool
|
||||
total_groups: int
|
||||
groups: List[GroupInfo]
|
||||
match_analysis: Optional[List[SizeMatchInfo]] = None
|
||||
|
||||
# ==================== 优化的辅助函数 ====================
|
||||
|
||||
def parse_plt_file(file_content: str, tolerance: int = 10):
|
||||
"""解析 PLT 文件内容"""
|
||||
reader = PltReader(io.StringIO(file_content))
|
||||
return reader.get_output(tolerance=tolerance)
|
||||
|
||||
def normalize_polygon(poly):
|
||||
"""
|
||||
预处理多边形:质心对齐 + 面积归一化
|
||||
返回: (归一化多边形, 原始面积, 原始周长)
|
||||
"""
|
||||
area = poly.area
|
||||
perimeter = poly.length
|
||||
|
||||
if area <= 0:
|
||||
return poly, 0, 0
|
||||
|
||||
# 质心对齐
|
||||
poly = affinity.translate(poly, xoff=-poly.centroid.x, yoff=-poly.centroid.y)
|
||||
|
||||
# 缩放到统一面积
|
||||
target_area = 10000
|
||||
scale = (target_area / area) ** 0.5
|
||||
poly = affinity.scale(poly, xfact=scale, yfact=scale, origin=(0, 0))
|
||||
|
||||
return poly, area, perimeter
|
||||
|
||||
def fast_shape_similarity(poly1_data: Tuple, poly2_data: Tuple) -> Tuple[float, int]:
|
||||
"""
|
||||
快速形状相似度计算
|
||||
poly_data: (归一化多边形, 原始面积, 原始周长)
|
||||
返回: (距离, 最佳角度)
|
||||
"""
|
||||
poly1, area1, peri1 = poly1_data
|
||||
poly2, area2, peri2 = poly2_data
|
||||
|
||||
# 面积比初筛(面积差异太大的直接跳过)
|
||||
if area1 > 0 and area2 > 0:
|
||||
area_ratio = min(area1, area2) / max(area1, area2)
|
||||
if area_ratio < 0.5: # 面积差异超过50%
|
||||
return float('inf'), 0
|
||||
|
||||
# 只尝试 0° 和 180°(服装裁片通常只需要这两个角度)
|
||||
dist_min = float('inf')
|
||||
best_angle = 0
|
||||
|
||||
for angle in [0, 180]:
|
||||
try:
|
||||
rotated = affinity.rotate(poly2, angle, origin='centroid') if angle != 0 else poly2
|
||||
# 使用 symmetric_difference 面积作为距离度量(比 hausdorff 快很多)
|
||||
diff_area = poly1.symmetric_difference(rotated).area
|
||||
dist = diff_area / max(poly1.area, 1)
|
||||
except:
|
||||
dist = float('inf')
|
||||
|
||||
if dist < dist_min:
|
||||
dist_min = dist
|
||||
best_angle = angle
|
||||
|
||||
return dist_min, best_angle
|
||||
|
||||
def batch_normalize_polygons(pieces: List[Dict]) -> List[Tuple]:
|
||||
"""批量预处理多边形"""
|
||||
return [normalize_polygon(p["data"]) for p in pieces]
|
||||
|
||||
def compute_matching_matrix(base_polys: List[Tuple], compare_polys: List[Tuple]) -> np.ndarray:
|
||||
"""计算匹配成本矩阵"""
|
||||
n, m = len(base_polys), len(compare_polys)
|
||||
cost_matrix = np.zeros((n, m))
|
||||
|
||||
for i in range(n):
|
||||
for j in range(m):
|
||||
dist, _ = fast_shape_similarity(base_polys[i], compare_polys[j])
|
||||
cost_matrix[i, j] = dist
|
||||
|
||||
return cost_matrix
|
||||
|
||||
def apply_rotation_to_image(pil_img: Image.Image, rotation: int) -> Image.Image:
|
||||
"""应用旋转变换"""
|
||||
if rotation == 90:
|
||||
return pil_img.rotate(-90, expand=True)
|
||||
elif rotation == -90:
|
||||
return pil_img.rotate(90, expand=True)
|
||||
elif rotation == 180:
|
||||
return pil_img.rotate(180, expand=True)
|
||||
return pil_img
|
||||
|
||||
def calculate_piece_coordinates(polygon, bounds, plt_to_cm, rotation=0) -> Dict:
|
||||
"""计算裁片坐标(厘米单位)"""
|
||||
min_x, min_y, max_x, max_y = bounds
|
||||
|
||||
centroid = polygon.centroid
|
||||
orig_center_x = (centroid.x - min_x) * plt_to_cm
|
||||
orig_center_y = (centroid.y - min_y) * plt_to_cm
|
||||
|
||||
piece_bounds = polygon.bounds
|
||||
orig_piece_width = (piece_bounds[2] - piece_bounds[0]) * plt_to_cm
|
||||
orig_piece_height = (piece_bounds[3] - piece_bounds[1]) * plt_to_cm
|
||||
|
||||
orig_canvas_width = (max_x - min_x) * plt_to_cm
|
||||
orig_canvas_height = (max_y - min_y) * plt_to_cm
|
||||
|
||||
if rotation == 90:
|
||||
center_x = orig_center_y
|
||||
center_y = orig_canvas_width - orig_center_x
|
||||
piece_width, piece_height = orig_piece_height, orig_piece_width
|
||||
elif rotation == -90:
|
||||
center_x = orig_canvas_height - orig_center_y
|
||||
center_y = orig_center_x
|
||||
piece_width, piece_height = orig_piece_height, orig_piece_width
|
||||
elif rotation == 180:
|
||||
center_x = orig_canvas_width - orig_center_x
|
||||
center_y = orig_canvas_height - orig_center_y
|
||||
piece_width, piece_height = orig_piece_width, orig_piece_height
|
||||
else:
|
||||
center_x, center_y = orig_center_x, orig_center_y
|
||||
piece_width, piece_height = orig_piece_width, orig_piece_height
|
||||
|
||||
left_cm = center_x - piece_width / 2
|
||||
top_cm = center_y - piece_height / 2
|
||||
|
||||
return {
|
||||
"center_x_cm": round(center_x, 2),
|
||||
"center_y_cm": round(center_y, 2),
|
||||
"left_cm": round(left_cm, 2),
|
||||
"top_cm": round(top_cm, 2),
|
||||
"width_cm": round(piece_width, 2),
|
||||
"height_cm": round(piece_height, 2)
|
||||
}
|
||||
|
||||
def get_size_matching(clusters: List, size_num: int) -> Dict[int, List[int]]:
|
||||
"""
|
||||
获取尺码间匹配关系(优化版)
|
||||
返回: {base_index: [matched_indices_per_size]}
|
||||
"""
|
||||
# 提取各尺码的父节点
|
||||
size_parents = []
|
||||
for cluster in clusters:
|
||||
parents = [p for p in cluster if p["parent"] is None]
|
||||
size_parents.append(parents)
|
||||
|
||||
# 预处理第一个尺码的多边形
|
||||
base_pieces = size_parents[0]
|
||||
base_polys = batch_normalize_polygons(base_pieces)
|
||||
|
||||
# 初始化匹配结果
|
||||
match_result = {p["index"]: [p["index"]] for p in base_pieces}
|
||||
|
||||
# 依次匹配其他尺码
|
||||
for size_idx in range(1, len(size_parents)):
|
||||
compare_pieces = size_parents[size_idx]
|
||||
compare_polys = batch_normalize_polygons(compare_pieces)
|
||||
|
||||
# 计算成本矩阵
|
||||
cost_matrix = compute_matching_matrix(base_polys, compare_polys)
|
||||
|
||||
# 匈牙利算法匹配
|
||||
row_ind, col_ind = linear_sum_assignment(cost_matrix)
|
||||
|
||||
for i, j in zip(row_ind, col_ind):
|
||||
base_index = base_pieces[i]['index']
|
||||
matched_index = compare_pieces[j]['index']
|
||||
match_result[base_index].append(matched_index)
|
||||
|
||||
return match_result
|
||||
|
||||
def generate_single_piece_image(args: Tuple) -> Dict:
|
||||
"""生成单个裁片图片(用于并行处理)"""
|
||||
output, node, scale_factor, rotation, dpi, size_label, group_id, global_bounds, plt_to_cm = args
|
||||
|
||||
try:
|
||||
nodes_to_draw = [node] + node['child']
|
||||
|
||||
# 绘制图像
|
||||
img = output._draw_nodes(nodes_to_draw, scale_factor, show_id=False)
|
||||
img_rgba = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA)
|
||||
pil_img = Image.fromarray(img_rgba)
|
||||
|
||||
# 应用旋转
|
||||
pil_img = apply_rotation_to_image(pil_img, rotation)
|
||||
|
||||
# 转换为 Base64
|
||||
buffer = io.BytesIO()
|
||||
pil_img.save(buffer, format='PNG', dpi=(dpi, dpi))
|
||||
buffer.seek(0)
|
||||
base64_str = base64.b64encode(buffer.read()).decode('utf-8')
|
||||
image_base64 = f"data:image/png;base64,{base64_str}"
|
||||
|
||||
# 计算坐标
|
||||
coords = calculate_piece_coordinates(node["data"], global_bounds, plt_to_cm, rotation)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"group_id": group_id,
|
||||
"size_label": size_label,
|
||||
"image_base64": image_base64,
|
||||
"width_px": pil_img.width,
|
||||
"height_px": pil_img.height,
|
||||
**coords
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e), "group_id": group_id, "size_label": size_label}
|
||||
|
||||
def get_match_result_between_files(standard_clusters, rotated_clusters) -> List[List[Dict]]:
|
||||
"""计算两个文件间的匹配结果(优化版)"""
|
||||
all_size_matches = []
|
||||
|
||||
for standard_cluster, rotated_cluster in zip(standard_clusters, rotated_clusters):
|
||||
standard_parents = [p for p in standard_cluster if p["parent"] is None]
|
||||
rotated_parents = [p for p in rotated_cluster if p["parent"] is None]
|
||||
|
||||
if len(standard_parents) != len(rotated_parents):
|
||||
continue
|
||||
|
||||
n = len(standard_parents)
|
||||
|
||||
# 批量预处理
|
||||
std_polys = batch_normalize_polygons(standard_parents)
|
||||
rot_polys = batch_normalize_polygons(rotated_parents)
|
||||
|
||||
# 计算成本矩阵和角度矩阵
|
||||
cost_matrix = np.zeros((n, n))
|
||||
rotation_matrix = np.zeros((n, n))
|
||||
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
dist, angle = fast_shape_similarity(std_polys[i], rot_polys[j])
|
||||
cost_matrix[i, j] = dist
|
||||
rotation_matrix[i, j] = angle
|
||||
|
||||
# 匈牙利算法匹配
|
||||
row_ind, col_ind = linear_sum_assignment(cost_matrix)
|
||||
|
||||
matches = []
|
||||
for i, j in zip(row_ind, col_ind):
|
||||
matches.append({
|
||||
"standard_id": standard_parents[i]["index"],
|
||||
"rotated_id": rotated_parents[j]["index"],
|
||||
"distance": round(cost_matrix[i, j], 4),
|
||||
"angle": rotation_matrix[i, j]
|
||||
})
|
||||
|
||||
all_size_matches.append(matches)
|
||||
|
||||
return all_size_matches
|
||||
|
||||
# ==================== API 接口 ====================
|
||||
|
||||
@router.post("/algorithm/process_plt", response_model=ProcessPltResponse)
|
||||
async def process_plt(
|
||||
file: UploadFile = File(..., description="标准 PLT 文件"),
|
||||
rotated_file: Optional[UploadFile] = File(None, description="旋转后的 PLT 文件(可选)"),
|
||||
size_labels: str = Form(..., description="尺码标签 JSON 数组"),
|
||||
dpi: int = Form(150, description="输出图片分辨率(DPI)"),
|
||||
rotation: int = Form(0, description="强制旋转角度 (0/90/-90/180)"),
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""PLT 裁片处理接口(优化版)"""
|
||||
|
||||
try:
|
||||
# 1. 解析参数
|
||||
try:
|
||||
size_labels_list = json.loads(size_labels)
|
||||
except:
|
||||
raise HTTPException(status_code=400, detail="size_labels 格式错误")
|
||||
|
||||
size_num = len(size_labels_list)
|
||||
scale_factor = dpi / 1016
|
||||
plt_to_cm = 2.54 / 1016
|
||||
|
||||
# 2. 读取并解析 PLT 文件
|
||||
print(f"[PLT API] 用户 {current_username} 正在处理: {file.filename}")
|
||||
file_content = (await file.read()).decode('utf-8', errors='ignore')
|
||||
output = parse_plt_file(file_content)
|
||||
|
||||
# 3. 获取尺码聚类
|
||||
clusters = output.get_single_size_info(size_num=size_num)
|
||||
|
||||
# 4. 获取尺码间匹配关系(优化版)
|
||||
match_result = get_size_matching(clusters, size_num)
|
||||
|
||||
# 5. 构建索引映射
|
||||
all_nodes = {node["index"]: node for node in output.nodes}
|
||||
|
||||
# 6. 计算全局边界
|
||||
from shapely.geometry import MultiPolygon as ShapelyMultiPolygon
|
||||
all_polygons = [node["data"] for node in output.nodes]
|
||||
global_bounds = ShapelyMultiPolygon(all_polygons).bounds
|
||||
|
||||
# 7. 并行生成图片和坐标数据
|
||||
print(f"[PLT API] 开始并行生成图片...")
|
||||
|
||||
# 准备所有任务参数
|
||||
tasks = []
|
||||
for group_id, (base_index, matched_indices) in enumerate(match_result.items(), start=1):
|
||||
for size_idx, piece_index in enumerate(matched_indices):
|
||||
size_label = size_labels_list[size_idx]
|
||||
node = all_nodes[piece_index]
|
||||
tasks.append((
|
||||
output, node, scale_factor, rotation, dpi,
|
||||
size_label, group_id, global_bounds, plt_to_cm
|
||||
))
|
||||
|
||||
# 使用线程池并行处理(CPU 核心数)
|
||||
max_workers = min(os.cpu_count() or 4, 8) # 最多8线程
|
||||
results = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_task = {executor.submit(generate_single_piece_image, task): task for task in tasks}
|
||||
for future in as_completed(future_to_task):
|
||||
result = future.result()
|
||||
if result["success"]:
|
||||
results.append(result)
|
||||
|
||||
# 上传图片到七牛云(如果启用)
|
||||
image_urls = {} # key: (group_id, size_label) -> url
|
||||
if qiniu_storage.enabled:
|
||||
# 获取用户ID用于文件隔离
|
||||
from app.models.user import User
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
user_id = user.id if user else None
|
||||
|
||||
print(f"[PLT API] 正在上传图片到七牛云,共 {len(results)} 张,用户: {current_username}(id={user_id})...")
|
||||
upload_tasks = []
|
||||
for r in results:
|
||||
base64_data = r["image_base64"]
|
||||
name = f"g{r['group_id']}_{r['size_label']}"
|
||||
upload_tasks.append((base64_data, name))
|
||||
|
||||
# 上传到 plt/{日期}/u{用户ID}/ 目录
|
||||
upload_results = qiniu_storage.upload_batch(upload_tasks, key_prefix="plt", user_id=user_id)
|
||||
|
||||
success_count = 0
|
||||
for i, (success, url_or_base64, name) in enumerate(upload_results):
|
||||
if success:
|
||||
r = results[i]
|
||||
image_urls[(r["group_id"], r["size_label"])] = url_or_base64
|
||||
success_count += 1
|
||||
|
||||
print(f"[PLT API] 七牛云上传完成,成功 {success_count}/{len(results)} 张")
|
||||
|
||||
# 按 group_id 分组
|
||||
groups_dict: Dict[int, List] = {}
|
||||
for r in results:
|
||||
gid = r["group_id"]
|
||||
key = (gid, r["size_label"])
|
||||
|
||||
# 优先使用云存储 URL
|
||||
if key in image_urls:
|
||||
piece = PieceInfo(
|
||||
size=r["size_label"],
|
||||
image_url=image_urls[key],
|
||||
image_base64=None,
|
||||
width_px=r["width_px"],
|
||||
height_px=r["height_px"],
|
||||
center_x_cm=r["center_x_cm"],
|
||||
center_y_cm=r["center_y_cm"],
|
||||
left_cm=r["left_cm"],
|
||||
top_cm=r["top_cm"],
|
||||
width_cm=r["width_cm"],
|
||||
height_cm=r["height_cm"]
|
||||
)
|
||||
else:
|
||||
piece = PieceInfo(
|
||||
size=r["size_label"],
|
||||
image_base64=r["image_base64"],
|
||||
image_url=None,
|
||||
width_px=r["width_px"],
|
||||
height_px=r["height_px"],
|
||||
center_x_cm=r["center_x_cm"],
|
||||
center_y_cm=r["center_y_cm"],
|
||||
left_cm=r["left_cm"],
|
||||
top_cm=r["top_cm"],
|
||||
width_cm=r["width_cm"],
|
||||
height_cm=r["height_cm"]
|
||||
)
|
||||
|
||||
if gid not in groups_dict:
|
||||
groups_dict[gid] = []
|
||||
groups_dict[gid].append(piece)
|
||||
|
||||
# 构建 groups 列表
|
||||
groups_list = []
|
||||
for gid, pieces in groups_dict.items():
|
||||
# 按尺码排序
|
||||
sorted_pieces = sorted(pieces, key=lambda p: size_labels_list.index(p.size) if p.size in size_labels_list else 999)
|
||||
# 计算该组的面积(用第一个裁片的面积作为代表)
|
||||
area = sorted_pieces[0].width_cm * sorted_pieces[0].height_cm if sorted_pieces else 0
|
||||
groups_list.append((gid, sorted_pieces, area))
|
||||
|
||||
# 按面积从大到小排序,重新分配 group_id
|
||||
groups_list.sort(key=lambda x: -x[2]) # 面积降序
|
||||
groups = [
|
||||
GroupInfo(group_id=new_id, pieces=pieces)
|
||||
for new_id, (_, pieces, _) in enumerate(groups_list, start=1)
|
||||
]
|
||||
|
||||
print(f"[PLT API] 图片生成完成,共 {len(results)} 张")
|
||||
|
||||
# 8. 处理双文件匹配(可选)
|
||||
match_analysis = None
|
||||
if rotated_file:
|
||||
print(f"[PLT API] 双文件匹配: {file.filename} vs {rotated_file.filename}")
|
||||
rotated_content = (await rotated_file.read()).decode('utf-8', errors='ignore')
|
||||
rotated_output = parse_plt_file(rotated_content)
|
||||
rotated_clusters = rotated_output.get_single_size_info(size_num=size_num)
|
||||
|
||||
all_matches = get_match_result_between_files(clusters, rotated_clusters)
|
||||
|
||||
match_analysis = [
|
||||
SizeMatchInfo(size=size_labels_list[i], matches=[MatchInfo(**m) for m in matches])
|
||||
for i, matches in enumerate(all_matches)
|
||||
]
|
||||
|
||||
# 9. 返回结果
|
||||
print(f"[PLT API] 处理完成,共 {len(groups)} 组裁片")
|
||||
return ProcessPltResponse(
|
||||
success=True,
|
||||
total_groups=len(groups),
|
||||
groups=groups,
|
||||
match_analysis=match_analysis
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"[PLT API] 处理失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"处理失败: {str(e)}")
|
||||
@@ -3,10 +3,12 @@
|
||||
记录和分析用户操作
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
from datetime import datetime
|
||||
from app.core.security import get_current_user
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -14,28 +16,28 @@ router = APIRouter()
|
||||
action_logs = []
|
||||
|
||||
class ActionLog(BaseModel):
|
||||
username: str
|
||||
device_id: str
|
||||
action: str
|
||||
details: Optional[Any] = None
|
||||
timestamp: int
|
||||
session_id: str
|
||||
device_id: Optional[str] = None
|
||||
|
||||
class ActionLogResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
@router.post("/log", response_model=ActionLogResponse)
|
||||
async def log_action(log: ActionLog):
|
||||
"""
|
||||
记录用户行为
|
||||
"""
|
||||
async def log_action(log: ActionLog, current_username: str = Depends(get_current_user)):
|
||||
"""记录用户行为(仅记录当前登录用户的操作)"""
|
||||
try:
|
||||
# 添加服务器时间
|
||||
log_entry = {
|
||||
**log.dict(),
|
||||
"username": current_username,
|
||||
"action": log.action,
|
||||
"details": log.details,
|
||||
"timestamp": log.timestamp,
|
||||
"session_id": log.session_id,
|
||||
"device_id": log.device_id,
|
||||
"server_time": datetime.now().isoformat(),
|
||||
"ip": "unknown" # 可以从请求中获取
|
||||
}
|
||||
|
||||
action_logs.append(log_entry)
|
||||
@@ -44,40 +46,37 @@ async def log_action(log: ActionLog):
|
||||
if len(action_logs) > 10000:
|
||||
action_logs.pop(0)
|
||||
|
||||
# 可以在这里添加异常检测逻辑
|
||||
# 例如:检测同一用户短时间内的大量操作
|
||||
|
||||
return ActionLogResponse(success=True, message="已记录")
|
||||
except Exception as e:
|
||||
return ActionLogResponse(success=False, message=str(e))
|
||||
|
||||
@router.get("/stats/{username}")
|
||||
async def get_user_stats(username: str):
|
||||
"""
|
||||
获取用户统计信息
|
||||
"""
|
||||
user_logs = [log for log in action_logs if log.get("username") == username]
|
||||
@router.get("/stats/me")
|
||||
async def get_my_stats(current_username: str = Depends(get_current_user)):
|
||||
"""获取当前用户的统计信息"""
|
||||
user_logs = [log for log in action_logs if log.get("username") == current_username]
|
||||
|
||||
# 统计各操作类型的次数
|
||||
action_counts = {}
|
||||
action_counts: dict[str, int] = {}
|
||||
for log in user_logs:
|
||||
action = log.get("action", "unknown")
|
||||
action_counts[action] = action_counts.get(action, 0) + 1
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"username": current_username,
|
||||
"total_actions": len(user_logs),
|
||||
"action_counts": action_counts,
|
||||
"recent_actions": user_logs[-10:] # 最近 10 条
|
||||
"recent_actions": user_logs[-10:]
|
||||
}
|
||||
|
||||
@router.get("/recent")
|
||||
async def get_recent_logs(limit: int = 100):
|
||||
"""
|
||||
获取最近的操作日志(管理员用)
|
||||
"""
|
||||
async def get_recent_logs(
|
||||
limit: int = 100,
|
||||
admin_token: Optional[str] = Header(None, alias="X-Admin-Token")
|
||||
):
|
||||
"""获取最近的操作日志(仅管理员)"""
|
||||
if admin_token != settings.ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
return {
|
||||
"total": len(action_logs),
|
||||
"logs": action_logs[-limit:]
|
||||
}
|
||||
|
||||
|
||||
@@ -55,13 +55,14 @@ async def reset_password(body: ResetPasswordRequest, db: Session = Depends(get_d
|
||||
return auth_service.reset_password(db, body.token, body.new_password, body.email)
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(body: UserLogout, db: Session = Depends(get_db)):
|
||||
# 登出接口:将指定设备会话置为非活跃
|
||||
return auth_service.logout(db, body.username, body.device_id)
|
||||
async def logout(body: UserLogout, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
# 登出接口:只允许登出自己的会话
|
||||
return auth_service.logout(db, current_username, body.device_id)
|
||||
|
||||
@router.get("/online-time/{username}")
|
||||
async def get_online_time(username: str, db: Session = Depends(get_db)):
|
||||
# 在线时长统计:累计历史会话的时长(秒),以及当前活跃会话的实时时长(秒)
|
||||
@router.get("/online-time")
|
||||
async def get_online_time(db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
# 在线时长统计:只查自己的数据
|
||||
username = current_username
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
return {"username": username, "total_seconds": 0, "active_seconds": 0}
|
||||
@@ -104,8 +105,8 @@ async def get_online_time(username: str, db: Session = Depends(get_db)):
|
||||
return {"username": username, "total_seconds": total_seconds, "active_seconds": active_seconds}
|
||||
|
||||
@router.post("/heartbeat")
|
||||
async def heartbeat(body: UserHeartbeat, db: Session = Depends(get_db)):
|
||||
# 心跳接口:更新会话的最近在线时间
|
||||
return auth_service.heartbeat(db, body.username, body.device_id)
|
||||
async def heartbeat(body: UserHeartbeat, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
# 心跳接口:只更新自己的会话
|
||||
return auth_service.heartbeat(db, current_username, body.device_id)
|
||||
|
||||
|
||||
|
||||
@@ -12,21 +12,17 @@ from sqlalchemy import func
|
||||
from app.db import get_db
|
||||
from app.models.user import User
|
||||
from app.models.business import CheckInConfig, VipConfig, CheckInRecord, PointsHistory
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class CheckInRequest(BaseModel):
|
||||
username: str
|
||||
|
||||
# ==================== 签到功能 ====================
|
||||
|
||||
@router.post("/checkin/daily")
|
||||
async def daily_checkin(data: CheckInRequest, db: Session = Depends(get_db)):
|
||||
async def daily_checkin(db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""每日签到"""
|
||||
# 1. 获取用户信息
|
||||
user = db.query(User).filter(User.username == data.username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -85,7 +81,7 @@ async def daily_checkin(data: CheckInRequest, db: Session = Depends(get_db)):
|
||||
# 7. 记录签到记录
|
||||
checkin_record = CheckInRecord(
|
||||
user_id=user.id,
|
||||
username=data.username,
|
||||
username=current_username,
|
||||
check_in_date=today,
|
||||
points_earned=points_earned,
|
||||
consecutive_days=consecutive_days,
|
||||
@@ -96,7 +92,7 @@ async def daily_checkin(data: CheckInRequest, db: Session = Depends(get_db)):
|
||||
# 8. 记录积分历史
|
||||
points_history = PointsHistory(
|
||||
user_id=user.id,
|
||||
username=data.username,
|
||||
username=current_username,
|
||||
type='checkin',
|
||||
amount=points_earned,
|
||||
balance=new_balance,
|
||||
@@ -145,9 +141,9 @@ async def get_checkin_config(db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.get("/checkin/status")
|
||||
async def get_checkin_status(username: str, db: Session = Depends(get_db)):
|
||||
async def get_checkin_status(db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""获取签到状态"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -160,14 +156,15 @@ async def get_checkin_status(username: str, db: Session = Depends(get_db)):
|
||||
"today_checked": today_checked,
|
||||
"consecutive_days": user.consecutive_check_in if user.consecutive_check_in else 0,
|
||||
"total_days": user.total_check_in_days if user.total_check_in_days else 0,
|
||||
"total_points": user.points if user.points else 0,
|
||||
"last_check_in_date": user.last_check_in_date.isoformat() if user.last_check_in_date else None
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/checkin/calendar/{year}/{month}")
|
||||
async def get_checkin_calendar(username: str, year: int, month: int, db: Session = Depends(get_db)):
|
||||
async def get_checkin_calendar(year: int, month: int, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""获取签到日历"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -199,9 +196,9 @@ async def get_checkin_calendar(username: str, year: int, month: int, db: Session
|
||||
}
|
||||
|
||||
@router.get("/checkin/history")
|
||||
async def get_checkin_history(username: str, page: int = 1, limit: int = 10, db: Session = Depends(get_db)):
|
||||
async def get_checkin_history(page: int = 1, limit: int = 10, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""签到记录"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -232,23 +229,3 @@ async def get_checkin_history(username: str, page: int = 1, limit: int = 10, db:
|
||||
"records": result_records
|
||||
}
|
||||
}
|
||||
|
||||
@router.get("/checkin/config")
|
||||
async def get_checkin_config(db: Session = Depends(get_db)):
|
||||
"""获取签到奖励规则 (公开接口)"""
|
||||
configs = db.query(CheckInConfig).filter(CheckInConfig.enabled == True).order_by(CheckInConfig.consecutive_days.asc()).all()
|
||||
|
||||
data = []
|
||||
for c in configs:
|
||||
data.append({
|
||||
"consecutive_days": c.consecutive_days,
|
||||
"base_points": c.base_points,
|
||||
"bonus_points": c.bonus_points,
|
||||
"total_points": c.total_points
|
||||
})
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": data
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,19 @@ from sqlalchemy.orm import Session
|
||||
from app.db import get_db
|
||||
from app.models.user import User
|
||||
from app.models.business import FeatureConfig, VipConfig, PointsHistory
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class UseFeatureRequest(BaseModel):
|
||||
username: str
|
||||
feature_key: str
|
||||
device_id: str
|
||||
|
||||
# ==================== 通用功能使用 ====================
|
||||
|
||||
@router.post("/feature/use")
|
||||
async def use_feature(data: UseFeatureRequest, db: Session = Depends(get_db)):
|
||||
async def use_feature(data: UseFeatureRequest, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""
|
||||
通用功能使用接口
|
||||
逻辑:
|
||||
@@ -38,7 +37,7 @@ async def use_feature(data: UseFeatureRequest, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=400, detail="功能不存在或已禁用")
|
||||
|
||||
# 2. 获取用户信息
|
||||
user = db.query(User).filter(User.username == data.username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -100,7 +99,7 @@ async def use_feature(data: UseFeatureRequest, db: Session = Depends(get_db)):
|
||||
# 记录积分历史
|
||||
points_history = PointsHistory(
|
||||
user_id=user.id,
|
||||
username=data.username,
|
||||
username=current_username,
|
||||
type='consume',
|
||||
amount=-points_cost,
|
||||
balance=new_balance,
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
服务器端计算 Demo - 简单数学计算
|
||||
演示:前端获取图层名称 → 后端计算数学表达式 → 返回结果
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
import re
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.core.api_keys import validate_api_key, get_key_info
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CalculateRequest(BaseModel):
|
||||
"""计算请求"""
|
||||
expression: str # 数学表达式,如 "87-98"
|
||||
|
||||
class CalculateResult(BaseModel):
|
||||
"""计算结果"""
|
||||
success: bool
|
||||
expression: str
|
||||
result: float = None
|
||||
message: str
|
||||
|
||||
@router.post("/calculate", response_model=CalculateResult)
|
||||
async def calculate_expression(
|
||||
request: CalculateRequest,
|
||||
x_api_key: Optional[str] = Header(None) # 可选的 API Key 验证
|
||||
):
|
||||
"""
|
||||
🔒 服务器端数学计算(核心算法)
|
||||
客户端只能拿到计算结果,看不到算法
|
||||
|
||||
示例:前端发送 "87-98" → 后端计算 → 返回 -11
|
||||
"""
|
||||
|
||||
# ==================== 📝 日志:打印请求 ====================
|
||||
logger.info("="*60)
|
||||
logger.info("📥 收到计算请求")
|
||||
logger.info(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
logger.info(f" 表达式: {request.expression}")
|
||||
logger.info(f" API Key: {x_api_key if x_api_key else '未提供'}")
|
||||
logger.info("="*60)
|
||||
|
||||
# ==================== 🔐 API Key 验证 ====================
|
||||
if not validate_api_key(x_api_key):
|
||||
logger.warning(f"❌ API Key 验证失败: {x_api_key}")
|
||||
raise HTTPException(status_code=403, detail="无效的 API Key")
|
||||
|
||||
# 获取 Key 信息
|
||||
key_info = get_key_info(x_api_key)
|
||||
logger.info(f"✅ API Key 验证通过 | 名称: {key_info['name']} | 权限: {key_info['permissions']}")
|
||||
|
||||
try:
|
||||
# 🔒 核心算法在这里(客户端看不到)
|
||||
# 可以是复杂的数学模型、AI 推理等
|
||||
|
||||
expression = request.expression.strip()
|
||||
|
||||
# ==================== 🛡️ 安全检查 ====================
|
||||
logger.info(f"🛡️ 安全检查: 验证表达式格式...")
|
||||
|
||||
# 只允许数字和基本运算符
|
||||
if not re.match(r'^[\d\s\+\-\*\/\(\)\.]+$', expression):
|
||||
logger.warning(f"❌ 表达式包含非法字符: {expression}")
|
||||
return CalculateResult(
|
||||
success=False,
|
||||
expression=expression,
|
||||
message="只支持基本数学运算(+、-、*、/)"
|
||||
)
|
||||
|
||||
logger.info("✅ 表达式格式验证通过")
|
||||
|
||||
# ==================== 🔒 核心算法执行 ====================
|
||||
logger.info("🔒 开始执行核心算法...")
|
||||
|
||||
# 这里可以放你的核心算法
|
||||
# 示例:简单计算
|
||||
result = eval(expression)
|
||||
|
||||
logger.info(f"✅ 计算完成: {expression} = {result}")
|
||||
|
||||
# ==================== 📤 日志:打印输出 ====================
|
||||
response = CalculateResult(
|
||||
success=True,
|
||||
expression=expression,
|
||||
result=float(result),
|
||||
message=f"计算成功: {expression} = {result}"
|
||||
)
|
||||
|
||||
logger.info("="*60)
|
||||
logger.info("📤 返回计算结果")
|
||||
logger.info(f" 成功: {response.success}")
|
||||
logger.info(f" 表达式: {response.expression}")
|
||||
logger.info(f" 结果: {response.result}")
|
||||
logger.info(f" 消息: {response.message}")
|
||||
logger.info("="*60)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 计算失败: {str(e)}")
|
||||
logger.error("="*60)
|
||||
|
||||
return CalculateResult(
|
||||
success=False,
|
||||
expression=request.expression,
|
||||
message=f"计算失败: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.security import get_current_user
|
||||
from app.db import get_db_session
|
||||
import json
|
||||
|
||||
router = APIRouter()
|
||||
@@ -74,14 +73,14 @@ class JSXExecuteResponse(BaseModel):
|
||||
@router.post("/execute", response_model=JSXExecuteResponse)
|
||||
async def execute_jsx(
|
||||
request: JSXExecuteRequest,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
服务器端生成 JSX 代码
|
||||
客户端只能通过 API 获取,无法直接看到核心逻辑
|
||||
"""
|
||||
try:
|
||||
username = current_user.get("username")
|
||||
username = current_user # get_current_user 返回用户名字符串
|
||||
|
||||
# 1. 验证用户和设备
|
||||
# ... (从数据库检查用户是否有权限、设备是否绑定)
|
||||
@@ -118,7 +117,7 @@ async def execute_jsx(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(current_user: dict = Depends(get_current_user)):
|
||||
async def list_templates(current_user: str = Depends(get_current_user)):
|
||||
"""
|
||||
列出可用的 JSX 模板(不返回具体代码)
|
||||
"""
|
||||
|
||||
340
Server/app/api/v1/logs.py
Normal file
340
Server/app/api/v1/logs.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
日志和历史记录API
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import desc
|
||||
|
||||
from app.db import get_db
|
||||
from app.core.security import get_current_user
|
||||
from app.models.logs import PltProcessRecord, UserActionLog, PltPiece
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class ActionLogCreate(BaseModel):
|
||||
"""创建操作日志"""
|
||||
session_id: Optional[str] = None
|
||||
action_type: str
|
||||
action_params: Optional[dict] = None
|
||||
page: Optional[str] = None
|
||||
result: str = "success"
|
||||
error_message: Optional[str] = None
|
||||
duration_ms: Optional[int] = None
|
||||
|
||||
|
||||
class ActionLogResponse(BaseModel):
|
||||
"""操作日志响应"""
|
||||
id: int
|
||||
action_type: str
|
||||
action_params: Optional[dict]
|
||||
page: Optional[str]
|
||||
result: str
|
||||
error_message: Optional[str]
|
||||
duration_ms: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PltPieceCreate(BaseModel):
|
||||
"""裁片信息"""
|
||||
group_id: int
|
||||
size: str
|
||||
image_url: Optional[str] = None
|
||||
width_px: int = 0
|
||||
height_px: int = 0
|
||||
width_cm: float = 0
|
||||
height_cm: float = 0
|
||||
left_cm: float = 0
|
||||
top_cm: float = 0
|
||||
center_x_cm: float = 0
|
||||
center_y_cm: float = 0
|
||||
|
||||
|
||||
class PltPieceResponse(BaseModel):
|
||||
"""裁片响应"""
|
||||
id: int
|
||||
group_id: int
|
||||
size: str
|
||||
image_url: Optional[str]
|
||||
width_px: int
|
||||
height_px: int
|
||||
width_cm: float
|
||||
height_cm: float
|
||||
left_cm: float
|
||||
top_cm: float
|
||||
center_x_cm: float
|
||||
center_y_cm: float
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PltRecordCreate(BaseModel):
|
||||
"""创建PLT处理记录"""
|
||||
filename: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
rotated_filename: Optional[str] = None
|
||||
size_labels: Optional[List[str]] = None
|
||||
dpi: int = 150
|
||||
rotation: int = 0
|
||||
total_groups: int = 0
|
||||
total_pieces: int = 0
|
||||
process_time_ms: Optional[int] = None
|
||||
status: str = "success"
|
||||
error_message: Optional[str] = None
|
||||
pieces: Optional[List[PltPieceCreate]] = None # 裁片详情
|
||||
|
||||
|
||||
class PltRecordResponse(BaseModel):
|
||||
"""PLT处理记录响应"""
|
||||
id: int
|
||||
filename: Optional[str]
|
||||
file_size: Optional[int]
|
||||
rotated_filename: Optional[str]
|
||||
size_labels: Optional[List[str]]
|
||||
dpi: int
|
||||
rotation: int
|
||||
total_groups: int
|
||||
total_pieces: int
|
||||
process_time_ms: Optional[int]
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PltRecordDetailResponse(PltRecordResponse):
|
||||
"""PLT处理记录详情响应(包含裁片)"""
|
||||
pieces: List[PltPieceResponse] = []
|
||||
|
||||
|
||||
class SessionLogsResponse(BaseModel):
|
||||
"""会话日志响应(给AI用)"""
|
||||
user_id: int
|
||||
username: str
|
||||
current_page: Optional[str]
|
||||
recent_actions: List[ActionLogResponse]
|
||||
recent_plt_records: List[PltRecordResponse]
|
||||
error_count: int
|
||||
|
||||
|
||||
# ==================== API端点 ====================
|
||||
|
||||
@router.post("/logs/action", response_model=ActionLogResponse)
|
||||
async def create_action_log(
|
||||
log_data: ActionLogCreate,
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""记录用户操作日志"""
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 创建日志
|
||||
log = UserActionLog(
|
||||
user_id=user.id,
|
||||
session_id=log_data.session_id,
|
||||
action_type=log_data.action_type,
|
||||
action_params=log_data.action_params,
|
||||
page=log_data.page,
|
||||
result=log_data.result,
|
||||
error_message=log_data.error_message,
|
||||
duration_ms=log_data.duration_ms
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
|
||||
return log
|
||||
|
||||
|
||||
@router.post("/plt/record", response_model=PltRecordResponse)
|
||||
async def create_plt_record(
|
||||
record_data: PltRecordCreate,
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""记录PLT处理记录(包含裁片详情)"""
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
record = PltProcessRecord(
|
||||
user_id=user.id,
|
||||
filename=record_data.filename,
|
||||
file_size=record_data.file_size,
|
||||
rotated_filename=record_data.rotated_filename,
|
||||
size_labels=record_data.size_labels,
|
||||
dpi=record_data.dpi,
|
||||
rotation=record_data.rotation,
|
||||
total_groups=record_data.total_groups,
|
||||
total_pieces=record_data.total_pieces,
|
||||
process_time_ms=record_data.process_time_ms,
|
||||
status=record_data.status,
|
||||
error_message=record_data.error_message
|
||||
)
|
||||
db.add(record)
|
||||
db.flush() # 获取 record.id
|
||||
|
||||
# 保存裁片详情
|
||||
if record_data.pieces:
|
||||
for piece_data in record_data.pieces:
|
||||
piece = PltPiece(
|
||||
record_id=record.id,
|
||||
group_id=piece_data.group_id,
|
||||
size=piece_data.size,
|
||||
image_url=piece_data.image_url,
|
||||
width_px=piece_data.width_px,
|
||||
height_px=piece_data.height_px,
|
||||
width_cm=piece_data.width_cm,
|
||||
height_cm=piece_data.height_cm,
|
||||
left_cm=piece_data.left_cm,
|
||||
top_cm=piece_data.top_cm,
|
||||
center_x_cm=piece_data.center_x_cm,
|
||||
center_y_cm=piece_data.center_y_cm
|
||||
)
|
||||
db.add(piece)
|
||||
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/plt/history/{record_id}", response_model=PltRecordDetailResponse)
|
||||
async def get_plt_record_detail(
|
||||
record_id: int,
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取PLT处理记录详情(包含裁片)"""
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
record = db.query(PltProcessRecord)\
|
||||
.options(joinedload(PltProcessRecord.pieces))\
|
||||
.filter(PltProcessRecord.id == record_id)\
|
||||
.filter(PltProcessRecord.user_id == user.id)\
|
||||
.first()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="记录不存在")
|
||||
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/plt/history", response_model=List[PltRecordResponse])
|
||||
async def get_plt_history(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
days: int = Query(7, ge=1, le=30), # 保留天数,默认7天
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户的PLT处理历史(默认保留7天)"""
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 只返回指定天数内的记录
|
||||
since = datetime.now() - timedelta(days=days)
|
||||
|
||||
records = db.query(PltProcessRecord)\
|
||||
.filter(PltProcessRecord.user_id == user.id)\
|
||||
.filter(PltProcessRecord.created_at >= since)\
|
||||
.order_by(desc(PltProcessRecord.created_at))\
|
||||
.limit(limit)\
|
||||
.all()
|
||||
|
||||
return records
|
||||
|
||||
|
||||
@router.get("/logs/recent", response_model=List[ActionLogResponse])
|
||||
async def get_recent_logs(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
action_type: Optional[str] = None,
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户最近的操作日志"""
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
query = db.query(UserActionLog).filter(UserActionLog.user_id == user.id)
|
||||
|
||||
if action_type:
|
||||
query = query.filter(UserActionLog.action_type.like(f"{action_type}%"))
|
||||
|
||||
logs = query.order_by(desc(UserActionLog.created_at)).limit(limit).all()
|
||||
|
||||
return logs
|
||||
|
||||
|
||||
@router.get("/logs/session", response_model=SessionLogsResponse)
|
||||
async def get_session_context(
|
||||
hours: int = Query(24, ge=1, le=168),
|
||||
current_username: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取会话上下文(给AI助手用)"""
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
since = datetime.now() - timedelta(hours=hours)
|
||||
|
||||
# 最近的操作日志
|
||||
recent_actions = db.query(UserActionLog)\
|
||||
.filter(UserActionLog.user_id == user.id)\
|
||||
.filter(UserActionLog.created_at >= since)\
|
||||
.order_by(desc(UserActionLog.created_at))\
|
||||
.limit(50)\
|
||||
.all()
|
||||
|
||||
# 最近的PLT处理记录
|
||||
recent_plt = db.query(PltProcessRecord)\
|
||||
.filter(PltProcessRecord.user_id == user.id)\
|
||||
.filter(PltProcessRecord.created_at >= since)\
|
||||
.order_by(desc(PltProcessRecord.created_at))\
|
||||
.limit(10)\
|
||||
.all()
|
||||
|
||||
# 错误数量
|
||||
error_count = db.query(UserActionLog)\
|
||||
.filter(UserActionLog.user_id == user.id)\
|
||||
.filter(UserActionLog.created_at >= since)\
|
||||
.filter(UserActionLog.result == "error")\
|
||||
.count()
|
||||
|
||||
# 当前页面(最近一次进入页面的记录)
|
||||
last_page_action = db.query(UserActionLog)\
|
||||
.filter(UserActionLog.user_id == user.id)\
|
||||
.filter(UserActionLog.action_type == "page.enter")\
|
||||
.order_by(desc(UserActionLog.created_at))\
|
||||
.first()
|
||||
|
||||
current_page = last_page_action.action_params.get("page") if last_page_action and last_page_action.action_params else None
|
||||
|
||||
return SessionLogsResponse(
|
||||
user_id=user.id,
|
||||
username=current_username,
|
||||
current_page=current_page,
|
||||
recent_actions=recent_actions,
|
||||
recent_plt_records=recent_plt,
|
||||
error_count=error_count
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Header, HTTPException
|
||||
from datetime import date, timedelta
|
||||
import pymysql
|
||||
from app.core.database import get_db_connection
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -15,8 +16,7 @@ router = APIRouter()
|
||||
|
||||
def verify_admin_token(token: str):
|
||||
"""验证管理员Token"""
|
||||
expected_token = "admin-secret-token"
|
||||
if token != expected_token:
|
||||
if token != settings.ADMIN_TOKEN:
|
||||
raise HTTPException(status_code=401, detail="管理员Token无效")
|
||||
|
||||
# ==================== 统计功能 ====================
|
||||
|
||||
@@ -8,17 +8,16 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from app.db import get_db
|
||||
from app.models.user import User
|
||||
from app.models.business import PointsHistory
|
||||
from app.core.security import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class UserProfileUpdate(BaseModel):
|
||||
username: str
|
||||
nickname: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
@@ -26,13 +25,12 @@ class UserProfileUpdate(BaseModel):
|
||||
# ==================== 用户资料管理 ====================
|
||||
|
||||
@router.get("/user/profile")
|
||||
async def get_user_profile(username: str, db: Session = Depends(get_db)):
|
||||
async def get_user_profile(db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""获取用户资料"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# 转换为字典以便序列化
|
||||
user_data = {
|
||||
"username": user.username,
|
||||
"nickname": user.nickname,
|
||||
@@ -54,9 +52,9 @@ async def get_user_profile(username: str, db: Session = Depends(get_db)):
|
||||
}
|
||||
|
||||
@router.put("/user/profile")
|
||||
async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get_db)):
|
||||
async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get_db), current_username: str = Depends(get_current_user)):
|
||||
"""更新用户资料"""
|
||||
user = db.query(User).filter(User.username == data.username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -78,14 +76,14 @@ async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get
|
||||
|
||||
@router.get("/points/history")
|
||||
async def get_points_history(
|
||||
username: str,
|
||||
type: Optional[str] = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_username: str = Depends(get_current_user)
|
||||
):
|
||||
"""获取积分历史"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
user = db.query(User).filter(User.username == current_username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
@@ -117,4 +115,3 @@ async def get_points_history(
|
||||
"records": result_records
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,20 +5,37 @@ class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = "DesignerCEP Backend"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
DATABASE_URL: str = "sqlite:///./designercep.db"
|
||||
SECRET_KEY: str = "change-me"
|
||||
SECRET_KEY: str = "" # 必须从 .env 读取
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080
|
||||
|
||||
ALLOWED_ORIGINS: str = "*"
|
||||
ADMIN_TOKEN: str = "admin-token"
|
||||
ADMIN_TOKEN: str = "" # 必须从 .env 读取
|
||||
|
||||
# Email Configuration
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
SMTP_PORT: int = 587
|
||||
SMTP_USER: str = "ly1104803132@gmail.com"
|
||||
SMTP_PASSWORD: str = "wsfrpnmkojpsqdkk"
|
||||
EMAILS_FROM_EMAIL: str = "ly1104803132@gmail.com"
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = "" # 必须从 .env 读取
|
||||
EMAILS_FROM_EMAIL: str = ""
|
||||
EMAILS_FROM_NAME: str = "Designer"
|
||||
|
||||
# 七牛云对象存储
|
||||
QINIU_ACCESS_KEY: str = ""
|
||||
QINIU_SECRET_KEY: str = ""
|
||||
QINIU_BUCKET: str = ""
|
||||
QINIU_DOMAIN: str = ""
|
||||
|
||||
# AI 配置 — 通义千问 Qwen(DashScope)
|
||||
AI_API_KEY: str = "" # LLM API Key(OpenAI / DeepSeek 等)
|
||||
AI_BASE_URL: str = "" # LLM API 地址(留空则用 OpenAI 默认)
|
||||
AI_MODEL: str = "qwen3-max-2026-01-23" # 默认模型(通义千问3深度思考)
|
||||
AI_VISION_MODEL: str = "qwen-vl-max-latest" # 视觉分析模型(看图分析成衣)
|
||||
AI_IMAGE_EDIT_MODEL: str = "qwen-image-edit-max-2026-01-16" # 图片编辑/生成模型
|
||||
|
||||
# AI 配置 — Gemini(第三方代理)
|
||||
GEMINI_API_KEY: str = ""
|
||||
GEMINI_BASE_URL: str = "https://api.apiqik.online"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
154
Server/app/core/qiniu_storage.py
Normal file
154
Server/app/core/qiniu_storage.py
Normal file
@@ -0,0 +1,154 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
七牛云对象存储工具
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
from qiniu import Auth, put_data, BucketManager
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# ==================== 配置 ====================
|
||||
# 从 settings 读取(通过 pydantic-settings 加载 .env)
|
||||
|
||||
QINIU_ACCESS_KEY = settings.QINIU_ACCESS_KEY
|
||||
QINIU_SECRET_KEY = settings.QINIU_SECRET_KEY
|
||||
QINIU_BUCKET = settings.QINIU_BUCKET
|
||||
QINIU_DOMAIN = settings.QINIU_DOMAIN # 例如: http://cdn.example.com
|
||||
|
||||
# 是否启用七牛云存储
|
||||
QINIU_ENABLED = bool(QINIU_ACCESS_KEY and QINIU_SECRET_KEY and QINIU_BUCKET and QINIU_DOMAIN)
|
||||
|
||||
|
||||
class QiniuStorage:
|
||||
"""七牛云存储工具类"""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = QINIU_ENABLED
|
||||
if self.enabled:
|
||||
self.auth = Auth(QINIU_ACCESS_KEY, QINIU_SECRET_KEY)
|
||||
self.bucket = QINIU_BUCKET
|
||||
self.domain = QINIU_DOMAIN.rstrip('/')
|
||||
print(f"✅ 七牛云存储已启用,Bucket: {self.bucket}")
|
||||
else:
|
||||
self.auth = None
|
||||
self.bucket = None
|
||||
self.domain = None
|
||||
print("⚠️ 七牛云存储未配置,图片将使用 base64 返回")
|
||||
|
||||
def upload_base64(
|
||||
self,
|
||||
base64_data: str,
|
||||
key_prefix: str = "plt",
|
||||
user_id: Optional[int] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
上传 base64 图片到七牛云
|
||||
|
||||
Args:
|
||||
base64_data: base64 编码的图片数据(可带或不带 data:image/png;base64, 前缀)
|
||||
key_prefix: 文件名前缀
|
||||
user_id: 用户ID(用于分目录)
|
||||
|
||||
Returns:
|
||||
(success, url_or_error): 成功返回 URL,失败返回错误信息
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False, "七牛云存储未启用"
|
||||
|
||||
try:
|
||||
# 去掉 base64 前缀
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',')[1]
|
||||
|
||||
# 解码
|
||||
image_data = base64.b64decode(base64_data)
|
||||
|
||||
# 生成文件名: plt/2024/02/05/u123/时间戳_哈希.png
|
||||
now = datetime.now()
|
||||
date_path = now.strftime("%Y/%m/%d")
|
||||
timestamp = now.strftime("%H%M%S") # 时分秒
|
||||
content_hash = hashlib.md5(image_data).hexdigest()[:8]
|
||||
|
||||
if user_id:
|
||||
key = f"{key_prefix}/{date_path}/u{user_id}/{timestamp}_{content_hash}.png"
|
||||
else:
|
||||
key = f"{key_prefix}/{date_path}/{timestamp}_{content_hash}.png"
|
||||
|
||||
# 生成上传凭证
|
||||
token = self.auth.upload_token(self.bucket, key, 3600)
|
||||
|
||||
# 上传
|
||||
ret, info = put_data(token, key, image_data)
|
||||
|
||||
if info.status_code == 200:
|
||||
url = f"{self.domain}/{key}"
|
||||
return True, url
|
||||
else:
|
||||
print(f"[七牛云] 上传失败: status={info.status_code}, error={info.error}, text={info.text_body}")
|
||||
return False, f"上传失败: {info.error}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[七牛云] 上传异常: {str(e)}")
|
||||
return False, f"上传异常: {str(e)}"
|
||||
|
||||
def upload_batch(
|
||||
self,
|
||||
images: list, # [(base64_data, name), ...]
|
||||
key_prefix: str = "plt",
|
||||
user_id: Optional[int] = None
|
||||
) -> list:
|
||||
"""
|
||||
批量上传图片
|
||||
|
||||
Returns:
|
||||
[(success, url_or_base64, name), ...]
|
||||
"""
|
||||
results = []
|
||||
success_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for base64_data, name in images:
|
||||
if self.enabled:
|
||||
success, result = self.upload_base64(base64_data, key_prefix, user_id)
|
||||
if success:
|
||||
results.append((True, result, name))
|
||||
success_count += 1
|
||||
else:
|
||||
# 上传失败,回退到 base64
|
||||
print(f"[七牛云] 上传失败 {name}: {result}")
|
||||
results.append((False, base64_data, name))
|
||||
fail_count += 1
|
||||
else:
|
||||
# 未启用,返回原始 base64
|
||||
results.append((False, base64_data, name))
|
||||
|
||||
if fail_count > 0:
|
||||
print(f"[七牛云] 批量上传结果: 成功 {success_count}, 失败 {fail_count}")
|
||||
|
||||
return results
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""删除文件"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
bucket_manager = BucketManager(self.auth)
|
||||
ret, info = bucket_manager.delete(self.bucket, key)
|
||||
return info.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_url(self, key: str) -> str:
|
||||
"""获取文件 URL"""
|
||||
if not self.enabled or not key:
|
||||
return ""
|
||||
return f"{self.domain}/{key}"
|
||||
|
||||
|
||||
# 全局实例
|
||||
qiniu_storage = QiniuStorage()
|
||||
@@ -34,10 +34,6 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
验证 JWT Token 并返回当前用户名 (sub)
|
||||
增加 Session 强校验:检查数据库中 Session 是否活跃
|
||||
"""
|
||||
print("="*60)
|
||||
print("[get_current_user] 开始验证 Token")
|
||||
print(f" - Token (前30字符): {token[:30]}...")
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证凭据",
|
||||
@@ -50,41 +46,24 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
username: str = payload.get("sub")
|
||||
device_id: str = payload.get("device_id")
|
||||
|
||||
print(f"[get_current_user] ✓ Token 解析成功")
|
||||
print(f" - username: {username}")
|
||||
print(f" - device_id: {device_id}")
|
||||
print(f" - exp: {payload.get('exp')}")
|
||||
|
||||
if username is None:
|
||||
print("[get_current_user] ✗ username 为空")
|
||||
raise credentials_exception
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
print("[get_current_user] ✗ Token 已过期")
|
||||
raise credentials_exception
|
||||
except jwt.InvalidTokenError as e:
|
||||
print(f"[get_current_user] ✗ Token 无效: {e}")
|
||||
except jwt.InvalidTokenError:
|
||||
raise credentials_exception
|
||||
except jwt.PyJWTError as e:
|
||||
print(f"[get_current_user] ✗ Token 解析失败: {e}")
|
||||
except jwt.PyJWTError:
|
||||
raise credentials_exception
|
||||
|
||||
# 2. Session 强校验 (如果有 device_id)
|
||||
# 如果 Token 是旧版本没有 device_id,可以选择放行或拒绝。为了安全建议逐步拒绝。
|
||||
# 这里我们假设所有新 Token 都有 device_id
|
||||
if device_id:
|
||||
print(f"[get_current_user] 开始 Session 强校验")
|
||||
# 查询 User ID
|
||||
from app.models.user import User
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
print(f"[get_current_user] ✗ 用户不存在: {username}")
|
||||
raise credentials_exception
|
||||
|
||||
print(f"[get_current_user] ✓ 用户存在: user_id={user.id}")
|
||||
|
||||
# 查询活跃 Session
|
||||
print(f"[get_current_user] 查询活跃 Session: user_id={user.id}, device_id={device_id}")
|
||||
session = db.query(UserSession).filter(
|
||||
UserSession.user_id == user.id,
|
||||
UserSession.device_id == device_id,
|
||||
@@ -92,24 +71,10 @@ def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
# Session 不存在或已失效(被踢下线/登出)
|
||||
print(f"[get_current_user] ✗ Session 不存在或已失效")
|
||||
|
||||
# 调试:列出该用户的所有 Session
|
||||
all_sessions = db.query(UserSession).filter(UserSession.user_id == user.id).all()
|
||||
print(f"[get_current_user] 该用户共有 {len(all_sessions)} 个 Session:")
|
||||
for s in all_sessions:
|
||||
print(f" - session_id={s.id}, device_id={s.device_id}, active={s.active}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="会话已失效或在其他设备登录",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
print(f"[get_current_user] ✓ Session 验证通过: session_id={session.id}")
|
||||
else:
|
||||
print(f"[get_current_user] ⚠️ Token 中没有 device_id,跳过 Session 校验")
|
||||
|
||||
print("="*60)
|
||||
return username
|
||||
|
||||
@@ -8,7 +8,15 @@ SQLALCHEMY_DATABASE_URL = getattr(settings, "DATABASE_URL", "sqlite:///./designe
|
||||
# 创建数据库引擎
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
|
||||
pool_pre_ping=True, # 每次连接前 ping 一下,防止连接断开
|
||||
pool_recycle=3600, # 1小时回收连接
|
||||
pool_size=10, # 连接池大小
|
||||
max_overflow=20, # 最大溢出连接数
|
||||
connect_args={
|
||||
"connect_timeout": 60, # 增加连接超时
|
||||
"read_timeout": 60, # 增加读取超时
|
||||
"write_timeout": 60 # 增加写入超时
|
||||
} if not SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {"check_same_thread": False}
|
||||
)
|
||||
|
||||
# 会话工厂与 ORM 基类
|
||||
@@ -29,6 +37,8 @@ def init_db():
|
||||
from app.models.group import PluginGroup
|
||||
from app.models.session import UserSession
|
||||
from app.models.business import FeatureConfig, VipConfig, CheckInConfig, CheckInRecord, PointsHistory
|
||||
from app.models.logs import PltProcessRecord, UserActionLog, PltPiece
|
||||
from app.models.chat import ChatSession, ChatMessage
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_migrations()
|
||||
seed_data()
|
||||
|
||||
@@ -2,9 +2,8 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
from pathlib import Path
|
||||
from app.core.config import settings
|
||||
from app.api.v1 import auth, client, admin, analytics, jsx_demo, admin_config, feature, checkin, user_profile, stats
|
||||
from app.api.v1 import auth, client, admin, analytics, admin_config, feature, checkin, user_profile, stats, algorithm, logs, ai_chat
|
||||
from app.db import init_db
|
||||
from datetime import datetime
|
||||
|
||||
@@ -59,7 +58,6 @@ app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["aut
|
||||
app.include_router(client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"])
|
||||
app.include_router(admin.router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"])
|
||||
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"])
|
||||
app.include_router(jsx_demo.router, prefix=f"{settings.API_V1_STR}/jsx_demo", tags=["jsx_demo"])
|
||||
|
||||
# 新增路由
|
||||
app.include_router(admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"])
|
||||
@@ -67,6 +65,9 @@ app.include_router(feature.router, prefix=settings.API_V1_STR, tags=["feature"])
|
||||
app.include_router(checkin.router, prefix=settings.API_V1_STR, tags=["checkin"])
|
||||
app.include_router(user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"])
|
||||
app.include_router(stats.router, prefix=settings.API_V1_STR, tags=["stats"])
|
||||
app.include_router(algorithm.router, prefix=settings.API_V1_STR, tags=["algorithm"])
|
||||
app.include_router(logs.router, prefix=settings.API_V1_STR, tags=["logs"])
|
||||
app.include_router(ai_chat.router, prefix=settings.API_V1_STR, tags=["ai-chat"])
|
||||
|
||||
# Health Check
|
||||
@app.get("/health")
|
||||
@@ -77,26 +78,6 @@ def health_check():
|
||||
if IS_DEV:
|
||||
# Mount archives directory for download
|
||||
app.mount("/download", StaticFiles(directory="archives"), name="download")
|
||||
|
||||
# Mount Shell directory (登录页面)
|
||||
# shell_dir = Path(__file__).parent.parent / "Designer"
|
||||
# if shell_dir.exists():
|
||||
# app.mount("/shell", StaticFiles(directory=str(shell_dir), html=True), name="shell")
|
||||
# print(f"✓ Shell 已挂载 (Dev): {shell_dir}")
|
||||
# else:
|
||||
# # print(f"⚠️ Shell 目录不存在: {shell_dir}")
|
||||
# # print(" 请先运行: cd Designer && npm run build:shell")
|
||||
# pass
|
||||
|
||||
# Mount DesignerCache directory to serve Core application files
|
||||
# designer_cache = Path.home() / "AppData" / "Roaming" / "DesignerCache"
|
||||
# if designer_cache.exists():
|
||||
# app.mount("/core", StaticFiles(directory=str(designer_cache), html=True), name="core")
|
||||
# print(f"✓ Core 已挂载 (Dev): {designer_cache}")
|
||||
# else:
|
||||
# # Create directory if it doesn't exist
|
||||
# designer_cache.mkdir(parents=True, exist_ok=True)
|
||||
# app.mount("/core", StaticFiles(directory=str(designer_cache), html=True), name="core")
|
||||
else:
|
||||
print("ℹ️ Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx).")
|
||||
|
||||
|
||||
31
Server/app/models/chat.py
Normal file
31
Server/app/models/chat.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
AI 对话模型
|
||||
- ChatSession: 对话会话(按用户隔离)
|
||||
- ChatMessage: 对话消息
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, func
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
"""对话会话表 — 每个用户可以有多个对话"""
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
username = Column(String(64), nullable=False, index=True)
|
||||
title = Column(String(200), default="新对话") # 对话标题(取首条消息摘要)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""对话消息表"""
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False) # user / assistant / system
|
||||
content = Column(Text, nullable=False) # 消息内容
|
||||
tool_calls = Column(Text, nullable=True) # 工具调用 JSON(预留)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
94
Server/app/models/logs.py
Normal file
94
Server/app/models/logs.py
Normal file
@@ -0,0 +1,94 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PLT处理记录和用户操作日志模型
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey, Text, JSON, Float
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PltProcessRecord(Base):
|
||||
"""PLT处理记录表"""
|
||||
__tablename__ = "plt_process_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 文件信息
|
||||
filename = Column(String(255), nullable=True)
|
||||
file_size = Column(Integer, nullable=True) # 字节
|
||||
rotated_filename = Column(String(255), nullable=True) # 旋转对比文件名
|
||||
|
||||
# 处理参数
|
||||
size_labels = Column(JSON, nullable=True) # ["S", "M", "L", ...]
|
||||
dpi = Column(Integer, default=150)
|
||||
rotation = Column(Integer, default=0)
|
||||
|
||||
# 处理结果
|
||||
total_groups = Column(Integer, default=0) # 裁片组数
|
||||
total_pieces = Column(Integer, default=0) # 裁片总数
|
||||
process_time_ms = Column(Integer, nullable=True) # 处理耗时(毫秒)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default='success') # success, error
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="plt_records")
|
||||
pieces = relationship("PltPiece", back_populates="record", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class PltPiece(Base):
|
||||
"""PLT裁片详情表"""
|
||||
__tablename__ = "plt_pieces"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
record_id = Column(Integer, ForeignKey("plt_process_records.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
# 裁片信息
|
||||
group_id = Column(Integer, nullable=False) # 组号
|
||||
size = Column(String(20), nullable=False) # 尺码
|
||||
image_url = Column(String(500), nullable=True) # 七牛云 URL
|
||||
|
||||
# 尺寸信息
|
||||
width_px = Column(Integer, default=0)
|
||||
height_px = Column(Integer, default=0)
|
||||
width_cm = Column(Float, default=0)
|
||||
height_cm = Column(Float, default=0)
|
||||
left_cm = Column(Float, default=0)
|
||||
top_cm = Column(Float, default=0)
|
||||
center_x_cm = Column(Float, default=0)
|
||||
center_y_cm = Column(Float, default=0)
|
||||
|
||||
# 关联
|
||||
record = relationship("PltProcessRecord", back_populates="pieces")
|
||||
|
||||
|
||||
class UserActionLog(Base):
|
||||
"""用户操作日志表"""
|
||||
__tablename__ = "user_action_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 会话追踪
|
||||
session_id = Column(String(64), nullable=True, index=True)
|
||||
|
||||
# 操作信息
|
||||
action_type = Column(String(50), nullable=False, index=True) # plt.upload, plt.process, etc.
|
||||
action_params = Column(JSON, nullable=True) # 操作参数
|
||||
page = Column(String(50), nullable=True) # 所在页面
|
||||
|
||||
# 结果
|
||||
result = Column(String(20), default='success') # success, error
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 时间
|
||||
duration_ms = Column(Integer, nullable=True) # 操作耗时
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="action_logs")
|
||||
Reference in New Issue
Block a user