后端设计治理:批次 0-4 全部完成(安全/部署/一致性/结构/架构)
按 ROADMAP §3.1 治理批次推进的后端设计审查整改:
- 批次 0(安全):/api/status/{task_id} 补 JWT 鉴权与任务归属校验;
pythonocc_available 真实探测;bcrypt 超 72 字节显式拒绝;
SECRET_KEY/RUSTFS_* 惰性校验,代码侧弱默认移除
- 批次 1(部署正确性):主处理链路改走 RustFS(分派入参 stp_file_id 化,
worker 按 object_key 下载);AUTO_MIGRATE 开关 + 迁移目录 alembic/→migrations/
修复包遮蔽(自动迁移此前从未真正生效);OCC 镜像改 conda 原生执行 +
基础镜像 tag 锁定;compose 关键项改 ${VAR:?} 强制显式配置
- 批次 2(任务一致性):删除 Redis 进程内存回退,PG 为任务状态单一事实源;
批量元数据入库(processing_tasks.batch_id,迁移 a3f8c2d91e47);
型腔失败任务标 failed 不再静默 completed;事务边界收口
(数据本体写 flush-only、失败先回滚再置 failed、进度更新保留即时 commit)
- 批次 3(API 与代码结构):592 行 advanced_router 拆为 design/cost/machining/
export 四子路由,请求体全量 Pydantic 化;ROUTE_MODULES + route_registry
(/api/health 呈现 degraded,DEBUG fail fast);纯计算端点统一 to_thread;
StorageIntegrationService 按职责三拆;MAX_FILE_SIZE 接线生效、
celery 复用 Settings.redis_url;管理员重置密码改 JSON body(端到端断裂修复);
openapi.json 重导出(76 paths)+ 前端 gen:api
- 批次 4(架构演进):共享 ORM 按模块拆分(shared/models/base.py + identity.py、
moldinsight/models/、inventory/models/,删除三条无使用方的跨模块
relationship,跨模块桥接收敛为裸 FK 硬规则,无兼容 facade);
OCC executor 重建补 cancel_futures=True(消除旧队列被慢恢复线程
并行消化的数据竞争);OCC 吞吐方案设计先行
(docs/topics/performance/OCC_THROUGHPUT.md);顺手清偿 D15
(vite.config.ts 未用参数致 npm run build 失败)
测试基线:125 passed, 2 skipped(pytest + sqlite+aiosqlite;归属边界、
路由契约、配置治理、鉴权回归等随批新增)
文档同步:STATUS / TECH_DEBT / ROADMAP / ARCHITECTURE / API_CONTRACT /
OPERATIONS / AGENTS
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+8
-15
@@ -1,23 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
"""Celery 应用入口。
|
||||
|
||||
D14:broker/backend 复用 settings 的 Redis 配置——此前本模块自行
|
||||
load_dotenv 并手拼 REDIS URL,与 settings 两份实现、行为可能漂移。
|
||||
"""
|
||||
from celery import Celery
|
||||
|
||||
load_dotenv()
|
||||
|
||||
redis_host = os.getenv("REDIS_HOST", "localhost")
|
||||
redis_port = os.getenv("REDIS_PORT", "6379")
|
||||
redis_password = os.getenv("REDIS_PASSWORD", "")
|
||||
redis_db = os.getenv("REDIS_DB", "0")
|
||||
|
||||
if redis_password:
|
||||
broker_url = f"redis://:{redis_password}@{redis_host}:{redis_port}/{redis_db}"
|
||||
else:
|
||||
broker_url = f"redis://{redis_host}:{redis_port}/{redis_db}"
|
||||
from shared.config.settings import settings
|
||||
|
||||
app = Celery(
|
||||
"moldinsight",
|
||||
broker=broker_url,
|
||||
backend=broker_url,
|
||||
broker=settings.redis_url,
|
||||
backend=settings.redis_url,
|
||||
include=["celery_tasks"],
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Customer
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer
|
||||
from ..schemas import CustomerCreate, CustomerResponse
|
||||
|
||||
router = APIRouter(prefix="/customers", tags=["客户管理"])
|
||||
|
||||
@@ -15,10 +15,8 @@ from sqlalchemy import select, func
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import (
|
||||
User, Product, Supplier, Customer, Warehouse,
|
||||
Inventory, PurchaseOrder, SalesOrder
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Supplier, Customer, Warehouse, Inventory, PurchaseOrder, SalesOrder
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["仪表盘"])
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
from ..services.inventory_service import inventory_service
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from typing import Optional, List
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, MaterialPriceHistory, MaterialSupplier, Supplier
|
||||
from ..schemas import (
|
||||
MaterialPriceHistoryCreate,
|
||||
MaterialPriceHistoryResponse,
|
||||
|
||||
@@ -18,7 +18,9 @@ from pathlib import Path
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Product, ProductMaterial, STPFile, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from inventory.models import Product, ProductMaterial
|
||||
from ..schemas import (
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseDemandCalculateRequest,
|
||||
PurchaseDemandResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..services.stock_movement_service import stock_movement_service
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user, get_current_admin_user
|
||||
from shared.models.database import User, Supplier
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier
|
||||
from ..schemas import SupplierCreate, SupplierResponse
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["供应商管理"])
|
||||
|
||||
@@ -15,7 +15,8 @@ from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, Warehouse
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Warehouse
|
||||
from ..schemas import WarehouseCreate, WarehouseResponse
|
||||
|
||||
router = APIRouter(prefix="/warehouses", tags=["仓库管理"])
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""inventory 域模型出口(目录 / 仓储 / 交易 / 财务四个域文件)。
|
||||
|
||||
全量模型注册点见 shared/models/base.py 模块 docstring;
|
||||
业务代码按需 `from inventory.models import Product, ...`。
|
||||
"""
|
||||
from inventory.models.catalog import (
|
||||
Product,
|
||||
ProductMaterial,
|
||||
MaterialPriceHistory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
Customer,
|
||||
)
|
||||
from inventory.models.warehouse import (
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
)
|
||||
from inventory.models.trading import (
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from inventory.models.finance import (
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Product",
|
||||
"ProductMaterial",
|
||||
"MaterialPriceHistory",
|
||||
"MaterialSupplier",
|
||||
"Supplier",
|
||||
"Customer",
|
||||
"Warehouse",
|
||||
"Inventory",
|
||||
"StockMovement",
|
||||
"PurchaseOrder",
|
||||
"PurchaseOrderItem",
|
||||
"SalesOrder",
|
||||
"SalesOrderItem",
|
||||
"FinanceTransaction",
|
||||
"FinanceAllocation",
|
||||
]
|
||||
@@ -0,0 +1,166 @@
|
||||
"""inventory 目录域模型:成品/物料/BOM/价格/供应商/客户。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
跨模块桥接只保留裸 FK(base.py 约定):operator 类字段 user_id -> users.id 不建 relationship。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品表"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Numeric(12, 2), default=0)
|
||||
sale_price = Column(Numeric(12, 2), default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
max_stock = Column(Integer, default=1000)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
loss_rate = Column(Numeric(5, 4), default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class MaterialPriceHistory(Base):
|
||||
"""物料价格历史表"""
|
||||
__tablename__ = "material_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
price = Column(Numeric(12, 2), nullable=False)
|
||||
effective_date = Column(DateTime, default=func.now(), index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
product = relationship("Product", backref="price_history")
|
||||
supplier = relationship("Supplier", backref="price_history")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
|
||||
|
||||
|
||||
class MaterialSupplier(Base):
|
||||
"""物料供应商关联表"""
|
||||
__tablename__ = "material_suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
lead_time = Column(Integer, nullable=True) # 交货周期(天)
|
||||
min_order_quantity = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", backref="suppliers")
|
||||
supplier = relationship("Supplier", backref="materials")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Supplier(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户表"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
credit_limit = Column(Numeric(12, 2), default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
sales_orders = relationship("SalesOrder", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer(id={self.id}, name='{self.name}')>"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""inventory 财务域模型:收付款交易与订单分摊。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
txn_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
txn_type = Column(String(20), nullable=False, index=True)
|
||||
partner_type = Column(String(20), nullable=False, index=True)
|
||||
partner_id = Column(Integer, nullable=False, index=True)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
txn_date = Column(DateTime, default=func.now(), index=True)
|
||||
method = Column(String(30), default="bank")
|
||||
account_name = Column(String(100), nullable=True)
|
||||
status = Column(String(20), default="confirmed", index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
|
||||
|
||||
|
||||
class FinanceAllocation(Base):
|
||||
__tablename__ = "finance_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False, index=True)
|
||||
order_id = Column(Integer, nullable=False, index=True)
|
||||
allocated_amount = Column(Numeric(12, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
transaction = relationship("FinanceTransaction", back_populates="allocations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
|
||||
@@ -0,0 +1,108 @@
|
||||
"""inventory 交易域模型:采购订单/销售订单及明细。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, Numeric, ForeignKey, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""采购订单表"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
expected_date = Column(Date, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
paid_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
# 状态变更时间
|
||||
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""采购订单明细表"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
received_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("PurchaseOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
"""销售订单表"""
|
||||
__tablename__ = "sales_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(Date, nullable=True)
|
||||
manufacturing_date = Column(DateTime, nullable=True)
|
||||
actual_delivery_date = Column(DateTime, nullable=True)
|
||||
actual_payment_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Numeric(12, 2), default=0)
|
||||
actual_material_cost = Column(Numeric(12, 2), default=0)
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
received_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
customer = relationship("Customer", back_populates="sales_orders")
|
||||
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
"""销售订单明细表"""
|
||||
__tablename__ = "sales_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("SalesOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""inventory 仓储域模型:仓库/库存/库存流水。"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Numeric, ForeignKey, UniqueConstraint, CheckConstraint
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
"""仓库表"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
address = Column(Text, nullable=True)
|
||||
manager = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
inventories = relationship("Inventory", back_populates="warehouse")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Warehouse(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
"""库存表"""
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
|
||||
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), default=0)
|
||||
locked_quantity = Column(Numeric(12, 4), default=0)
|
||||
batch_number = Column(String(50), nullable=True)
|
||||
location = Column(String(100), nullable=True)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="inventory")
|
||||
warehouse = relationship("Warehouse", back_populates="inventories")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.locked_quantity
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""库存变动记录表"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||
movement_type = Column(String(20), nullable=False)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
before_quantity = Column(Numeric(12, 4), default=0)
|
||||
after_quantity = Column(Numeric(12, 4), default=0)
|
||||
reference_type = Column(String(50), nullable=True)
|
||||
reference_id = Column(Integer, nullable=True)
|
||||
reference_no = Column(String(50), nullable=True)
|
||||
unit_price = Column(Numeric(12, 2), nullable=True)
|
||||
total_amount = Column(Numeric(12, 2), nullable=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
product = relationship("Product", back_populates="stock_movements")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
|
||||
@@ -11,18 +11,8 @@ from typing import Optional, List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Supplier,
|
||||
Product,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
FinanceTransaction,
|
||||
FinanceAllocation,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Supplier, Product, SalesOrder, SalesOrderItem, PurchaseOrder, PurchaseOrderItem, FinanceTransaction, FinanceAllocation
|
||||
from ..schemas import (
|
||||
ReceiptCreate,
|
||||
PaymentCreate,
|
||||
|
||||
@@ -10,7 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory
|
||||
from ..schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse
|
||||
|
||||
|
||||
|
||||
@@ -10,18 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
Inventory,
|
||||
MaterialSupplier,
|
||||
Supplier,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, ProductMaterial, SalesOrder, SalesOrderItem, Inventory, MaterialSupplier, Supplier, PurchaseOrder, PurchaseOrderItem
|
||||
from ..utils import generate_order_no
|
||||
from ..schemas.purchase_demand_schemas import (
|
||||
PurchaseDemandItemResponse,
|
||||
|
||||
@@ -10,16 +10,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Supplier,
|
||||
Product,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
PurchaseOrder,
|
||||
PurchaseOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Supplier, Product, Warehouse, Inventory, StockMovement, PurchaseOrder, PurchaseOrderItem
|
||||
from ..schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderResponse,
|
||||
|
||||
@@ -12,17 +12,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, delete, update
|
||||
|
||||
from shared.models.database import (
|
||||
User,
|
||||
Customer,
|
||||
Product,
|
||||
ProductMaterial,
|
||||
Warehouse,
|
||||
Inventory,
|
||||
StockMovement,
|
||||
SalesOrder,
|
||||
SalesOrderItem,
|
||||
)
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Customer, Product, ProductMaterial, Warehouse, Inventory, StockMovement, SalesOrder, SalesOrderItem
|
||||
from ..schemas import (
|
||||
SalesOrderCreate,
|
||||
SalesOrderResponse,
|
||||
|
||||
@@ -9,7 +9,8 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update, func
|
||||
|
||||
from shared.models.database import User, Product, Warehouse, Inventory, StockMovement
|
||||
from shared.models.identity import User
|
||||
from inventory.models import Product, Warehouse, Inventory, StockMovement
|
||||
from ..schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse
|
||||
from ..utils import generate_order_no
|
||||
|
||||
|
||||
@@ -3,32 +3,54 @@ import importlib
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def _safe_include(module_path: str, label: str):
|
||||
# 业务路由装载清单:新增路由必须登记于此。
|
||||
# 失败语义(原 _safe_include 仅 WARNING 跳过,进程带病启动不可感知):
|
||||
# - 非 DEBUG:记录进 route_load_status["failed"],/api/health 呈现 degraded
|
||||
# - DEBUG:直接抛错 fail fast——开发环境路由缺失必须当场暴露
|
||||
ROUTE_MODULES = [
|
||||
# (label, module_path, debug_only)
|
||||
("健康检查", "moldinsight.api.health_router", False),
|
||||
("上传", "moldinsight.api.upload_router", False),
|
||||
("批量", "moldinsight.api.batch_router", False),
|
||||
("任务", "moldinsight.api.task_router", False),
|
||||
("历史", "moldinsight.api.history_router", False),
|
||||
("CAM", "moldinsight.api.cam_router", False),
|
||||
("设计", "moldinsight.api.design_router", False),
|
||||
("成本", "moldinsight.api.cost_router", False),
|
||||
("加工", "moldinsight.api.machining_router", False),
|
||||
("导出", "moldinsight.api.export_router", False),
|
||||
("铝价", "moldinsight.api.aluminum_price_routes", False),
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
("调试", "moldinsight.api.debug_router", True),
|
||||
]
|
||||
|
||||
|
||||
def _safe_include(label: str, module_path: str, debug_only: bool = False):
|
||||
if debug_only and not settings.DEBUG:
|
||||
route_load_status["disabled"].append({"label": label, "module": module_path})
|
||||
return
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
router_obj = getattr(module, "router", None)
|
||||
if router_obj is None:
|
||||
raise ValueError("未找到 router 对象")
|
||||
router.include_router(router_obj)
|
||||
route_load_status["loaded"].append({"label": label, "module": module_path})
|
||||
logger.info(f"{label} 路由加载成功")
|
||||
except Exception as exc:
|
||||
logger.warning(f"{label} 路由加载失败,已跳过: {exc}")
|
||||
route_load_status["failed"].append(
|
||||
{"label": label, "module": module_path, "error": str(exc)}
|
||||
)
|
||||
logger.error(f"{label} 路由加载失败: {exc}")
|
||||
if settings.DEBUG:
|
||||
raise
|
||||
|
||||
|
||||
_safe_include("moldinsight.api.health_router", "健康检查")
|
||||
_safe_include("moldinsight.api.upload_router", "上传")
|
||||
_safe_include("moldinsight.api.batch_router", "批量")
|
||||
_safe_include("moldinsight.api.task_router", "任务")
|
||||
_safe_include("moldinsight.api.history_router", "历史")
|
||||
_safe_include("moldinsight.api.cam_router", "CAM")
|
||||
_safe_include("moldinsight.api.advanced_router", "高级")
|
||||
_safe_include("moldinsight.api.aluminum_price_routes", "铝价")
|
||||
|
||||
# 调试端点会 dump 全量任务数据,仅 DEBUG 模式注册(双重防线:还需登录)
|
||||
if settings.DEBUG:
|
||||
_safe_include("moldinsight.api.debug_router", "调试")
|
||||
for _label, _module_path, _debug_only in ROUTE_MODULES:
|
||||
_safe_include(_label, _module_path, _debug_only)
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
storage_service = StorageIntegrationService()
|
||||
|
||||
_cached_instances = {}
|
||||
|
||||
|
||||
def _get_cached_import(key: str):
|
||||
"""惰性导入核心模块,避免路由器模块级加载时的循环依赖。"""
|
||||
if key in _cached_instances:
|
||||
return _cached_instances[key]
|
||||
try:
|
||||
if key == "side_action_designer":
|
||||
from moldinsight.core.side_action_designer import SideActionDesigner
|
||||
instance = SideActionDesigner()
|
||||
elif key == "cavity_layout_optimizer":
|
||||
from moldinsight.core.cavity_layout_optimizer import CavityLayoutOptimizer
|
||||
instance = CavityLayoutOptimizer()
|
||||
elif key == "mold_system_designer":
|
||||
from moldinsight.core.mold_system_designer import MoldSystemDesigner
|
||||
instance = MoldSystemDesigner()
|
||||
elif key == "mold_cam_designer":
|
||||
from moldinsight.core.mold_cam import MoldCAMDesigner
|
||||
instance = MoldCAMDesigner()
|
||||
elif key == "collision_detector":
|
||||
from moldinsight.core.mold_machining import CollisionDetector
|
||||
instance = CollisionDetector()
|
||||
elif key == "toolpath_optimizer":
|
||||
from moldinsight.core.mold_machining import ToolpathOptimizer
|
||||
instance = ToolpathOptimizer()
|
||||
elif key == "edm_designer":
|
||||
from moldinsight.core.mold_machining import EDMElectrodeDesigner
|
||||
instance = EDMElectrodeDesigner()
|
||||
elif key == "machining_simulator":
|
||||
from moldinsight.core.mold_machining import MachiningSimulator
|
||||
instance = MachiningSimulator()
|
||||
else:
|
||||
return None
|
||||
_cached_instances[key] = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _ensure_task_access(
|
||||
db_session: AsyncSession,
|
||||
task_id: str,
|
||||
user_id: int,
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
return await TaskQueryService.ensure_task_access(db_session, task_id, user_id)
|
||||
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
mold_base_size = body.get("mold_base_size")
|
||||
layout_type = body.get("layout_type", "auto")
|
||||
if cavity_count < 1 or cavity_count > 64:
|
||||
raise HTTPException(400, "型腔数量必须在 1-64 之间")
|
||||
optimizer = _get_cached_import("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = optimizer.optimize_layout(
|
||||
product_bbox=product_bbox,
|
||||
cavity_count=cavity_count,
|
||||
mold_base_size=mold_base_size,
|
||||
layout_type=layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = designer.design_cooling_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
cycle_time_target=cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
layout_positions = body.get("layout_positions")
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = designer.design_gating_system(
|
||||
product_bbox=product_bbox, material=material,
|
||||
cavity_count=cavity_count, gate_type=gate_type,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
product_bbox = body.get("product_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "ABS")
|
||||
cavity_count = body.get("cavity_count", 1)
|
||||
gate_type = body.get("gate_type", "auto")
|
||||
cycle_time_target = body.get("cycle_time_target")
|
||||
layout_positions = body.get("layout_positions")
|
||||
ds = _get_cached_import("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ds.design_complete_system(
|
||||
mold_size=mold_size, product_bbox=product_bbox,
|
||||
material=material, cavity_count=cavity_count,
|
||||
gate_type=gate_type, cycle_time_target=cycle_time_target,
|
||||
layout_positions=layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
parting_direction = body.get("parting_direction", [0, 0, 1])
|
||||
mold_size = body.get("mold_size", {"length": 300, "width": 300, "height": 200})
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
sd = _get_cached_import("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
|
||||
from moldinsight.services.shape_loader import get_shape_loader
|
||||
shape = await get_shape_loader().load_shape_for_task(db_session, task_id)
|
||||
if shape is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
result = await processing_service.run_occ(
|
||||
sd.analyze_and_design,
|
||||
shape,
|
||||
parting_direction,
|
||||
mold_size,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
|
||||
|
||||
@router.post("/design-cam")
|
||||
async def design_mold_cam(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [150, 150, 100], "min": [-75, -75, -50], "max": [75, 75, 50]})
|
||||
mold_steel = body.get("mold_steel", "P20")
|
||||
surface_quality = body.get("surface_quality", "standard")
|
||||
controller = body.get("controller", "fanuc")
|
||||
cam = _get_cached_import("mold_cam_designer")
|
||||
if not cam:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cam.design_mold_cam(
|
||||
cavity_bbox=cavity_bbox, stock_bbox=stock_bbox,
|
||||
mold_steel=mold_steel, surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/check-collision")
|
||||
async def check_toolpath_collision(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
tool = body.get("tool", {"diameter": 10, "flute_length": 30, "shank_diameter": 10})
|
||||
stock_bbox = body.get("stock_bbox", {"min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
clamp_positions = body.get("clamp_positions")
|
||||
cd = _get_cached_import("collision_detector")
|
||||
if not cd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = cd.check_toolpath_safety(toolpath_points, tool, stock_bbox, clamp_positions)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/optimize-toolpath")
|
||||
async def optimize_toolpath(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
toolpath_points = body.get("toolpath_points", [[0, 0, 50], [10, 10, -5], [20, 20, -10]])
|
||||
cutting_params = body.get("cutting_params", {"feed_rate_mm_min": 500})
|
||||
stock_bbox = body.get("stock_bbox")
|
||||
to = _get_cached_import("toolpath_optimizer")
|
||||
if not to:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = to.optimize_toolpath(toolpath_points, cutting_params, stock_bbox)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-electrodes")
|
||||
async def design_edm_electrodes(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
undercut_regions = body.get("undercut_regions", [{"center": [0, 0, 0], "area": 100, "type": "undercut"}])
|
||||
cavity_bbox = body.get("cavity_bbox", {"dimensions": [100, 100, 50]})
|
||||
material = body.get("material", "copper")
|
||||
spark_gap = body.get("spark_gap", 0.05)
|
||||
overburn = body.get("overburn", 0.1)
|
||||
ed = _get_cached_import("edm_designer")
|
||||
if not ed:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ed.design_electrodes(undercut_regions, cavity_bbox, material, spark_gap, overburn)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/simulate-machining")
|
||||
async def simulate_machining(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
body = await request.json()
|
||||
operations = body.get("operations", [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}])
|
||||
stock_bbox = body.get("stock_bbox", {"dimensions": [100, 100, 50], "min": [-50, -50, -25], "max": [50, 50, 25]})
|
||||
resolution = body.get("resolution", 2.0)
|
||||
ms = _get_cached_import("machining_simulator")
|
||||
if not ms:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = ms.simulate_machining(operations, stock_bbox, resolution)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
formats = body.get("formats", ["step", "stl"])
|
||||
components = body.get("components", ["cavity", "core"])
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(404, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
cavity_shapes = processing_service.get_export_shapes(
|
||||
task_id,
|
||||
resolved_scheme_id,
|
||||
)
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
|
||||
if not cavity_shapes:
|
||||
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
|
||||
# 现场转换缺失格式,用户无需重新分析
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||||
)
|
||||
|
||||
base_filename = Path(filename).stem
|
||||
result = cad_exporter.export_mold_results(
|
||||
cavity_data=cavity_shapes,
|
||||
base_filename=base_filename,
|
||||
formats=formats,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
)
|
||||
result["files"] = _augment_export_files(task_id, result.get("files", []))
|
||||
result["source"] = "generated"
|
||||
|
||||
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
|
||||
await storage_service.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
|
||||
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
|
||||
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
await _ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@@ -18,19 +18,22 @@ from sqlalchemy.orm import joinedload
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User, ProcessingTask, STPFile
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from shared.utils.logger import get_logger
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from shared.config.settings import settings
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.task_dispatcher import dispatch_processing
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
|
||||
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
|
||||
|
||||
|
||||
@router.post("/batch-upload")
|
||||
@@ -60,7 +63,7 @@ async def batch_upload(
|
||||
|
||||
batch_id = str(uuid.uuid4())
|
||||
tasks: List[Dict[str, Any]] = []
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = TaskStorageService()
|
||||
|
||||
for file in files:
|
||||
# 文件类型检查
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime
|
||||
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, ProcessingTask
|
||||
from shared.models.identity import User
|
||||
from moldinsight.models import ProcessingTask
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from moldinsight.services.cam_bundle_service import cam_bundle_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
@@ -21,20 +26,25 @@ DEFAULT_CAM_PREFERENCES = {
|
||||
}
|
||||
|
||||
|
||||
class CamPlanRequest(BaseModel):
|
||||
"""未提供的偏好字段回落到任务持久化偏好,再回落到默认值。"""
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
mold_steel: Optional[str] = None
|
||||
surface_quality: Optional[str] = None
|
||||
controller: Optional[str] = None
|
||||
include_gcode: Optional[bool] = None
|
||||
|
||||
|
||||
@router.post("/cam/plan")
|
||||
async def generate_cam_plan(
|
||||
request: Request,
|
||||
body: CamPlanRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""基于任务分模结果生成 CAM 准备包(MVP)。"""
|
||||
_ = current_user
|
||||
body = await request.json()
|
||||
task_id = body.get("task_id")
|
||||
scheme_id = body.get("scheme_id")
|
||||
|
||||
if not task_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 task_id")
|
||||
task_id = body.task_id
|
||||
|
||||
task_result = await db_session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
@@ -47,23 +57,26 @@ async def generate_cam_plan(
|
||||
processing_task.parameters.get("cam_preferences", {}) or {}
|
||||
)
|
||||
|
||||
mold_steel = body.get(
|
||||
"mold_steel",
|
||||
persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"]),
|
||||
# 注意 include_gcode 显式判 None:False 是有效值,不能走 or 回落
|
||||
mold_steel = (
|
||||
body.mold_steel
|
||||
if body.mold_steel is not None
|
||||
else persisted_preferences.get("mold_steel", DEFAULT_CAM_PREFERENCES["mold_steel"])
|
||||
)
|
||||
surface_quality = body.get(
|
||||
"surface_quality",
|
||||
persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"]),
|
||||
surface_quality = (
|
||||
body.surface_quality
|
||||
if body.surface_quality is not None
|
||||
else persisted_preferences.get("surface_quality", DEFAULT_CAM_PREFERENCES["surface_quality"])
|
||||
)
|
||||
controller = body.get(
|
||||
"controller",
|
||||
persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"]),
|
||||
controller = (
|
||||
body.controller
|
||||
if body.controller is not None
|
||||
else persisted_preferences.get("controller", DEFAULT_CAM_PREFERENCES["controller"])
|
||||
)
|
||||
include_gcode = bool(
|
||||
body.get(
|
||||
"include_gcode",
|
||||
persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"]),
|
||||
)
|
||||
include_gcode = (
|
||||
body.include_gcode
|
||||
if body.include_gcode is not None
|
||||
else persisted_preferences.get("include_gcode", DEFAULT_CAM_PREFERENCES["include_gcode"])
|
||||
)
|
||||
|
||||
task_view = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
@@ -73,9 +86,11 @@ async def generate_cam_plan(
|
||||
raise HTTPException(status_code=400, detail="任务尚未完成,无法生成CAM计划")
|
||||
|
||||
try:
|
||||
data = cam_bundle_service.build_bundle(
|
||||
# CAM 刀路计算为纯 Python 重计算,投放线程池避免阻塞事件循环
|
||||
data = await asyncio.to_thread(
|
||||
cam_bundle_service.build_bundle,
|
||||
task_view=task_view,
|
||||
scheme_id=scheme_id,
|
||||
scheme_id=body.scheme_id,
|
||||
mold_steel=mold_steel,
|
||||
surface_quality=surface_quality,
|
||||
controller=controller,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""核心计算模块的惰性装载器(原 advanced_router._get_cached_import,D1 拆分时上提共用)。
|
||||
|
||||
- 惰性导入:避免路由模块级加载核心包(含 OCC 重模块)的导入开销与循环依赖
|
||||
- 装载失败返回 None 且不缓存失败(与原实现一致,端点统一 503「服务不可用」)
|
||||
- 实例缓存:设计/加工模块为纯 Python 计算(构造后无 self 突变,方法仅读入参),
|
||||
可安全地被 asyncio.to_thread 并发调用;OCC 相关的 side_action_designer
|
||||
必须经 processing_service.run_occ 的单线程 executor 使用
|
||||
"""
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_instances: dict = {}
|
||||
|
||||
_LOADERS = {
|
||||
"side_action_designer": ("moldinsight.core.side_action_designer", "SideActionDesigner"),
|
||||
"cavity_layout_optimizer": ("moldinsight.core.cavity_layout_optimizer", "CavityLayoutOptimizer"),
|
||||
"mold_system_designer": ("moldinsight.core.mold_system_designer", "MoldSystemDesigner"),
|
||||
"mold_cam_designer": ("moldinsight.core.mold_cam", "MoldCAMDesigner"),
|
||||
"collision_detector": ("moldinsight.core.mold_machining", "CollisionDetector"),
|
||||
"toolpath_optimizer": ("moldinsight.core.mold_machining", "ToolpathOptimizer"),
|
||||
"edm_designer": ("moldinsight.core.mold_machining", "EDMElectrodeDesigner"),
|
||||
"machining_simulator": ("moldinsight.core.mold_machining", "MachiningSimulator"),
|
||||
}
|
||||
|
||||
|
||||
def get_core_module(key: str):
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
if key not in _LOADERS:
|
||||
return None
|
||||
with _lock:
|
||||
if key in _instances:
|
||||
return _instances[key]
|
||||
module_path, class_name = _LOADERS[key]
|
||||
try:
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
instance = getattr(module, class_name)()
|
||||
except Exception as e:
|
||||
logger.warning(f"核心模块 {key} 加载失败: {e}")
|
||||
return None
|
||||
_instances[key] = instance
|
||||
return instance
|
||||
@@ -0,0 +1,51 @@
|
||||
# api/cost_router.py
|
||||
"""成本估算接口(批次 3 自 advanced_router 拆分,D1)。"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CostEstimateRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
@router.post("/cost-estimate")
|
||||
async def estimate_cost(
|
||||
body: CostEstimateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
"""模具成本估算:优先使用 LLM,未启用时降级为规则式估算"""
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
# 统一走任务视图:进行中读 Redis,完成态由 PG+RustFS 组装(Redis 大对象已瘦身)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, body.task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
analysis_result = task_data.get("analysis_result")
|
||||
if not analysis_result:
|
||||
raise HTTPException(400, "该任务尚未完成分析")
|
||||
detailed_context = {
|
||||
"candidate_schemes": task_data.get("candidate_schemes", []),
|
||||
"geometry_data": task_data.get("geometry_data", {}),
|
||||
"metadata": {"selected_material": task_data.get("material")},
|
||||
}
|
||||
# 优先使用 LLM
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
result = await llm_service.estimate_cost(analysis_result, detailed_context)
|
||||
if result is not None:
|
||||
result["source"] = "ai"
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
# LLM 未启用或失败,降级为规则估算
|
||||
from moldinsight.services.cost_estimate_service import estimate_cost_by_rules
|
||||
rules_result = estimate_cost_by_rules(analysis_result, detailed_context)
|
||||
return {"status": "success", "data": rules_result}
|
||||
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# api/design_router.py
|
||||
"""模具结构设计类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
- 请求体一律 Pydantic 模型(原 request.json() 手动解析退役,校验失败统一 422)
|
||||
- 纯 Python 设计计算统一经 asyncio.to_thread 投放线程池,不阻塞事件循环;
|
||||
OCC 相关的倒扣检测仍走 processing_service.run_occ 的单线程 executor
|
||||
(PythonOCC 非线程安全)
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.api.core_modules import get_core_module
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---- 请求模型 ----
|
||||
|
||||
class BBox3D(BaseModel):
|
||||
dimensions: List[float] = Field(default_factory=lambda: [100.0, 100.0, 50.0])
|
||||
min: Optional[List[float]] = None
|
||||
max: Optional[List[float]] = None
|
||||
|
||||
|
||||
class MoldSize(BaseModel):
|
||||
length: float = 300.0
|
||||
width: float = 300.0
|
||||
height: float = 200.0
|
||||
|
||||
|
||||
class OptimizeLayoutRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
mold_base_size: Optional[BBox3D] = None
|
||||
layout_type: str = "auto"
|
||||
|
||||
|
||||
class CoolingDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
cycle_time_target: Optional[float] = None
|
||||
|
||||
|
||||
class GatingDesignRequest(BaseModel):
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class MoldSystemDesignRequest(BaseModel):
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
product_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "ABS"
|
||||
cavity_count: int = Field(default=1, ge=1, le=64)
|
||||
gate_type: str = "auto"
|
||||
cycle_time_target: Optional[float] = None
|
||||
layout_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class UndercutDetectRequest(BaseModel):
|
||||
task_id: str
|
||||
parting_direction: List[float] = Field(default_factory=lambda: [0.0, 0.0, 1.0])
|
||||
mold_size: MoldSize = Field(default_factory=MoldSize)
|
||||
|
||||
|
||||
@router.post("/optimize-layout")
|
||||
async def optimize_cavity_layout(
|
||||
body: OptimizeLayoutRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
optimizer = get_core_module("cavity_layout_optimizer")
|
||||
if not optimizer:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
# 纯 Python 布局优化,投放线程池避免阻塞事件循环
|
||||
result = await asyncio.to_thread(
|
||||
optimizer.optimize_layout,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
cavity_count=body.cavity_count,
|
||||
mold_base_size=body.mold_base_size.model_dump(exclude_none=True) if body.mold_base_size else None,
|
||||
layout_type=body.layout_type,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-cooling")
|
||||
async def design_cooling_system(
|
||||
body: CoolingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import CoolingSystemDesigner
|
||||
designer = CoolingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_cooling_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-gating")
|
||||
async def design_gating_system(
|
||||
body: GatingDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
from moldinsight.core.mold_system_designer import GatingSystemDesigner
|
||||
designer = GatingSystemDesigner()
|
||||
result = await asyncio.to_thread(
|
||||
designer.design_gating_system,
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-mold-system")
|
||||
async def design_complete_mold_system(
|
||||
body: MoldSystemDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ds = get_core_module("mold_system_designer")
|
||||
if not ds:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ds.design_complete_system,
|
||||
mold_size=body.mold_size.model_dump(),
|
||||
product_bbox=body.product_bbox.model_dump(exclude_none=True),
|
||||
material=body.material,
|
||||
cavity_count=body.cavity_count,
|
||||
gate_type=body.gate_type,
|
||||
cycle_time_target=body.cycle_time_target,
|
||||
layout_positions=body.layout_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/detect-undercuts")
|
||||
async def detect_undercuts(
|
||||
body: UndercutDetectRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, body.task_id, current_user.id)
|
||||
|
||||
sd = get_core_module("side_action_designer")
|
||||
if not sd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
|
||||
# 从持久化 STP 原件重建几何(此前传 shape=None 会被兜底吞掉,永远返回"无倒扣")
|
||||
from moldinsight.services.shape_loader import get_shape_loader
|
||||
shape = await get_shape_loader().load_shape_for_task(db_session, body.task_id)
|
||||
if shape is None:
|
||||
raise HTTPException(410, "任务几何不可用:无法从存储重建 STP 形状,请重新上传分析")
|
||||
|
||||
# OCC 操作必须走单线程 executor(PythonOCC 非线程安全)
|
||||
result = await processing_service.run_occ(
|
||||
sd.analyze_and_design,
|
||||
shape,
|
||||
body.parting_direction,
|
||||
body.mold_size.model_dump(),
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -0,0 +1,302 @@
|
||||
# api/export_router.py
|
||||
"""导出类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
导出产物清单(export_artifacts)的合并/校验辅助函数自原文件平移,
|
||||
行为不变;任务归属校验直接调用 TaskQueryService.ensure_task_access。
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.identity import User
|
||||
from moldinsight.services.processing_service import processing_service
|
||||
from moldinsight.services.task_query_service import TaskQueryService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
cad_exporter = CADExporter()
|
||||
|
||||
|
||||
class ExportMoldRequest(BaseModel):
|
||||
task_id: str
|
||||
scheme_id: Optional[str] = None
|
||||
formats: List[str] = Field(default_factory=lambda: ["step", "stl"])
|
||||
components: List[str] = Field(default_factory=lambda: ["cavity", "core"])
|
||||
|
||||
|
||||
# ---- 导出产物清单辅助(自原 advanced_router 平移) ----
|
||||
|
||||
def _get_export_artifacts(task_data: dict) -> dict:
|
||||
if not isinstance(task_data, dict):
|
||||
return {}
|
||||
direct = task_data.get("export_artifacts")
|
||||
if isinstance(direct, dict):
|
||||
return direct
|
||||
parameters = task_data.get("parameters")
|
||||
if isinstance(parameters, dict) and isinstance(parameters.get("export_artifacts"), dict):
|
||||
return parameters.get("export_artifacts")
|
||||
return {}
|
||||
|
||||
|
||||
def _expand_components(components):
|
||||
requested = components or ["cavity", "core"]
|
||||
if "all" in requested:
|
||||
return ["cavity", "core", "parting_surface"]
|
||||
return list(dict.fromkeys(requested))
|
||||
|
||||
|
||||
def _augment_export_files(task_id: str, files):
|
||||
items = []
|
||||
for file in files or []:
|
||||
item = dict(file)
|
||||
relative_path = item.get("relative_path")
|
||||
if not relative_path and item.get("filepath"):
|
||||
relative_path = cad_exporter.get_relative_path(item["filepath"])
|
||||
if relative_path:
|
||||
relative_path = str(relative_path).replace("\\", "/").strip("/")
|
||||
item["relative_path"] = relative_path
|
||||
item["download_path"] = f"/api/export-download/{quote(relative_path, safe='/')}?task_id={task_id}"
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
def _merge_export_artifacts(existing: dict, export_result: dict) -> dict:
|
||||
merged = dict(existing or {})
|
||||
schemes = dict(merged.get("schemes") or {})
|
||||
scheme_id = export_result.get("scheme_id") or "default"
|
||||
previous = dict(schemes.get(scheme_id) or {})
|
||||
|
||||
file_map = {}
|
||||
for file in previous.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
for file in export_result.get("files", []):
|
||||
file_map[(file.get("component"), file.get("format"))] = file
|
||||
|
||||
schemes[scheme_id] = {
|
||||
"base_filename": export_result.get("base_filename") or previous.get("base_filename"),
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"files": sorted(
|
||||
file_map.values(),
|
||||
key=lambda item: (item.get("component", ""), item.get("format", "")),
|
||||
),
|
||||
"errors": export_result.get("errors", []),
|
||||
"total_files": len(file_map),
|
||||
"total_errors": len(export_result.get("errors", [])),
|
||||
}
|
||||
|
||||
merged["version"] = 1
|
||||
merged["task_id"] = export_result.get("task_id") or merged.get("task_id")
|
||||
merged["generated_at"] = merged.get("generated_at") or datetime.now().isoformat()
|
||||
merged["schemes"] = schemes
|
||||
return merged
|
||||
|
||||
|
||||
def _select_persisted_files(task_id: str, task_data: dict, scheme_id: str, formats, components):
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(scheme_id)
|
||||
if not scheme_data:
|
||||
return None
|
||||
|
||||
component_list = _expand_components(components)
|
||||
format_list = list(dict.fromkeys(formats or ["step", "stl"]))
|
||||
expected = {(component, fmt) for component in component_list for fmt in format_list}
|
||||
|
||||
available = []
|
||||
available_keys = set()
|
||||
for file in scheme_data.get("files", []):
|
||||
component = file.get("component")
|
||||
fmt = file.get("format")
|
||||
if component not in component_list or fmt not in format_list:
|
||||
continue
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if not relative_path:
|
||||
continue
|
||||
full_path = os.path.join(cad_exporter.output_dir, relative_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
continue
|
||||
available.append(file)
|
||||
available_keys.add((component, fmt))
|
||||
|
||||
if expected and not expected.issubset(available_keys):
|
||||
return None
|
||||
|
||||
return _augment_export_files(task_id, available)
|
||||
|
||||
|
||||
# ---- 端点 ----
|
||||
|
||||
@router.post("/export-mold")
|
||||
async def export_mold_results(
|
||||
body: ExportMoldRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
task_id = body.task_id
|
||||
formats = body.formats
|
||||
components = body.components
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
resolved_scheme_id = body.scheme_id or task_data.get("best_scheme_id") or "default"
|
||||
persisted_files = _select_persisted_files(
|
||||
task_id=task_id,
|
||||
task_data=task_data,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=components,
|
||||
)
|
||||
if persisted_files:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"base_filename": Path(task_data.get("filename", f"mold_{task_id}")).stem,
|
||||
"task_id": task_id,
|
||||
"scheme_id": resolved_scheme_id,
|
||||
"files": persisted_files,
|
||||
"errors": [],
|
||||
"total_files": len(persisted_files),
|
||||
"total_errors": 0,
|
||||
"source": "persisted",
|
||||
},
|
||||
}
|
||||
|
||||
cavity_shapes = processing_service.get_export_shapes(
|
||||
task_id,
|
||||
resolved_scheme_id,
|
||||
)
|
||||
filename = task_data.get("filename", f"mold_{task_id}")
|
||||
|
||||
if not cavity_shapes:
|
||||
# 内存 shape 缓存失效(如服务重启):从持久化的单组件 STEP
|
||||
# 现场转换缺失格式,用户无需重新分析
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
scheme_data = (artifacts.get("schemes") or {}).get(resolved_scheme_id)
|
||||
if scheme_data:
|
||||
base_filename = scheme_data.get("base_filename") or Path(filename).stem
|
||||
regenerated = await processing_service.regenerate_export_from_persisted(
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
formats=formats,
|
||||
components=_expand_components(components),
|
||||
base_filename=base_filename,
|
||||
scheme_files=scheme_data.get("files", []),
|
||||
)
|
||||
if regenerated:
|
||||
regenerated["files"] = _augment_export_files(
|
||||
task_id, regenerated.get("files", [])
|
||||
)
|
||||
# 合并进持久化 manifest,后续请求直接命中持久化路径
|
||||
merged_artifacts = _merge_export_artifacts(artifacts, regenerated)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(
|
||||
task_id, {"export_artifacts": merged_artifacts}
|
||||
)
|
||||
TaskQueryService.invalidate_task_view(task_id)
|
||||
|
||||
return {"status": "success", "data": regenerated}
|
||||
|
||||
raise HTTPException(
|
||||
409,
|
||||
"导出缓存已失效或任务尚未完成,请重新分析后再导出以保证方案一致性",
|
||||
)
|
||||
|
||||
base_filename = Path(filename).stem
|
||||
result = cad_exporter.export_mold_results(
|
||||
cavity_data=cavity_shapes,
|
||||
base_filename=base_filename,
|
||||
formats=formats,
|
||||
components=components,
|
||||
task_id=task_id,
|
||||
scheme_id=resolved_scheme_id,
|
||||
)
|
||||
result["files"] = _augment_export_files(task_id, result.get("files", []))
|
||||
result["source"] = "generated"
|
||||
|
||||
merged_artifacts = _merge_export_artifacts(_get_export_artifacts(task_data), result)
|
||||
await TaskStorageService().update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{"export_artifacts": merged_artifacts},
|
||||
)
|
||||
# D9:存储方法已不再自行 commit,请求侧显式提交
|
||||
await db_session.commit()
|
||||
await redis_task_manager.update_task(task_id, {"export_artifacts": merged_artifacts})
|
||||
TaskQueryService.invalidate_task_view(task_id) # parameters 已变更,缓存视图失效
|
||||
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.get("/export-download/{filepath:path}")
|
||||
async def download_export_file(
|
||||
filepath: str,
|
||||
task_id: str,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
if not task_id:
|
||||
raise HTTPException(400, "缺少 task_id")
|
||||
|
||||
# 归属校验统一走 TaskQueryService(与 /api/status 共用,含 404/403 语义)
|
||||
await TaskQueryService.ensure_task_access(db_session, task_id, current_user.id)
|
||||
task_data = await TaskQueryService.get_task_view(db_session, task_id)
|
||||
if not task_data:
|
||||
raise HTTPException(404, "任务不存在")
|
||||
|
||||
allowed_paths = set()
|
||||
artifacts = _get_export_artifacts(task_data)
|
||||
for scheme in (artifacts.get("schemes") or {}).values():
|
||||
for file in scheme.get("files", []):
|
||||
relative_path = str(file.get("relative_path") or "").replace("\\", "/").strip("/")
|
||||
if relative_path:
|
||||
allowed_paths.add(relative_path)
|
||||
|
||||
normalized_path = str(filepath or "").replace("\\", "/").strip("/")
|
||||
if normalized_path not in allowed_paths:
|
||||
raise HTTPException(403, "该文件不在任务允许下载清单中")
|
||||
|
||||
full_path = os.path.join(cad_exporter.output_dir, normalized_path.replace("/", os.sep))
|
||||
if not os.path.exists(full_path):
|
||||
raise HTTPException(404, "文件不存在")
|
||||
if not os.path.abspath(full_path).startswith(os.path.abspath(cad_exporter.output_dir)):
|
||||
raise HTTPException(403, "禁止访问")
|
||||
media_types = {
|
||||
".step": "application/step", ".stp": "application/step",
|
||||
".iges": "application/iges", ".igs": "application/iges",
|
||||
".stl": "model/stl", ".brep": "application/octet-stream",
|
||||
}
|
||||
ext = Path(full_path).suffix.lower()
|
||||
media_type = media_types.get(ext, "application/octet-stream")
|
||||
return FileResponse(full_path, media_type=media_type, filename=os.path.basename(full_path))
|
||||
|
||||
|
||||
@router.get("/export-recommendations")
|
||||
async def get_export_recommendations(
|
||||
target: str = "ug",
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
result = cad_exporter.get_export_recommendations(target)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -1,7 +1,11 @@
|
||||
# api/v1/health_router.py
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.api.route_registry import route_load_status
|
||||
from moldinsight.core.occ_availability import is_pythonocc_available
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -9,10 +13,19 @@ router = APIRouter()
|
||||
@router.get("/health")
|
||||
@router.post("/health")
|
||||
async def health():
|
||||
task_count = await redis_task_manager.get_task_count()
|
||||
# 首次调用会触发 PythonOCC 导入(可能耗时数秒),投放线程池避免阻塞事件循环
|
||||
pythonocc_available = await asyncio.to_thread(is_pythonocc_available)
|
||||
failed_routes = route_load_status["failed"]
|
||||
return {
|
||||
"status": "healthy",
|
||||
"pythonocc": True,
|
||||
"total_tasks": task_count,
|
||||
"redis_connected": redis_task_manager.is_connected
|
||||
# 有业务路由装载失败即 degraded:进程活着但功能残缺,监控必须可感知
|
||||
"status": "degraded" if failed_routes else "healthy",
|
||||
# 真实探测 PythonOCC(此前硬编码 True,与上传预检的诚实化同源)
|
||||
"pythonocc": pythonocc_available,
|
||||
"total_tasks": await redis_task_manager.get_task_count(),
|
||||
"redis_connected": redis_task_manager.is_connected,
|
||||
"routes": {
|
||||
"loaded": [m["label"] for m in route_load_status["loaded"]],
|
||||
"failed": failed_routes,
|
||||
"disabled": [m["label"] for m in route_load_status["disabled"]],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
import urllib.parse
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.file_history_service import FileHistoryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -21,7 +21,7 @@ async def get_file_history(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""获取当前用户按文件名分组的文件历史记录(支持多上传)"""
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_groups = await storage_service.get_all_file_groups(
|
||||
db_session, user_id=current_user.id
|
||||
)
|
||||
@@ -42,7 +42,7 @@ async def get_file_records(
|
||||
"""获取当前用户指定文件名的所有上传记录(支持多上传历史)"""
|
||||
decoded_filename = urllib.parse.unquote(filename)
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = FileHistoryService()
|
||||
file_records = await storage_service.get_file_history_by_filename(
|
||||
db_session,
|
||||
decoded_filename,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# api/machining_router.py
|
||||
"""CAM / 加工仿真类接口(批次 3 自 advanced_router 拆分,D1)。
|
||||
|
||||
加工计算为纯 Python 重计算(非 OCC),统一经 asyncio.to_thread
|
||||
投放线程池执行,不阻塞事件循环。
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.identity import User
|
||||
from moldinsight.api.core_modules import get_core_module
|
||||
from moldinsight.api.design_router import BBox3D
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---- 请求模型 ----
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
diameter: float = 10.0
|
||||
flute_length: float = 30.0
|
||||
shank_diameter: float = 10.0
|
||||
|
||||
|
||||
def _cam_cavity_bbox() -> BBox3D:
|
||||
return BBox3D(dimensions=[100.0, 100.0, 50.0], min=[-50.0, -50.0, -25.0], max=[50.0, 50.0, 25.0])
|
||||
|
||||
|
||||
def _cam_stock_bbox() -> BBox3D:
|
||||
return BBox3D(dimensions=[150.0, 150.0, 100.0], min=[-75.0, -75.0, -50.0], max=[75.0, 75.0, 50.0])
|
||||
|
||||
|
||||
class CamDesignRequest(BaseModel):
|
||||
cavity_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_stock_bbox)
|
||||
mold_steel: str = "P20"
|
||||
surface_quality: str = "standard"
|
||||
controller: str = "fanuc"
|
||||
|
||||
|
||||
class CollisionCheckRequest(BaseModel):
|
||||
toolpath_points: List[List[float]] = Field(
|
||||
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
|
||||
)
|
||||
tool: ToolSpec = Field(default_factory=ToolSpec)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
clamp_positions: Optional[List[List[float]]] = None
|
||||
|
||||
|
||||
class ToolpathOptimizeRequest(BaseModel):
|
||||
toolpath_points: List[List[float]] = Field(
|
||||
default_factory=lambda: [[0, 0, 50], [10, 10, -5], [20, 20, -10]]
|
||||
)
|
||||
cutting_params: Dict[str, Any] = Field(default_factory=lambda: {"feed_rate_mm_min": 500})
|
||||
stock_bbox: Optional[BBox3D] = None
|
||||
|
||||
|
||||
class ElectrodeDesignRequest(BaseModel):
|
||||
undercut_regions: List[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: [{"center": [0, 0, 0], "area": 100, "type": "undercut"}]
|
||||
)
|
||||
cavity_bbox: BBox3D = Field(default_factory=BBox3D)
|
||||
material: str = "copper"
|
||||
spark_gap: float = 0.05
|
||||
overburn: float = 0.1
|
||||
|
||||
|
||||
class MachiningSimulateRequest(BaseModel):
|
||||
operations: List[Dict[str, Any]] = Field(
|
||||
default_factory=lambda: [{"strategy": "z_level_roughing", "levels": [{"z": -5}]}]
|
||||
)
|
||||
stock_bbox: BBox3D = Field(default_factory=_cam_cavity_bbox)
|
||||
resolution: float = 2.0
|
||||
|
||||
|
||||
@router.post("/design-cam")
|
||||
async def design_mold_cam(
|
||||
body: CamDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
cam = get_core_module("mold_cam_designer")
|
||||
if not cam:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
cam.design_mold_cam,
|
||||
cavity_bbox=body.cavity_bbox.model_dump(exclude_none=True),
|
||||
stock_bbox=body.stock_bbox.model_dump(exclude_none=True),
|
||||
mold_steel=body.mold_steel,
|
||||
surface_quality=body.surface_quality,
|
||||
controller=body.controller,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/check-collision")
|
||||
async def check_toolpath_collision(
|
||||
body: CollisionCheckRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
cd = get_core_module("collision_detector")
|
||||
if not cd:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
cd.check_toolpath_safety,
|
||||
body.toolpath_points,
|
||||
body.tool.model_dump(),
|
||||
body.stock_bbox.model_dump(exclude_none=True),
|
||||
body.clamp_positions,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/optimize-toolpath")
|
||||
async def optimize_toolpath(
|
||||
body: ToolpathOptimizeRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
to = get_core_module("toolpath_optimizer")
|
||||
if not to:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
to.optimize_toolpath,
|
||||
body.toolpath_points,
|
||||
body.cutting_params,
|
||||
body.stock_bbox.model_dump(exclude_none=True) if body.stock_bbox else None,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/design-electrodes")
|
||||
async def design_edm_electrodes(
|
||||
body: ElectrodeDesignRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ed = get_core_module("edm_designer")
|
||||
if not ed:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ed.design_electrodes,
|
||||
body.undercut_regions,
|
||||
body.cavity_bbox.model_dump(exclude_none=True),
|
||||
body.material,
|
||||
body.spark_gap,
|
||||
body.overburn,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
|
||||
|
||||
@router.post("/simulate-machining")
|
||||
async def simulate_machining(
|
||||
body: MachiningSimulateRequest,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
ms = get_core_module("machining_simulator")
|
||||
if not ms:
|
||||
raise HTTPException(503, "服务不可用:核心模块未加载")
|
||||
result = await asyncio.to_thread(
|
||||
ms.simulate_machining,
|
||||
body.operations,
|
||||
body.stock_bbox.model_dump(exclude_none=True),
|
||||
body.resolution,
|
||||
)
|
||||
return {"status": "success", "data": result}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""路由装载注册表。
|
||||
|
||||
moldinsight/api/__init__.py 的 _safe_include 将装载结果登记于此,
|
||||
由 /api/health 对外呈现——路由装载失败不再只是 WARNING 日志(此前
|
||||
业务路由加载失败会被静默跳过,进程照常 healthy,功能残缺不可感知)。
|
||||
|
||||
本模块保持零依赖,供聚合入口与 health_router 双向引用而不产生循环导入。
|
||||
"""
|
||||
from typing import Dict, List
|
||||
|
||||
route_load_status: Dict[str, List[Dict[str, str]]] = {
|
||||
# 装载成功:{"label", "module"}
|
||||
"loaded": [],
|
||||
# 装载失败:{"label", "module", "error"}——存在条目时 /api/health 返回 degraded
|
||||
"failed": [],
|
||||
# 有意不注册(如 DEBUG 关闭时的调试路由):{"label", "module"}
|
||||
"disabled": [],
|
||||
}
|
||||
@@ -7,7 +7,7 @@ from moldinsight.services.task_query_service import TaskQueryService
|
||||
from shared.database.database import get_db_session
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.utils.logger import get_logger
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -4,34 +4,24 @@ import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from shared.models.schemas import ProcessingStatus, create_task_info
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.file_handler import FileHandler
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.task_dispatcher import dispatch_processing
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.database.database import get_db_session
|
||||
from shared.utils.logger import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shared.services.auth_service import get_current_active_user
|
||||
from shared.models.database import User
|
||||
from shared.models.identity import User
|
||||
from moldinsight.core.occ_availability import is_pythonocc_available
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
file_handler = FileHandler()
|
||||
|
||||
|
||||
def _occ_available() -> bool:
|
||||
"""真实检测 PythonOCC 可用性(惰性导入,缺失时不影响本路由加载)。
|
||||
|
||||
此前该字段硬编码 True,响应不诚实;几何处理依赖 OCC,
|
||||
不可用时任务会在处理阶段以明确错误失败。
|
||||
"""
|
||||
try:
|
||||
import OCC.Core.STEPControl # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
# D14:上传限制接 settings(MAX_FILE_SIZE 此前为死配置,文件处理器硬编码 50MB)
|
||||
file_handler = FileHandler(upload_dir=settings.UPLOAD_DIR, max_file_size=settings.MAX_FILE_SIZE)
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
@@ -72,7 +62,7 @@ async def upload_stp(
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
logger.info(f"[UPLOAD] 文件已保存: {file_path} ({file_size} bytes), task_id={task_id}")
|
||||
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = TaskStorageService()
|
||||
|
||||
stp_file = await storage_service.save_stp_file(
|
||||
session=db_session,
|
||||
@@ -114,7 +104,7 @@ async def upload_stp(
|
||||
"file_info": {
|
||||
"filename": file.filename,
|
||||
"size": file_size,
|
||||
"pythonocc_available": _occ_available(),
|
||||
"pythonocc_available": is_pythonocc_available(),
|
||||
"database_file_id": stp_file.id,
|
||||
"sha256": file_meta["sha256"],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""PythonOCC 可用性探测。
|
||||
|
||||
惰性导入探测,供上传预检(upload_router)与 /api/health 共用——
|
||||
此前两处各自实现或硬编码,探测语义收口于一处。
|
||||
"""
|
||||
|
||||
|
||||
def is_pythonocc_available() -> bool:
|
||||
try:
|
||||
import OCC.Core.STEPControl # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,28 @@
|
||||
"""moldinsight 域模型出口。
|
||||
|
||||
全量模型注册点见 shared/models/base.py 模块 docstring;
|
||||
业务代码按需 `from moldinsight.models import STPFile, ...`。
|
||||
"""
|
||||
from moldinsight.models.stp_analysis import (
|
||||
STPFile,
|
||||
GeometryData,
|
||||
MeshData,
|
||||
HTMLFile,
|
||||
ProcessingTask,
|
||||
MoldCavityData,
|
||||
FeatureDetection,
|
||||
DesignRecommendation,
|
||||
AnalysisMetrics,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"STPFile",
|
||||
"GeometryData",
|
||||
"MeshData",
|
||||
"HTMLFile",
|
||||
"ProcessingTask",
|
||||
"MoldCavityData",
|
||||
"FeatureDetection",
|
||||
"DesignRecommendation",
|
||||
"AnalysisMetrics",
|
||||
]
|
||||
@@ -0,0 +1,352 @@
|
||||
"""moldinsight 域模型:STEP 分析链路(源文件 + 各阶段产物 + 任务)。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
跨模块桥接只保留裸 FK,不建 ORM relationship(base.py 约定):
|
||||
- STPFile.user_id -> users.id(原 user relationship 无使用方,已删)
|
||||
- STPFile.product_id -> products.id(原 product relationship 无使用方,已删;
|
||||
分析结果一键转成品的桥接在 inventory/api/product_routes.py 显式 select 两表)
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, JSON, LargeBinary, Boolean, Float, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class STPFile(Base):
|
||||
"""STP源文件元数据表 - 支持同一文件多次上传"""
|
||||
__tablename__ = "stp_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
# 关联进销存成品(P2-1:分析结果可一键创建为成品并回写;裸 FK,见模块 docstring)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=True, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
|
||||
storage_bucket = Column(String(100), nullable=False) # 存储桶名称
|
||||
object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
|
||||
|
||||
# 文件信息
|
||||
original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
|
||||
mime_type = Column(String(50), default="application/octet-stream")
|
||||
|
||||
# 上传批次标识 - 用于区分同一文件的多次上传
|
||||
upload_batch = Column(String(36), index=True) # UUID批次号
|
||||
|
||||
# 时间戳
|
||||
upload_time = Column(DateTime, default=func.now())
|
||||
processed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 分析摘要 - 快速查询字段
|
||||
volume = Column(Float, nullable=True) # 体积 mm³
|
||||
surface_area = Column(Float, nullable=True) # 表面积 mm²
|
||||
product_weight = Column(Float, nullable=True) # 产品重量 g
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
|
||||
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
|
||||
filename = Column(String(255), nullable=True) # 已弃用
|
||||
|
||||
# 关联关系(均为本模块内子表)
|
||||
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
|
||||
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
|
||||
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
|
||||
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
|
||||
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
|
||||
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
|
||||
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
|
||||
processing_tasks = relationship("ProcessingTask", back_populates="stp_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
|
||||
|
||||
class GeometryData(Base):
|
||||
"""几何数据JSON元数据表"""
|
||||
__tablename__ = "geometry_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 分析方法
|
||||
analysis_method = Column(String(50), default="pythonocc") # pythonocc, simulated
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 几何属性摘要(便于快速查询)
|
||||
volume = Column(Float, nullable=True)
|
||||
surface_area = Column(Float, nullable=True)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
center_of_mass = Column(JSON, nullable=True)
|
||||
|
||||
# 拓扑信息
|
||||
topology_faces = Column(Integer, nullable=True)
|
||||
topology_edges = Column(Integer, nullable=True)
|
||||
topology_vertices = Column(Integer, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="geometry_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GeometryData(id={self.id}, stp_file_id={self.stp_file_id})>"
|
||||
|
||||
|
||||
class MeshData(Base):
|
||||
"""网格数据JSON元数据表(详细网格存 RustFS,PostgreSQL 存摘要)"""
|
||||
__tablename__ = "mesh_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 生成设置
|
||||
quality = Column(String(20), default="medium") # low / medium / high
|
||||
|
||||
# 网格规模信息
|
||||
vertex_count = Column(Integer, nullable=True)
|
||||
face_count = Column(Integer, nullable=True)
|
||||
point_count = Column(Integer, nullable=True) # 采样点云数量
|
||||
|
||||
# 网格边界框(便于快速查询)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mesh_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeshData(id={self.id}, stp_file_id={self.stp_file_id}, quality='{self.quality}')>"
|
||||
|
||||
class HTMLFile(Base):
|
||||
"""网页文件元数据表"""
|
||||
__tablename__ = "html_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 文件信息
|
||||
filename = Column(String(255), nullable=False)
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 可视化相关元数据
|
||||
visualization_type = Column(String(50), default="3d_viewer")
|
||||
has_interactive_elements = Column(Boolean, default=True)
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True)
|
||||
html_content = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="html_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HTMLFile(id={self.id}, stp_file_id={self.stp_file_id}, object_key='{self.object_key}')>"
|
||||
|
||||
class ProcessingTask(Base):
|
||||
"""处理任务记录表"""
|
||||
__tablename__ = "processing_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 批量上传聚合 ID(批次 2:批量元数据入库——PG 为单一事实源,
|
||||
# 同批任务经此列聚合查询,不再依赖 Redis/进程内存存批量元数据)
|
||||
batch_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# 任务类型和状态
|
||||
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
|
||||
status = Column(String(20), default="pending") # pending, processing, completed, failed
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
started_time = Column(DateTime, nullable=True)
|
||||
completed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 处理进度
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
current_step = Column(String(100), nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message = Column(Text, nullable=True)
|
||||
error_stack = Column(Text, nullable=True)
|
||||
|
||||
# 处理参数
|
||||
parameters = Column(JSON, nullable=True) # 任务参数
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="processing_tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProcessingTask(id={self.id}, task_id='{self.task_id}', status='{self.status}')>"
|
||||
|
||||
class MoldCavityData(Base):
|
||||
"""模具型腔数据元数据表"""
|
||||
__tablename__ = "mold_cavity_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
detailed_object_key = Column(String(500), nullable=False) # 完整三维数据
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
|
||||
# 模具类型和材料
|
||||
mold_material = Column(String(100), default="Aluminum Alloy 7075")
|
||||
mold_type = Column(String(50), default="single_cavity") # single_cavity, multi_cavity
|
||||
|
||||
# 工艺参数
|
||||
shrinkage_rate = Column(Float, nullable=False)
|
||||
draft_angle = Column(Float, nullable=False)
|
||||
parting_line_length = Column(Float, nullable=True)
|
||||
|
||||
# 生成时间
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关键信息摘要(快速查询字段)
|
||||
cavity_key_info = Column(JSON, nullable=True) # 完整关键信息
|
||||
|
||||
# 提取的字段(便于查询和排序)
|
||||
mold_size_length = Column(Float, nullable=True)
|
||||
mold_size_width = Column(Float, nullable=True)
|
||||
mold_size_height = Column(Float, nullable=True)
|
||||
estimated_clamping_force = Column(String(50), nullable=True)
|
||||
product_weight = Column(String(50), nullable=True)
|
||||
product_volume = Column(Float, nullable=True)
|
||||
wall_thickness_range = Column(String(50), nullable=True)
|
||||
complexity_score = Column(Float, nullable=True)
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk = Column(String(50), nullable=True) # 熔接痕风险
|
||||
sink_mark_risk = Column(String(50), nullable=True) # 缩痕风险
|
||||
warpage_risk = Column(String(50), nullable=True) # 翘曲风险
|
||||
|
||||
# 多方案可信化摘要(第1周阶段1)
|
||||
best_scheme_id = Column(String(64), nullable=True, index=True)
|
||||
confidence_score = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=True, index=True)
|
||||
fallback_reason = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mold_cavity_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MoldCavityData(stp_file_id={self.stp_file_id}, mold_material='{self.mold_material}')>"
|
||||
|
||||
|
||||
class FeatureDetection(Base):
|
||||
"""特征检测结果表"""
|
||||
__tablename__ = "feature_detections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 特征信息
|
||||
feature_type = Column(String(50), nullable=False, index=True) # thin_wall, thick_wall, wall_non_uniform, rib, boss, draft_angle, high_curvature, fillet
|
||||
confidence = Column(Float, nullable=False) # 0.0 - 1.0
|
||||
|
||||
# 位置和尺寸
|
||||
location = Column(JSON, nullable=True) # [x, y, z]
|
||||
dimensions = Column(JSON, nullable=True) # [length, width, height]
|
||||
|
||||
# 特征参数
|
||||
parameters = Column(JSON, nullable=True) # 自定义参数
|
||||
|
||||
# 检测时间
|
||||
detected_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联的几何数据
|
||||
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="feature_detections")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
|
||||
|
||||
|
||||
class DesignRecommendation(Base):
|
||||
"""设计建议表"""
|
||||
__tablename__ = "design_recommendations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 建议信息
|
||||
rec_type = Column(String(50), nullable=False) # wall_thickness, draft_angle, etc.
|
||||
priority = Column(String(20), nullable=False) # high, medium, low
|
||||
description = Column(String(500), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
# 建议参数
|
||||
parameters = Column(JSON, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending") # pending, accepted, rejected
|
||||
user_notes = Column(Text, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="design_recommendations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
|
||||
|
||||
|
||||
class AnalysisMetrics(Base):
|
||||
"""分析指标表"""
|
||||
__tablename__ = "analysis_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 质量指标
|
||||
volume_utilization = Column(Float, default=0) # 体积利用率
|
||||
topology_complexity = Column(Float, default=0) # 拓扑复杂度
|
||||
wall_uniformity = Column(Float, default=0) # 壁厚均匀性
|
||||
|
||||
# 分析摘要
|
||||
analysis_summary = Column(Text, nullable=True)
|
||||
|
||||
# FreeCAD 验证结果
|
||||
verification_status = Column(String(20), nullable=True) # passed, failed, pending, error
|
||||
verification_volume_diff = Column(Float, nullable=True) # 体积差异百分比
|
||||
verification_area_diff = Column(Float, nullable=True) # 表面积差异百分比
|
||||
verification_details = Column(JSON, nullable=True) # 完整验证结果
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="analysis_metrics")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AnalysisMetrics(stp_file_id={self.stp_file_id}, volume_utilization={self.volume_utilization})>"
|
||||
+15
-363
@@ -1,27 +1,25 @@
|
||||
# services/storage_integration_rustfs.py
|
||||
"""存储集成服务 - 协调 PostgreSQL 和 RustFS"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
# services/analysis_storage_service.py
|
||||
"""分析结果数据存储——几何/网格/型腔/HTML/特征的 RustFS 上传与 PG 元数据,
|
||||
以及任务完整数据视图的组装。
|
||||
|
||||
from shared.models.database import (
|
||||
STPFile, GeometryData, MeshData, MoldCavityData,
|
||||
HTMLFile, ProcessingTask, User,
|
||||
FeatureDetection, DesignRecommendation,
|
||||
UserActivity, SystemLog
|
||||
)
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
|
||||
"""
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.models import STPFile, GeometryData, MeshData, MoldCavityData, HTMLFile, FeatureDetection, DesignRecommendation
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class StorageIntegrationService:
|
||||
"""存储集成服务 - PostgreSQL + RustFS"""
|
||||
class AnalysisStorageService:
|
||||
"""分析结果数据(几何/网格/型腔/HTML/特征)存储与视图组装"""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_best_scheme_payload(cavity_json: Dict[str, Any]) -> Dict[str, Any]:
|
||||
@@ -80,169 +78,6 @@ class StorageIntegrationService:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
upload_batch: Optional[str] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
|
||||
|
||||
支持同一文件多次上传,每次上传都会创建新记录
|
||||
"""
|
||||
|
||||
# 1. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_file(
|
||||
file_type='stp_files',
|
||||
file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
metadata={
|
||||
'original_filename': original_filename,
|
||||
'user_id': str(user_id) if user_id else 'anonymous',
|
||||
'upload_batch': upload_batch or str(uuid.uuid4())
|
||||
}
|
||||
)
|
||||
|
||||
file_hash = upload_result['file_hash']
|
||||
batch_id = upload_batch or str(uuid.uuid4())
|
||||
|
||||
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
|
||||
from datetime import datetime
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=file_hash,
|
||||
upload_batch=batch_id,
|
||||
status="uploaded",
|
||||
file_path=str(file_path),
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
# D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录)
|
||||
await session.flush()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
|
||||
return stp_file
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口——
|
||||
与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询)
|
||||
"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now(),
|
||||
parameters=parameters or {},
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态(保留即时 commit:进度/状态需跨事务对外可见,
|
||||
处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_parameters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
parameters: Dict[str, Any],
|
||||
):
|
||||
"""合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)"""
|
||||
try:
|
||||
task = await session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
task = task.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
|
||||
merged = dict(task.parameters or {})
|
||||
merged.update(parameters or {})
|
||||
task.parameters = merged
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务参数失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态(保留即时 commit,理由同 update_task_status)"""
|
||||
try:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def save_geometry_data(self, session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
geometry_json: Dict[str, Any],
|
||||
@@ -517,37 +352,9 @@ class StorageIntegrationService:
|
||||
await session.flush()
|
||||
logger.info(f"保存了 {len(features)} 个特征和 {len(recommendations)} 个建议")
|
||||
|
||||
async def log_user_activity(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
activity_type: str,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[int] = None,
|
||||
description: Optional[str] = None,
|
||||
metadata: Optional[Dict] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None):
|
||||
"""记录用户活动"""
|
||||
|
||||
activity = UserActivity(
|
||||
user_id=user_id,
|
||||
activity_type=activity_type,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
description=description,
|
||||
meta_data=metadata,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
session.add(activity)
|
||||
await session.commit()
|
||||
logger.debug(f"用户活动记录: {activity_type} by user {user_id}")
|
||||
|
||||
async def get_stp_file_with_data(self, session: AsyncSession,
|
||||
stp_file_id: int) -> Dict[str, Any]:
|
||||
"""获取STP文件及其所有关联数据"""
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
try:
|
||||
# 1. 获取STP文件记录(使用 joinedload 预加载关联数据)
|
||||
result = await session.execute(
|
||||
@@ -666,157 +473,6 @@ class StorageIntegrationService:
|
||||
|
||||
return result
|
||||
|
||||
async def get_file_history_by_filename(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""获取同一文件名的所有上传历史记录"""
|
||||
from shared.models.database import ProcessingTask
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
query = select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
).where(
|
||||
STPFile.original_filename == filename
|
||||
).order_by(STPFile.upload_time.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(STPFile.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
files = result.unique().scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': f.id,
|
||||
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
|
||||
'upload_batch': f.upload_batch,
|
||||
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'file_size': f.file_size,
|
||||
'status': f.status,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight,
|
||||
'has_analysis': f.status == 'completed'
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def get_all_file_groups(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
|
||||
|
||||
from sqlalchemy import func, desc
|
||||
from sqlalchemy.orm import joinedload
|
||||
from shared.models.database import ProcessingTask
|
||||
|
||||
# 子查询:获取每个文件名的最新上传
|
||||
subquery = (
|
||||
select(
|
||||
STPFile.original_filename,
|
||||
func.max(STPFile.upload_time).label('latest_upload')
|
||||
)
|
||||
.group_by(STPFile.original_filename)
|
||||
.order_by(desc('latest_upload'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
subquery = subquery.where(STPFile.user_id == user_id)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
# 主查询:获取最新记录和统计信息
|
||||
query = (
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
)
|
||||
.join(
|
||||
subquery,
|
||||
(STPFile.original_filename == subquery.c.original_filename) &
|
||||
(STPFile.upload_time == subquery.c.latest_upload)
|
||||
)
|
||||
.order_by(STPFile.upload_time.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
latest_files = result.unique().scalars().all()
|
||||
|
||||
# 一次性聚合每个文件名的上传次数(替代逐文件 count 的 N+1 查询)
|
||||
count_subquery = (
|
||||
select(STPFile.original_filename, func.count().label("upload_count"))
|
||||
.group_by(STPFile.original_filename)
|
||||
)
|
||||
if user_id:
|
||||
count_subquery = count_subquery.where(STPFile.user_id == user_id)
|
||||
count_result = await session.execute(count_subquery)
|
||||
upload_counts = {
|
||||
row.original_filename: row.upload_count for row in count_result
|
||||
}
|
||||
|
||||
# 获取每个文件名的上传次数
|
||||
file_groups = []
|
||||
for f in latest_files:
|
||||
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
|
||||
upload_count = upload_counts.get(f.original_filename, 1)
|
||||
|
||||
file_groups.append({
|
||||
'filename': f.original_filename,
|
||||
'latest_id': f.id,
|
||||
'latest_task_id': task_id,
|
||||
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'latest_status': f.status,
|
||||
'upload_count': upload_count,
|
||||
'file_size': f.file_size,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight
|
||||
})
|
||||
|
||||
return file_groups
|
||||
|
||||
async def update_stp_file_analysis_summary(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
volume: Optional[float] = None,
|
||||
surface_area: Optional[float] = None,
|
||||
product_weight: Optional[float] = None
|
||||
):
|
||||
"""更新STP文件的分析摘要字段(用于快速查询)"""
|
||||
try:
|
||||
update_data = {}
|
||||
if volume is not None:
|
||||
update_data['volume'] = volume
|
||||
if surface_area is not None:
|
||||
update_data['surface_area'] = surface_area
|
||||
if product_weight is not None:
|
||||
update_data['product_weight'] = product_weight
|
||||
|
||||
if update_data:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
# D9:flush 不 commit,随结果包由编排层统一提交
|
||||
await session.flush()
|
||||
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件分析摘要失败: {e}")
|
||||
raise
|
||||
|
||||
async def delete_stp_file_cascade(self, session: AsyncSession,
|
||||
stp_file_id: int):
|
||||
"""级联删除STP文件及其所有关联数据"""
|
||||
@@ -861,7 +517,3 @@ class StorageIntegrationService:
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件及其关联数据已删除: {stp_file_id}")
|
||||
|
||||
|
||||
# 全局存储集成服务实例
|
||||
storage_integration = StorageIntegrationService()
|
||||
@@ -0,0 +1,131 @@
|
||||
# services/file_history_service.py
|
||||
"""文件历史查询视图——按文件名分组的多版本上传历史。
|
||||
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(见 task_storage_service.py 头注)。
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileHistoryService:
|
||||
"""按文件名聚合的上传历史查询"""
|
||||
|
||||
async def get_file_history_by_filename(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 50
|
||||
) -> list:
|
||||
"""获取同一文件名的所有上传历史记录"""
|
||||
|
||||
query = select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
).where(
|
||||
STPFile.original_filename == filename
|
||||
).order_by(STPFile.upload_time.desc())
|
||||
|
||||
if user_id:
|
||||
query = query.where(STPFile.user_id == user_id)
|
||||
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
files = result.unique().scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
'id': f.id,
|
||||
'task_id': f.processing_tasks[0].task_id if f.processing_tasks else None,
|
||||
'upload_batch': f.upload_batch,
|
||||
'upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'file_size': f.file_size,
|
||||
'status': f.status,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight,
|
||||
'has_analysis': f.status == 'completed'
|
||||
}
|
||||
for f in files
|
||||
]
|
||||
|
||||
async def get_all_file_groups(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: Optional[int] = None,
|
||||
limit: int = 100
|
||||
) -> list:
|
||||
"""获取所有文件分组(按文件名分组),包含每个文件的最新分析结果"""
|
||||
|
||||
# 子查询:获取每个文件名的最新上传
|
||||
subquery = (
|
||||
select(
|
||||
STPFile.original_filename,
|
||||
func.max(STPFile.upload_time).label('latest_upload')
|
||||
)
|
||||
.group_by(STPFile.original_filename)
|
||||
.order_by(desc('latest_upload'))
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
subquery = subquery.where(STPFile.user_id == user_id)
|
||||
|
||||
subquery = subquery.subquery()
|
||||
|
||||
# 主查询:获取最新记录和统计信息
|
||||
query = (
|
||||
select(STPFile).options(
|
||||
joinedload(STPFile.processing_tasks)
|
||||
)
|
||||
.join(
|
||||
subquery,
|
||||
(STPFile.original_filename == subquery.c.original_filename) &
|
||||
(STPFile.upload_time == subquery.c.latest_upload)
|
||||
)
|
||||
.order_by(STPFile.upload_time.desc())
|
||||
)
|
||||
|
||||
result = await session.execute(query)
|
||||
latest_files = result.unique().scalars().all()
|
||||
|
||||
# 一次性聚合每个文件名的上传次数(替代逐文件 count 的 N+1 查询)
|
||||
count_subquery = (
|
||||
select(STPFile.original_filename, func.count().label("upload_count"))
|
||||
.group_by(STPFile.original_filename)
|
||||
)
|
||||
if user_id:
|
||||
count_subquery = count_subquery.where(STPFile.user_id == user_id)
|
||||
count_result = await session.execute(count_subquery)
|
||||
upload_counts = {
|
||||
row.original_filename: row.upload_count for row in count_result
|
||||
}
|
||||
|
||||
# 获取每个文件名的上传次数
|
||||
file_groups = []
|
||||
for f in latest_files:
|
||||
task_id = f.processing_tasks[0].task_id if f.processing_tasks else None
|
||||
upload_count = upload_counts.get(f.original_filename, 1)
|
||||
|
||||
file_groups.append({
|
||||
'filename': f.original_filename,
|
||||
'latest_id': f.id,
|
||||
'latest_task_id': task_id,
|
||||
'latest_upload_time': f.upload_time.strftime('%Y-%m-%d %H:%M:%S') if f.upload_time else None,
|
||||
'latest_status': f.status,
|
||||
'upload_count': upload_count,
|
||||
'file_size': f.file_size,
|
||||
'volume': f.volume,
|
||||
'surface_area': f.surface_area,
|
||||
'product_weight': f.product_weight
|
||||
})
|
||||
|
||||
return file_groups
|
||||
@@ -20,14 +20,15 @@ from moldinsight.core.geometry_analyzer import GeometryAnalyzer
|
||||
from moldinsight.core.mesh_generator import MeshGenerator
|
||||
from moldinsight.core.multi_scheme_planner import MultiSchemeMoldPlanner
|
||||
from moldinsight.core.cad_exporter import CADExporter
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.task_storage_service import TaskStorageService
|
||||
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from moldinsight.services.material_service import MaterialService
|
||||
from moldinsight.services.calculation_service import CalculationService
|
||||
from moldinsight.services.llm_service import llm_service
|
||||
from shared.models.schemas import ProcessingStatus
|
||||
from shared.models.database import STPFile
|
||||
from moldinsight.models import STPFile
|
||||
from shared.database.database import db_manager
|
||||
from shared.utils.html_generator import HTMLGenerator
|
||||
from shared.utils.logger import get_logger
|
||||
@@ -43,7 +44,9 @@ class ProcessingService:
|
||||
self.geometry_analyzer = GeometryAnalyzer()
|
||||
self.mesh_generator = MeshGenerator(quality="medium")
|
||||
self.html_generator = HTMLGenerator()
|
||||
self.storage_service = StorageIntegrationService()
|
||||
# 批次 3 按职责拆分:任务/文件生命周期 与 分析结果数据(原 StorageIntegrationService)
|
||||
self.task_storage = TaskStorageService()
|
||||
self.analysis_storage = AnalysisStorageService()
|
||||
self.multi_scheme_planner = MultiSchemeMoldPlanner()
|
||||
self.cad_exporter = CADExporter()
|
||||
# TopoDS_Shape 为 C++ 原生内存对象,LRU 上限防止长期运行内存只涨不降
|
||||
@@ -59,12 +62,15 @@ class ProcessingService:
|
||||
|
||||
asyncio.wait_for 只能取消协程,正在执行 OCC 布尔运算的线程无法中断;
|
||||
单 worker executor 中一个挂死线程会让后续任务永久排队直至重启。
|
||||
代价是泄漏 1 个线程,收益是恢复服务可用性。
|
||||
cancel_futures=True 丢弃旧 executor 中尚未开跑的排队任务(否则旧线程
|
||||
恢复后仍会继续消化旧队列,与新 executor 并发操作 OCC 必然崩溃)。
|
||||
已在运行中的 C++ 线程在 Python 层不可杀,仍会滞留——这是已知残留
|
||||
泄漏(每次超时 1 线程),根治需进程级 OCC 隔离,见 docs/OCC_THROUGHPUT.md。
|
||||
"""
|
||||
old = self._occ_executor
|
||||
self._occ_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="occ")
|
||||
old.shutdown(wait=False)
|
||||
logger.warning("OCC executor 已因处理超时重建(放弃等待旧线程,可能泄漏 1 个线程)")
|
||||
old.shutdown(wait=False, cancel_futures=True)
|
||||
logger.warning("OCC executor 已因处理超时重建(排队任务已丢弃,运行中线程可能滞留 1 个)")
|
||||
|
||||
async def run_occ(self, fn, *args):
|
||||
"""在 OCC 单线程 executor 中执行同步几何操作。
|
||||
@@ -169,8 +175,8 @@ class ProcessingService:
|
||||
# 避免 failed 更新把半成品 flush 数据一起带上
|
||||
await db_session.rollback()
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "failed", error_message=str(e)
|
||||
)
|
||||
|
||||
@@ -203,7 +209,7 @@ class ProcessingService:
|
||||
stage_timings: Dict[str, float] = {}
|
||||
|
||||
# 1. 解析STP文件
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 20, "解析STP文件"
|
||||
)
|
||||
|
||||
@@ -218,7 +224,7 @@ class ProcessingService:
|
||||
stage_timings["parse_stp"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 2. 生成网格数据并持久化
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 30, "生成网格数据"
|
||||
)
|
||||
|
||||
@@ -229,7 +235,7 @@ class ProcessingService:
|
||||
stage_timings["generate_mesh"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 3. 生成模具型腔
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 40, "生成模具型腔"
|
||||
)
|
||||
|
||||
@@ -257,7 +263,7 @@ class ProcessingService:
|
||||
)
|
||||
|
||||
# 4. 生成详细JSON数据 — 委托 CalculationService
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 60, "生成型腔详细数据"
|
||||
)
|
||||
|
||||
@@ -284,12 +290,12 @@ class ProcessingService:
|
||||
cavity_key_info = best_key_info
|
||||
|
||||
# 6. 保存几何数据到数据库
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 70, "保存几何数据"
|
||||
)
|
||||
|
||||
stage_started = time.perf_counter()
|
||||
await self.storage_service.save_geometry_data(
|
||||
await self.analysis_storage.save_geometry_data(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
geometry_data,
|
||||
@@ -301,7 +307,7 @@ class ProcessingService:
|
||||
await db_session.commit()
|
||||
|
||||
# 7. 生成HTML可视化
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 85, "生成可视化报告"
|
||||
)
|
||||
|
||||
@@ -337,7 +343,7 @@ class ProcessingService:
|
||||
best_key_info = best_scheme.get("key_info", {}) if best_scheme else best_key_info
|
||||
|
||||
# 8. 保存模具型腔数据(包含方案级预览链接)
|
||||
await self.storage_service.save_mold_cavity_data(
|
||||
await self.analysis_storage.save_mold_cavity_data(
|
||||
db_session, stp_file_id, detailed_cavity_json
|
||||
)
|
||||
|
||||
@@ -349,7 +355,7 @@ class ProcessingService:
|
||||
lod_data=lod_data,
|
||||
)
|
||||
|
||||
await self.storage_service.save_html_file(
|
||||
await self.analysis_storage.save_html_file(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
Path(html_file_path).name,
|
||||
@@ -370,7 +376,7 @@ class ProcessingService:
|
||||
)
|
||||
|
||||
if analysis_result:
|
||||
await self.storage_service.save_features_and_recommendations(
|
||||
await self.analysis_storage.save_features_and_recommendations(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
analysis_result.get("detected_features", []),
|
||||
@@ -381,7 +387,7 @@ class ProcessingService:
|
||||
stage_timings["analyze_design"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 9.6 更新STP文件的分析摘要字段
|
||||
await self.storage_service.update_stp_file_analysis_summary(
|
||||
await self.task_storage.update_stp_file_analysis_summary(
|
||||
db_session,
|
||||
stp_file_id,
|
||||
volume=geometry_data.get("volume", 0),
|
||||
@@ -419,7 +425,7 @@ class ProcessingService:
|
||||
stage_timings["generate_llm_report"] = round(time.perf_counter() - stage_started, 3)
|
||||
|
||||
# 10. 完成处理——先 flush 任务参数,完成状态提交时一并原子落库(D9)
|
||||
await self.storage_service.update_task_parameters(
|
||||
await self.task_storage.update_task_parameters(
|
||||
db_session,
|
||||
task_id,
|
||||
{
|
||||
@@ -431,8 +437,8 @@ class ProcessingService:
|
||||
**process_params,
|
||||
},
|
||||
)
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "completed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "completed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "completed", 100, "模具型腔生成完成"
|
||||
)
|
||||
|
||||
@@ -464,8 +470,8 @@ class ProcessingService:
|
||||
# D9:先丢弃未提交的数据本体再置失败(同外层说明)
|
||||
await db_session.rollback()
|
||||
|
||||
await self.storage_service.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_stp_file_status(db_session, stp_file_id, "failed")
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "failed", error_message=str(e)
|
||||
)
|
||||
|
||||
@@ -524,7 +530,7 @@ class ProcessingService:
|
||||
"bounding_box": bbox,
|
||||
}
|
||||
|
||||
await self.storage_service.save_mesh_data(
|
||||
await self.analysis_storage.save_mesh_data(
|
||||
db_session,
|
||||
stp_file_id=stp_file_id,
|
||||
mesh_json=mesh_json,
|
||||
@@ -732,7 +738,7 @@ class ProcessingService:
|
||||
logger.info("FreeCAD验证已禁用(设置 ENABLE_FREECAD_VERIFICATION=true 启用)")
|
||||
return {"status": "disabled", "reason": "FreeCAD验证已禁用"}
|
||||
|
||||
await self.storage_service.update_task_status(
|
||||
await self.task_storage.update_task_status(
|
||||
db_session, task_id, "processing", 90, "FreeCAD几何验证"
|
||||
)
|
||||
|
||||
@@ -787,7 +793,7 @@ class ProcessingService:
|
||||
|
||||
async def _save_analysis_metrics(self, session: AsyncSession, stp_file_id: int, analysis_result: dict):
|
||||
"""保存分析指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
from moldinsight.models import AnalysisMetrics
|
||||
|
||||
quality_metrics = analysis_result.get("quality_metrics", {})
|
||||
analysis_summary = analysis_result.get("analysis_summary", "")
|
||||
@@ -807,7 +813,7 @@ class ProcessingService:
|
||||
|
||||
async def _save_verification_metrics(self, session: AsyncSession, stp_file_id: int, verification_result: dict):
|
||||
"""保存验证指标到数据库"""
|
||||
from shared.models.database import AnalysisMetrics
|
||||
from moldinsight.models import AnalysisMetrics
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.models.database import ProcessingTask, STPFile
|
||||
from moldinsight.models import ProcessingTask, STPFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -10,9 +10,9 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from moldinsight.services.storage_integration_rustfs import StorageIntegrationService
|
||||
from moldinsight.services.analysis_storage_service import AnalysisStorageService
|
||||
from shared.services.redis_task_manager import redis_task_manager
|
||||
from shared.models.database import ProcessingTask, STPFile, MeshData, HTMLFile
|
||||
from moldinsight.models import ProcessingTask, STPFile, MeshData, HTMLFile
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -103,7 +103,7 @@ class TaskQueryService:
|
||||
return cached
|
||||
|
||||
# 3. 持久化任务(已完成/失败,或服务重启后的任务)
|
||||
storage_service = StorageIntegrationService()
|
||||
storage_service = AnalysisStorageService()
|
||||
|
||||
# 查询任务和文件元数据(预加载 html_file 关联)
|
||||
result = await db_session.execute(
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# services/task_storage_service.py
|
||||
"""任务与源文件生命周期存储——PostgreSQL(+ 源文件 RustFS 上传)。
|
||||
|
||||
批次 3 自 storage_integration_rustfs.py 按职责拆分(原 867 行混杂
|
||||
写入/查询/历史三类职责):
|
||||
- 本模块:STPFile 生命周期 + ProcessingTask 创建/状态/参数
|
||||
- 分析结果数据:analysis_storage_service.AnalysisStorageService
|
||||
- 历史查询视图:file_history_service.FileHistoryService
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import update, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from moldinsight.models import STPFile, ProcessingTask
|
||||
from moldinsight.storage.rustfs_storage import rustfs_manager
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class TaskStorageService:
|
||||
"""STP 文件与处理任务的生命周期存储"""
|
||||
|
||||
async def save_stp_file(self, session: AsyncSession,
|
||||
file_path: Path,
|
||||
original_filename: str,
|
||||
user_id: Optional[int] = None,
|
||||
upload_batch: Optional[str] = None) -> STPFile:
|
||||
"""保存STP文件到PostgreSQL元数据 + RustFS对象存储
|
||||
|
||||
支持同一文件多次上传,每次上传都会创建新记录
|
||||
"""
|
||||
|
||||
# 1. 上传到RustFS
|
||||
upload_result = await rustfs_manager.upload_file(
|
||||
file_type='stp_files',
|
||||
file_path=file_path,
|
||||
original_filename=original_filename,
|
||||
metadata={
|
||||
'original_filename': original_filename,
|
||||
'user_id': str(user_id) if user_id else 'anonymous',
|
||||
'upload_batch': upload_batch or str(uuid.uuid4())
|
||||
}
|
||||
)
|
||||
|
||||
file_hash = upload_result['file_hash']
|
||||
batch_id = upload_batch or str(uuid.uuid4())
|
||||
|
||||
# 2. 创建新PostgreSQL记录(每次上传都创建新记录)
|
||||
stp_file = STPFile(
|
||||
user_id=user_id,
|
||||
object_key=upload_result['object_key'],
|
||||
storage_bucket=upload_result['bucket'],
|
||||
original_filename=original_filename,
|
||||
file_size=upload_result['file_size'],
|
||||
file_hash=file_hash,
|
||||
upload_batch=batch_id,
|
||||
status="uploaded",
|
||||
file_path=str(file_path),
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
|
||||
session.add(stp_file)
|
||||
# D9:仅 flush,与 ProcessingTask 由路由层一并原子提交(避免孤儿文件记录)
|
||||
await session.flush()
|
||||
await session.refresh(stp_file)
|
||||
|
||||
logger.info(f"STP文件保存成功 RustFS: {stp_file.id}, 批次: {batch_id}")
|
||||
return stp_file
|
||||
|
||||
async def create_processing_task(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
stp_file_id: int,
|
||||
task_type: str = "stp_parsing",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
batch_id: Optional[str] = None,
|
||||
) -> ProcessingTask:
|
||||
"""创建处理任务记录(D9:仅 flush 不 commit,事务由调用方收口——
|
||||
与 STPFile 记录同批提交,避免留下无任务的孤儿文件记录;batch_id 用于批量任务聚合查询)
|
||||
"""
|
||||
try:
|
||||
task = ProcessingTask(
|
||||
task_id=task_id,
|
||||
stp_file_id=stp_file_id,
|
||||
task_type=task_type,
|
||||
status="pending",
|
||||
started_time=datetime.now(),
|
||||
parameters=parameters or {},
|
||||
batch_id=batch_id,
|
||||
)
|
||||
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
|
||||
logger.info(f"处理任务创建成功: {task_id}")
|
||||
return task
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"创建处理任务失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
status: str,
|
||||
progress: Optional[int] = None,
|
||||
current_step: Optional[str] = None,
|
||||
error_message: Optional[str] = None
|
||||
):
|
||||
"""更新任务状态(保留即时 commit:进度/状态需跨事务对外可见,
|
||||
处理链路中的各阶段进度依赖它落库——D9 收口仅针对数据本体写方法)"""
|
||||
try:
|
||||
update_data = {
|
||||
"status": status,
|
||||
"completed_time": datetime.now() if status in ["completed", "failed"] else None,
|
||||
"error_message": error_message
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
update_data["progress"] = progress
|
||||
if current_step is not None:
|
||||
update_data["current_step"] = current_step
|
||||
|
||||
await session.execute(
|
||||
update(ProcessingTask)
|
||||
.where(ProcessingTask.task_id == task_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"任务状态更新: {task_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_task_parameters(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
task_id: str,
|
||||
parameters: Dict[str, Any],
|
||||
):
|
||||
"""合并更新任务参数,便于保存阶段耗时等元数据。(D9:flush 不 commit,事务由调用方收口)"""
|
||||
try:
|
||||
task = await session.execute(
|
||||
select(ProcessingTask).where(ProcessingTask.task_id == task_id)
|
||||
)
|
||||
task = task.scalar_one_or_none()
|
||||
if task is None:
|
||||
return
|
||||
|
||||
merged = dict(task.parameters or {})
|
||||
merged.update(parameters or {})
|
||||
task.parameters = merged
|
||||
await session.flush()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新任务参数失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_status(self, session: AsyncSession, stp_file_id: int, status: str):
|
||||
"""更新STP文件状态(保留即时 commit,理由同 update_task_status)"""
|
||||
try:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(
|
||||
status=status,
|
||||
processed_time=datetime.now() if status in ["completed", "failed"] else None
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"STP文件状态更新: ID {stp_file_id} -> {status}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件状态失败: {e}")
|
||||
raise
|
||||
|
||||
async def update_stp_file_analysis_summary(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
stp_file_id: int,
|
||||
volume: Optional[float] = None,
|
||||
surface_area: Optional[float] = None,
|
||||
product_weight: Optional[float] = None
|
||||
):
|
||||
"""更新STP文件的分析摘要字段(用于快速查询)"""
|
||||
try:
|
||||
update_data = {}
|
||||
if volume is not None:
|
||||
update_data['volume'] = volume
|
||||
if surface_area is not None:
|
||||
update_data['surface_area'] = surface_area
|
||||
if product_weight is not None:
|
||||
update_data['product_weight'] = product_weight
|
||||
|
||||
if update_data:
|
||||
await session.execute(
|
||||
update(STPFile)
|
||||
.where(STPFile.id == stp_file_id)
|
||||
.values(**update_data)
|
||||
)
|
||||
# D9:flush 不 commit,随结果包由编排层统一提交
|
||||
await session.flush()
|
||||
logger.info(f"STP文件分析摘要更新: ID {stp_file_id}")
|
||||
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"更新STP文件分析摘要失败: {e}")
|
||||
raise
|
||||
@@ -9,7 +9,7 @@ sys.path.insert(0, str(src_root))
|
||||
|
||||
from sqlalchemy import select
|
||||
from shared.database.database import db_manager
|
||||
from shared.models.database import User, Role, UserRole
|
||||
from shared.models.identity import User, Role, UserRole
|
||||
from shared.services.auth_service import get_password_hash
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -101,6 +101,13 @@ class Settings:
|
||||
def allowed_extensions_set(self) -> set:
|
||||
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(","))
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Redis 连接串(Celery broker/backend 使用;RedisTaskManager 走分参数连接,不经此处)"""
|
||||
if self.REDIS_PASSWORD:
|
||||
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_cors_origins(raw: str) -> List[str]:
|
||||
"""解析 CORS_ORIGINS 环境变量,逗号分隔。
|
||||
|
||||
@@ -115,18 +115,6 @@ class DatabaseManager:
|
||||
await self.connect()
|
||||
|
||||
return self.async_session()
|
||||
|
||||
async def create_tables(self):
|
||||
"""创建数据库表"""
|
||||
from shared.models.database import Base
|
||||
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("数据库表创建成功")
|
||||
except Exception as e:
|
||||
logger.error(f"数据库表创建失败: {e}")
|
||||
raise
|
||||
|
||||
# 全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
from pathlib import Path
|
||||
from sqlalchemy import text, select
|
||||
from shared.database.database import db_manager
|
||||
from shared.models.database import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.models.identity import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.services.auth_service import get_password_hash
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""ORM Base——全项目唯一的 declarative base。
|
||||
|
||||
模型归属(D3 拆分,2026-09-17):
|
||||
- shared.models.identity 用户/角色/权限/审计(平台层,所有部署形态共用)
|
||||
- moldinsight.models STEP 分析域模型
|
||||
- inventory.models 进销存域模型
|
||||
|
||||
约定:
|
||||
- 各模块模型只 import 本文件拿 Base,模型间跨模块只允许裸 FK(字符串表名),
|
||||
不建跨模块 ORM relationship(单模块部署下另一模块的模型类可能未注册,
|
||||
relationship 会让 mapper 配置直接失败;历史上三条跨模块 relationship 均无使用方,已删除)。
|
||||
- 全量模型注册点(create_all / alembic autogenerate 前 import 全部三包):
|
||||
migrations/env.py 与 tests/conftest.py。
|
||||
"""
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -1,891 +0,0 @@
|
||||
# models/database.py
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric, CheckConstraint
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, date
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
__excluded_fields__ = {'hashed_password'}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
stp_files = relationship("STPFile", back_populates="user")
|
||||
user_roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return [ur.role for ur in self.user_roles]
|
||||
|
||||
@property
|
||||
def is_superuser(self):
|
||||
return any(r.code == 'admin' for r in self.roles)
|
||||
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
for role in self.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.code == permission_code:
|
||||
return True
|
||||
return False
|
||||
|
||||
def safe_dict(self):
|
||||
return {k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith('_') and k not in self.__excluded_fields__}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
|
||||
role_permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return [rp.permission for rp in self.role_permissions]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""权限表"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(100), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
module = Column(String(50), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表"""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="user_roles")
|
||||
role = relationship("Role", back_populates="user_roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_permissions")
|
||||
permission = relationship("Permission", back_populates="role_permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
|
||||
|
||||
class STPFile(Base):
|
||||
"""STP源文件元数据表 - 支持同一文件多次上传"""
|
||||
__tablename__ = "stp_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
# 关联进销存成品(P2-1:分析结果可一键创建为成品并回写)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=True, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False, index=True) # MinIO对象键
|
||||
storage_bucket = Column(String(100), nullable=False) # 存储桶名称
|
||||
object_url = Column(String(1000), nullable=True) # 预签名URL(可选)
|
||||
|
||||
# 文件信息
|
||||
original_filename = Column(String(255), nullable=False, index=True) # 添加索引支持按文件名查询
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_hash = Column(String(64), index=True) # 移除unique约束,允许同一文件多次上传
|
||||
mime_type = Column(String(50), default="application/octet-stream")
|
||||
|
||||
# 上传批次标识 - 用于区分同一文件的多次上传
|
||||
upload_batch = Column(String(36), index=True) # UUID批次号
|
||||
|
||||
# 时间戳
|
||||
upload_time = Column(DateTime, default=func.now())
|
||||
processed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending", index=True) # pending, processing, completed, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
# 分析摘要 - 快速查询字段
|
||||
volume = Column(Float, nullable=True) # 体积 mm³
|
||||
surface_area = Column(Float, nullable=True) # 表面积 mm²
|
||||
product_weight = Column(Float, nullable=True) # 产品重量 g
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True) # 本地路径(已弃用)
|
||||
file_content = Column(LargeBinary, nullable=True) # 本地存储(已弃用)
|
||||
filename = Column(String(255), nullable=True) # 已弃用
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", back_populates="stp_files")
|
||||
product = relationship("Product") # P2-1: 关联的进销存成品
|
||||
geometry_data = relationship("GeometryData", back_populates="stp_file", uselist=False)
|
||||
mesh_data = relationship("MeshData", back_populates="stp_file", uselist=False)
|
||||
mold_cavity_data = relationship("MoldCavityData", back_populates="stp_file", uselist=False)
|
||||
html_file = relationship("HTMLFile", back_populates="stp_file", uselist=False)
|
||||
analysis_metrics = relationship("AnalysisMetrics", back_populates="stp_file", uselist=False)
|
||||
feature_detections = relationship("FeatureDetection", back_populates="stp_file")
|
||||
design_recommendations = relationship("DesignRecommendation", back_populates="stp_file")
|
||||
processing_tasks = relationship("ProcessingTask", back_populates="stp_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<STPFile(id={self.id}, original_filename='{self.original_filename}', status='{self.status}')>"
|
||||
|
||||
class GeometryData(Base):
|
||||
"""几何数据JSON元数据表"""
|
||||
__tablename__ = "geometry_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 分析方法
|
||||
analysis_method = Column(String(50), default="pythonocc") # pythonocc, simulated
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 几何属性摘要(便于快速查询)
|
||||
volume = Column(Float, nullable=True)
|
||||
surface_area = Column(Float, nullable=True)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
center_of_mass = Column(JSON, nullable=True)
|
||||
|
||||
# 拓扑信息
|
||||
topology_faces = Column(Integer, nullable=True)
|
||||
topology_edges = Column(Integer, nullable=True)
|
||||
topology_vertices = Column(Integer, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="geometry_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GeometryData(id={self.id}, stp_file_id={self.stp_file_id})>"
|
||||
|
||||
|
||||
class MeshData(Base):
|
||||
"""网格数据JSON元数据表(详细网格存 RustFS,PostgreSQL 存摘要)"""
|
||||
__tablename__ = "mesh_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 生成设置
|
||||
quality = Column(String(20), default="medium") # low / medium / high
|
||||
|
||||
# 网格规模信息
|
||||
vertex_count = Column(Integer, nullable=True)
|
||||
face_count = Column(Integer, nullable=True)
|
||||
point_count = Column(Integer, nullable=True) # 采样点云数量
|
||||
|
||||
# 网格边界框(便于快速查询)
|
||||
bounding_box_min = Column(JSON, nullable=True)
|
||||
bounding_box_max = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mesh_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MeshData(id={self.id}, stp_file_id={self.stp_file_id}, quality='{self.quality}')>"
|
||||
|
||||
class HTMLFile(Base):
|
||||
"""网页文件元数据表"""
|
||||
__tablename__ = "html_files"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
object_key = Column(String(500), nullable=False)
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
object_url = Column(String(1000), nullable=True)
|
||||
|
||||
# 文件信息
|
||||
filename = Column(String(255), nullable=False)
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 可视化相关元数据
|
||||
visualization_type = Column(String(50), default="3d_viewer")
|
||||
has_interactive_elements = Column(Boolean, default=True)
|
||||
|
||||
# 保留旧字段以兼容
|
||||
file_path = Column(String(500), nullable=True)
|
||||
html_content = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="html_file")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HTMLFile(id={self.id}, stp_file_id={self.stp_file_id}, object_key='{self.object_key}')>"
|
||||
|
||||
class ProcessingTask(Base):
|
||||
"""处理任务记录表"""
|
||||
__tablename__ = "processing_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(String(36), unique=True, index=True, nullable=False)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 批量上传聚合 ID(批次 2:批量元数据入库——PG 为单一事实源,
|
||||
# 同批任务经此列聚合查询,不再依赖 Redis/进程内存存批量元数据)
|
||||
batch_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# 任务类型和状态
|
||||
task_type = Column(String(50), default="stp_parsing") # stp_parsing, geometry_analysis, mold_generation
|
||||
status = Column(String(20), default="pending") # pending, processing, completed, failed
|
||||
|
||||
# 时间戳
|
||||
created_time = Column(DateTime, default=func.now())
|
||||
started_time = Column(DateTime, nullable=True)
|
||||
completed_time = Column(DateTime, nullable=True)
|
||||
|
||||
# 处理进度
|
||||
progress = Column(Integer, default=0) # 0-100
|
||||
current_step = Column(String(100), nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message = Column(Text, nullable=True)
|
||||
error_stack = Column(Text, nullable=True)
|
||||
|
||||
# 处理参数
|
||||
parameters = Column(JSON, nullable=True) # 任务参数
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="processing_tasks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProcessingTask(id={self.id}, task_id='{self.task_id}', status='{self.status}')>"
|
||||
|
||||
class MoldCavityData(Base):
|
||||
"""模具型腔数据元数据表"""
|
||||
__tablename__ = "mold_cavity_data"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 对象存储信息
|
||||
detailed_object_key = Column(String(500), nullable=False) # 完整三维数据
|
||||
storage_bucket = Column(String(100), nullable=False)
|
||||
|
||||
# 模具类型和材料
|
||||
mold_material = Column(String(100), default="Aluminum Alloy 7075")
|
||||
mold_type = Column(String(50), default="single_cavity") # single_cavity, multi_cavity
|
||||
|
||||
# 工艺参数
|
||||
shrinkage_rate = Column(Float, nullable=False)
|
||||
draft_angle = Column(Float, nullable=False)
|
||||
parting_line_length = Column(Float, nullable=True)
|
||||
|
||||
# 生成时间
|
||||
generated_time = Column(DateTime, default=func.now())
|
||||
|
||||
# 关键信息摘要(快速查询字段)
|
||||
cavity_key_info = Column(JSON, nullable=True) # 完整关键信息
|
||||
|
||||
# 提取的字段(便于查询和排序)
|
||||
mold_size_length = Column(Float, nullable=True)
|
||||
mold_size_width = Column(Float, nullable=True)
|
||||
mold_size_height = Column(Float, nullable=True)
|
||||
estimated_clamping_force = Column(String(50), nullable=True)
|
||||
product_weight = Column(String(50), nullable=True)
|
||||
product_volume = Column(Float, nullable=True)
|
||||
wall_thickness_range = Column(String(50), nullable=True)
|
||||
complexity_score = Column(Float, nullable=True)
|
||||
|
||||
# 质量评估
|
||||
weld_line_risk = Column(String(50), nullable=True) # 熔接痕风险
|
||||
sink_mark_risk = Column(String(50), nullable=True) # 缩痕风险
|
||||
warpage_risk = Column(String(50), nullable=True) # 翘曲风险
|
||||
|
||||
# 多方案可信化摘要(第1周阶段1)
|
||||
best_scheme_id = Column(String(64), nullable=True, index=True)
|
||||
confidence_score = Column(Float, nullable=True)
|
||||
is_fallback = Column(Boolean, nullable=True, index=True)
|
||||
fallback_reason = Column(Text, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="mold_cavity_data")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MoldCavityData(stp_file_id={self.stp_file_id}, mold_material='{self.mold_material}')>"
|
||||
|
||||
|
||||
class FeatureDetection(Base):
|
||||
"""特征检测结果表"""
|
||||
__tablename__ = "feature_detections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 特征信息
|
||||
feature_type = Column(String(50), nullable=False, index=True) # thin_wall, thick_wall, wall_non_uniform, rib, boss, draft_angle, high_curvature, fillet
|
||||
confidence = Column(Float, nullable=False) # 0.0 - 1.0
|
||||
|
||||
# 位置和尺寸
|
||||
location = Column(JSON, nullable=True) # [x, y, z]
|
||||
dimensions = Column(JSON, nullable=True) # [length, width, height]
|
||||
|
||||
# 特征参数
|
||||
parameters = Column(JSON, nullable=True) # 自定义参数
|
||||
|
||||
# 检测时间
|
||||
detected_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联的几何数据
|
||||
geometry_data_id = Column(Integer, ForeignKey("geometry_data.id"), nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="feature_detections")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FeatureDetection(id={self.id}, feature_type='{self.feature_type}', confidence={self.confidence})>"
|
||||
|
||||
|
||||
class DesignRecommendation(Base):
|
||||
"""设计建议表"""
|
||||
__tablename__ = "design_recommendations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 建议信息
|
||||
rec_type = Column(String(50), nullable=False) # wall_thickness, draft_angle, etc.
|
||||
priority = Column(String(20), nullable=False) # high, medium, low
|
||||
description = Column(String(500), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
|
||||
# 建议参数
|
||||
parameters = Column(JSON, nullable=True)
|
||||
|
||||
# 状态
|
||||
status = Column(String(20), default="pending") # pending, accepted, rejected
|
||||
user_notes = Column(Text, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, nullable=True)
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="design_recommendations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DesignRecommendation(id={self.id}, rec_type='{self.rec_type}', priority='{self.priority}')>"
|
||||
|
||||
|
||||
class UserActivity(Base):
|
||||
"""用户活动日志表"""
|
||||
__tablename__ = "user_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 活动信息
|
||||
activity_type = Column(String(50), nullable=False, index=True) # upload, view, download, delete, export
|
||||
resource_type = Column(String(50), nullable=True) # stp_file, geometry_data, mold_cavity
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
# 活动详情
|
||||
description = Column(Text, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# IP和设备信息
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserActivity(id={self.id}, user_id={self.user_id}, activity_type='{self.activity_type}')>"
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
"""系统日志表(重要操作和错误)"""
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 日志级别
|
||||
level = Column(String(20), nullable=False, index=True) # INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# 日志信息
|
||||
message = Column(Text, nullable=False)
|
||||
module = Column(String(100), nullable=True) # 模块名
|
||||
function_name = Column(String(100), nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# 用户信息(如果有关联用户)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# 额外信息
|
||||
request_id = Column(String(100), nullable=True) # 关联的请求ID
|
||||
execution_time_ms = Column(Integer, nullable=True) # 执行时间
|
||||
|
||||
# 关联数据
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemLog(id={self.id}, level='{self.level}', module='{self.module}')>"
|
||||
|
||||
|
||||
class Product(Base):
|
||||
"""产品表"""
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
sku = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
category = Column(String(100), nullable=True)
|
||||
unit = Column(String(20), default="件")
|
||||
item_type = Column(String(20), default="finished", index=True)
|
||||
cost_price = Column(Numeric(12, 2), default=0)
|
||||
sale_price = Column(Numeric(12, 2), default=0)
|
||||
min_stock = Column(Integer, default=0)
|
||||
max_stock = Column(Integer, default=1000)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
inventory = relationship("Inventory", back_populates="product", uselist=False)
|
||||
stock_movements = relationship("StockMovement", back_populates="product")
|
||||
bom_materials = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.finished_product_id",
|
||||
back_populates="finished_product",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
used_in_products = relationship(
|
||||
"ProductMaterial",
|
||||
foreign_keys="ProductMaterial.material_product_id",
|
||||
back_populates="material_product"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Product(id={self.id}, sku='{self.sku}', name='{self.name}')>"
|
||||
|
||||
|
||||
class ProductMaterial(Base):
|
||||
__tablename__ = "product_materials"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("finished_product_id", "material_product_id", name="uq_product_material_unique"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
finished_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
material_product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
loss_rate = Column(Numeric(5, 4), default=0)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
finished_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[finished_product_id],
|
||||
back_populates="bom_materials"
|
||||
)
|
||||
material_product = relationship(
|
||||
"Product",
|
||||
foreign_keys=[material_product_id],
|
||||
back_populates="used_in_products"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductMaterial(finished_product_id={self.finished_product_id}, material_product_id={self.material_product_id})>"
|
||||
|
||||
|
||||
class MaterialPriceHistory(Base):
|
||||
"""物料价格历史表"""
|
||||
__tablename__ = "material_price_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
price = Column(Numeric(12, 2), nullable=False)
|
||||
effective_date = Column(DateTime, default=func.now(), index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=True, index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
product = relationship("Product", backref="price_history")
|
||||
supplier = relationship("Supplier", backref="price_history")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialPriceHistory(product_id={self.product_id}, price={self.price}, date={self.effective_date})>"
|
||||
|
||||
|
||||
class MaterialSupplier(Base):
|
||||
"""物料供应商关联表"""
|
||||
__tablename__ = "material_suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
is_primary = Column(Boolean, default=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
contact_phone = Column(String(50), nullable=True)
|
||||
lead_time = Column(Integer, nullable=True) # 交货周期(天)
|
||||
min_order_quantity = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", backref="suppliers")
|
||||
supplier = relationship("Supplier", backref="materials")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MaterialSupplier(product_id={self.product_id}, supplier_id={self.supplier_id}, primary={self.is_primary})>"
|
||||
|
||||
|
||||
class Supplier(Base):
|
||||
"""供应商表"""
|
||||
__tablename__ = "suppliers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
purchase_orders = relationship("PurchaseOrder", back_populates="supplier")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Supplier(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Customer(Base):
|
||||
"""客户表"""
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
contact_person = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
email = Column(String(100), nullable=True)
|
||||
address = Column(Text, nullable=True)
|
||||
bank_name = Column(String(100), nullable=True)
|
||||
bank_account = Column(String(50), nullable=True)
|
||||
tax_number = Column(String(50), nullable=True)
|
||||
credit_limit = Column(Numeric(12, 2), default=0)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
sales_orders = relationship("SalesOrder", back_populates="customer")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Customer(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Warehouse(Base):
|
||||
"""仓库表"""
|
||||
__tablename__ = "warehouses"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
address = Column(Text, nullable=True)
|
||||
manager = Column(String(100), nullable=True)
|
||||
phone = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_default = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
inventories = relationship("Inventory", back_populates="warehouse")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Warehouse(id={self.id}, name='{self.name}')>"
|
||||
|
||||
|
||||
class Inventory(Base):
|
||||
"""库存表"""
|
||||
__tablename__ = "inventory"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"),
|
||||
CheckConstraint("quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity", name="ck_inventory_qty_nonnegative"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
|
||||
quantity = Column(Numeric(12, 4), default=0)
|
||||
locked_quantity = Column(Numeric(12, 4), default=0)
|
||||
batch_number = Column(String(50), nullable=True)
|
||||
location = Column(String(100), nullable=True)
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
product = relationship("Product", back_populates="inventory")
|
||||
warehouse = relationship("Warehouse", back_populates="inventories")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Inventory(product_id={self.product_id}, quantity={self.quantity})>"
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.locked_quantity
|
||||
|
||||
|
||||
class StockMovement(Base):
|
||||
"""库存变动记录表"""
|
||||
__tablename__ = "stock_movements"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
|
||||
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
|
||||
movement_type = Column(String(20), nullable=False)
|
||||
quantity = Column(Numeric(12, 4), nullable=False)
|
||||
before_quantity = Column(Numeric(12, 4), default=0)
|
||||
after_quantity = Column(Numeric(12, 4), default=0)
|
||||
reference_type = Column(String(50), nullable=True)
|
||||
reference_id = Column(Integer, nullable=True)
|
||||
reference_no = Column(String(50), nullable=True)
|
||||
unit_price = Column(Numeric(12, 2), nullable=True)
|
||||
total_amount = Column(Numeric(12, 2), nullable=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
product = relationship("Product", back_populates="stock_movements")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<StockMovement(id={self.id}, type='{self.movement_type}', qty={self.quantity})>"
|
||||
|
||||
|
||||
class PurchaseOrder(Base):
|
||||
"""采购订单表"""
|
||||
__tablename__ = "purchase_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
supplier_id = Column(Integer, ForeignKey("suppliers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
expected_date = Column(Date, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
paid_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
# 状态变更时间
|
||||
received_date = Column(DateTime, nullable=True) # 已收货时间
|
||||
paid_date = Column(DateTime, nullable=True) # 已付款时间
|
||||
|
||||
supplier = relationship("Supplier", back_populates="purchase_orders")
|
||||
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class PurchaseOrderItem(Base):
|
||||
"""采购订单明细表"""
|
||||
__tablename__ = "purchase_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity", name="ck_purchase_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("purchase_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
received_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("PurchaseOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PurchaseOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
|
||||
class SalesOrder(Base):
|
||||
"""销售订单表"""
|
||||
__tablename__ = "sales_orders"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True)
|
||||
order_date = Column(DateTime, default=func.now())
|
||||
delivery_date = Column(Date, nullable=True)
|
||||
manufacturing_date = Column(DateTime, nullable=True)
|
||||
actual_delivery_date = Column(DateTime, nullable=True)
|
||||
actual_payment_date = Column(DateTime, nullable=True)
|
||||
status = Column(String(20), default="draft")
|
||||
production_status = Column(String(20), default="not_started", index=True)
|
||||
production_no = Column(String(50), nullable=True, index=True)
|
||||
planned_material_cost = Column(Numeric(12, 2), default=0)
|
||||
actual_material_cost = Column(Numeric(12, 2), default=0)
|
||||
total_amount = Column(Numeric(12, 2), default=0)
|
||||
received_amount = Column(Numeric(12, 2), default=0)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
customer = relationship("Customer", back_populates="sales_orders")
|
||||
items = relationship("SalesOrderItem", back_populates="order", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrder(order_no='{self.order_no}', status='{self.status}')>"
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
txn_no = Column(String(50), unique=True, index=True, nullable=False)
|
||||
txn_type = Column(String(20), nullable=False, index=True)
|
||||
partner_type = Column(String(20), nullable=False, index=True)
|
||||
partner_id = Column(Integer, nullable=False, index=True)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
txn_date = Column(DateTime, default=func.now(), index=True)
|
||||
method = Column(String(30), default="bank")
|
||||
account_name = Column(String(100), nullable=True)
|
||||
status = Column(String(20), default="confirmed", index=True)
|
||||
remark = Column(Text, nullable=True)
|
||||
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
allocations = relationship("FinanceAllocation", back_populates="transaction", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceTransaction(txn_no='{self.txn_no}', txn_type='{self.txn_type}', amount={self.amount})>"
|
||||
|
||||
|
||||
class FinanceAllocation(Base):
|
||||
__tablename__ = "finance_allocations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
transaction_id = Column(Integer, ForeignKey("finance_transactions.id"), nullable=False, index=True)
|
||||
order_type = Column(String(20), nullable=False, index=True)
|
||||
order_id = Column(Integer, nullable=False, index=True)
|
||||
allocated_amount = Column(Numeric(12, 2), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
transaction = relationship("FinanceTransaction", back_populates="allocations")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<FinanceAllocation(transaction_id={self.transaction_id}, order_type='{self.order_type}', amount={self.allocated_amount})>"
|
||||
|
||||
|
||||
class AnalysisMetrics(Base):
|
||||
"""分析指标表"""
|
||||
__tablename__ = "analysis_metrics"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
stp_file_id = Column(Integer, ForeignKey("stp_files.id"), nullable=False, index=True)
|
||||
|
||||
# 质量指标
|
||||
volume_utilization = Column(Float, default=0) # 体积利用率
|
||||
topology_complexity = Column(Float, default=0) # 拓扑复杂度
|
||||
wall_uniformity = Column(Float, default=0) # 壁厚均匀性
|
||||
|
||||
# 分析摘要
|
||||
analysis_summary = Column(Text, nullable=True)
|
||||
|
||||
# FreeCAD 验证结果
|
||||
verification_status = Column(String(20), nullable=True) # passed, failed, pending, error
|
||||
verification_volume_diff = Column(Float, nullable=True) # 体积差异百分比
|
||||
verification_area_diff = Column(Float, nullable=True) # 表面积差异百分比
|
||||
verification_details = Column(JSON, nullable=True) # 完整验证结果
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 关联关系
|
||||
stp_file = relationship("STPFile", back_populates="analysis_metrics")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AnalysisMetrics(stp_file_id={self.stp_file_id}, volume_utilization={self.volume_utilization})>"
|
||||
|
||||
|
||||
class SalesOrderItem(Base):
|
||||
"""销售订单明细表"""
|
||||
__tablename__ = "sales_order_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint("quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity", name="ck_sales_order_items_qty"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
order_id = Column(Integer, ForeignKey("sales_orders.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
quantity = Column(Integer, nullable=False)
|
||||
delivered_quantity = Column(Integer, default=0)
|
||||
unit_price = Column(Numeric(12, 2), nullable=False)
|
||||
amount = Column(Numeric(12, 2), nullable=False)
|
||||
remark = Column(Text, nullable=True)
|
||||
|
||||
order = relationship("SalesOrder", back_populates="items")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SalesOrderItem(order_id={self.order_id}, product_id={self.product_id})>"
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""身份与权限模型(平台层,三种部署形态共用)。
|
||||
|
||||
从旧 shared/models/database.py 拆出(D3,2026-09-17)。
|
||||
原 User.stp_files ↔ STPFile.user 跨模块 relationship 已删除(无使用方):
|
||||
用户与 STP 文件的关联走 STPFile.user_id 裸 FK,查询由 moldinsight 侧显式 select。
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from shared.models.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户表"""
|
||||
__tablename__ = "users"
|
||||
__excluded_fields__ = {'hashed_password'}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100))
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
return [ur.role for ur in self.user_roles]
|
||||
|
||||
@property
|
||||
def is_superuser(self):
|
||||
return any(r.code == 'admin' for r in self.roles)
|
||||
|
||||
def has_permission(self, permission_code: str) -> bool:
|
||||
if self.is_superuser:
|
||||
return True
|
||||
for role in self.roles:
|
||||
for perm in role.permissions:
|
||||
if perm.code == permission_code:
|
||||
return True
|
||||
return False
|
||||
|
||||
def safe_dict(self):
|
||||
return {k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith('_') and k not in self.__excluded_fields__}
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}')>"
|
||||
|
||||
|
||||
class Role(Base):
|
||||
"""角色表"""
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(50), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
is_system = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user_roles = relationship("UserRole", back_populates="role", cascade="all, delete-orphan")
|
||||
role_permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
return [rp.permission for rp in self.role_permissions]
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Role(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
"""权限表"""
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
code = Column(String(100), unique=True, index=True, nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
module = Column(String(50), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role_permissions = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Permission(code='{self.code}', name='{self.name}')>"
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户角色关联表"""
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="user_roles")
|
||||
role = relationship("Role", back_populates="user_roles")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserRole(user_id={self.user_id}, role_id={self.role_id})>"
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False, index=True)
|
||||
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
role = relationship("Role", back_populates="role_permissions")
|
||||
permission = relationship("Permission", back_populates="role_permissions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<RolePermission(role_id={self.role_id}, permission_id={self.permission_id})>"
|
||||
|
||||
|
||||
class UserActivity(Base):
|
||||
"""用户活动日志表"""
|
||||
__tablename__ = "user_activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# 活动信息
|
||||
activity_type = Column(String(50), nullable=False, index=True) # upload, view, download, delete, export
|
||||
resource_type = Column(String(50), nullable=True) # stp_file, geometry_data, mold_cavity
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
# 活动详情
|
||||
description = Column(Text, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# IP和设备信息
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserActivity(id={self.id}, user_id={self.user_id}, activity_type='{self.activity_type}')>"
|
||||
|
||||
|
||||
class SystemLog(Base):
|
||||
"""系统日志表(重要操作和错误)"""
|
||||
__tablename__ = "system_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
|
||||
# 日志级别
|
||||
level = Column(String(20), nullable=False, index=True) # INFO, WARNING, ERROR, CRITICAL
|
||||
|
||||
# 日志信息
|
||||
message = Column(Text, nullable=False)
|
||||
module = Column(String(100), nullable=True) # 模块名
|
||||
function_name = Column(String(100), nullable=True)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, default=func.now(), index=True)
|
||||
|
||||
# 用户信息(如果有关联用户)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# 额外信息
|
||||
request_id = Column(String(100), nullable=True) # 关联的请求ID
|
||||
execution_time_ms = Column(Integer, nullable=True) # 执行时间
|
||||
|
||||
# 关联数据
|
||||
resource_type = Column(String(50), nullable=True)
|
||||
resource_id = Column(Integer, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemLog(id={self.id}, level='{self.level}', module='{self.module}')>"
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import select
|
||||
@@ -14,7 +14,7 @@ from shared.services.auth_service import (
|
||||
get_current_active_user,
|
||||
get_password_hash
|
||||
)
|
||||
from shared.models.database import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.models.identity import User, Role, Permission, UserRole, RolePermission
|
||||
from shared.config.settings import settings
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
@@ -97,6 +97,13 @@ class UserUpdate(BaseModel):
|
||||
role_ids: Optional[List[int]] = None
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
# 新密码走 JSON body(与前端 api-client.ts 的 { new_password } 结构一致)。
|
||||
# 此前声明为裸 str 参数被 FastAPI 解析为 query param,前端发 body 必然 422,
|
||||
# 重置密码功能端到端断裂;最短 6 位对齐 UsersView 前端校验。
|
||||
new_password: str = Field(min_length=6)
|
||||
|
||||
|
||||
def check_admin(user: User) -> bool:
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
@@ -307,7 +314,7 @@ async def delete_user(
|
||||
@router.put("/users/{user_id}/reset-password")
|
||||
async def reset_user_password(
|
||||
user_id: int,
|
||||
new_password: str,
|
||||
body: ResetPasswordRequest,
|
||||
db_session: AsyncSession = Depends(get_db_session),
|
||||
current_user: User = Depends(get_current_active_user)
|
||||
):
|
||||
@@ -319,7 +326,7 @@ async def reset_user_password(
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
try:
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
user.hashed_password = get_password_hash(body.new_password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
await db_session.commit()
|
||||
|
||||
@@ -10,7 +10,7 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from shared.config.settings import settings
|
||||
from shared.database.database import get_db_session
|
||||
from shared.models.database import User, UserRole
|
||||
from shared.models.identity import User, UserRole
|
||||
from shared.utils.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
Reference in New Issue
Block a user