feat: AI套图分层方案 + Gemini集成 - 4种图案类型处理 + 正片叠底 + 宽高比 + 模型选择

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-07 16:59:56 +08:00
parent 12395d8eca
commit dae906aba7
277 changed files with 15009 additions and 19922 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
# -*- coding: utf-8 -*-
"""
AI 工具定义
将 PS 操作注册为 function calling 的 tool schema
前端收到 tool_calls 后执行 JSX把结果回传
"""
# ==================== 工具 SchemaOpenAI 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": "验证套图效果",
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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无效")
# ==================== 统计功能 ====================

View File

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

View File

@@ -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 配置 — 通义千问 QwenDashScope
AI_API_KEY: str = "" # LLM API KeyOpenAI / 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()

View 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()

View File

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

View File

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

View File

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