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

1
Server/.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

View File

@@ -0,0 +1,340 @@
# PLT 裁片处理 API 集成完成
## ✅ 已完成的工作
### 1. **核心文件创建**
-`Server/app/api/v1/algorithm.py` - PLT处理API接口
-`Server/pltreader.py` - 已复制到Server目录
-`Server/app/main.py` - 已注册algorithm路由
### 2. **依赖包更新**
- ✅ 添加 `scipy` - 用于匈牙利算法匹配
- ✅ 添加 `Pillow` - 用于图像处理和Base64编码
- ✅ 已有 `shapely`, `numpy`, `opencv-python-headless`
### 3. **API功能特性**
#### 核心功能
1. **PLT文件解析** - 自动识别裁片轮廓
2. **按尺码分组** - 自动匹配不同尺码的相同裁片
3. **生成透明PNG** - 每个裁片独立输出带Alpha通道
4. **精确坐标计算** - 返回厘米单位的中心点和左上角坐标
5. **旋转支持** - 可指定0°/90°/-90°/180°旋转
6. **双文件匹配** - 可上传两个PLT文件进行轮廓对比
#### 安全特性
- ✅ JWT Token 认证保护
- ✅ 需要登录才能访问
- ✅ 集成现有用户系统
---
## 📋 API 接口说明
### 接口地址
```
POST /api/v1/algorithm/process_plt
```
### 请求方式
`multipart/form-data`
### 请求头
```
Authorization: Bearer <access_token>
```
### 请求参数
| 参数名 | 类型 | 必填 | 说明 | 示例 |
|--------|------|------|------|------|
| `file` | File | **是** | 标准PLT文件 | `standard.plt` |
| `rotated_file` | File | 否 | 旋转后的PLT文件用于匹配分析 | `rotated.plt` |
| `size_labels` | String | **是** | 尺码标签JSON数组 | `["S","M","L","XL","2XL"]` |
| `dpi` | Integer | 否 | 输出分辨率默认150 | `150` |
| `rotation` | Integer | 否 | 旋转角度0/90/-90/180默认0 | `90` |
### 响应示例
```json
{
"success": true,
"total_groups": 5,
"groups": [
{
"group_id": 1,
"pieces": [
{
"size": "S",
"image_base64": "data:image/png;base64,iVBORw0KGgo...",
"width_px": 800,
"height_px": 600,
"width_cm": 13.54,
"height_cm": 10.16,
"center_x_cm": 25.4,
"center_y_cm": 30.2,
"left_cm": 18.63,
"top_cm": 35.28
},
{
"size": "M",
"image_base64": "data:image/png;base64,iVBORw0KGgo...",
"width_px": 820,
"height_px": 615,
"width_cm": 13.89,
"height_cm": 10.41,
"center_x_cm": 26.1,
"center_y_cm": 30.8,
"left_cm": 19.15,
"top_cm": 35.60
}
]
}
],
"match_analysis": [
{
"size": "S",
"matches": [
{
"standard_id": 0,
"rotated_id": 3,
"distance": 0.7032,
"angle": 0.0
}
]
}
]
}
```
---
## 🧪 测试步骤
### 1. 安装依赖
```bash
cd Server
pip install -r requirements.txt
```
### 2. 启动服务
```bash
cd Server
python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
### 3. 运行测试脚本
```bash
cd Server
python test_plt_api.py
```
测试脚本会自动:
1. 登录获取Token
2. 上传PLT文件进行处理
3. 显示匹配结果
---
## 🔧 技术实现细节
### 1. **裁片识别算法**
- 使用 Shapely 进行几何计算
- 自动构建多边形包含关系树
- 过滤噪声面积阈值0.68%² ~ 80%²)
### 2. **尺码匹配算法**
- **Hausdorff距离** - 衡量轮廓相似度
- **匈牙利算法** - 最优匹配分配
- 自动处理旋转0°/90°/180°/270°
- 质心对齐 + 面积归一化
### 3. **坐标系统**
- PLT单位1016单位 = 1英寸 = 2.54cm
- 原点:左下角
- Y轴向上为正数学坐标系
### 4. **图像处理**
- OpenCV绘制裁片轮廓
- 自动添加Alpha通道透明背景
- Pillow处理旋转和Base64编码
- DPI元数据写入PNG文件
---
## 📝 使用示例 (Python)
```python
import requests
import json
# 1. 登录
login_response = requests.post(
"http://localhost:8000/api/v1/auth/login",
json={
"username": "admin",
"password": "123456",
"device_id": "test-device"
}
)
token = login_response.json()["access_token"]
# 2. 处理PLT文件
with open("standard.plt", "rb") as f1:
files = [
("file", ("standard.plt", f1, "application/octet-stream"))
]
data = {
"size_labels": json.dumps(["S", "M", "L", "XL", "2XL"]),
"dpi": 150,
"rotation": 90 # 顺时针旋转90度
}
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
"http://localhost:8000/api/v1/algorithm/process_plt",
files=files,
data=data,
headers=headers
)
result = response.json()
print(f"共生成 {result['total_groups']} 组裁片")
# 3. 保存图片
for group in result["groups"]:
for piece in group["pieces"]:
# 提取Base64数据
base64_data = piece["image_base64"].split(",")[1]
# 解码并保存
import base64
img_data = base64.b64decode(base64_data)
with open(f"{piece['size']}-{group['group_id']}.png", "wb") as img_file:
img_file.write(img_data)
print(f"已保存: {piece['size']}-{group['group_id']}.png")
print(f" 位置: ({piece['left_cm']}, {piece['top_cm']}) cm")
print(f" 尺寸: {piece['width_cm']} x {piece['height_cm']} cm")
```
---
## 🎯 Photoshop插件集成要点
### 1. **创建画布**
```javascript
// 根据API返回的尺寸创建画布
var canvasWidth = 100; // cm根据实际需求
var canvasHeight = 150; // cm
var dpi = 150;
var doc = app.documents.add(
UnitValue(canvasWidth, "cm"),
UnitValue(canvasHeight, "cm"),
dpi,
"PLT裁片",
NewDocumentMode.RGB,
DocumentFill.TRANSPARENT
);
```
### 2. **加载Base64图片**
```javascript
function loadBase64Image(base64String, layerName) {
// 去除前缀
var base64Data = base64String.split(",")[1];
// 保存为临时文件
var tempFile = new File(Folder.temp + "/temp_piece.png");
tempFile.encoding = "BINARY";
tempFile.open("w");
tempFile.write(decode64(base64Data));
tempFile.close();
// 打开并复制到主文档
var tempDoc = app.open(tempFile);
tempDoc.activeLayer.copy();
app.activeDocument = mainDoc;
var newLayer = mainDoc.paste();
newLayer.name = layerName;
tempDoc.close(SaveOptions.DONOTSAVECHANGES);
tempFile.remove();
return newLayer;
}
```
### 3. **定位图层**
```javascript
function positionLayer(layer, leftCm, topCm, dpi) {
// 厘米转像素
var leftPx = leftCm * dpi / 2.54;
var topPx = topCm * dpi / 2.54;
// 移动到指定位置
var deltaX = leftPx - layer.bounds[0];
var deltaY = topPx - layer.bounds[1];
layer.translate(deltaX, deltaY);
}
```
---
## ⚠️ 注意事项
1. **文件编码** - PLT文件必须是文本格式如果是二进制格式会导致解析失败
2. **尺码数量** - `size_labels` 的数量必须与PLT文件中的实际尺码数量一致
3. **内存占用** - 处理大型PLT文件>1000个裁片可能需要较长时间
4. **坐标系** - Photoshop的Y轴向下如需适配可能需要翻转Y坐标
---
## 🐛 故障排查
### 问题1: 导入错误 `cannot import name 'algorithm'`
**解决**:
```bash
# 确保已重启uvicorn服务
python -m uvicorn app.main:app --reload
```
### 问题2: `ModuleNotFoundError: No module named 'scipy'`
**解决**:
```bash
pip install scipy Pillow
```
### 问题3: PLT文件解析失败
**检查**:
- 文件是否是文本格式(用记事本能打开)
- 文件编码是否正确建议UTF-8或GBK
- 文件是否包含有效的PLT命令IN, PU, PD, PA等
---
## 📚 相关文档
- `Server/docs/API_PLT_Processing.md` - 详细API文档
- `Server/test_plt_api.py` - 测试示例代码
- `pltreader.py` - PLT解析器源码
- `run.py` - 命令行工具示例
---
## 🎉 完成!
现在你可以启动服务器并测试API了
```bash
cd D:\main\DesignerCEP\Server
python -m uvicorn app.main:app --reload
```
然后在浏览器访问文档:
```
http://localhost:8000/docs
```
祝使用愉快!如有问题请随时反馈。

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

View File

@@ -0,0 +1,136 @@
# PLT 裁片处理接口文档 (PS 插件专用)
本文档描述了用于处理 PLT 样板文件并生成 Photoshop 可用图像数据的后端接口。
## 1. 接口概览
该接口接收 PLT 格式的服装样板文件,在服务器端进行解析、几何计算和渲染,最终返回按尺码分组的 **透明背景 PNG 图片 (Base64)** 以及在画布上的 **精确坐标信息**
**新功能**: 支持同时上传两个文件(标准文件 + 旋转后文件),接口会自动进行轮廓匹配,计算出最佳的旋转角度和相似度。
Photoshop 插件可以直接使用返回的 Base64 图片数据创建图层,并根据坐标信息自动进行排版。
## 2. 接口定义
- **URL**: `/api/v1/algorithm/process_plt`
- **Method**: `POST`
- **Content-Type**: `multipart/form-data`
- **认证方式**: Bearer Token (需登录)
### 2.1 请求参数 (Form Data)
| 参数名 | 类型 | 必填 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- | :--- |
| `file` | File | **是** | - | **标准 PLT 文件** (.plt)。 |
| `rotated_file` | File | **否** | - | **已旋转/待匹配的 PLT 文件** (.plt)。<br>如果上传此文件,接口将执行轮廓匹配算法。 |
| `size_labels` | String | **是** | - | **尺码列表** (JSON 字符串)。<br>例如: `["S", "M", "L", "XL"]`<br>用于告诉算法文件里包含了哪些尺码。 |
| `dpi` | Integer | 否 | `150` | **输出图片分辨率**。<br>建议与 PS 画布分辨率一致。 |
| `rotation` | Integer | 否 | `0` | **强制旋转角度** (仅针对 `file`)。<br>可选值: `0`, `90`, `-90`, `180`。<br>接口会自动处理图片旋转和坐标变换。 |
### 2.2 响应结构 (JSON)
```json
{
"success": true,
"total_groups": 5,
// 裁片分组数据 (主要返回 file 的内容)
"groups": [
{
"group_id": 1,
"pieces": [
{
"size": "S",
"image_base64": "data:image/png;base64,iVBORw0KGgo...",
"width_px": 800,
"height_px": 600,
"width_cm": 13.54,
"height_cm": 10.16,
"center_x_cm": 25.4,
"center_y_cm": 30.2,
"left_cm": 18.63,
"top_cm": 35.28
},
...
]
}
],
// 匹配分析结果 (仅当上传了 rotated_file 时存在)
"match_analysis": [
{
"size": "S",
"matches": [
{
"standard_id": 0, // 标准文件中的裁片ID
"rotated_id": 3, // 旋转文件中的裁片ID
"distance": 0.7032, // Hausdorff 距离 (越小越相似)
"angle": 0.0 // 建议旋转角度
},
{
"standard_id": 5,
"rotated_id": 1,
"distance": 1.0371,
"angle": 180.0 // 建议旋转 180 度
}
]
},
...
]
}
```
### 2.3 关键字段说明
* **`image_base64`**:
* 这是最核心的字段。
* 包含了裁片的 **PNG 图片数据**(带透明通道)。
* 格式为标准 Data URI: `data:image/png;base64,...`
* PS 插件可以直接读取此字符串并转换为图像对象。
* **`left_cm` / `top_cm`**:
* **推荐使用**这两个字段进行定位。
* 表示裁片 **左上角** 在画布中的坐标(单位:厘米)。
* 坐标系原点通常取决于 PLT 文件的最小包围盒。
* **注意**: PS 的 Y 轴向下,而 CAD 通常向上。接口返回的 `top_cm` 是基于数学坐标系的,在 PS 中使用时可能需要根据画布高度进行翻转 (`CanvasHeight - top_cm`),或者直接使用(取决于具体需求)。
* **`match_analysis`**:
* 用于分析两个 PLT 文件之间的裁片对应关系。
* 可以用来判断某些裁片是否需要旋转 180 度才能匹配(例如倒顺毛面料)。
## 3. Photoshop 插件调用示例 (JavaScript/ExtendScript)
```javascript
// 假设 response 是从接口获取的 JSON 对象
var groups = response.groups;
for (var i = 0; i < groups.length; i++) {
var group = groups[i];
var pieces = group.pieces;
for (var j = 0; j < pieces.length; j++) {
var piece = pieces[j];
// 1. 获取 Base64 图片数据 (去掉前缀)
var base64Data = piece.image_base64.split(",")[1];
// 2. 保存为临时文件并打开 (伪代码)
var tempFile = saveBase64ToPng(base64Data);
var doc = app.open(tempFile);
// 3. 复制到主文档
doc.activeLayer.copy();
app.activeDocument = mainDoc;
var newLayer = mainDoc.paste();
newLayer.name = piece.size + "-" + group.group_id;
// 4. 定位 (将厘米转换为像素)
var dpi = 150; // 与请求参数一致
var x_px = piece.left_cm * dpi / 2.54;
var y_px = piece.top_cm * dpi / 2.54;
// 移动图层
newLayer.translate(x_px, y_px);
}
}
```

620
Server/pltreader.py Normal file
View File

@@ -0,0 +1,620 @@
'''
参考资料:
https://www.gnu.org/software/hp2xx/hp2xx.html
https://zhuanlan.zhihu.com/p/622090369
'''
import os
import typing
import string
import re
import warnings
from enum import Enum
import shapely
import shapely.ops
from shapely.geometry import LineString, Polygon, MultiPolygon, MultiLineString
import numpy as np
import cv2
import itertools
import logging
from shapely.validation import make_valid
SHOW_PLT_WARNINGS = False
# 如果没配置日志则配置日志
if not logging.getLogger().handlers:
logging.basicConfig(level=logging.INFO, format='[%(asctime)s][%(name)s][%(levelname)s] %(message)s')
def assert_warning(condition, message):
if SHOW_PLT_WARNINGS and (not condition):
logging.warning(message)
class PltCommand(Enum):
IN = 0
PU = 1
PD = 2
PA = 3
AbstractSyntaxTree = typing.List[typing.Dict]
class VirtualMachineOutput(object):
'''虚拟机执行后的结果'''
MIN_FILTER_AREA_FACTOR = 0.0068 ** 2
MAX_FILTER_AREA_FACTOR = 0.8 ** 2
def __init__(self, polygons :typing.List[Polygon], dispensable: typing.List[LineString]) -> None:
for poly in polygons:
assert isinstance(poly, Polygon)
for line in dispensable:
assert isinstance(line, LineString)
# 过滤结果
# 计算画布大小
min_x, min_y, max_x, max_y = MultiPolygon(polygons).bounds
filter_polygons = []
for polygon in polygons:
if (max_x - min_x) * (max_y - min_y) * VirtualMachineOutput.MIN_FILTER_AREA_FACTOR < polygon.area < (max_x - min_x) * (max_y - min_y) * VirtualMachineOutput.MAX_FILTER_AREA_FACTOR:
filter_polygons.append(polygon)
else:
boundary = polygon.boundary
if isinstance(boundary, LineString):
dispensable.append(boundary)
elif isinstance(boundary, MultiLineString):
dispensable.extend(list(boundary.geoms))
logging.info(f"过滤了{len(polygons) - len(filter_polygons)}个多边形")
polygons = filter_polygons
# 计算多边形的包含关系,构建多边形树
self.nodes = [{"data":polygon, "child":[], "parent":None, "index":index} for index, polygon in enumerate(polygons)]
self.dispensable = dispensable
# 找每个node的最佳parent
for node1 in self.nodes:
best_parent = None
for node2 in self.nodes:
if node1 == node2:
continue
if node1["index"] == 8 and node2["index"] == 9:
pass
# 判断是否包含
#if node2["data"].contains(node1["data"]):
if node2["data"].area > node1["data"].area:
intersection = node2["data"].intersection(node1["data"])
if intersection.area > node1["data"].area * 0.99:
if best_parent is None:
best_parent = node2
else:
if best_parent["data"].contains(node2["data"]):
best_parent = node2
node1["parent"] = best_parent
# 填充child
self.tree = []
for node in self.nodes:
if node["parent"] is not None:
node["parent"]["child"].append(node)
else:
self.tree.append(node)
# 构建广度优先遍历
self.bfs = self.tree.copy()
index = 0
while index < len(self.bfs):
self.bfs.extend(self.bfs[index]["child"])
index += 1
def _draw_nodes(self, nodes: typing.List, scale_factor: float, show_id: bool = True) -> np.ndarray:
# 计算画布大小
min_x, min_y, max_x, max_y = MultiPolygon([node["data"] for node in nodes]).bounds
max_x = int(np.ceil(max_x * scale_factor))
max_y = int(np.ceil(max_y * scale_factor))
min_x = int(np.floor(min_x * scale_factor))
min_y = int(np.floor(min_y * scale_factor))
max_x -= min_x
max_y -= min_y
#print("画布大小", max_x, max_y)
result_img = np.zeros((max_y, max_x, 4), dtype=np.uint8)
nodes = sorted(nodes, key=lambda node: self.bfs.index(node))
for node in nodes:
pts = (np.asarray(node["data"].exterior.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.fillPoly(result_img, [pts], (255,255,255,255))
cv2.polylines(result_img, [pts], True, (0,0,0,255), 2)
# 画无关紧要的东西
multiPolygon = MultiPolygon([node["data"] for node in nodes])
multiPolygon = make_valid(multiPolygon.buffer(0))
for shape in self.dispensable:
if multiPolygon.contains(shape):
pts = (np.asarray(shape.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.polylines(result_img, [pts], False, (0,0,0,255), 1)
# 画parent为None的id
if show_id:
for node in nodes:
if node["parent"] is None:
centroid = node["data"].centroid
text_pos = (int((centroid.x * scale_factor) - min_x), int(max_y - (centroid.y * scale_factor) - min_y))
cv2.putText(result_img, str(node["index"]), text_pos, cv2.FONT_HERSHEY_SIMPLEX, 3, (255,0,0,255), 5)
return result_img
def full_sheet(self, scale_factor: float = 0.1) -> np.ndarray:
'''整幅输出'''
return self._draw_nodes(self.bfs, scale_factor)
def debug_full_sheet(self, scale_factor: float = 0.1) -> np.ndarray:
"""
用随机颜色画出所有多边形和被过滤掉的线段
"""
# 计算画布大小画布大小应该考虑dispensable
all_shapes = [node["data"] for node in self.nodes] + self.dispensable
temp = []
for shape in all_shapes:
if isinstance(shape, LineString):
convex_hull = shape.convex_hull
if isinstance(convex_hull, Polygon):
temp.append(convex_hull)
else:
temp.append(shape)
min_x, min_y, max_x, max_y = MultiPolygon(temp).bounds
max_x = int(np.ceil(max_x * scale_factor))
max_y = int(np.ceil(max_y * scale_factor))
min_x = int(np.floor(min_x * scale_factor))
min_y = int(np.floor(min_y * scale_factor))
max_x -= min_x
max_y -= min_y
result_img = np.zeros((max_y, max_x, 4), dtype=np.uint8)
# 画多边形
for node in self.nodes:
#overlay = result_img.copy()
color = tuple(np.random.randint(0,256,size=3).tolist() + [255])
pts = (np.asarray(node["data"].exterior.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.fillPoly(result_img, [pts], color)
if node["parent"] is None:
cv2.polylines(result_img, [pts], True, (0,0,255,255), 1)
else:
cv2.polylines(result_img, [pts], True, (0,0,0,255), 1)
#alpha = 0.5
#cv2.addWeighted(overlay, alpha, result_img, 1 - alpha, 0, result_img)
# 画被过滤掉的线段
for shape in self.dispensable:
#overlay = result_img.copy()
color = tuple(np.random.randint(0,256,size=3).tolist() + [255])
pts = (np.asarray(shape.coords) * scale_factor - [min_x, min_y]).astype(np.int32)
pts[:,1] = max_y - pts[:,1]
cv2.polylines(result_img, [pts], False, color, 1)
#cv2.addWeighted(overlay, alpha, result_img, 1 - alpha, 0, result_img)
# 画parent为None的id
for node in self.nodes:
if node["parent"] is None:
centroid = node["data"].centroid
text_pos = (int((centroid.x * scale_factor) - min_x), int(max_y - (centroid.y * scale_factor) - min_y))
cv2.putText(result_img, str(node["index"]), text_pos, cv2.FONT_HERSHEY_SIMPLEX, 3, (0,0,255,255), 5)
return result_img
def _distance_between_cluster(self, cluster1, cluster2):
min_distance = float("inf")
for node1 in cluster1:
for node2 in cluster2:
assert isinstance(node1["data"], Polygon)
assert isinstance(node2["data"], Polygon)
d = node1["data"].distance(node2["data"])
if d < min_distance:
min_distance = d
return min_distance
def get_single_size_info(self, size_num: int) -> typing.List[typing.List]:
'''获取单码信息'''
logging.info(f"开始进行距离聚类,目标聚类数目: {size_num}")
# 计算所有多边形两两之间的距离
distances: list[tuple[float, int, int]] = []
for i in range(len(self.nodes)):
for j in range(i + 1, len(self.nodes)):
poly1 = self.nodes[i]["data"]
poly2 = self.nodes[j]["data"]
dist = poly1.distance(poly2)
distances.append((dist, i, j))
distances.sort(key=lambda x: x[0])
# 使用并查集进行聚类
parent = list(range(len(self.nodes)))
def find(i):
if parent[i] != i:
parent[i] = find(parent[i]) # 路径压缩
return parent[i]
def union(i, j):
root_i = find(i)
root_j = find(j)
parent[root_i] = root_j
return root_i != root_j
# 进行聚类直到聚类数目达到size_num
current_cluster_count = len(self.nodes)
for _, i, j in distances:
if current_cluster_count <= size_num:
break
if union(i, j):
current_cluster_count -= 1
# 构建聚类结果
clusters_dict = {}
for i in range(len(self.nodes)):
root = find(i)
if root not in clusters_dict:
clusters_dict[root] = []
clusters_dict[root].append(self.nodes[i])
clusters = list(clusters_dict.values())
logging.info(f"距离聚类得到{len(clusters)}个聚类")
# 排序
sorted_clusters = sorted(clusters, key=lambda x: min([node["index"] for node in x]))
return list(sorted_clusters)
def single_size(self, size_num: int, scale_factor: float = 0.1) -> typing.List[np.ndarray]:
'''单码输出'''
sorted_clusters = self.get_single_size_info(size_num)
result = []
for cluster in sorted_clusters:
result.append(self._draw_nodes(cluster, scale_factor))
return result
def single_piece(self, scale_factor: float = 0.1) -> typing.List[np.ndarray]:
'''单片输出'''
result = []
for node in self.nodes:
# 如果是顶层则新建一副图像
if node["parent"] is None:
nodes = [node] + node['child']
result.append(self._draw_nodes(nodes, scale_factor))
return result
class _VirtualMachine(object):
'''运行PLT文件的虚拟机'''
def __init__(self, tolerance) -> None:
self.tolerance = tolerance
def __run_v2(self, abstract_syntax_tree: AbstractSyntaxTree) -> VirtualMachineOutput:
current_x, current_y = 0, 0
pen_state = 'UP'
raw_segments: typing.List[typing.List[typing.Tuple[float, float]]] = []
current_path: typing.List[typing.Tuple[float, float]] = []
for code in abstract_syntax_tree:
try:
if code["cmd"] == PltCommand.IN:
logging.info("初始化")
elif code["cmd"] == PltCommand.PA:
new_x = float(code["args"][0])
new_y = float(code["args"][1])
if pen_state == 'DOWN':
if not current_path:
current_path.append((current_x, current_y))
current_path.append((new_x, new_y))
elif pen_state == 'UP':
pass
# 更新当前位置
current_x, current_y = new_x, new_y
elif code["cmd"] == PltCommand.PU:
pen_state = 'UP'
if current_path:
assert len(current_path) > 1 and len(set(current_path)) > 1
raw_segments.append(current_path)
current_path = []
elif code["cmd"] == PltCommand.PD:
pen_state = 'DOWN'
except Exception as e:
assert_warning(False, f"[code {code}] 执行指令异常: {e}")
# 处理最后一条路径
if current_path:
assert len(current_path) > 1 and len(set(current_path)) > 1
raw_segments.append(current_path)
current_path = []
merged_geometry = shapely.ops.linemerge(raw_segments)
final_lines: typing.List[LineString] = []
if isinstance(merged_geometry, LineString):
final_lines.append(merged_geometry)
elif isinstance(merged_geometry, MultiLineString):
final_lines.extend(merged_geometry.geoms)
return self.__build_polygons_and_dispensable(final_lines)
def run(self, abstract_syntax_tree: AbstractSyntaxTree, version: int = 2) -> VirtualMachineOutput:
if version == 1:
logging.warning("版本1已过时 请使用版本2")
return self.__run_v1(abstract_syntax_tree)
elif version == 2:
return self.__run_v2(abstract_syntax_tree)
else:
raise ValueError(f"不支持的版本号: {version}")
def __run_v1(self, abstract_syntax_tree: AbstractSyntaxTree) -> VirtualMachineOutput:
context = abstract_syntax_tree.copy()
current_x = 0
current_y = 0
self.pen_state = 'UP'
lines :typing.List[typing.List[typing.Tuple]] = []
lines_flag :typing.List[int] = []
while len(context) != 0:
code = context[0]
try:
if code["cmd"] == PltCommand.IN:
logging.info("初始化")
elif code["cmd"] == PltCommand.PA:
if self.pen_state == 'UP':
pass
elif self.pen_state == 'DOWN':
new_x = float(code["args"][0])
new_y = float(code["args"][1])
# 判断是否与已有的线相连接
for i in range(len(lines) - 1, -1, -1):
if lines_flag[i] == 1:
continue
if lines[i][0] == (current_x, current_y):
lines[i].insert(0, (new_x, new_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][0] == (new_x, new_y):
lines[i].insert(0, (current_x, current_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][-1] == (current_x, current_y):
lines[i].append((new_x, new_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
elif lines[i][-1] == (new_x, new_y):
lines[i].append((current_x, current_y))
if lines[i][0] == lines[i][-1]:
lines_flag[i] = 1
break
else:
# 不与已有的线相连接
lines.append([(current_x, current_y), (new_x, new_y)])
lines_flag.append(0)
current_x = float(code["args"][0])
current_y = float(code["args"][1])
elif code["cmd"] == PltCommand.PU:
self.pen_state = 'UP'
elif code["cmd"] == PltCommand.PD:
self.pen_state = 'DOWN'
except Exception as e:
assert_warning(False, f"[code {code}] 执行指令异常: {e}")
finally:
context = context[1:]
# 创建LineString :typing.List[LineString] = []
lineStrings :typing.List[LineString] = []
lineStrings = [LineString(line) for line in lines if len(set(line)) > 1]
'''
import matplotlib.pyplot as plt
for line in lineStrings:
x, y = line.xy
plt.plot(x, y)
plt.show()
'''
return self.__build_polygons_and_dispensable(lineStrings)
def __build_polygons_and_dispensable(self, lineStrings: typing.List[LineString]) -> VirtualMachineOutput:
# 为非常近的线搭桥
tree = shapely.STRtree(lineStrings)
bridges = []
# 遍历所有线段的端点,寻找需要搭桥的地方
for line in lineStrings:
endpoints = [shapely.Point(line.coords[0]), shapely.Point(line.coords[-1])]
for p in endpoints:
for idx in tree.query(p.buffer(self.tolerance)):
other_line = lineStrings[idx]
if other_line == line:
continue
dist = p.distance(other_line)
if 0 < dist <= self.tolerance:
_, p_target = shapely.ops.nearest_points(p, other_line)
bridge = LineString([p, p_target])
bridges.append(bridge)
lineStrings.extend(bridges)
# 节点化并提取闭合区域
all_shapes = shapely.ops.unary_union(lineStrings)
assert isinstance(all_shapes, MultiLineString)
polygons :typing.List[Polygon] = list(shapely.ops.polygonize(all_shapes))
# 合并重叠多边形
merged_polygons = shapely.unary_union(polygons)
assert isinstance(merged_polygons, MultiPolygon)
polygons = list(merged_polygons.geoms)
# 只保留外环
polygons = [Polygon(poly.exterior) for poly in polygons]
# 找到多余的线段
dispensable :typing.List[LineString] = []
closed_rings = [poly.exterior for poly in polygons]
if closed_rings:
leftovers = all_shapes.difference(shapely.ops.unary_union(closed_rings))
assert isinstance(leftovers, shapely.MultiLineString)
for shape in leftovers.geoms:
dispensable.append(shape)
else:
dispensable = list(all_shapes.geoms)
for i in range(len(polygons)):
polygons[i] = polygons[i].buffer(0)
logging.info(f"共生成多边形{len(polygons)}个,线段{len(dispensable)}")
output = VirtualMachineOutput(polygons, dispensable)
return output
class _Preprocessor(object):
'''PLT文件预处理器'''
def __init__(self) -> None:
pass
def get_abstract_syntax_tree(self, src_content: str) -> AbstractSyntaxTree:
content = src_content
# 换行符转分号 某些文件的语句末尾没有分号
content = content.replace("\n", ";")
content = content.replace(" ", ",")
# 删除所有空白字符
for space in string.whitespace:
content = content.replace(space, "")
result = []
# 遍历每一行命令
for line_index, line in enumerate(content.split(";")):
if line == "":
continue
# 提取开头的英文
pattern = r'^[a-zA-Z]+'
match = re.search(pattern, line)
if match is None:
assert_warning(False, f"[line {line_index}] 未找到命令: {line}")
continue
# 获取对应的指令
command_str = match.group().upper()
try:
command = PltCommand[command_str]
except KeyError:
assert_warning(False, f"[line {line_index}] 不支持的指令: {command_str}")
continue
# 获取参数
if len(command_str) == len(line):
stack = []
else:
stack = line[len(command_str):].split(",")
if command == PltCommand.IN:
result.append({'cmd': PltCommand.IN, 'args': []})
elif command == PltCommand.PA:
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
elif command == PltCommand.PU:
result.append({'cmd': PltCommand.PU, 'args': []})
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
elif command == PltCommand.PD:
result.append({'cmd': PltCommand.PD, 'args': []})
for i in range(len(stack) // 2):
result.append({'cmd': PltCommand.PA, 'args': [stack[0], stack[1]]})
stack = stack[2:]
assert_warning(len(stack) == 0, f"[line {line_index}] 栈非空: {stack}")
return result
class PltReader(object):
'''PLT文件读取器'''
def __init__(self, pltfile: typing.TextIO) -> None:
self.pltfile = pltfile
logging.info("正在预处理PLT文件...")
preprocessor = _Preprocessor()
self.abstract_syntax_tree = preprocessor.get_abstract_syntax_tree(self.pltfile.read())
logging.info("预处理完成")
def get_output(self, tolerance=5, version: int = 2) -> VirtualMachineOutput:
machine = _VirtualMachine(tolerance=tolerance)
output = machine.run(self.abstract_syntax_tree, version)
return output
if __name__ == "__main__":
filepath = "标准.plt"
with open(filepath, 'r') as f:
reader = PltReader(f)
output = reader.get_output()
debug_full_sheet_path = "debug_full_sheet.png"
cv2.imwrite(debug_full_sheet_path, output.debug_full_sheet(scale_factor=72/1016))
print(f"调试整幅输出已写入{debug_full_sheet_path}")
full_sheet_path = "full_sheet.png"
cv2.imwrite(full_sheet_path, output.full_sheet(scale_factor=72/1016))
print(f"整幅输出已写入{full_sheet_path}")
single_size_path = "single_size"
for index, img in enumerate(output.single_size(size_num=5, scale_factor=72/1016)):
cv2.imwrite(os.path.join(single_size_path, f"{index}.png"), img)
print(f"单码输出已写入{single_size_path}")
single_piece_path = "single_piece"
for index, img in enumerate(output.single_piece(scale_factor=72/1016)):
cv2.imwrite(os.path.join(single_piece_path, f"{index}.png"), img)
print(f"单片输出已写入{single_piece_path}")

7
Server/pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[project]
name = "server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = []

View File

@@ -12,3 +12,11 @@ email-validator
requests
alembic>=1.13.0
pyjwt
shapely
numpy
opencv-python-headless
scipy
Pillow
qiniu
openai
httpx

8
Server/uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "server"
version = "0.1.0"
source = { virtual = "." }