后端模块拆分

This commit is contained in:
2026-05-29 18:10:08 +08:00
parent 5bb9bc84ea
commit 823a387118
93 changed files with 198 additions and 833 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
# config package
# 配置模块包
+99
View File
@@ -0,0 +1,99 @@
import os
import urllib.parse
from typing import Dict, Any
from dotenv import load_dotenv
load_dotenv()
class Settings:
"""配置管理器"""
def __init__(self):
self.HOST = os.getenv("HOST", "0.0.0.0")
self.PORT = int(os.getenv("PORT", "8000"))
self.DEBUG = os.getenv("DEBUG", "false").lower() == "true"
self.UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads")
self.MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", "104857600"))
self.ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", ".stp,.step,.stp.gz")
self.POINTCLOUD_SAMPLE_COUNT = int(os.getenv("POINTCLOUD_SAMPLE_COUNT", "10000"))
self.MESH_QUALITY = os.getenv("MESH_QUALITY", "high")
self.PARALLEL_PROCESSING = os.getenv("PARALLEL_PROCESSING", "true").lower() == "true"
self.RUSTFS_ENDPOINT = os.getenv("RUSTFS_ENDPOINT") or os.getenv("MINIO_ENDPOINT") or "http://localhost:8080"
self.RUSTFS_ACCESS_KEY = os.getenv("RUSTFS_ACCESS_KEY") or os.getenv("MINIO_ACCESS_KEY") or "your-access-key"
self.RUSTFS_SECRET_KEY = os.getenv("RUSTFS_SECRET_KEY") or os.getenv("MINIO_SECRET_KEY") or "your-secret-key"
self.RUSTFS_TIMEOUT = int(os.getenv("RUSTFS_TIMEOUT", "30"))
self.RUSTFS_PRESIGNED_URL_EXPIRES = int(os.getenv("RUSTFS_PRESIGNED_URL_EXPIRES", "3600"))
db_host = os.getenv("DB_HOST")
db_port_str = os.getenv("DB_PORT")
db_name = os.getenv("DB_NAME")
db_user = os.getenv("DB_USER")
db_password = os.getenv("DB_PASSWORD")
missing_configs = []
if not db_host:
missing_configs.append("DB_HOST")
if not db_port_str:
missing_configs.append("DB_PORT")
if not db_name:
missing_configs.append("DB_NAME")
if not db_user:
missing_configs.append("DB_USER")
if not db_password:
missing_configs.append("DB_PASSWORD")
if missing_configs:
raise ValueError(f"数据库配置缺失,请在.env文件中设置: {', '.join(missing_configs)}")
self.DB_HOST = db_host
self.DB_PORT = int(db_port_str)
self.DB_NAME = db_name
self.DB_USER = db_user
self.DB_PASSWORD = db_password
self.SECRET_KEY = os.getenv("SECRET_KEY")
self.ALGORITHM = os.getenv("ALGORITHM", "HS256")
self.ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "1440"))
self.ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
self.ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
self.ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "admin@gemold.com")
self.ADMIN_FULL_NAME = os.getenv("ADMIN_FULL_NAME", "系统管理员")
self.ENABLE_FREECAD_VERIFICATION = os.getenv("ENABLE_FREECAD_VERIFICATION", "false").lower() == "true"
self.FREECAD_VERIFICATION_TIMEOUT = int(os.getenv("FREECAD_VERIFICATION_TIMEOUT", "120"))
self.PROCESSING_TIMEOUT_BASE = int(os.getenv("PROCESSING_TIMEOUT_BASE", "300"))
self.PROCESSING_TIMEOUT_PER_MB = int(os.getenv("PROCESSING_TIMEOUT_PER_MB", "15"))
# Redis
self.REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
self.REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
self.REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
self.REDIS_DB = int(os.getenv("REDIS_DB", "0"))
# LLM 增强分析配置(可选)
self.LLM_ENABLED = os.getenv("LLM_ENABLED", "false").lower() == "true"
self.LLM_API_URL = os.getenv("LLM_API_URL", "https://api.openai.com/v1")
self.LLM_API_KEY = os.getenv("LLM_API_KEY", "")
self.LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini")
self.LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "60"))
self.LLM_MAX_TOKENS = int(os.getenv("LLM_MAX_TOKENS", "2000"))
@property
def DATABASE_URL(self) -> str:
if self.DB_PASSWORD:
safe_password = urllib.parse.quote(self.DB_PASSWORD.encode("utf-8"), safe="")
else:
safe_password = ""
return f"postgresql+asyncpg://{self.DB_USER}:{safe_password}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def allowed_extensions_set(self) -> set:
return set(ext.strip() for ext in self.ALLOWED_EXTENSIONS.split(","))
settings = Settings()
View File
+108
View File
@@ -0,0 +1,108 @@
# shared/database/database.py
import sys
import os
from pathlib import Path
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from shared.config.settings import settings
import asyncio
from contextlib import asynccontextmanager
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class DatabaseManager:
"""数据库管理器"""
def __init__(self):
self.engine = None
self.async_session = None
self.is_connected = False
async def connect(self):
"""连接数据库"""
if not settings.DATABASE_URL:
logger.warning("未配置数据库连接,跳过数据库初始化")
self.is_connected = False
return
try:
# 创建异步引擎
self.engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_size=20,
max_overflow=30,
pool_recycle=3600
)
# 创建异步会话工厂
self.async_session = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False
)
# 测试连接
async with self.engine.begin() as conn:
await conn.execute(text("SELECT 1"))
self.is_connected = True
logger.info("数据库连接成功")
except Exception as e:
logger.error(f"数据库连接失败: {e}")
self.is_connected = False
raise
async def disconnect(self):
"""断开数据库连接"""
if self.engine:
await self.engine.dispose()
self.is_connected = False
logger.info("数据库连接已断开")
@asynccontextmanager
async def session(self):
"""获取数据库会话的异步上下文管理器"""
if not self.is_connected:
await self.connect()
session = self.async_session()
try:
yield session
finally:
await session.close()
async def get_session(self) -> AsyncSession:
"""获取数据库会话"""
if not self.is_connected:
await self.connect()
return self.async_session()
async def create_tables(self):
"""创建数据库表"""
from 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()
# 数据库依赖注入
async def get_db_session():
"""获取数据库会话的依赖函数"""
session = await db_manager.get_session()
try:
yield session
finally:
await session.close()
+249
View File
@@ -0,0 +1,249 @@
import asyncio
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.services.auth_service import get_password_hash
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_PERMISSIONS = [
{"code": "view_dashboard", "name": "查看仪表盘", "module": "dashboard"},
{"code": "view_moldinsight", "name": "使用模具分析", "module": "moldinsight"},
{"code": "upload_file", "name": "上传文件", "module": "moldinsight"},
{"code": "view_history", "name": "查看历史记录", "module": "moldinsight"},
{"code": "view_inventory", "name": "查看库存", "module": "inventory"},
{"code": "manage_inventory", "name": "管理库存", "module": "inventory"},
{"code": "view_products", "name": "查看产品", "module": "inventory"},
{"code": "manage_products", "name": "管理产品", "module": "inventory"},
{"code": "view_suppliers", "name": "查看供应商", "module": "inventory"},
{"code": "manage_suppliers", "name": "管理供应商", "module": "inventory"},
{"code": "view_customers", "name": "查看客户", "module": "inventory"},
{"code": "manage_customers", "name": "管理客户", "module": "inventory"},
{"code": "view_finance", "name": "查看财务", "module": "finance"},
{"code": "manage_receipts", "name": "管理收款", "module": "finance"},
{"code": "manage_payments", "name": "管理付款", "module": "finance"},
{"code": "void_finance_transaction", "name": "作废财务单据", "module": "finance"},
{"code": "view_users", "name": "查看用户", "module": "admin"},
{"code": "manage_users", "name": "管理用户", "module": "admin"},
{"code": "manage_roles", "name": "管理角色", "module": "admin"},
]
DEFAULT_ROLES = [
{"code": "admin", "name": "管理员", "description": "系统管理员,拥有所有权限", "is_system": True, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "manage_inventory", "view_products", "manage_products", "view_suppliers", "manage_suppliers", "view_customers", "manage_customers", "view_finance", "manage_receipts", "manage_payments", "void_finance_transaction", "view_users", "manage_users", "manage_roles"]},
{"code": "user", "name": "普通用户", "description": "普通用户,可使用模具分析和查看库存", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "upload_file", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance", "manage_receipts", "manage_payments"]},
{"code": "viewer", "name": "只读用户", "description": "只读用户,只能查看数据", "is_system": False, "permissions": ["view_dashboard", "view_moldinsight", "view_history", "view_inventory", "view_products", "view_suppliers", "view_customers", "view_finance"]},
]
async def init_permissions(session):
"""初始化权限"""
result = await session.execute(select(Permission))
existing_perms = result.scalars().all()
if existing_perms:
logger.info("权限已初始化")
return
perm_map = {}
for perm_data in DEFAULT_PERMISSIONS:
perm = Permission(**perm_data)
session.add(perm)
await session.flush()
perm_map[perm.code] = perm.id
logger.info(f"创建了 {len(DEFAULT_PERMISSIONS)} 个权限")
return perm_map
async def init_roles(session, perm_map):
"""初始化角色"""
result = await session.execute(select(Role))
existing_roles = result.scalars().all()
if existing_roles:
logger.info("角色已初始化")
return
for role_data in DEFAULT_ROLES:
perm_ids = [perm_map[code] for code in role_data.pop("permissions")]
role = Role(**role_data)
session.add(role)
await session.flush()
for perm_id in perm_ids:
rp = RolePermission(role_id=role.id, permission_id=perm_id)
session.add(rp)
logger.info(f"创建了 {len(DEFAULT_ROLES)} 个角色")
async def create_admin_user(session):
"""创建默认管理员"""
result = await session.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
existing_admin = result.scalar_one_or_none()
if existing_admin:
logger.info("管理员账户已存在")
return
admin = User(
username=settings.ADMIN_USERNAME,
email=settings.ADMIN_EMAIL,
hashed_password=get_password_hash(settings.ADMIN_PASSWORD),
full_name=settings.ADMIN_FULL_NAME,
is_active=True
)
session.add(admin)
await session.flush()
result = await session.execute(select(Role).where(Role.code == "admin"))
admin_role = result.scalar_one_or_none()
if admin_role:
user_role = UserRole(user_id=admin.id, role_id=admin_role.id)
session.add(user_role)
await session.commit()
logger.info(f"创建了管理员账户: {settings.ADMIN_USERNAME}")
async def init_database(keep_connected: bool = True):
"""初始化数据库"""
try:
await db_manager.connect()
await db_manager.create_tables()
await ensure_schema_updates()
async with db_manager.session() as session:
perm_map = await init_permissions(session)
if perm_map is None:
# Permissions already existed, fetch them from database
result = await session.execute(select(Permission))
perms = result.scalars().all()
perm_map = {perm.code: perm.id for perm in perms}
await init_roles(session, perm_map)
await create_admin_user(session)
logger.info("数据库初始化完成")
print("=" * 60)
print("数据库初始化成功!")
print("=" * 60)
print(f"管理员用户名: {settings.ADMIN_USERNAME}")
print(f"管理员邮箱: {settings.ADMIN_EMAIL}")
print("=" * 60)
print("可以在 .env 文件中修改管理员配置:")
print(" ADMIN_USERNAME")
print(" ADMIN_EMAIL")
print(" ADMIN_FULL_NAME")
print("=" * 60)
return True
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
print(f"数据库初始化失败: {e}")
return False
finally:
if not keep_connected:
await db_manager.disconnect()
async def ensure_schema_updates():
async with db_manager.engine.begin() as conn:
await conn.execute(text("ALTER TABLE products ADD COLUMN IF NOT EXISTS item_type VARCHAR(20) DEFAULT 'finished'"))
await conn.execute(text("UPDATE products SET item_type = 'finished' WHERE item_type IS NULL"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_status VARCHAR(20) DEFAULT 'not_started'"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS product_materials (
id SERIAL PRIMARY KEY,
finished_product_id INTEGER NOT NULL REFERENCES products(id),
material_product_id INTEGER NOT NULL REFERENCES products(id),
quantity DOUBLE PRECISION NOT NULL,
loss_rate DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
await conn.execute(text("""
CREATE UNIQUE INDEX IF NOT EXISTS uq_product_material_unique
ON product_materials (finished_product_id, material_product_id)
"""))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS best_scheme_id VARCHAR(64)"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS confidence_score DOUBLE PRECISION"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS is_fallback BOOLEAN"))
await conn.execute(text("ALTER TABLE mold_cavity_data ADD COLUMN IF NOT EXISTS fallback_reason TEXT"))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_best_scheme_id
ON mold_cavity_data (best_scheme_id)
"""))
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_mold_cavity_is_fallback
ON mold_cavity_data (is_fallback)
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_inventory_product_warehouse'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_qty_nonnegative'
) THEN
ALTER TABLE inventory
ADD CONSTRAINT ck_inventory_qty_nonnegative
CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_purchase_order_items_qty'
) THEN
ALTER TABLE purchase_order_items
ADD CONSTRAINT ck_purchase_order_items_qty
CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_sales_order_items_qty'
) THEN
ALTER TABLE sales_order_items
ADD CONSTRAINT ck_sales_order_items_qty
CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity);
END IF;
END $$;
"""))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS manufacturing_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("ALTER TABLE sales_orders ALTER COLUMN manufacturing_date TYPE TIMESTAMP WITHOUT TIME ZONE"))
if __name__ == "__main__":
asyncio.run(init_database(keep_connected=False))
+55
View File
@@ -0,0 +1,55 @@
"""数据库迁移脚本 - 删除旧表并重新创建"""
import asyncio
from shared.database.database import db_manager
from shared.models.database import Base
from shared.utils.logger import get_logger
logger = get_logger(__name__)
async def migrate_database():
"""迁移数据库:删除所有表并重新创建"""
try:
# 连接数据库
await db_manager.connect()
# 删除所有表
logger.info("正在删除所有数据库表...")
async with db_manager.engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# 重新创建所有表
logger.info("正在创建所有数据库表...")
async with db_manager.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("数据库迁移完成!")
return True
except Exception as e:
logger.error(f"数据库迁移失败: {e}")
return False
finally:
await db_manager.disconnect()
if __name__ == "__main__":
import sys
# 检查命令行参数
if len(sys.argv) > 1 and sys.argv[1] == '--force':
confirm = 'yes'
else:
print("=== 数据库迁移 ===")
print("警告:这将删除所有数据库表和数据!")
confirm = input("确认继续?(yes/no): ")
if confirm.lower() == 'yes':
asyncio.run(migrate_database())
else:
print("已取消迁移")
+874
View File
@@ -0,0 +1,874 @@
# models/database.py
from sqlalchemy import Column, Integer, String, Text, DateTime, Date, JSON, LargeBinary, Boolean, Float, ForeignKey, UniqueConstraint, Numeric
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)
# 对象存储信息
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")
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)
# 任务类型和状态
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"
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"
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"
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})>"
+135
View File
@@ -0,0 +1,135 @@
# models/schemas.py
from typing import Dict, List, Optional, Any
from enum import Enum
class ProcessingStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
# 简化的数据模型,避免复杂的Pydantic验证
def create_geometry_data(
bounding_box: Dict[str, List[float]],
volume: float,
surface_area: float,
topology: Dict[str, int],
analysis_method: str,
center_of_mass: Optional[List[float]] = None,
inertia_properties: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""创建几何数据"""
return {
"bounding_box": bounding_box,
"volume": volume,
"surface_area": surface_area,
"topology": topology,
"center_of_mass": center_of_mass or [0.0, 0.0, 0.0],
"inertia_properties": inertia_properties or {},
"analysis_method": analysis_method
}
def create_mold_feature(
feature_type: str,
confidence: float,
location: List[float],
dimensions: List[float],
parameters: Dict[str, Any],
recommendations: List[str]
) -> Dict[str, Any]:
"""创建模具特征"""
return {
"feature_type": feature_type,
"confidence": confidence,
"location": location,
"dimensions": dimensions,
"parameters": parameters,
"recommendations": recommendations
}
def create_design_recommendation(
rec_type: str,
priority: str,
description: str,
parameters: Dict[str, Any],
reason: str
) -> Dict[str, Any]:
"""创建设计建议"""
return {
"type": rec_type,
"priority": priority,
"description": description,
"parameters": parameters,
"reason": reason
}
def create_analysis_result(
geometry_data: Dict[str, Any],
detected_features: List[Dict[str, Any]],
design_recommendations: List[Dict[str, Any]],
quality_metrics: Dict[str, float],
analysis_summary: str
) -> Dict[str, Any]:
"""创建分析结果"""
return {
"geometry_data": geometry_data,
"detected_features": detected_features,
"design_recommendations": design_recommendations,
"quality_metrics": quality_metrics,
"analysis_summary": analysis_summary
}
def create_task_info(
task_id: str,
status: ProcessingStatus,
filename: str,
file_path: str,
file_size: int,
upload_time: str,
completed_at: Optional[str] = None,
geometry_data: Optional[Dict[str, Any]] = None,
analysis_result: Optional[Dict[str, Any]] = None,
error: Optional[str] = None
) -> Dict[str, Any]:
"""创建任务信息"""
return {
"task_id": task_id,
"status": status,
"filename": filename,
"file_path": file_path,
"file_size": file_size,
"upload_time": upload_time,
"completed_at": completed_at,
"geometry_data": geometry_data,
"analysis_result": analysis_result,
"error": error
}
# 添加到 schemas.py
def create_mold_cavity_data(
cavity_geometry: Dict[str, Any],
core_geometry: Dict[str, Any],
parting_surface: Dict[str, Any],
manufacturing_info: Dict[str, Any]
) -> Dict[str, Any]:
"""创建模具型腔详细数据"""
return {
"cavity_geometry": cavity_geometry,
"core_geometry": core_geometry,
"parting_surface": parting_surface,
"manufacturing_info": manufacturing_info
}
def create_mold_key_info(
mold_parameters: Dict[str, Any],
geometric_characteristics: Dict[str, Any],
manufacturing_requirements: Dict[str, Any],
quality_considerations: Dict[str, Any]
) -> Dict[str, Any]:
"""创建模具型腔关键信息"""
return {
"mold_parameters": mold_parameters,
"geometric_characteristics": geometric_characteristics,
"manufacturing_requirements": manufacturing_requirements,
"quality_considerations": quality_considerations
}
View File
+549
View File
@@ -0,0 +1,549 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional, List
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.database.database import get_db_session
from shared.services.auth_service import (
authenticate_user,
create_access_token,
get_current_active_user,
get_password_hash
)
from shared.models.database import User, Role, Permission, UserRole, RolePermission
from shared.config.settings import settings
from shared.utils.logger import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/api/auth", tags=["认证"])
class UserResponse(BaseModel):
id: int
username: str
email: str
full_name: Optional[str]
is_active: bool
roles: List[str]
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
user: UserResponse
class LoginRequest(BaseModel):
username: str
password: str
class RoleCreate(BaseModel):
code: str
name: str
description: Optional[str] = None
class RoleResponse(BaseModel):
id: int
code: str
name: str
description: Optional[str]
is_system: bool
permissions: List[str]
class Config:
from_attributes = True
class PermissionCreate(BaseModel):
code: str
name: str
module: Optional[str] = None
description: Optional[str] = None
class PermissionResponse(BaseModel):
id: int
code: str
name: str
module: Optional[str]
description: Optional[str]
class Config:
from_attributes = True
class UserCreate(BaseModel):
username: str
email: str
password: str
full_name: Optional[str] = None
role_ids: List[int] = []
class UserUpdate(BaseModel):
email: Optional[str] = None
full_name: Optional[str] = None
is_active: Optional[bool] = None
role_ids: Optional[List[int]] = None
def check_admin(user: User) -> bool:
if not user.is_superuser:
raise HTTPException(status_code=403, detail="需要管理员权限")
return True
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db_session: AsyncSession = Depends(get_db_session)
):
user = await authenticate_user(db_session, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return Token(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
)
@router.post("/login/json", response_model=Token)
async def login_json(
login_data: LoginRequest,
db_session: AsyncSession = Depends(get_db_session)
):
user = await authenticate_user(db_session, login_data.username, login_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return Token(
access_token=access_token,
token_type="bearer",
user=UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
)
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
current_user: User = Depends(get_current_active_user)
):
return UserResponse(
id=current_user.id,
username=current_user.username,
email=current_user.email,
full_name=current_user.full_name,
is_active=current_user.is_active,
roles=[r.code for r in current_user.roles]
)
@router.post("/logout")
async def logout():
return {"message": "已登出"}
@router.get("/users", response_model=List[UserResponse])
async def list_users(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(
select(User).options(selectinload(User.user_roles).selectinload(UserRole.role))
)
users = result.scalars().all()
return [
UserResponse(
id=u.id,
username=u.username,
email=u.email,
full_name=u.full_name,
is_active=u.is_active,
roles=[r.code for r in u.roles]
) for u in users
]
@router.post("/users", response_model=UserResponse, status_code=201)
async def create_user(
user_data: UserCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
existing = await db_session.execute(
select(User).where(User.username == user_data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
existing_email = await db_session.execute(
select(User).where(User.email == user_data.email)
)
if existing_email.scalar_one_or_none():
raise HTTPException(status_code=400, detail="邮箱已存在")
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=get_password_hash(user_data.password),
full_name=user_data.full_name,
is_active=True
)
db_session.add(user)
await db_session.flush()
for role_id in user_data.role_ids:
user_role = UserRole(user_id=user.id, role_id=role_id)
db_session.add(user_role)
await db_session.commit()
await db_session.refresh(user)
logger.info(f"管理员 {current_user.username} 创建了用户 {user.username}")
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
@router.put("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_data: UserUpdate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user_data.email is not None:
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.is_active is not None:
user.is_active = user_data.is_active
if user_data.role_ids is not None:
await db_session.execute(
select(UserRole).where(UserRole.user_id == user_id)
)
for ur in (await db_session.execute(select(UserRole).where(UserRole.user_id == user_id))).scalars().all():
await db_session.delete(ur)
for role_id in user_data.role_ids:
user_role = UserRole(user_id=user.id, role_id=role_id)
db_session.add(user_role)
await db_session.commit()
await db_session.refresh(user)
logger.info(f"管理员 {current_user.username} 更新了用户 {user.username}")
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
is_active=user.is_active,
roles=[r.code for r in user.roles]
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="不能删除自己的账户")
username = user.username
await db_session.delete(user)
await db_session.commit()
logger.info(f"管理员 {current_user.username} 删除了用户 {username}")
return {"message": "用户已删除"}
@router.put("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: int,
new_password: str,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.hashed_password = get_password_hash(new_password)
await db_session.commit()
logger.info(f"管理员 {current_user.username} 重置了用户 {user.username} 的密码")
return {"message": "密码已重置"}
@router.get("/roles", response_model=List[RoleResponse])
async def list_roles(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Role))
roles = result.scalars().all()
return [
RoleResponse(
id=r.id,
code=r.code,
name=r.name,
description=r.description,
is_system=r.is_system,
permissions=[p.code for p in r.permissions]
) for r in roles
]
@router.post("/roles", response_model=RoleResponse, status_code=201)
async def create_role(
role_data: RoleCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
existing = await db_session.execute(
select(Role).where(Role.code == role_data.code)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="角色编码已存在")
role = Role(
code=role_data.code,
name=role_data.name,
description=role_data.description
)
db_session.add(role)
await db_session.commit()
await db_session.refresh(role)
logger.info(f"管理员 {current_user.username} 创建了角色 {role.code}")
return RoleResponse(
id=role.id,
code=role.code,
name=role.name,
description=role.description,
is_system=role.is_system,
permissions=[]
)
@router.put("/roles/{role_id}", response_model=RoleResponse)
async def update_role(
role_id: int,
role_data: RoleCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_system:
raise HTTPException(status_code=400, detail="系统角色不能修改")
role.name = role_data.name
role.description = role_data.description
await db_session.commit()
await db_session.refresh(role)
return RoleResponse(
id=role.id,
code=role.code,
name=role.name,
description=role.description,
is_system=role.is_system,
permissions=[p.code for p in role.permissions]
)
@router.delete("/roles/{role_id}")
async def delete_role(
role_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
if role.is_system:
raise HTTPException(status_code=400, detail="系统角色不能删除")
await db_session.delete(role)
await db_session.commit()
logger.info(f"管理员 {current_user.username} 删除了角色 {role.code}")
return {"message": "角色已删除"}
@router.put("/roles/{role_id}/permissions")
async def set_role_permissions(
role_id: int,
permission_ids: List[int],
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Role).where(Role.id == role_id))
role = result.scalar_one_or_none()
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
for rp in (await db_session.execute(select(RolePermission).where(RolePermission.role_id == role_id))).scalars().all():
await db_session.delete(rp)
for perm_id in permission_ids:
rp = RolePermission(role_id=role_id, permission_id=perm_id)
db_session.add(rp)
await db_session.commit()
logger.info(f"管理员 {current_user.username} 更新了角色 {role.code} 的权限")
return {"message": "权限已更新"}
@router.get("/permissions", response_model=List[PermissionResponse])
async def list_permissions(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Permission))
permissions = result.scalars().all()
return [PermissionResponse.from_orm(p) for p in permissions]
@router.post("/permissions", response_model=PermissionResponse, status_code=201)
async def create_permission(
perm_data: PermissionCreate,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
existing = await db_session.execute(
select(Permission).where(Permission.code == perm_data.code)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="权限编码已存在")
permission = Permission(
code=perm_data.code,
name=perm_data.name,
module=perm_data.module,
description=perm_data.description
)
db_session.add(permission)
await db_session.commit()
await db_session.refresh(permission)
logger.info(f"管理员 {current_user.username} 创建了权限 {permission.code}")
return PermissionResponse.from_orm(permission)
@router.delete("/permissions/{permission_id}")
async def delete_permission(
permission_id: int,
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
check_admin(current_user)
result = await db_session.execute(select(Permission).where(Permission.id == permission_id))
permission = result.scalar_one_or_none()
if not permission:
raise HTTPException(status_code=404, detail="权限不存在")
await db_session.delete(permission)
await db_session.commit()
logger.info(f"管理员 {current_user.username} 删除了权限 {permission.code}")
return {"message": "权限已删除"}
+168
View File
@@ -0,0 +1,168 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, ExpiredSignatureError, jwt
import bcrypt
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.utils.logger import get_logger
logger = get_logger(__name__)
pwd_context = bcrypt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
if len(password.encode('utf-8')) > 72:
password = password[:72]
return pwd_context.hashpw(password.encode('utf-8'), pwd_context.gensalt()).decode('utf-8')
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
db_session: AsyncSession = Depends(get_db_session)
) -> Optional[User]:
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
logger.warning(f"[AUTH] Token 中缺少 sub 字段")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 格式无效:缺少用户标识",
headers={"WWW-Authenticate": "Bearer"},
)
except ExpiredSignatureError:
logger.warning(f"[AUTH] Token 已过期")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录已过期,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError as e:
logger.warning(f"[AUTH] Token 验证失败: {type(e).__name__}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token 无效,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
result = await db_session.execute(
select(User).options(selectinload(User.user_roles).selectinload(UserRole.role)).where(User.username == username)
)
user = result.scalar_one_or_none()
if user is None:
logger.warning(f"[AUTH] Token 有效但用户不存在: {username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户账户不存在,请重新登录",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
logger.warning(f"[AUTH] 用户已被禁用: {username}")
raise HTTPException(status_code=400, detail="用户已被禁用")
return user
async def get_current_active_user(
current_user: Optional[User] = Depends(get_current_user)
) -> User:
if not current_user:
logger.warning("[AUTH] 未提供认证信息,拒绝访问")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="请先登录",
headers={"WWW-Authenticate": "Bearer"},
)
return current_user
async def get_current_admin_user(
current_user: User = Depends(get_current_active_user)
) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return current_user
async def authenticate_user(db_session: AsyncSession, username: str, password: str) -> Optional[User]:
result = await db_session.execute(
select(User).options(selectinload(User.user_roles).selectinload(UserRole.role)).where(User.username == username)
)
user = result.scalar_one_or_none()
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
user.last_login = datetime.utcnow()
await db_session.commit()
return user
async def create_user(
db_session: AsyncSession,
username: str,
email: str,
password: str,
full_name: Optional[str] = None
) -> User:
hashed_password = get_password_hash(password)
user = User(
username=username,
email=email,
hashed_password=hashed_password,
full_name=full_name,
is_active=True
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
async def get_user_by_username(db_session: AsyncSession, username: str) -> Optional[User]:
result = await db_session.execute(
select(User).where(User.username == username)
)
return result.scalar_one_or_none()
async def get_user_by_email(db_session: AsyncSession, email: str) -> Optional[User]:
result = await db_session.execute(
select(User).where(User.email == email)
)
return result.scalar_one_or_none()
+224
View File
@@ -0,0 +1,224 @@
# services/redis_task_manager.py
"""Redis 任务管理器 - 替代内存字典,支持 TTL 自动清理"""
import json
import os
from typing import Dict, Any, Optional
from datetime import datetime
import redis.asyncio as aioredis
from shared.utils.logger import get_logger
logger = get_logger(__name__)
class RedisTaskManager:
"""基于 Redis 的任务状态管理"""
_instance: Optional["RedisTaskManager"] = None
def __init__(self):
self._redis: Optional[aioredis.Redis] = None
self._prefix = "moldinsight:task:"
self._ttl = 86400 * 7 # 任务默认保留 7 天
self._connected = False
@classmethod
def get_instance(cls) -> "RedisTaskManager":
if cls._instance is None:
cls._instance = RedisTaskManager()
return cls._instance
async def connect(self):
"""连接 Redis"""
if self._connected and self._redis:
return
host = os.getenv("REDIS_HOST", "szcjw")
port = int(os.getenv("REDIS_PORT", "6379"))
password = os.getenv("REDIS_PASSWORD", "")
db = int(os.getenv("REDIS_DB", "0"))
try:
self._redis = aioredis.Redis(
host=host,
port=port,
password=password if password else None,
db=db,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5,
retry_on_timeout=True,
)
# 测试连接
await self._redis.ping()
self._connected = True
logger.info(f"Redis 连接成功: {host}:{port}")
except Exception as e:
logger.error(f"Redis 连接失败: {e},任务状态将使用内存回退")
self._redis = None
self._connected = False
async def disconnect(self):
"""断开 Redis 连接"""
if self._redis:
await self._redis.aclose()
self._redis = None
self._connected = False
logger.info("Redis 连接已断开")
@property
def is_connected(self) -> bool:
return self._connected and self._redis is not None
# ---- 内存回退 ----
_fallback_tasks: Dict[str, Dict[str, Any]] = {}
def _fallback_set(self, task_id: str, data: Dict[str, Any]):
self._fallback_tasks[task_id] = data
def _fallback_get(self, task_id: str) -> Optional[Dict[str, Any]]:
return self._fallback_tasks.get(task_id)
def _fallback_delete(self, task_id: str):
self._fallback_tasks.pop(task_id, None)
def _fallback_all(self) -> Dict[str, Dict[str, Any]]:
return dict(self._fallback_tasks)
def _fallback_count(self) -> int:
return len(self._fallback_tasks)
# ---- 公共接口 ----
async def set_task(self, task_id: str, data: Dict[str, Any], ttl: Optional[int] = None):
"""设置任务数据"""
effective_ttl = ttl or self._ttl
# 确保数据可序列化
serializable = self._make_serializable(data)
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
await self._redis.setex(key, effective_ttl, json.dumps(serializable, ensure_ascii=False))
return
except Exception as e:
logger.warning(f"Redis 写入失败,回退到内存: {e}")
self._fallback_set(task_id, serializable)
async def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
"""获取任务数据"""
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
raw = await self._redis.get(key)
if raw:
return json.loads(raw)
return None
except Exception as e:
logger.warning(f"Redis 读取失败,回退到内存: {e}")
return self._fallback_get(task_id)
async def update_task(self, task_id: str, updates: Dict[str, Any]):
"""更新任务的部分字段"""
current = await self.get_task(task_id)
if current is None:
logger.warning(f"任务 {task_id} 不存在,无法更新")
return
current.update(self._make_serializable(updates))
await self.set_task(task_id, current)
async def delete_task(self, task_id: str):
"""删除任务"""
if self.is_connected:
try:
key = f"{self._prefix}{task_id}"
await self._redis.delete(key)
return
except Exception as e:
logger.warning(f"Redis 删除失败,回退到内存: {e}")
self._fallback_delete(task_id)
async def get_all_tasks(self) -> Dict[str, Dict[str, Any]]:
"""获取所有任务"""
if self.is_connected:
try:
pattern = f"{self._prefix}*"
keys = []
async for key in self._redis.scan_iter(match=pattern):
keys.append(key)
result = {}
for key in keys:
task_id = key.replace(self._prefix, "")
raw = await self._redis.get(key)
if raw:
result[task_id] = json.loads(raw)
return result
except Exception as e:
logger.warning(f"Redis 扫描失败,回退到内存: {e}")
return self._fallback_all()
async def get_task_count(self) -> int:
"""获取任务总数"""
if self.is_connected:
try:
pattern = f"{self._prefix}*"
count = 0
async for _ in self._redis.scan_iter(match=pattern):
count += 1
return count
except Exception as e:
logger.warning(f"Redis 计数失败,回退到内存: {e}")
return self._fallback_count()
async def cleanup_old_tasks(self, max_age_seconds: int = 86400 * 7):
"""清理过期任务(Redis 由 TTL 自动管理,内存回退需手动清理)"""
now = datetime.now()
to_delete = []
for task_id, task in self._fallback_tasks.items():
completed_at = task.get("completed_at")
if completed_at:
try:
completed_dt = datetime.fromisoformat(completed_at)
if (now - completed_dt).total_seconds() > max_age_seconds:
to_delete.append(task_id)
except (ValueError, TypeError):
pass
for task_id in to_delete:
del self._fallback_tasks[task_id]
if to_delete:
logger.info(f"清理了 {len(to_delete)} 个过期内存任务")
# ---- 工具方法 ----
@staticmethod
def _make_serializable(obj: Any) -> Any:
"""确保对象可 JSON 序列化"""
if isinstance(obj, dict):
return {k: RedisTaskManager._make_serializable(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [RedisTaskManager._make_serializable(v) for v in obj]
if isinstance(obj, datetime):
return obj.isoformat()
if hasattr(obj, "value"):
# Enum 类型
return obj.value
if isinstance(obj, (int, float, str, bool, type(None))):
return obj
return str(obj)
# 全局单例
redis_task_manager = RedisTaskManager.get_instance()
+1
View File
@@ -0,0 +1 @@
# Utils 模块
+71
View File
@@ -0,0 +1,71 @@
# utils/file_handler.py
import aiofiles
import hashlib
import re
import uuid
from pathlib import Path
from fastapi import UploadFile
from typing import Tuple, Dict, Any
class FileHandler:
def __init__(self, upload_dir: str = "uploads", max_file_size: int = 50 * 1024 * 1024):
self.upload_dir = Path(upload_dir)
self.upload_dir.mkdir(exist_ok=True)
self.max_file_size = max_file_size
def _sanitize_filename(self, filename: str) -> str:
original = Path(filename or "upload.step").name
suffix = Path(original).suffix.lower()
stem = Path(original).stem or "upload"
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-") or "upload"
if suffix not in {".stp", ".step"}:
suffix = ".step"
return f"{safe_stem}{suffix}"
@staticmethod
def _looks_like_step(content: bytes) -> bool:
if not content:
return False
head = content[:4096].decode("utf-8", errors="ignore").upper()
return (
"ISO-10303-21" in head
or "HEADER;" in head
or "FILE_SCHEMA" in head
or "DATA;" in head
)
async def save_uploaded_file(self, file: UploadFile) -> Tuple[Path, int, Dict[str, Any]]:
"""保存上传的文件并返回安全元数据"""
content = await file.read()
if not content:
raise ValueError("上传文件为空")
if len(content) > self.max_file_size:
raise ValueError(f"上传文件过大,限制 {self.max_file_size // (1024 * 1024)}MB")
if not self._looks_like_step(content):
raise ValueError("文件内容不是有效的 STP/STEP 数据")
safe_name = self._sanitize_filename(file.filename)
unique_name = f"{uuid.uuid4().hex}_{safe_name}"
file_path = self.upload_dir / unique_name
async with aiofiles.open(file_path, "wb") as f:
await f.write(content)
metadata = {
"original_filename": file.filename,
"safe_original_name": safe_name,
"stored_filename": unique_name,
"sha256": hashlib.sha256(content).hexdigest(),
}
return file_path, len(content), metadata
def cleanup_file(self, file_path: Path):
"""清理文件"""
try:
if file_path.exists():
file_path.unlink()
except Exception as e:
print(f"文件清理失败: {e}")
+913
View File
@@ -0,0 +1,913 @@
# utils/html_generator.py
from pathlib import Path
from typing import Dict, Any, Optional
from datetime import datetime
from shared.utils.logger import get_logger
logger = get_logger(__name__)
try:
import orjson
def _json_dumps(obj: Any) -> bytes:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS)
def _json_dumps_str(obj: Any) -> str:
return orjson.dumps(obj, option=orjson.OPT_SERIALIZE_NUMPY | orjson.OPT_NON_STR_KEYS).decode("utf-8")
_JSON_FAST = True
except ImportError:
import json
def _json_dumps(obj: Any) -> bytes:
return json.dumps(obj, ensure_ascii=False).encode("utf-8")
def _json_dumps_str(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False)
_JSON_FAST = False
class HTMLGenerator:
"""HTML文件生成器 — 数据分离架构,Three.js 0.170 + PBR渲染"""
def __init__(self, output_dir: str = "./html_output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
def generate_3d_viewer_html(self, stp_filename: str, data_filename: str, summary_filename: str = "") -> str:
"""生成3D可视化HTML页面 — 先加载摘要秒显信息面板,再加载网格数据"""
cavity_html = self._build_cavity_info_panel_template()
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D模具几何可视化 - {stp_filename}</title>
<script type="importmap">
{{
"imports": {{
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}}
}}
</script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ overflow: hidden; font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f0f2f5; }}
#container {{ position: relative; width: 100vw; height: 100vh; }}
#canvas {{ display: block; }}
#loading-overlay {{
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; background: rgba(240,242,245,0.95);
z-index: 100; transition: opacity 0.4s;
}}
#loading-overlay.hidden {{ opacity: 0; pointer-events: none; }}
.spinner {{
width: 48px; height: 48px; border: 3px solid rgba(0,0,0,0.1);
border-top-color: #4CAF50; border-radius: 50%; animation: spin 0.8s linear infinite;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.loading-text {{ color: #666; margin-top: 16px; font-size: 14px; }}
#info-panel {{
position: absolute; top: 10px; left: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 12px 16px; border-radius: 10px; font-size: 13px;
max-width: 340px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
#info-panel h3 {{ margin: 0 0 6px 0; font-size: 14px; color: #2E7D32; }}
#info-panel .info-row {{ display: flex; justify-content: space-between; padding: 2px 0; }}
#info-panel .info-label {{ color: #888; }}
#info-panel .info-value {{ color: #111; font-weight: 500; }}
#cavity-info-panel {{
position: absolute; top: 10px; right: 10px; background: rgba(255,255,255,0.92);
color: #333; padding: 15px; border-radius: 10px; font-size: 12px;
max-width: 310px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
display: none;
}}
#cavity-info-panel h3 {{ margin: 0 0 10px 0; font-size: 14px; color: #E65100; }}
#cavity-info-panel .metric {{ display: flex; justify-content: space-between; padding: 3px 0; }}
#cavity-info-panel .metric-label {{ color: #888; }}
#cavity-info-panel .metric-value {{ color: #111; font-weight: 500; }}
#toolbar {{
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
display: flex; gap: 6px; background: rgba(255,255,255,0.92); padding: 8px 12px;
border-radius: 24px; backdrop-filter: blur(10px); border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
flex-wrap: wrap; justify-content: center;
}}
#toolbar button {{
background: rgba(0,0,0,0.04); color: #555; border: 1px solid rgba(0,0,0,0.1);
padding: 6px 14px; border-radius: 18px; cursor: pointer; font-size: 12px;
transition: all 0.2s; white-space: nowrap;
}}
#toolbar button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
#toolbar button.active {{ background: rgba(76,175,80,0.18); border-color: #4CAF50; color: #2E7D32; }}
#toolbar button.accent {{
background: rgba(255,87,34,0.15); border-color: #FF5722; color: #D84315;
font-weight: bold;
}}
#toolbar button.accent:hover {{ background: rgba(255,87,34,0.28); }}
#view-presets {{
position: absolute; bottom: 75px; left: 50%; transform: translateX(-50%);
display: flex; gap: 4px; background: rgba(255,255,255,0.88); padding: 6px 8px;
border-radius: 20px; backdrop-filter: blur(8px); border: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 1px 8px rgba(0,0,0,0.06);
}}
#view-presets button {{
background: rgba(0,0,0,0.03); color: #777; border: none;
padding: 5px 10px; border-radius: 14px; cursor: pointer; font-size: 11px;
transition: all 0.2s;
}}
#view-presets button:hover {{ background: rgba(0,0,0,0.1); color: #222; }}
@media (max-width: 768px) {{
#info-panel {{ max-width: 240px; font-size: 11px; padding: 8px 12px; }}
#cavity-info-panel {{ max-width: 220px; font-size: 10px; padding: 10px; }}
#toolbar {{ gap: 3px; padding: 6px 8px; }}
#toolbar button {{ padding: 5px 10px; font-size: 10px; }}
#view-presets {{ bottom: 68px; }}
}}
</style>
</head>
<body>
<div id="container">
<canvas id="canvas"></canvas>
<div id="loading-overlay">
<div class="spinner"></div>
<div class="loading-text" id="loading-status">加载几何数据...</div>
</div>
<div id="info-panel">
<h3>📐 {stp_filename}</h3>
<div class="info-row"><span class="info-label">顶点</span><span class="info-value" id="info-verts">-</span></div>
<div class="info-row"><span class="info-label">三角面</span><span class="info-value" id="info-faces">-</span></div>
<div class="info-row"><span class="info-label">点云</span><span class="info-value" id="info-points">-</span></div>
<div class="info-row"><span class="info-label">体积</span><span class="info-value" id="info-vol">-</span></div>
</div>
{cavity_html}
<div id="view-presets">
<button onclick="setView('front')" title="前视">前</button>
<button onclick="setView('back')" title="后视">后</button>
<button onclick="setView('left')" title="左视">左</button>
<button onclick="setView('right')" title="右视">右</button>
<button onclick="setView('top')" title="俯视">俯</button>
<button onclick="setView('bottom')" title="仰视">仰</button>
<button onclick="setView('iso')" title="等轴测" style="font-weight:bold;color:#FF9800;">3D</button>
</div>
<div id="toolbar">
<button id="btn-product" class="active" onclick="toggleProduct()">产品</button>
<button id="btn-mold" class="active" onclick="toggleMold()">模具</button>
<button id="btn-parting" class="active" onclick="toggleParting()">分型面</button>
<button id="btn-pointcloud" onclick="togglePointcloud()">点云</button>
<button onclick="toggleWireframe()">线框</button>
<button onclick="resetView()">重置</button>
<button id="splitBtn" class="accent" onclick="splitMold()">分模拆分</button>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import {{ OrbitControls }} from 'three/addons/controls/OrbitControls.js';
const SUMMARY_URL = '{summary_filename}';
const DATA_URL = '{data_filename}';
let productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh;
let productVisible = true, moldVisible = true, partingVisible = true, pointcloudVisible = false;
let isSplit = false, splitAnimId = null;
let sceneBox = null;
let cavityDataGlobal = null;
const renderer = new THREE.WebGLRenderer({{ canvas: document.getElementById('canvas'), antialias: true, alpha: true }});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f2f5);
scene.fog = new THREE.Fog(0xf0f2f5, 500, 3000);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 10000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.12;
controls.minDistance = 1;
controls.maxDistance = 5000;
controls.target.set(0, 0, 0);
function setupLighting() {{
const ambient = new THREE.AmbientLight(0xccccdd, 4);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 7);
keyLight.position.set(1, 1.2, 0.8);
keyLight.castShadow = true;
keyLight.shadow.mapSize.width = 2048;
keyLight.shadow.mapSize.height = 2048;
keyLight.shadow.camera.near = 0.5;
keyLight.shadow.camera.far = 500;
keyLight.shadow.bias = -0.0001;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xccddff, 3);
fillLight.position.set(-0.6, 0.3, -0.4);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xffffff, 4);
rimLight.position.set(0, -0.3, -1);
scene.add(rimLight);
const bottomLight = new THREE.DirectionalLight(0x8899cc, 1.5);
bottomLight.position.set(0, -1, 0.2);
scene.add(bottomLight);
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
const envScene = new THREE.Scene();
envScene.background = new THREE.Color(0xddeeff);
const envMap = pmremGenerator.fromScene(envScene).texture;
scene.environment = envMap;
scene.background = new THREE.Color(0xf0f2f5);
}}
setupLighting();
const axesHelper = new THREE.AxesHelper(50);
scene.add(axesHelper);
const gridHelper = new THREE.GridHelper(400, 40, 0xccccdd, 0xe8e8f0);
scene.add(gridHelper);
function toFlatArray(data) {{
if (!Array.isArray(data)) return [];
if (data.length === 0) return [];
return Array.isArray(data[0]) ? data.flat(Infinity) : data;
}}
function normalizePositions(rawPositions, center) {{
const flat = toFlatArray(rawPositions);
if (!flat.length) return [];
const cx = Number(center[0] || 0), cy = Number(center[1] || 0), cz = Number(center[2] || 0);
const normalized = [];
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) {{
normalized.push(x - cx, y - cy, z - cz);
}}
}}
return normalized;
}}
function computeBounds(rawPositionsList) {{
let minX = Infinity, minY = Infinity, minZ = Infinity;
let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
let hasPoint = false;
for (const raw of rawPositionsList) {{
const flat = toFlatArray(raw);
for (let i = 0; i + 2 < flat.length; i += 3) {{
const x = Number(flat[i]), y = Number(flat[i + 1]), z = Number(flat[i + 2]);
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue;
hasPoint = true;
minX = Math.min(minX, x); minY = Math.min(minY, y); minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); maxZ = Math.max(maxZ, z);
}}
}}
if (!hasPoint) return null;
return {{
center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2],
dimensions: [Math.max(maxX - minX, 1), Math.max(maxY - minY, 1), Math.max(maxZ - minZ, 1)],
}};
}}
function isValidIndexedGeometry(positions, indices) {{
if (!positions || !indices) return false;
if (positions.length < 9 || indices.length < 3) return false;
if (positions.length % 3 !== 0 || indices.length % 3 !== 0) return false;
const vertexCount = positions.length / 3;
for (let i = 0; i < indices.length; i++) {{
const idx = Number(indices[i]);
if (!Number.isFinite(idx) || idx < 0 || idx >= vertexCount) return false;
}}
return true;
}}
function createPBRMaterial(colorHex, opts = {{}}) {{
return new THREE.MeshStandardMaterial({{
color: new THREE.Color(colorHex),
metalness: opts.metalness ?? 0.05,
roughness: opts.roughness ?? 0.35,
transparent: true,
opacity: opts.opacity ?? 0.55,
side: THREE.DoubleSide,
depthWrite: opts.depthWrite ?? true,
}});
}}
function registerInitialPose(mesh) {{
if (!mesh) return;
mesh.userData.initialPosition = mesh.position.clone();
mesh.userData.initialVisible = mesh.visible;
}}
function fitCameraToScene() {{
const objects = [productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].filter(Boolean);
if (!objects.length) return;
sceneBox = new THREE.Box3();
objects.forEach(obj => sceneBox.expandByObject(obj));
if (sceneBox.isEmpty()) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z) || 100;
const distance = Math.max(maxDim * 1.6, 30);
camera.near = Math.max(maxDim / 2000, 0.01);
camera.far = Math.max(maxDim * 200, 10000);
camera.updateProjectionMatrix();
camera.position.set(center.x + distance * 0.7, center.y + distance * 0.7, center.z + distance * 0.8);
controls.target.copy(center);
controls.minDistance = Math.max(maxDim * 0.03, 0.5);
controls.maxDistance = Math.max(maxDim * 30, 5000);
controls.update();
}}
function setView(direction) {{
if (!sceneBox) return;
const center = new THREE.Vector3();
const size = new THREE.Vector3();
sceneBox.getCenter(center);
sceneBox.getSize(size);
const dist = Math.max(size.x, size.y, size.z) * 1.5;
const positions = {{
front: [0, 0, dist],
back: [0, 0, -dist],
left: [-dist, 0, 0],
right: [dist, 0, 0],
top: [0, dist, 0],
bottom: [0, -dist, 0],
iso: [dist * 0.7, dist * 0.7, dist * 0.8],
}};
const pos = positions[direction] || positions.iso;
camera.position.set(center.x + pos[0], center.y + pos[1], center.z + pos[2]);
controls.target.copy(center);
controls.update();
}}
window.setView = setView;
function buildScene(geometryData, cavityData, pointcloudData) {{
cavityDataGlobal = cavityData;
const bbox = geometryData.bounding_box || computeBounds([
pointcloudData?.points,
cavityData?.mold_cavities?.cavity?.vertices,
cavityData?.mold_cavities?.core?.vertices
]) || {{ center: [0, 0, 0], dimensions: [100, 100, 100] }};
const centerOffset = bbox.center || [0, 0, 0];
const width = (bbox.dimensions && bbox.dimensions[0]) || 100;
const height = (bbox.dimensions && bbox.dimensions[1]) || 100;
const depth = (bbox.dimensions && bbox.dimensions[2]) || 100;
const coreRequired = cavityData?.metadata?.core_required !== false;
const maxDim = Math.max(width, height, depth) || 100;
const lods = pointcloudData?.lods;
if (lods && lods["0"] && lods["0"].vertices && lods["0"].faces) {{
const lodGroup = new THREE.LOD();
const lodKeys = Object.keys(lods).sort((a, b) => Number(a) - Number(b));
for (const key of lodKeys) {{
const entry = lods[key];
if (!entry.vertices || !entry.faces || entry.vertices.length === 0 || entry.faces.length === 0) continue;
const productVerts = normalizePositions(entry.vertices, centerOffset);
const productFaces = toFlatArray(entry.faces);
if (productVerts.length < 9 || productFaces.length < 3) continue;
const lodGeo = new THREE.BufferGeometry();
lodGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
lodGeo.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
lodGeo.computeVertexNormals();
const lodMat = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
const lodMesh = new THREE.Mesh(lodGeo, lodMat);
lodMesh.castShadow = true;
lodMesh.receiveShadow = true;
const dist = key === "0" ? 0 : key === "1" ? maxDim * 3 : maxDim * 8;
lodGroup.addLevel(lodMesh, dist);
}}
productMesh = lodGroup;
scene.add(productMesh);
registerInitialPose(productMesh);
}} else if (pointcloudData && pointcloudData.vertices && pointcloudData.faces && pointcloudData.vertices.length > 0 && pointcloudData.faces.length > 0) {{
const productVerts = normalizePositions(pointcloudData.vertices, centerOffset);
const productFaces = toFlatArray(pointcloudData.faces);
if (productVerts.length >= 9 && productFaces.length >= 3) {{
const productGeometry = new THREE.BufferGeometry();
productGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(productVerts), 3));
productGeometry.setIndex(new THREE.BufferAttribute(new Uint32Array(productFaces), 1));
productGeometry.computeVertexNormals();
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.20, opacity: 0.55 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.castShadow = true;
productMesh.receiveShadow = true;
scene.add(productMesh);
registerInitialPose(productMesh);
}}
}}
if (!productMesh && pointcloudData && pointcloudData.points && pointcloudData.points.length > 0) {{
const pointPositions = normalizePositions(pointcloudData.points, centerOffset);
if (pointPositions.length >= 3) {{
const ptGeometry = new THREE.BufferGeometry();
ptGeometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pointPositions), 3));
if (pointcloudData.normals && pointcloudData.normals.length > 0) {{
const normals = new Float32Array(toFlatArray(pointcloudData.normals));
if (normals.length === pointPositions.length) {{
ptGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
}}
}}
const ptMaterial = new THREE.PointsMaterial({{
color: 0xFFFFFF, size: 0.5, sizeAttenuation: true,
transparent: true, opacity: 0.90, blending: THREE.NormalBlending,
depthWrite: false,
}});
pointcloudMesh = new THREE.Points(ptGeometry, ptMaterial);
pointcloudMesh.visible = pointcloudVisible;
scene.add(pointcloudMesh);
registerInitialPose(pointcloudMesh);
}}
}}
if (!productMesh && !pointcloudMesh) {{
const productGeometry = new THREE.BoxGeometry(width * 0.9, height * 0.9, depth * 0.9);
const productMaterial = createPBRMaterial(0xFFFFFF, {{ metalness: 0.0, roughness: 0.25, opacity: 0.65 }});
productMesh = new THREE.Mesh(productGeometry, productMaterial);
productMesh.position.set(0, 0, 0);
scene.add(productMesh);
registerInitialPose(productMesh);
}}
if (cavityData && cavityData.mold_cavities && cavityData.mold_cavities.cavity) {{
const cd = cavityData.mold_cavities.cavity;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const cavityGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
cavityGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
cavityGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
cavityGeometry.computeVertexNormals();
const cavityMaterial = createPBRMaterial(0x4488cc, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
cavityMesh = new THREE.Mesh(cavityGeometry, cavityMaterial);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cavityGeometry, 0x3388bb);
}}
}}
}}
if (!cavityMesh) createSimpleCavity(width, height, depth, centerOffset);
if (coreRequired && cavityData && cavityData.mold_cavities && cavityData.mold_cavities.core) {{
const cd = cavityData.mold_cavities.core;
if (cd.vertices && cd.faces && cd.vertices.length > 0 && cd.faces.length > 0) {{
const coreGeometry = new THREE.BufferGeometry();
const positions = new Float32Array(normalizePositions(cd.vertices, centerOffset));
const indices = new Uint32Array(toFlatArray(cd.faces));
if (isValidIndexedGeometry(positions, indices)) {{
coreGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
coreGeometry.setIndex(new THREE.BufferAttribute(indices, 1));
coreGeometry.computeVertexNormals();
const coreMaterial = createPBRMaterial(0xdd8822, {{ metalness: 0.7, roughness: 0.3, opacity: 0.45, depthWrite: false }});
coreMesh = new THREE.Mesh(coreGeometry, coreMaterial);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, coreGeometry, 0xcc6600);
}}
}}
}}
if (!coreMesh && coreRequired) createSimpleCore(width, height, depth, centerOffset);
const partingGeometry = new THREE.PlaneGeometry(width * 1.2, height * 1.2);
const partingMaterial = new THREE.MeshBasicMaterial({{
color: 0xF44336, transparent: true, opacity: 0.25, side: THREE.DoubleSide, depthWrite: false,
}});
partingMesh = new THREE.Mesh(partingGeometry, partingMaterial);
partingMesh.position.set(centerOffset[0], centerOffset[1], centerOffset[2]);
partingMesh.renderOrder = 2;
scene.add(partingMesh);
registerInitialPose(partingMesh);
if (productMesh) {{
if (productMesh.isLOD) {{
productMesh.traverse(child => {{
if (child.isMesh && child.geometry) {{
addWireframe(child, child.geometry, 0xCCCCCC);
}}
}});
}} else {{
addWireframe(productMesh, productMesh.geometry, 0xCCCCCC);
}}
}}
updateInfoPanel(pointcloudData, cavityData);
fitCameraToScene();
}}
function addWireframe(parent, geometry, colorHex) {{
const wf = new THREE.WireframeGeometry(geometry);
const line = new THREE.LineSegments(wf, new THREE.LineBasicMaterial({{
color: colorHex, transparent: true, opacity: 0.25, depthTest: true, depthWrite: false,
}}));
line.renderOrder = 3;
parent.add(line);
}}
function createSimpleCavity(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0x4488cc, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
cavityMesh = new THREE.Mesh(cGeo, cMat);
cavityMesh.position.set(center[0], center[1], center[2] + halfDepth / 2 + 5);
cavityMesh.renderOrder = 1;
scene.add(cavityMesh);
registerInitialPose(cavityMesh);
addWireframe(cavityMesh, cGeo, 0x3388bb);
}}
function createSimpleCore(width, height, depth, center) {{
const halfDepth = depth / 2;
const cGeo = new THREE.BoxGeometry(width * 1.2, height * 1.2, halfDepth + 10);
const cMat = createPBRMaterial(0xdd8822, {{ metalness: 0.6, roughness: 0.35, opacity: 0.35, depthWrite: false }});
coreMesh = new THREE.Mesh(cGeo, cMat);
coreMesh.position.set(center[0], center[1], center[2] - halfDepth / 2 - 5);
coreMesh.renderOrder = 1;
scene.add(coreMesh);
registerInitialPose(coreMesh);
addWireframe(coreMesh, cGeo, 0xcc6600);
}}
function updateInfoPanel(pointcloudData, cavityData) {{
const verts = pointcloudData?.vertex_count || cavityData?.mold_cavities?.cavity?.vertex_count || '-';
const faces = pointcloudData?.face_count || cavityData?.mold_cavities?.cavity?.face_count || '-';
const pts = pointcloudData?.point_count || '-';
const vol = cavityData?.mold_cavities?.cavity_key_info?.geometric_characteristics?.product_volume || '-';
document.getElementById('info-verts').textContent = typeof verts === 'number' ? verts.toLocaleString() : verts;
document.getElementById('info-faces').textContent = typeof faces === 'number' ? faces.toLocaleString() : faces;
document.getElementById('info-points').textContent = typeof pts === 'number' ? pts.toLocaleString() : pts;
document.getElementById('info-vol').textContent = vol;
const panel = document.getElementById('cavity-info-panel');
if (panel && cavityData) {{
panel.style.display = 'block';
const meta = cavityData.metadata || {{}};
const mfg = cavityData.manufacturing_info || {{}};
const geo = cavityData.mold_cavities?.cavity_key_info?.geometric_characteristics || {{}};
const setVal = (id, val) => {{ const el = document.getElementById(id); if (el) el.textContent = val || 'N/A'; }};
setVal('cp-shrink', meta.shrinkage_rate);
setVal('cp-draft', meta.draft_angle != null ? meta.draft_angle + '°' : null);
setVal('cp-parting', mfg.parting_line_length);
setVal('cp-vol', geo.product_volume);
setVal('cp-weight', geo.product_weight);
setVal('cp-wall', geo.wall_thickness_range);
setVal('cp-material', mfg.mold_material);
setVal('cp-hardness', mfg.mold_hardness);
setVal('cp-finish', mfg.surface_finish);
setVal('cp-cycle', mfg.estimated_cycle_time);
}}
}}
function updateSummaryPanels(summary) {{
const cd = summary.cavity || summary;
const verts = cd?.mold_cavities?.cavity?.vertex_count || '-';
const faces = cd?.mold_cavities?.cavity?.face_count || '-';
const vol = cd?.mold_cavities?.cavity_key_info?.geometric_characteristics?.product_volume || '-';
document.getElementById('info-verts').textContent = typeof verts === 'number' ? verts.toLocaleString() : verts;
document.getElementById('info-faces').textContent = typeof faces === 'number' ? faces.toLocaleString() : faces;
document.getElementById('info-vol').textContent = vol;
const panel = document.getElementById('cavity-info-panel');
if (panel) {{
panel.style.display = 'block';
const meta = cd.metadata || {{}};
const mfg = cd.manufacturing_info || {{}};
const geo = cd.mold_cavities?.cavity_key_info?.geometric_characteristics || {{}};
const setVal = (id, val) => {{ const el = document.getElementById(id); if (el) el.textContent = val || 'N/A'; }};
setVal('cp-shrink', meta.shrinkage_rate);
setVal('cp-draft', meta.draft_angle != null ? meta.draft_angle + '°' : null);
setVal('cp-parting', mfg.parting_line_length);
setVal('cp-vol', geo.product_volume);
setVal('cp-weight', geo.product_weight);
setVal('cp-wall', geo.wall_thickness_range);
setVal('cp-material', mfg.mold_material);
setVal('cp-hardness', mfg.mold_hardness);
setVal('cp-finish', mfg.surface_finish);
setVal('cp-cycle', mfg.estimated_cycle_time);
}}
}}
async function loadData() {{
const statusEl = document.getElementById('loading-status');
try {{
if (SUMMARY_URL) {{
statusEl.textContent = '加载摘要...';
const summaryResp = await fetch(SUMMARY_URL);
if (summaryResp.ok) {{
const summary = await summaryResp.json();
updateSummaryPanels(summary);
}}
}}
statusEl.textContent = '加载几何数据...';
const resp = await fetch(DATA_URL);
if (!resp.ok) throw new Error(`HTTP ${{resp.status}}`);
const data = await resp.json();
statusEl.textContent = '正在构建3D场景...';
await new Promise(r => setTimeout(r, 30));
buildScene(
data.geometry || {{}},
data.cavity || null,
data.pointcloud || null
);
statusEl.textContent = '完成';
document.getElementById('loading-overlay').classList.add('hidden');
}} catch (err) {{
console.error('数据加载失败:', err);
statusEl.textContent = '加载失败: ' + err.message;
statusEl.style.color = '#F44336';
}}
}}
function animate() {{
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}}
loadData().then(() => animate());
window.addEventListener('resize', () => {{
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}});
window.resetView = function() {{
if (splitAnimId) {{ cancelAnimationFrame(splitAnimId); splitAnimId = null; }}
isSplit = false;
const btn = document.getElementById('splitBtn');
if (btn) btn.textContent = '分模拆分';
[productMesh, cavityMesh, coreMesh, partingMesh, pointcloudMesh].forEach(mesh => {{
if (!mesh) return;
if (mesh.userData.initialPosition) mesh.position.copy(mesh.userData.initialPosition);
else mesh.position.set(0, 0, 0);
mesh.visible = mesh.userData.initialVisible !== false;
}});
if (partingMesh && partingMesh.material) {{ partingMesh.material.opacity = 0.25; partingMesh.visible = true; }}
productVisible = true; moldVisible = true; partingVisible = true;
document.getElementById('btn-product').classList.add('active');
document.getElementById('btn-mold').classList.add('active');
document.getElementById('btn-parting').classList.add('active');
fitCameraToScene();
}};
window.toggleWireframe = function() {{
scene.traverse(child => {{ if (child.isMesh) child.material.wireframe = !child.material.wireframe; }});
}};
window.toggleProduct = function() {{
if (!productMesh && !pointcloudMesh) return;
productVisible = !productVisible;
if (productMesh) productMesh.visible = productVisible;
document.getElementById('btn-product').classList.toggle('active', productVisible);
}};
window.toggleMold = function() {{
moldVisible = !moldVisible;
if (cavityMesh) cavityMesh.visible = moldVisible;
if (coreMesh) coreMesh.visible = moldVisible;
document.getElementById('btn-mold').classList.toggle('active', moldVisible);
}};
window.toggleParting = function() {{
partingVisible = !partingVisible;
if (partingMesh) partingMesh.visible = partingVisible;
document.getElementById('btn-parting').classList.toggle('active', partingVisible);
}};
window.togglePointcloud = function() {{
pointcloudVisible = !pointcloudVisible;
if (pointcloudMesh) pointcloudMesh.visible = pointcloudVisible;
document.getElementById('btn-pointcloud').classList.toggle('active', pointcloudVisible);
}};
function getPartingDirection() {{
if (cavityDataGlobal?.metadata?.parting_direction) return cavityDataGlobal.metadata.parting_direction;
if (cavityDataGlobal?.manufacturing_info?.parting_direction) return cavityDataGlobal.manufacturing_info.parting_direction;
if (cavityDataGlobal?.metadata?.is_foam) return 'Z';
return 'Z';
}}
window.splitMold = function() {{
if (!cavityMesh && !coreMesh) return;
isSplit = !isSplit;
const btn = document.getElementById('splitBtn');
btn.textContent = isSplit ? '合模' : '分模拆分';
const dir = getPartingDirection();
let splitDist, axis;
if (dir === 'Z') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).z : 100) * 0.4; axis = 'z'; }}
else if (dir === 'Y') {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).y : 100) * 0.4; axis = 'y'; }}
else {{ splitDist = (sceneBox ? sceneBox.getSize(new THREE.Vector3()).x : 100) * 0.4; axis = 'x'; }}
const partingTargetOpacity = isSplit ? 0 : 0.25;
const cavityStart = cavityMesh ? cavityMesh.position[axis] : 0;
const coreStart = coreMesh ? coreMesh.position[axis] : 0;
const partingStartOpacity = partingMesh ? partingMesh.material.opacity : 0.25;
const cavityTarget = isSplit ? splitDist : 0;
const coreTarget = isSplit ? -splitDist : 0;
const duration = 900;
const startTime = performance.now();
if (splitAnimId) cancelAnimationFrame(splitAnimId);
function animateSplit(now) {{
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
const ease = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
if (cavityMesh) cavityMesh.position[axis] = cavityStart + (cavityTarget - cavityStart) * ease;
if (coreMesh) coreMesh.position[axis] = coreStart + (coreTarget - coreStart) * ease;
if (partingMesh) {{
partingMesh.material.opacity = partingStartOpacity + (partingTargetOpacity - partingStartOpacity) * ease;
partingMesh.visible = !(isSplit && t >= 1);
}}
if (t < 1) splitAnimId = requestAnimationFrame(animateSplit);
else splitAnimId = null;
}}
splitAnimId = requestAnimationFrame(animateSplit);
}};
</script>
</body>
</html>"""
return html_content
def _build_cavity_info_panel_template(self) -> str:
"""构建型腔信息面板 — 由JS动态填充,这里放置容器"""
return """
<div id="cavity-info-panel">
<h3>🔧 关键工艺参数</h3>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">模具参数</strong>
</div>
<div class="metric"><span class="metric-label">收缩率</span><span class="metric-value" id="cp-shrink">-</span></div>
<div class="metric"><span class="metric-label">拔模角</span><span class="metric-value" id="cp-draft">-</span></div>
<div class="metric"><span class="metric-label">分型线长度</span><span class="metric-value" id="cp-parting">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">几何特性</strong>
</div>
<div class="metric"><span class="metric-label">产品体积</span><span class="metric-value" id="cp-vol">-</span></div>
<div class="metric"><span class="metric-label">产品重量</span><span class="metric-value" id="cp-weight">-</span></div>
<div class="metric"><span class="metric-label">壁厚范围</span><span class="metric-value" id="cp-wall">-</span></div>
<div style="margin: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 5px;">
<strong style="color: #FF9800;">制造要求</strong>
</div>
<div class="metric"><span class="metric-label">模仁材料</span><span class="metric-value" id="cp-material">-</span></div>
<div class="metric"><span class="metric-label">硬度</span><span class="metric-value" id="cp-hardness">-</span></div>
<div class="metric"><span class="metric-label">表面光洁度</span><span class="metric-value" id="cp-finish">-</span></div>
<div class="metric"><span class="metric-label">预估周期</span><span class="metric-value" id="cp-cycle">-</span></div>
</div>
"""
def generate_3d_viewer_data(
self,
geometry_data: Dict[str, Any],
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""生成companion JSON数据文件内容,支持多级LOD"""
pc = dict(pointcloud_data) if pointcloud_data else {}
if lod_data and lod_data.get("lods"):
pc["lods"] = lod_data["lods"]
data = {
"version": "4.0.0",
"generated_at": datetime.now().isoformat(),
"geometry": geometry_data,
"cavity": cavity_data,
"pointcloud": pc if pc else pointcloud_data,
}
return data
@staticmethod
def _strip_heavy_geometry(cavity_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""从型腔数据中移除顶点/面数组,仅保留元数据和统计信息"""
if not cavity_data:
return None
light: Dict[str, Any] = {}
for key in ("metadata", "manufacturing_info", "scheme_id", "best_scheme_id", "cavity_key_info"):
if key in cavity_data:
light[key] = cavity_data[key]
mc = cavity_data.get("mold_cavities", {})
if mc:
light_mc: Dict[str, Any] = {}
if "cavity_key_info" in mc:
light_mc["cavity_key_info"] = mc["cavity_key_info"]
for part_name in ("cavity", "core"):
part = mc.get(part_name, {})
if part:
light_mc[part_name] = {
"vertex_count": part.get("vertex_count", 0),
"face_count": part.get("face_count", 0),
}
light["mold_cavities"] = light_mc
return light
def generate_3d_viewer_summary(
self,
geometry_data: Dict[str, Any],
cavity_data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""生成轻量摘要JSON — 含几何摘要和型腔元数据,不含网格顶点数据"""
return {
"version": "4.1.0",
"generated_at": datetime.now().isoformat(),
"geometry": geometry_data,
"cavity": self._strip_heavy_geometry(cavity_data),
}
def save_html_file(self, html_content: str, filename: str) -> str:
"""保存HTML文件到磁盘"""
try:
file_path = self.output_dir / filename
file_path.write_text(html_content, encoding='utf-8')
logger.info(f"HTML文件保存成功: {file_path}")
return str(file_path)
except Exception as e:
logger.error(f"保存HTML文件失败: {e}")
raise
def save_data_file(self, data_content: Dict[str, Any], filename: str) -> str:
"""保存JSON数据文件到磁盘 — 使用orjson高速序列化"""
try:
file_path = self.output_dir / filename
file_path.write_bytes(_json_dumps(data_content))
logger.info(f"数据文件保存成功: {file_path} (orjson={_JSON_FAST})")
return str(file_path)
except Exception as e:
logger.error(f"保存数据文件失败: {e}")
raise
def generate_and_save_visualization(
self,
geometry_data: Dict[str, Any],
stp_filename: str,
cavity_data: Optional[Dict[str, Any]] = None,
pointcloud_data: Optional[Dict[str, Any]] = None,
suffix: Optional[str] = None,
lod_data: Optional[Dict[str, Any]] = None,
) -> str:
"""生成并保存可视化HTML + 摘要JSON + 完整数据JSON。返回HTML文件路径(向后兼容)"""
try:
base_stem = Path(stp_filename).stem.replace(" ", "_")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
suffix_part = f"_{suffix}" if suffix else ""
base_name = f"mold_{base_stem}{suffix_part}_{ts}"
html_filename = f"{base_name}.html"
summary_filename = f"{base_name}_summary.json"
data_filename = f"{base_name}_data.json"
summary_content = self.generate_3d_viewer_summary(geometry_data, cavity_data)
self.save_data_file(summary_content, summary_filename)
data_content = self.generate_3d_viewer_data(
geometry_data, cavity_data, pointcloud_data, lod_data=lod_data
)
self.save_data_file(data_content, data_filename)
html_content = self.generate_3d_viewer_html(stp_filename, data_filename, summary_filename)
html_file_path = self.save_html_file(html_content, html_filename)
return html_file_path
except Exception as e:
logger.error(f"生成可视化文件失败: {e}")
raise
+17
View File
@@ -0,0 +1,17 @@
# utils/logger.py
import logging
import sys
def setup_logging():
"""设置日志配置"""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
def get_logger(name: str):
"""获取日志器"""
return logging.getLogger(name)