后端设计治理:批次 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:
2026-09-17 16:15:49 +08:00
parent 4537faf2c4
commit 0e6b3b1811
82 changed files with 6513 additions and 3709 deletions
+8 -15
View File
@@ -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"],
)
+2 -1
View File
@@ -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=["客户管理"])
+2 -4
View File
@@ -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=["仪表盘"])
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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,
+3 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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=["供应商管理"])
+2 -1
View File
@@ -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=["仓库管理"])
+46
View File
@@ -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",
]
+166
View File
@@ -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}')>"
+45
View File
@@ -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})>"
+108
View File
@@ -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})>"
+80
View File
@@ -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})>"
+2 -12
View File
@@ -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,
+2 -1
View File
@@ -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,
+2 -11
View File
@@ -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
+36 -14
View File
@@ -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)
-592
View File
@@ -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}
+7 -4
View File
@@ -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:
# 文件类型检查
+41 -26
View File
@@ -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,
+47
View File
@@ -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
+51
View File
@@ -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}
+1 -1
View File
@@ -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()
+181
View File
@@ -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}
+302
View File
@@ -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}
+18 -5
View File
@@ -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"]],
},
}
+4 -4
View File
@@ -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,
+167
View File
@@ -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}
+18
View File
@@ -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": [],
}
+1 -1
View File
@@ -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__)
+8 -18
View File
@@ -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"],
},
+13
View File
@@ -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
+28
View File
@@ -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",
]
+352
View File
@@ -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})>"
@@ -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
+34 -28
View File
@@ -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(
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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 环境变量,逗号分隔。
-12
View File
@@ -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()
+1 -1
View File
@@ -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
+17
View File
@@ -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()
-891
View File
@@ -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})>"
+182
View File
@@ -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}')>"
+11 -4
View File
@@ -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()
+1 -1
View File
@@ -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__)