后端模块拆分
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# config package
|
||||
# 配置模块包
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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("已取消迁移")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "权限已删除"}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
# Utils 模块
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user