Compare commits

..

36 Commits

Author SHA1 Message Date
Codex
8d6eed4583 feat(基础资料库): 移除系统名称、优化花色筛选和表单布局
- 全栈移除 SubstrateSystemNameOption(字典管理、表单、API、数据库)
- 花色改为单选 + 自动搜索下拉,排除五金油漆/漆面颜色/五金漆面色
- 基材名称自动生成格式:尺寸+环保等级+材质+花色+贴面
- 材质改为纯下拉选择,贴面始终显示
- 花色框体与 Select 组件宽度/高度统一
- 厚度规格字典只保留厚度数值+排序值
2026-04-11 18:05:17 +08:00
Codex
c1606f9af6 merge: sync main (5945858) — adopt assembly/board rules, keep material library UI and intake 2026-04-11 13:50:35 +08:00
Codex
6eaa4a0797 chore: stash current material library changes before merge 2026-04-11 13:38:14 +08:00
Codex
ef8c961626 feat(基础资料库): 实现 5 字段唯一约束收口
## 5 字段唯一约束最终方案

createSubstrate 新增合并逻辑:

1. **完全重复** (相同 5 字段 + 相同花色) → 拒绝,报错「基材库已有相同规格的基材,不可重复创建」
2. **花色不同** (相同 5 字段 + 不同花色) → 合并新花色到已有基材,重建名称
3. **规格不同** (5 字段不同) → 新建基材,自动编码 BC-XXXX

5 字段 = categoryId + environmentGradeId + thicknessSpecId + surfaceOptionId + systemNameOptionId
实木 (无 surfaceOptionId) 按 null 处理,纳入 5 字段匹配

## 验证

- TypeScript 编译: 0 errors
- 基础资料库测试: 48/48 passed
2026-04-11 11:01:14 +08:00
Codex
b101c5a634 merge: sync main (8c4ad11) into codex/xuxiake-packaging 2026-04-11 10:46:43 +08:00
WuYongJie
8c4ad11092 feat(首页工作台): 回归工作台首页并统一主题交互 2026-03-31 14:48:52 +08:00
WuYongJie
2b6894528c docs(协作流程): 增加高频协作五条铁律 2026-03-31 11:13:55 +08:00
WuYongJie
a8e0adc69b docs(协作流程): 补充主线同步与同事本地AGENTS文档 2026-03-31 11:01:14 +08:00
WuYongJie
8d1f86756d chore(协作流程): 收口团队协作规则与主线清理机制
为新平台主链补齐团队协作模式、模块边界、任务单和提测清单。

新增本地清理脚本,移除误纳入仓库的 npm 缓存日志,并把清理后 type-check 失效的问题收回到脚本层修复。
2026-03-31 10:23:56 +08:00
WuYongJie
f14df4a24b feat(新平台): 提交开放产品主链与装配语义能力
- 新增资产中心、自动组合、草稿组、入库与轻仓库一期骨架

- 接入 Panel 装配语义保存、预览与步骤生成测试

- 更新包装方案同事交接文档与非程序员操作手册
2026-03-31 09:37:13 +08:00
WuYongJie
be43d12051 perf(孔位引导): 收口板件引导线静态分组计算
- 将孔位板件分组与标签聚合前移到静态计算阶段
- 减少 requestAnimationFrame 帧内对象创建和重复聚合
- 补充板件别名归一与引导线分组测试
2026-03-26 10:30:02 +08:00
WuYongJie
1ef169218a fix(登录): 修复登录后失效链接跳转
- 服务端统一解析登录后的安全跳转地址
- SKU 链接失效时自动回退到可访问的产品页面
- 补充登录跳转路由与重定向服务测试
2026-03-26 10:29:46 +08:00
WuYongJie
cd1b1f1903 fix(装配编辑器): 修复材质保存与详情回显链路
- 拆分材质保存和步骤保存入口

- 补齐板件材质到真实模型节点的映射与回显

- 收敛装配页入口并稳定 hydration 首屏渲染
2026-03-26 10:16:43 +08:00
WuYongJie
127b33fa5c feat: expand assembly workflow and common components 2026-03-24 14:18:20 +08:00
WuYongJie
9513bf01f8 同步当前项目源码并收口装配播放链路 2026-03-13 20:13:07 +08:00
WuYongJie
0adce76c5c feat: 完善孔位检测系统和ZIP上传功能 2026-01-31 17:01:05 +08:00
WuYongJie
0938990faf feat: 实现苹果风格视觉系统与文档重构 2026-01-29 11:55:38 +08:00
WuYongJie
62e9dbb275 fix: 优化 ESLint 配置,将严格规则降级为警告 2025-12-23 09:49:00 +08:00
WuYongJie
144a5fc944 fix: 修复 HomeClient 和 ProductListItem 类型兼容性问题 2025-12-22 18:29:19 +08:00
WuYongJie
8a88fcde10 添加服务状态和资源监控组件 2025-12-22 18:23:14 +08:00
WuYongJie
6c954f6e57 完成 Phase 10 生产就绪: 更新备份脚本支持 Docker, 保留策略改为5份, 创建完成报告 2025-12-22 18:19:02 +08:00
WuYongJie
9d58c6f707 feat: 添加生产环境 Docker 配置和配置验证脚本
- 创建 docker-compose.prod.yml 生产环境配置
  - 添加资源限制(CPU、内存)
  - 添加健康检查配置
  - 优化日志配置
  - 添加网络隔离
- 创建 validate-config.ps1 和 validate-config.sh 配置验证脚本
  - 验证环境变量配置
  - 验证端口可用性
  - 验证依赖服务连接
  - 验证开发环境和 Docker
2025-12-22 18:00:55 +08:00
WuYongJie
6860ba010f feat: 实现备份保留策略并更新文档
- 在 backup-all.ps1 和 backup-all.sh 中添加备份保留策略
- 默认保留最近 7 天的备份,自动删除过期备份
- 添加备份日志记录功能
- 更新 README.md 添加生产部署说明
- 更新项目状态为 Phase 10 进行中(95% 完成)
2025-12-22 17:52:23 +08:00
WuYongJie
5a9482bc93 feat: 实现包装方案功能并修复所有类型错误
- 新增 PackagingPlan 数据库模型和迁移
- 实现包装方案 API (GET/POST/PUT/DELETE)
- 创建 PackagingEditor 组件用于管理包装方案
- 在产品详情页集成包装方案标签页
- 修复 API 路由 params Promise 类型适配 (Next.js 16)
- 修复脚本文件中 SKU ownerId 字段缺失问题
- 修复 jwt.ts、performance.ts、VirtualList.tsx 类型错误
- 修复测试文件导入路径和类型注解
- 所有 TypeScript 类型检查通过
2025-12-22 17:43:28 +08:00
WuYongJie
2f7344be05 feat: 完善分享链接管理UI功能 2025-12-22 16:30:08 +08:00
WuYongJie
75db836d29 feat(3D核心功能): 完成所有必需实现任务
- 文件上传验证: ModelUploader组件增强、上传API错误处理
- GLTF解析器: 增强节点提取和元数据
- 板材编辑: PanelEditor客户端验证 + panels API服务端验证
- 装配步骤编辑: 修复未使用代码,添加预览按钮
- 装配动画播放: AssemblyPlayer步骤导航边界检查优化
- 分享链接: 完整CRUD API(创建、获取、删除),含验证和过期检查

跳过可选测试任务以加快MVP开发
2025-12-22 16:18:13 +08:00
WuYongJie
6735ceda3e feat: 实现装配步骤编辑器功能
- 新增 AssemblyStepEditor 组件,支持拖拽排序、添加/删除/复制步骤
- 新增装配步骤 API (GET/PUT/POST)
- 新增装配编辑页面,左右分栏布局(3D预览+步骤编辑)
- 支持编辑步骤标题、说明、部件选择、动画参数
- 支持设置目标变换(位置、旋转、缩放)
- 安装 @dnd-kit 拖拽库依赖
2025-12-22 15:26:52 +08:00
WuYongJie
dda4365e7e feat: Phase 3 权限系统 - 权限检查和前端控制
- 权限检查函数库 (permissions.ts)
  - 用户管理权限检查
  - 产品管理权限检查
  - SKU 管理权限检查
  - 反馈管理权限检查
  - 分享功能权限检查
  - 系统管理权限检查

- 增强版认证中间件 (auth-middleware.ts)
  - 集成用户完整信息查询
  - 支持管理关系验证
  - 用户状态检查

- 前端权限 Hook (useAuth.ts)
  - useAuth: 认证状态管理
  - usePermissions: 权限检查

- 权限控制组件
  - PermissionGuard: 通用权限守卫
  - AdminGuard: 管理员守卫
  - DepartmentManagerGuard: 部门管理员守卫
  - TeamLeaderGuard: 组长守卫
  - CreatorGuard: 创建者守卫
  - RequireAuth: 路由认证保护
  - RoleBadge: 角色徽章显示
2025-12-12 21:57:43 +08:00
WuYongJie
20fa3b5809 docs: 添加 Phase 2 测试指南和更新文档
- 添加快速测试指南
- 更新 README 进度
- 更新文档导航
2025-12-12 21:53:10 +08:00
WuYongJie
faff54449b feat: 完成 Phase 2 - 认证系统实现
- 系统初始化:创建默认超级管理员
- 密码管理:bcrypt 加密、强度验证
- JWT Token:生成、验证、解析
- 认证中间件:API 路由保护
- 注册 API:邮箱/姓名唯一性验证
- 登录 API:支持邮箱或姓名登录、智能提示
- 首次登录强制修改 API
- 登录页面:Apple HIG 2025 风格
- 首次登录修改页面:实时密码强度验证
- 环境变量:JWT 配置

默认超级管理员:
- 邮箱: admin@system.local
- 姓名: SuperAdmin
- 密码: Admin@123456
- 首次登录后强制修改
2025-12-12 21:51:56 +08:00
WuYongJie
3d2014db37 feat: Phase 1 完成 - 数据库模型重构
- 更新 User 模型:添加 name、isFirstLogin、role 枚举、team 关联
- 创建 Department 模型:支持层级结构和部门负责人
- 创建 Team 模型:小组管理和组长关联
- 更新 Product 模型:添加 createdBy、collaborators、visibility、软删除
- 更新 ProductSKU 模型:添加 ownerId、transferHistory、软删除
- 创建 Feedback 模型:问题反馈系统
- 创建 ActivityLog 模型:操作日志记录
- 添加 6 种角色枚举:SUPER_ADMIN、ADMIN、DEPARTMENT_MANAGER、TEAM_LEADER、DESIGNER、VIEWER
- 添加用户状态枚举:ACTIVE、INACTIVE、SUSPENDED
- 运行数据库迁移并生成 Prisma Client
- 更新文档:TODO.md、PHASE1_COMPLETE.md
2025-12-12 21:45:58 +08:00
WuYongJie
ff41839238 feat: 实现Apple风格UI系统和产品上传功能
- 完整实现Apple HIG 2025设计系统
- 添加Modal弹窗组件(毛玻璃效果、流畅动画)
- 添加FileUpload文件上传组件(拖拽、批量上传、进度显示)
- 实现产品上传功能(支持DWG/GLB/GLTF文件)
- 优化产品列表展示(可折叠的产品+SKU结构)
- 实现Tab切换功能(3D模型管理、五金件、BOM、文档)
- 修复主题系统hydration错误
- 优化按钮和间距,符合Apple设计规范
- 添加分享页面的Apple风格重设计
- 创建测试数据脚本
2025-12-12 20:16:41 +08:00
WuYongJie
0db3c07dfb 添加多样化演示数据和模型更新脚本
✨ 新功能:
- 创建多样化的演示产品(办公椅、餐桌、书架)
- 添加 update-demo-models.js 脚本,支持更丰富的演示数据
- 每个产品类别都有专门的装配步骤和板件信息
- 随机生成访问量,模拟真实使用场景

📦 演示数据:
- 现代办公椅:6个装配步骤,4个板件
- 实木餐桌:4个装配步骤,5个板件
- 北欧书架:4个装配步骤,6个板件
- 保持向后兼容的 demo123 链接

🔧 脚本命令:
- npm run demo:update - 更新演示模型
- npm run demo:refresh - 推送数据库并更新模型
- 支持多种家具类别的自动化数据生成

🎯 用户体验:
- 产品管理页面显示多样化的产品列表
- 每个产品都有独特的分享链接
- 真实的统计数据和访问量显示
2025-12-11 17:14:55 +08:00
WuYongJie
c98c0519c8 完善用户界面和3D模型展示功能
✨ 新功能:
- 现代化主页设计,包含实时系统统计
- 完整的产品管理页面,显示产品列表和统计信息
- 改进的分享页面界面,支持全屏模式和更好的用户体验
- 增强的3D模型查看器,包含加载状态和错误处理
- 操作提示和交互指导

🎨 界面改进:
- 渐变背景和现代化卡片设计
- 响应式布局和移动端适配
- 统一的颜色主题和视觉风格
- 更好的按钮和交互效果

🔧 技术优化:
- 修复Next.js 16配置问题
- 优化Turbopack配置
- 改进图像配置和远程模式
- 更好的错误处理和用户反馈
2025-12-11 17:11:35 +08:00
WuYongJie
97a34af406 添加演示数据脚本和改进计划文档
- 新增 scripts/create-demo-data.js 演示数据创建脚本
- 添加 npm run demo:setup 和 demo:create 命令
- 创建下一步改进计划文档
- 更新 README 添加演示数据使用说明
- 完善项目功能和使用流程
2025-12-11 17:03:07 +08:00
WuYongJie
9859afc4aa 初始化3D家具展示系统项目
- 完整的Next.js 14 + TypeScript项目架构
- 集成Three.js和GSAP用于3D渲染和动画
- Prisma ORM + PostgreSQL数据库
- Docker容器化部署支持
- 本地缓存优化系统
- 中文界面和注释
- 移动端响应式设计
- 健康检查和监控功能
2025-12-11 16:42:59 +08:00
6012 changed files with 224587 additions and 965876 deletions

View File

@ -0,0 +1,135 @@
---
description: 3D 工程师规范 - Three.js 渲染、模型处理、资源管理
alwaysApply: false
---
# 3D 工程师规范
## 一、技术栈
| 库 | 版本 | 用途 |
|------|------|------|
| Three.js | 0.182.0 | 3D 渲染核心 |
| @react-three/fiber | 9.5.0 | React 集成 |
| @react-three/drei | 10.7.7 | 常用工具 |
| GSAP | - | 动画 |
---
## 二、目录结构
```
src/components/3d/
├── Scene.tsx # 主场景
├── ModelViewer.tsx # 模型查看器
├── AnimationEngine.ts # 动画引擎
└── controls/ # 控制器
src/lib/three/
├── loaders/ # 加载器
├── materials/ # 材质
└── utils/ # 工具函数
```
---
## 三、资源清理(必须)
组件卸载时必须清理所有资源:
```typescript
public dispose(): void {
// 1. 停止动画循环
if (this.animationId) {
cancelAnimationFrame(this.animationId)
}
// 2. 移除事件监听
window.removeEventListener('resize', this.handleResize)
// 3. 深度清理场景对象
this.scene.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
this.disposeMaterial(obj.material)
}
})
// 4. 清理控制器和渲染器
this.controls?.dispose()
this.renderer?.dispose()
this.renderer?.forceContextLoss()
}
private disposeMaterial(material: THREE.Material | THREE.Material[]): void {
const materials = Array.isArray(material) ? material : [material]
for (const mat of materials) {
// 清理所有贴图
;['map', 'normalMap', 'roughnessMap', 'metalnessMap'].forEach((key) => {
const texture = mat[key]
if (texture) texture.dispose()
})
mat.dispose()
}
}
```
---
## 四、GSAP 动画清理
```typescript
dispose(): void {
if (this.currentTimeline) {
this.currentTimeline.kill()
this.currentTimeline = null
}
if (this.scene) {
this.scene.traverse((object) => {
gsap.killTweensOf(object)
gsap.killTweensOf(object.position)
gsap.killTweensOf(object.rotation)
gsap.killTweensOf(object.scale)
})
}
}
```
---
## 五、性能优化
### LOD 策略
根据距离切换模型精度
### 内存管理
- 及时释放 geometry、material、texture
- 避免在循环中创建新对象
### 渲染优化
- 使用 `React.memo` 包装组件
- 避免不必要的重渲染
---
## 六、模型存储
```
public/uploads/models/
├── demo/ # 演示模型
└── {productId}/ # 按产品ID组织
├── original/ # 原始上传文件(DWG等)
└── model.glb # 转换后的 3D 模型
```
---
## 七、禁止事项
```
❌ 组件卸载时不清理资源
❌ 不清理事件监听器
❌ 不停止 GSAP 动画
❌ 在循环中创建新对象
```

90
.cursor/rules/autocad.mdc Normal file
View File

@ -0,0 +1,90 @@
---
description: C#/AutoCAD 工程师规范 - AutoCAD 插件开发
alwaysApply: false
---
# C#/AutoCAD 工程师规范
## 一、项目结构
```
autocad-plugin/
├── FurnitureExporter/ # 主项目
│ ├── Commands/ # AutoCAD 命令
│ ├── Models/ # 数据模型
│ ├── Services/ # 业务服务
│ └── Utils/ # 工具类
├── build.ps1 # 构建脚本
└── build-installer.ps1 # 安装包脚本
```
---
## 二、构建命令
```bash
# 一键构建
powershell -File .\build.ps1
# 生成安装包
powershell -File .\build-installer.ps1
# 手动构建(在 FurnitureExporter 目录)
dotnet build -c Release
```
---
## 三、AutoCAD API 规范
### 命令定义
```csharp
[CommandMethod("EXPORTFURNITURE")]
public void ExportFurniture()
{
var doc = Application.DocumentManager.MdiActiveDocument;
var ed = doc.Editor;
// ...
}
```
### 事务处理
```csharp
using (var tr = doc.TransactionManager.StartTransaction())
{
// 操作代码
tr.Commit();
}
```
---
## 四、输出格式
### JSON 结构
```json
{
"products": [
{
"modelNo": "ABC123",
"name": "产品名称",
"panels": [
{
"name": "板件1",
"geometry": {...},
"holes": [...]
}
]
}
]
}
```
---
## 五、注意事项
1. **兼容性**:支持 AutoCAD 2020+
2. **错误处理**:所有 AutoCAD API 调用需要 try-catch
3. **资源释放**:及时释放非托管资源
4. **性能**:大量数据处理使用后台线程

146
.cursor/rules/backend.mdc Normal file
View File

@ -0,0 +1,146 @@
---
description: 后端工程师规范 - API、Service、Repository 分层架构
alwaysApply: false
---
# 后端工程师规范
## 一、分层架构(必须遵循)
```
API Route → Service → Repository → Database
↓ ↓
Cache DTO
```
---
## 二、API 路由规范
### 正确写法
```typescript
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const data = await xxxService.getXxx(ctx)
return jsonSuccess(data)
})
}
```
### 禁止写法
```typescript
// ❌ 禁止在 API 中直接写 Prisma
export async function GET(request: NextRequest) {
const data = await prisma.xxx.findMany() // 禁止!
}
```
---
## 三、Service 层规范
### 职责
1. 调用 Repository 获取数据
2. 业务逻辑处理
3. 权限检查
4. 缓存读写
5. 转换为 DTO
### 模板
```typescript
async getXxx(ctx: ServiceContext): Promise<XxxDTO> {
// 1. 检查缓存
const cached = await this.cache.get(CacheKeys.xxx(id))
if (cached) return cached
// 2. 调用 Repository
const entity = await this.repository.findById(id)
// 3. 权限检查
this.checkPermission(entity, ctx.user)
// 4. 转换 DTO
const dto = toXxxDTO(entity)
// 5. 写入缓存
await this.cache.set(CacheKeys.xxx(id), dto, CACHE_TTL.MEDIUM)
return dto
}
```
---
## 四、Repository 层规范
### 职责
只负责数据访问,不含业务逻辑
### 模板
```typescript
async findById(id: string): Promise<Entity | null> {
return this.prisma.xxx.findUnique({ where: { id, deletedAt: null } })
}
```
---
## 五、必须使用的工具
| 工具 | 用途 | 示例 |
|------|------|------|
| `handleRequest` | 统一请求处理 | `handleRequest(request, async (ctx) => {...})` |
| `jsonSuccess` | 成功响应 | `return jsonSuccess(data)` |
| `jsonError` | 错误响应 | `return jsonError(error)` |
| `AppError` | 抛出错误 | `throw new AppError(ErrorCode.XXX_NOT_FOUND)` |
| `CacheKeys` | 缓存键管理 | `CacheKeys.product(id)` |
| `$transaction` | 事务处理 | `prisma.$transaction(async (tx) => {...})` |
---
## 六、API 响应格式
| 操作类型 | 格式 |
|----------|------|
| 列表查询 | `{ items: [], pagination: { page, limit, total, hasMore } }` |
| 详情/创建/更新 | `{ data: {...} }` |
| 删除 | `{ success: true }` |
| 错误 | `{ error: { code, message } }` |
---
## 七、目录结构
```
src/server/
├── core/ # 响应、错误码、常量
├── repositories/ # 数据访问层
├── services/ # 业务逻辑层
├── dto/ # 数据传输对象
├── validators/ # 输入验证(Zod)
└── cache/ # 缓存层
```
---
## 八、事务处理
多表操作必须使用事务:
```typescript
const result = await prisma.$transaction(async (tx) => {
const product = await tx.product.create({ data: {...} })
const panels = await tx.panel.createMany({ data: [...] })
return { product, panels }
})
```
---
## 九、缓存策略
| 数据类型 | TTL | 失效策略 |
|---------|-----|---------|
| 用户信息 | 5分钟 | 更新时失效 |
| 产品详情 | 5分钟 | 更新/删除时失效 |
| 产品列表 | 2分钟 | 任何变更失效 |

View File

@ -0,0 +1,84 @@
---
description: 规则审查员检查清单
alwaysApply: false
---
# 规则审查员检查清单
## 一、后端检查
### 分层架构
- [ ] API 是否只调用 Service?
- [ ] Service 是否调用 Repository?
- [ ] Repository 是否只负责数据访问?
### 统一处理
- [ ] 是否使用 `handleRequest`?
- [ ] 是否使用 `jsonSuccess/jsonError`?
### 错误处理
- [ ] 是否使用 `AppError + ErrorCode`?
- [ ] 禁止 `throw new Error()`
### 事务处理
- [ ] 多表操作是否使用 `$transaction`?
---
## 二、前端检查
### 元组件
- [ ] 是否使用 `@/components/ui/` 元组件?
- [ ] 禁止原生 `<button>` / `<input>` / `<select>`
### 颜色规范
- [ ] 是否使用 `text-apple-label-*`?
- [ ] 是否使用 `bg-apple-bg-*`?
- [ ] 禁止 `text-gray-*` / `bg-gray-*`
### 圆角规范
- [ ] 是否使用 `rounded-apple-md/lg`?
---
## 三、审查报告格式
```
## 审查结果
### 通过项 ✅
- 使用了 Service 层
- 使用了 Button 元组件
### 违规项 ❌
- API 中直接使用 prisma(第 15 行)
→ 修复:将数据库查询移到 Repository 层
- 使用了 text-gray-500(第 23 行)
→ 修复:改为 text-apple-label-secondary
### 评分:X/10
```
---
## 四、快速对照表
### 后端
| 正确 | 错误 |
|------|------|
| `await xxxService.getXxx()` | `await prisma.xxx.findMany()` 在 API 中 |
| `handleRequest(request, ...)` | 自己写 try-catch |
| `jsonSuccess(data)` | `NextResponse.json({...})` |
| `AppError(ErrorCode.XXX)` | `throw new Error('xxx')` |
| `$transaction(...)` | 多个独立 prisma 调用 |
### 前端
| 正确 | 错误 |
|------|------|
| `<Button>` | `<button>` |
| `text-apple-label-*` | `text-gray-*` |
| `bg-apple-bg-*` | `bg-gray-*` |
| `rounded-apple-md/lg` | `rounded-lg/xl` |

View File

@ -0,0 +1,65 @@
---
description: 核心规则摘要 - 必须在写代码前阅读并遵循
alwaysApply: true
---
# 核心规则摘要
> **这是所有规则的浓缩版,必须 100% 遵循!**
---
## 一、后端必遵循(5 条)
| # | 规则 | 正确示例 | 错误示例 |
|---|------|----------|----------|
| 1 | **分层架构**:API → Service → Repository | `await xxxService.getXxx()` | `await prisma.xxx.findMany()` |
| 2 | **统一请求处理**:使用 `handleRequest` | `handleRequest(request, async (ctx) => {...})` | 自己写 try-catch |
| 3 | **统一响应**:使用 `jsonSuccess/jsonError` | `return jsonSuccess(data)` | `return NextResponse.json({...})` |
| 4 | **错误抛出**:使用 `AppError + ErrorCode` | `throw new AppError(ErrorCode.XXX)` | `throw new Error('xxx')` |
| 5 | **多表操作**:必须使用 `$transaction` | `prisma.$transaction(async (tx) => {...})` | 多个独立的 prisma 调用 |
---
## 二、前端必遵循(5 条)
| # | 规则 | 正确示例 | 错误示例 |
|---|------|----------|----------|
| 1 | **使用元组件** | `<Button>` `<Input>` `<Modal>` | `<button>` `<input>` `<div className="modal">` |
| 2 | **文本颜色** | `text-apple-label-primary/secondary` | `text-gray-900` `text-gray-500` |
| 3 | **背景颜色** | `bg-apple-bg-primary/secondary` | `bg-gray-100` `bg-white` |
| 4 | **品牌色** | `#008D74` 或 `text-apple-brand` | `blue-500` `green-600` |
| 5 | **圆角** | `rounded-apple-md/lg` | `rounded-lg` `rounded-xl` |
---
## 三、禁止事项(绝对不能做)
```
❌ API 中直接写 prisma.xxx.findMany() / create() / update()
❌ 使用 text-gray-* / bg-gray-* / shadow-* 类名
❌ 直接写原生 HTML 元素:<button> <input> <select>
❌ 组件内直接 fetch() 请求数据
❌ 不使用 AppError 直接 throw new Error()
```
---
## 四、文件路径速查
| 用途 | 路径 |
|------|------|
| 元组件 | `@/components/ui/` |
| 后端服务 | `src/server/services/` |
| 数据仓库 | `src/server/repositories/` |
| 错误码 | `src/server/core/errors.ts` |
| 响应工具 | `src/server/core/response.ts` |
---
## 五、详细规范位置
如需更多细节,查看以下文件:
- 完整架构规范:`.cursor/rules/backend.mdc`
- 视觉标准:`docs/设计规范/苹果风格视觉标准.md`
- 开发检查清单:`.cursor/rules/checklist.mdc`

146
.cursor/rules/frontend.mdc Normal file
View File

@ -0,0 +1,146 @@
---
description: 前端工程师规范 - 元组件、苹果风格视觉标准
alwaysApply: false
---
# 前端工程师规范
## 一、元组件体系(必须使用)
| 元组件 | 路径 | 用途 |
|--------|------|------|
| `Button` | `@/components/ui/Button` | 所有按钮 |
| `Input` | `@/components/ui/Input` | 所有输入框 |
| `Textarea` | `@/components/ui/Input` | 多行输入 |
| `Modal` | `@/components/ui/Modal` | 所有弹窗 |
| `Card` | `@/components/ui/Card` | 所有卡片 |
| `Toast` | `@/components/ui/Toast` | 所有提示 |
| `Select` | `@/components/ui/Select` | 所有下拉框 |
| `Badge` | `@/components/ui/Badge` | 所有标签徽章 |
### Toast 使用
```tsx
import { useToast, ToastContainer } from '@/components/ui/Toast'
const toast = useToast()
toast.success('操作成功')
toast.error('操作失败')
// JSX 中添加容器
<ToastContainer toasts={toast.toasts} onClose={toast.removeToast} />
```
---
## 二、色彩系统
### 文本颜色
| 用途 | Tailwind 类 |
|------|-------------|
| 主文本 | `text-apple-label-primary` |
| 次文本 | `text-apple-label-secondary` |
| 辅助文本 | `text-apple-label-tertiary` |
### 背景颜色
| 用途 | Tailwind 类 |
|------|-------------|
| 主背景 | `bg-apple-bg-primary` |
| 次级背景 | `bg-apple-bg-secondary` |
| 三级背景 | `bg-apple-bg-tertiary` |
### 品牌色
| 模式 | 颜色 | Tailwind 类 |
|------|------|-------------|
| 浅色 | `#008D74` | `text-apple-brand` / `bg-apple-brand` |
| 深色 | `#00B896` | `text-apple-brand` / `bg-apple-brand` |
---
## 三、圆角规范
| 大小 | 像素 | Tailwind 类 |
|------|------|-------------|
| 中 | 10px | `rounded-apple-md` |
| 大 | 16px | `rounded-apple-lg` |
---
## 四、禁止事项
| 禁止 | 替代方案 |
|------|---------|
| `text-gray-*` | `text-apple-label-*` |
| `bg-gray-*` | `bg-apple-bg-*` |
| `shadow-*` | `shadow-apple-*` |
| `rounded-lg/xl` | `rounded-apple-md/lg` |
| `<button>` | `<Button>` |
| `<input>` | `<Input>` |
| 蓝色 focus ring | 品牌色 `#008D74` |
---
## 五、去 AI 味
| 禁止 | 推荐 |
|------|------|
| ❌ 均等分布(4个功能各占25%) | ✅ 非对称布局 |
| ❌ 默认实心图标 | ✅ 细线条 stroke-width 1.5 |
| ❌ 彩虹渐变背景 | ✅ 纯色/微妙渐变 |
| ❌ Lorem Ipsum 占位 | ✅ 真实场景数据 |
---
## 六、动画规范
| 场景 | 时长 | 缓动函数 |
|------|------|---------|
| 展开 | 500ms | `cubic-bezier(0.32, 0.72, 0, 1)` |
| 收起 | 350ms | `cubic-bezier(0.32, 0, 0.68, 0)` |
| 按钮按压 | 100ms | `scale(0.98)` |
| 悬停放大 | 200ms | `scale(1.02)` |
---
## 七、状态管理
### Zustand Stores
```
src/stores/
├── auth.store.ts # 认证状态
├── ui.store.ts # UI 状态
└── product.store.ts # 产品状态
```
### React Query Hooks
```
src/hooks/api/
├── useProducts.ts
├── useSKUs.ts
└── useUsers.ts
```
### 禁止
```typescript
// ❌ 禁止组件内直接 fetch
const [data, setData] = useState()
useEffect(() => { fetch('/api/xxx').then(...) }, [])
```
---
## 八、目录结构
```
src/components/
├── ui/ # 元组件(必须复用)
├── 3d/ # 3D 组件
└── [feature]/ # 功能组件
src/app/
├── (main)/ # 主布局页面
├── api/ # API 路由
└── share/ # 分享页面
```

View File

@ -0,0 +1,113 @@
---
description: 性能优化师规范 - 检查性能问题
alwaysApply: false
---
# 性能优化师规范
## 一、数据库性能
### 检查项
- [ ] 是否存在 N+1 查询问题?
- [ ] 是否使用了合理的索引?
- [ ] 大数据量是否分页加载?
### N+1 查询示例
```typescript
// ❌ N+1 问题
const products = await prisma.product.findMany()
for (const product of products) {
const skus = await prisma.sKU.findMany({ where: { productId: product.id } })
}
// ✅ 正确:使用 include
const products = await prisma.product.findMany({
include: { skus: true }
})
```
---
## 二、前端性能
### 检查项
- [ ] 是否使用了 React Query 缓存?
- [ ] 大列表是否使用虚拟滚动?
- [ ] 图片是否懒加载?
### React Query 缓存
```typescript
const { data } = useQuery({
queryKey: ['products', filters],
queryFn: () => fetchProducts(filters),
staleTime: 5 * 60 * 1000, // 5分钟
})
```
---
## 三、3D 性能
### 检查项
- [ ] 模型是否使用 LOD 策略?
- [ ] 组件卸载时是否清理资源?
- [ ] 是否避免内存泄漏?
### 内存泄漏检查
- 事件监听器是否移除?
- 定时器是否清除?
- GSAP 动画是否停止?
- geometry/material/texture 是否释放?
---
## 四、性能监控
### 请求计时
```typescript
const result = await timeAsync('product.import', async () => {
// 耗时操作
})
```
### 日志规范
```typescript
logger.info('Product created', {
productId: product.id,
userId: ctx.user.id,
duration: elapsed
})
```
---
## 五、性能检查清单
| 类别 | 检查项 |
|------|--------|
| 数据库 | N+1 查询、索引、分页 |
| 前端 | React Query 缓存、虚拟滚动、懒加载 |
| 3D | LOD、资源清理、内存泄漏 |
| 监控 | 请求计时、日志记录 |
---
## 六、审查报告格式
```
## 性能审查结果
### 通过项 ✅
- 使用了 React Query 缓存
- 使用了分页加载
### 优化建议 ⚠️
- 第 X 行存在 N+1 查询
→ 修复:使用 include 预加载关联数据
### 严重问题 ❌
- 组件卸载时未清理资源
→ 修复:添加 dispose() 方法
### 评分:X/10
```

101
.cursor/rules/security.mdc Normal file
View File

@ -0,0 +1,101 @@
---
description: 安全审查员规范 - 检查安全漏洞
alwaysApply: false
---
# 安全审查员规范
## 一、输入验证
### 检查项
- [ ] 所有用户输入是否经过 Zod 验证?
- [ ] 文件上传是否检查类型和大小限制?
- [ ] 是否防止了路径遍历攻击?
### 文件上传安全
```typescript
const MAX_TOTAL_SIZE = 500 * 1024 * 1024 // 500MB
const MAX_FILE_COUNT = 100
const MAX_SINGLE_FILE_SIZE = 200 * 1024 * 1024 // 200MB
// 解压前必须检查
let totalSize = 0
let fileCount = 0
for (const [name, file] of Object.entries(zip.files)) {
if (!file.dir) {
fileCount++
if (fileCount > MAX_FILE_COUNT) throw new Error('文件数量超限')
if (size > MAX_SINGLE_FILE_SIZE) throw new Error('单文件过大')
totalSize += size
}
}
```
---
## 二、权限检查
### 检查项
- [ ] API 是否验证用户身份?
- [ ] 是否检查用户对资源的操作权限?
- [ ] 敏感操作是否有二次确认?
### 示例
```typescript
// 检查用户是否有权操作该资源
this.checkPermission(entity, ctx.user)
// 如果没有权限
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED)
```
---
## 三、常见漏洞
### SQL 注入
- **检查**:是否使用 Prisma 参数化查询
- **禁止**:字符串拼接 SQL
### XSS
- **检查**:React 自动转义 + DOMPurify 清理 HTML
- **禁止**:使用 `dangerouslySetInnerHTML` 未经清理
### CSRF
- **检查**:Next.js CSRF Token 保护
- **禁止**:跨域请求不带凭证
---
## 四、安全检查清单
| 检查项 | 说明 |
|--------|------|
| 身份验证 | API 是否验证 JWT/Session |
| 资源权限 | 用户是否有权操作该资源 |
| 输入验证 | 所有输入是否经过 Zod 验证 |
| 文件上传 | 是否检查类型、大小、路径 |
| SQL 注入 | 是否使用参数化查询 |
| XSS | 是否转义用户输入 |
| CSRF | 是否有 Token 保护 |
---
## 五、审查报告格式
```
## 安全审查结果
### 通过项 ✅
- 使用了 Zod 验证输入
- 使用了 Prisma 参数化查询
### 风险项 ⚠️
- 文件上传未检查路径遍历(第 X 行)
→ 修复:添加路径检查,禁止 .. 和绝对路径
### 严重漏洞 ❌
- 无
### 评分:X/10
```

91
.cursor/rules/testing.mdc Normal file
View File

@ -0,0 +1,91 @@
---
description: QA 测试员规范 - 单元测试、集成测试
alwaysApply: false
---
# QA 测试员规范
## 一、测试类型
| 类型 | 用途 | 位置 |
|------|------|------|
| 单元测试 | 测试独立函数和组件 | `tests/` |
| 集成测试 | 测试 API 端到端流程 | `tests/` |
| 边界测试 | 测试异常输入和边界条件 | `tests/` |
---
## 二、测试框架
| 工具 | 用途 |
|------|------|
| Jest | 测试框架 |
| @testing-library/react | React 组件测试 |
---
## 三、测试重点
### 必测项
- 核心业务逻辑
- 错误处理路径
- 权限检查逻辑
### 示例
```typescript
describe('ProductService', () => {
it('should create product successfully', async () => {
const result = await productService.create(validData)
expect(result).toBeDefined()
expect(result.id).toBeDefined()
})
it('should throw error when product not found', async () => {
await expect(
productService.getById('non-existent-id')
).rejects.toThrow(AppError)
})
})
```
---
## 四、常用命令
```bash
# 运行所有测试
npm test
# 运行单个文件
npm test -- tests/lib/zip-parser.test.ts
# 运行单个用例
npm test -- tests/lib/zip-parser.test.ts -t "用例名"
# 串行运行
npm test -- --runInBand tests/lib/zip-parser.test.ts
# 覆盖率
npm run test:coverage
```
---
## 五、测试原则
1. **AAA 模式**:Arrange(准备)→ Act(执行)→ Assert(断言)
2. **单一职责**:每个测试只验证一个行为
3. **独立性**:测试之间互不影响
4. **可读性**:测试名称清晰描述预期行为
---
## 六、测试目录结构
```
tests/
├── lib/ # 工具函数测试
├── services/ # Service 层测试
├── repositories/ # Repository 层测试
└── integration/ # 集成测试
```

228
.dockerignore Normal file
View File

@ -0,0 +1,228 @@
# ============================================
# 3D家具安装展示系统 - Docker忽略文件
# 优化构建上下文,减少构建时间
# ============================================
# 开发文件
.git
.gitignore
README.md
测试指南.md
SETUP_COMPLETE.md
# Node.js
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Next.js
.next/
out/
build/
dist/
# 环境变量文件(保留.env.example)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 数据库
*.db
*.db-journal
prisma/dev.db*
# 日志文件
logs
*.log
# 运行时数据
pids
*.pid
*.seed
*.pid.lock
# 覆盖率目录
lib-cov
coverage
*.lcov
.nyc_output
# 依赖目录
jspm_packages/
# TypeScript
*.tsbuildinfo
# 可选的npm缓存目录
.npm
# 可选的eslint缓存
.eslintcache
# 微束缓存
.parcel-cache
# Next.js缓存
.next
# Nuxt.js构建/生成输出
.nuxt
dist
# Gatsby文件
.cache/
public
# Vuepress构建输出
.vuepress/dist
# Serverless目录
.serverless/
# FuseBox缓存
.fusebox/
# DynamoDB本地文件
.dynamodb/
# TernJS端口文件
.tern-port
# 存储VSCode版本的文件
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IDE文件
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统文件
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# 临时文件
*.tmp
*.temp
temp/
tmp/
# 测试文件
test/
tests/
__tests__/
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# 文档文件
docs/
*.md
!README.md
# 脚本文件(除了必要的)
scripts/
!scripts/cache-manager.ps1
# Docker文件(避免递归)
Dockerfile*
docker-compose*.yml
.dockerignore
# 缓存目录(构建时会重新创建)
cache/
.cache/
# 上传文件(运行时生成)
uploads/
public/uploads/
# 备份文件
*.bak
*.backup
# 压缩文件
*.zip
*.tar
*.tar.gz
*.rar
*.7z
# 证书文件
*.pem
*.key
*.crt
*.csr
# 配置文件(敏感信息)
config.json
secrets.json
# 监控和分析
.nyc_output/
coverage/
*.lcov
# Storybook
.storybook/
storybook-static/
# 本地开发工具
.vscode/
.idea/
*.sublime-*
# 包管理器锁文件(保留package-lock.json用于精确依赖)
yarn.lock
pnpm-lock.yaml
# 构建工具缓存
.webpack/
.rollup.cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# 编辑器临时文件
*~
.#*
\#*#
.*.swp
.*.swo
# 本地环境特定文件
.env.local
.env.*.local
# 性能分析文件
*.cpuprofile
*.heapprofile
# 调试文件
debug.log
error.log
# 本项目特定忽略
PROJECT_SETUP_COMPLETE.md
产品安装展示系统-*.md
开发部署计划.md
项目*.md
架构*.md

10
.gitignore vendored
View File

@ -21,16 +21,6 @@ _logs/
# Temp
temp/
*.tmp
/.tmp-*/
/tmp-*/
/tmp-*.html
/tmp-*.png
/tmp-*.env
/tmp-*.service
/tmp-*.timer
/tmp-*.err
/tmp-*.out
/.gemini/
# Build artifacts (C# / .NET)
**/bin/

View File

@ -0,0 +1,21 @@
# 开发规范
## 🗣️ 交流语言
**重要**: 始终使用中文与用户交流
- 所有回复和解释用中文
- 错误信息和建议用中文
- 代码注释用中文
- 文档说明用中文
## 📋 项目信息
- **名称**: 3D家具安装展示系统
- **技术**: Next.js + Three.js + Apple UI
- **目标**: 现代化3D家具装配展示
## ⚡ 开发原则
- TypeScript 严格模式
- Apple HIG 2025 设计标准
- 性能优先 (React.memo + 懒加载)
- 代码整洁 (精简文档,避免冗余)
- 不重要的东西不要写文档
- 能在原有文档上更新就采用更新的方式

1
.npmrc Normal file
View File

@ -0,0 +1 @@
legacy-peer-deps=true

197
AGENTS.md
View File

@ -1,167 +1,80 @@
# AGENTS.md
适用范围:`D:\AI Code\3danzhuang-wuyongjie-mainline` 全仓库。
未单独说明时,先遵循当前 agent 自己的全局规则(ZCode:`C:\Users\54972\.zcode\v2\AGENTS.md`;Codex:`C:\Users\54972\.codex\AGENTS.md`)。
这份文件只记录本仓库当前有效的补充规则和核查入口,不保存密码、密钥、数据库连接串或对象存储密钥。
适用范围:`D:\AI Code\3danzhuang` 全仓库。
重要边界:`D:\AI Code\3danzhuang` 是历史旧现场;当前正式工作目录以 `D:\AI Code\3danzhuang-wuyongjie-mainline` 为准。除非用户明确要求,不要混用旧目录、旧服务器别名、旧部署脚本或旧截图结论。
## 1. 默认方式
## 1. 当前项目真相
- 默认中文沟通,面向非程序员表达。
- 默认回复尽量简短、直白、好理解,非必要不展开。
- 默认先说“现在你要做什么 / 结论是什么”;技术细节、文件路径、命令和长解释只在用户要求时展开。
- 默认按当前会话直接开发。
- 没有“规范审查结论”,任务不算完成。
- 外层目录 `D:\AI Code\3danzhuang-wuyongjie-mainline` 是 Git 仓库根目录。
- 主应用目录是 `furniture-3d-system/`;用户未指定目录时,默认在这里执行代码、测试、构建和脚本。
- 技术栈以当前 `furniture-3d-system/package.json` 为准(核心:Next.js App Router + React + TypeScript + Prisma/PostgreSQL + Jest + Playwright + Tailwind CSS + Three.js/R3F;另有 ioredis、puppeteer、exceljs/xlsx、zod、zustand 等)。package.json 变动后文档不自动同步,以实时核查为准。
- `CLAUDE.md`、旧 README、`docs/历史归档/` 和早期部署文档里可能仍有旧苹果风格、MinIO、旧 Docker 部署、旧模型数量、旧 Product/SKU 口径等信息;只能当历史参考,不能覆盖当前代码、workflow 文档和实时命令结果。
- 工作树可能有用户或其他协作者留下的未跟踪文件、日志、贴图和本地产物。只处理本次任务明确相关的文件,不要顺手清理或回退无关现场。
## 2. 三条硬规则
## 2. 开工前固定入口
- 必须符合企业级架构标准。
- 必须符合统一视觉标准。
- 必须优先做根因修复,不能拿补丁冒充完成。
当任务发生在 `furniture-3d-system/` 时,改代码前必须先读这 3 份当前协作入口:
## 3. 固定流程
1. 开始前先用 1-2 句说明:目标、验收标准、根因判断。
2. 开发时优先复用现有实现,不顺手改无关问题。
3. 改完先做最小必要验证。
4. 交付前必须单独做一次规范审查。
5. 最终默认只交代:现在你要做什么、是否完成、有没有风险;技术细节只在用户要求时展开。
### 团队协作固定入口
当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面这些协作文档,再开始改代码:
- `furniture-3d-system/docs/workflow/团队协作模式.md`
- `furniture-3d-system/docs/workflow/模块边界说明.md`
- `furniture-3d-system/docs/workflow/主线同步操作手册.md`
按任务类型额外读取:
这 3 份文档就是进入 `furniture-3d-system/` 的固定协作入口,不再额外约定第二套平级入口,也不再靠聊天记录补口头规则。
- 生产、部署、线上排障:`furniture-3d-system/docs/workflow/生产部署入口说明.md`,并核对 `scripts/deploy-yydd-prod-from-git.ps1` 与其调用的 `scripts/create-yydd-prod-deploy.ps1` 当前默认参数。
- 前端、视觉、交互:`furniture-3d-system/docs/设计规范/白曜石工坊统一视觉标准.md` 和 `furniture-3d-system/docs/workflow/规范审查清单.md`。
- 包装、仓库、轻仓库协作:再读包装/仓库相关交接文档和 `src/app/warehouse`、`src/app/api/warehouse`、`src/app/api/pre-skus/[id]/packaging/route.ts` 的当前实现。
- 数据库、主数据、迁移:优先读 `docs/架构设计/内部主数据巡检基线-20260529.md`、`docs/架构设计/数据库混乱点治理台账.md` 和相关 `db:foundation:*` 脚本说明。
如果任务和包装协作有关,再额外读取:
如果文档和代码冲突,按这个顺序判断:当前代码和脚本、当前线上/命令返回、当前 workflow 文档、历史文档或旧记忆。
- `furniture-3d-system/docs/workflow/同事交接模板.md`
- `furniture-3d-system/docs/workflow/同事本地AGENTS模板.md`
- `furniture-3d-system/docs/开发指南/包装方案增强/新平台包装底座-同事AI对接说明-20260331.md`
## 3. Git 与主线协作
固定协作口径:
- `main` 是最终发布主线,由 `WuYongJie` 收口;不要直接把半成品推到 `main`。
- `origin/codex/xuxiake-mainline` 是日常共享开发主线和默认同步基线。
- 新功能、优化和修复默认从最新 `origin/codex/xuxiake-mainline` 开任务分支,再按协作流程回流。
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`,不要再新建 `feature/...`、`packaging/...`、`dev`、`codex/xuxiake-dev` 这类泛分支名。
- 长期共享分支同步共享开发主线时固定使用 `merge origin/codex/xuxiake-mainline`;只有确认无人共用的临时个人任务分支才使用 `rebase origin/codex/xuxiake-mainline`。
- 每次开工或接续旧任务时,先看 `git status --short --branch`;涉及主线同步时再看 `git rev-list --left-right --count HEAD...origin/codex/xuxiake-mainline`,不要把“当前所在分支”误当成“已同步共享主线”。
- 用户问“xuxiake 主线有没有新提交”时,必须先 `git fetch origin`,再基于最新远端状态回答。
- 不要在脏工作树上直接 merge/rebase。先看 `git status --short`,分清本次改动、用户改动、本地产物和无关未跟踪文件。
- 常规任务完成必要验证后,可以按协作流程推进 `commit / push`;`main`、生产发布、破坏性操作、删除数据、改远端历史等高风险动作必须有用户明确授权。
- `main` 只作为 `WuYongJie` 的主线收口分支,其他协作者默认不能直接推主线
- `xuxiake` 的长期包装分支固定为 `codex/xuxiake-packaging`
- `xuxiake` 的临时任务分支固定为 `codex/xuxiake-packaging-<任务简称>`
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名
- 长期包装分支同步 `main` 时固定使用 `merge main`
- 临时个人任务分支同步 `main` 时才使用 `rebase main`
### 主线部署标准流程(每次"主线部署"都走这一套)
## 4. 规范审查
本仓库有两根主线,都不直接提交:`main` 是生产部署唯一来源(由 `WuYongJie` 收口),`origin/codex/xuxiake-mainline` 是共享开发次主线(日常回流基线)。
规范审查固定只查三件事:
- 架构有没有被破坏
- 视觉有没有跑偏
- 这次是不是根因修复
每次用户要求"主线部署",严格按以下顺序,不跳步、不直接支线 → main:
结论只能是:
- `passed`
- `passed with warnings`
- `rejected`
1. **在支线开发** → 提交到任务分支(如 `codex/wuyongjie-xxx`),不碰两根主线。
2. **fetch xuxiake** → 必须先 `git fetch origin`,看 `origin/codex/xuxiake-mainline` 有没有新提交。
3. **有新提交** → `merge origin/codex/xuxiake-mainline` 拉下来处理冲突(长期共享分支用 merge,不 rebase);只处理冲突文件,不扩范围。
4. **合并到 xuxiake** → 把支线合进 `origin/codex/xuxiake-mainline`,并 `push` 更新次主线。
5. **合并到 main** → 把 xuxiake 合进 `main`,并 `push origin main`(本步需用户授权)。
6. **用 main 部署** → 走 `scripts/deploy-yydd-prod-from-git.ps1`(默认源 `origin/main`),部署后核对 commit / 进程 / 端口 / 日志 / 关键页面(本步需用户授权)。
只要架构违规、视觉违规,或明显是补丁式修复,就必须 `rejected`。
如果这次不是独立复审,必须明确写出“这是自审,不是独立复审”。
发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致。
统一清单:`docs/workflow/规范审查清单.md`
## 4. 生产与部署
## 5. 标准来源
- 当前正式网址:`https://cp.jiansun.vip/`。
- 当前权威生产 SSH 入口:`root@ltai5tdb2ogyj59fquyedh.yydd.ai`,端口 `60022`。
- 当前生产部署目录:`/opt/furniture-3d-system-prod`。
- 当前 PM2 进程名:`furniture-3d-system-prod`。
- 当前应用本机端口:`3001`。
- 默认生产部署来源:`origin/main`。
- 当前主部署入口是外层 `scripts/deploy-yydd-prod-from-git.ps1`;它默认部署 `origin/main`,并调用 `scripts/create-yydd-prod-deploy.ps1` 生成发布包/远端发布脚本。不要把下层打包脚本单独当成唯一部署入口。
- 发布前必须确认 `git ls-remote origin refs/heads/main`、本地 `origin/main`、目标部署 commit 三者一致;本地 `main` 是否领先或落后远端不能替代 `origin/main` 的真实发布来源。
- 旧 `192.168.58.20`、本机泛别名 `prod`、旧 `gongxiangp2 / youyidian-3d-material` 生产信息都不能套用到本项目。
- 生产动作前至少实时确认:目标主机、远端目录、PM2 进程、`current` 软链接、监听端口和 `https://cp.jiansun.vip/api/health`。
- 生产数据库、迁移、主数据地基相关动作先只读核查;需要写入时必须有备份、目标库身份、确认口令和可回滚路径。
- 对象存储当前要按 `src/lib/storage.ts`、`storage:*` 脚本、七牛迁移/GC 文档和生产环境变量实时核对;不要把历史 MinIO/S3 备份脚本当成当前生产存储真相。
- `furniture-3d-system/.cursor/rules/*.mdc`
- `furniture-3d-system/docs/设计规范/苹果风格视觉标准.md`
- `docs/workflow/规范审查清单.md`
## 5. 当前主链与模块边界
## 6. 边界
当前新平台数据主链按这条线理解:
`资产中心 -> 辅助装配 -> 草稿组 -> 产品资料/正式入库 -> 仓库管理/轻仓库`
当前全局工作台导航还包含首页、OA 新品流程看板、仓库质检、AI 出图工作台等运营入口。导航入口不等于数据主链,不要把页面入口和主数据归属混成一件事。
主链默认入口和目录:
- 首页与全局壳:`src/app/page.tsx`、`src/components/layout`
- 资产中心:`src/app/asset-center`、`src/app/api/asset-center`
- 辅助装配:`src/app/assisted-assembly`、`src/app/api/assisted-assembly`
- 草稿组:`src/app/draft-groups`、`src/app/api/draft-groups`
- 产品资料/正式入库:`src/app/intake`、`src/app/api/intake`
- 仓库管理/轻仓库:`src/app/warehouse`、`src/app/api/warehouse`
- 仓库质检:`src/app/warehouse-quality`、`src/app/api/warehouse-quality`
- AI 出图工作台:`src/app/ai-render-workbench`
- 审批工作流:`src/app/approval-workflows`
- 素材库:`src/app/materials`
- 移动端:`src/app/mobile`
- 平台组件库:`src/components/platform`
- 主链规则与类型:`src/lib/open-product`、`src/lib/planning`
业务真相:
- 资产族:`AssetFamily`
- 规格款:`AssetVariant`
- 草稿组:`DraftGroup`
- 准 SKU:`PreSku`
- 入库结果:正式 `Product / ProductSKU`(`SKU` 是业务概念,Prisma 模型名是 `ProductSKU`)
- 产品资料侧非集成开品:`IntakeNonIntegratedProduct / IntakeNonIntegratedSkuItem`
旧 CAD 兼容链继续存在,但不是新平台主数据源:
- 页面:`src/app/products`、`src/app/products/[id]`、`src/app/products/[id]/assembly`、`src/app/products/[id]/skus/[skuId]`
- 旧链模型和服务包括 `Product / ProductSKU / Panel / Hole / ProductHardware`、`assembly.service`、`panel.service`、`hole.service` 等。
- 旧链可以继续服务历史产品、CAD 导入、装配步骤和兼容发布后能力,但不要反向主导资产中心、草稿组或产品资料主链。
共享接缝默认谨慎处理:
- `prisma/schema.prisma`
- `prisma/migrations`
- `src/app/layout.tsx`
- `src/app/intake`
- `src/app/draft-groups/[id]`
- `src/app/api/platform/schemas.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/repositories/draft-group.repository.ts`
改这些地方前先说明原因、影响范围和验证计划;不要为了局部 UI 或临时兼容绕过模块边界。
## 6. 架构硬规则
- API Route 负责收参、鉴权和返回,不直接堆业务规则或 Prisma 查询。
- Service 负责业务规则、权限校验、事务编排、缓存和跨模块协调。
- Repository 负责数据访问。
- 任何新接口必须有明确输入输出 DTO/校验口径。
- 不是必须的话,不要使用兜底逻辑;原生主逻辑能做好,就先把主逻辑、状态契约和数据来源修正确。兜底只能用于明确的兼容、降级或应急场景,不能用来掩盖本应修好的业务规则、接口契约或前端状态问题。
- 任何 Prisma 模型调整必须带迁移,并补齐主数据/迁移核查;生产/准生产禁止用 `prisma db push` 替代迁移。
- 影响命名、BOM、包装、安装步骤、SKU、barcode、批次、入库承接的数据问题,优先从数据源和业务规则修,不在页面上压报错或绕校验。
- SPU、SKU、barcode、批次不能混用:SPU 是父级产品款,SKU 是 SPU 下的具体子项(Prisma 模型名 `ProductSKU`),barcode 是绑定到最终 SKU 的业务定位值;基于某 SKU 派生的新 SKU 必须仍属于同一 SPU,并按业务要求继承或明确指定批次。
## 7. 前端与视觉硬规则
- 白曜石工坊统一视觉标准是本项目新平台第一视觉标准;默认是浅色工作台,不再提供深浅色主题切换入口。
- 本项目有自己的组件库与 `Meta Component(元组件)` 体系;前端实现必须优先复用 `src/components/platform`、资产中心已沉淀的工作台表达和现有元组件,不允许绕开组件库另起一套孤立 UI。
- 当用户指定 Stitch 设计稿或已有 Stitch 设计稿可参考时,Stitch 是当前功能的视觉规范与验收标准;实现方式是在本项目组件库/元组件体系内对齐 Stitch 的密度、层级、控件形态、留白、字号、圆角、阴影和低噪音表达,而不是无视组件库硬抄静态稿。
- 资产中心页面是当前项目内的优先参考样板;涉及资产、图片、SKU、AI 白底图、3D 预览、工作区和检视器时,默认参考资产中心的工作台感、元组件表达、低噪音操作面板和重点图片区。
- `Inspector(检视器)`、数据列表、表格、长文本和表单密集区必须优先保证可读性;毛玻璃只允许用于顶栏、HUD、浮动工具条和轻量状态浮层,不承载正文。
- 板材、五金、组件、包装等资产必须按元组件表达,不能退回普通图片卡片或营销式大卡片布局。
- 现有 `apple-*` token、深色 `ObsidianControlShell`、历史主题 CSS 只能作为兼容遗留;新功能不得以它们作为视觉方向。
- 前端改造不得把现有后台习惯、普通 SaaS 审美或共享组件默认皮肤当成最终依据;如果组件库默认视觉和 Stitch/资产中心标准冲突,应在组件库体系内做专用变体、重写皮肤或局部重构。
- 没有出现在设计稿、组件规范或业务必要路径里的说明文案、辅助卡片、重复统计块、解释区,不要为了“更易懂”自行添加;工作区必须抓重点,图片类功能要让图片和关键动作成为第一层级。
- 涉及前端动画、入退场、滚动联动、时间线、拖拽、SVG 或动效性能时,先判断是否有助于理解和操作;需要时优先按技术栈调用对应 GSAP Skill,并继续遵守 Impeccable。React 必须使用 `useGSAP` 或带 `gsap.context().revert()` 的完整清理,滚动相关实例必须销毁并在布局变化时正确刷新;优先动画 `transform/opacity`,尊重 `prefers-reduced-motion`,不得为装饰滥用动效。
- 前端修改后优先用真实页面、截图、1920×1080 视口和必要的移动端视口验证,不只看代码。
## 8. 验证、收口与规范审查
- 修改后必须做最小但有效的验证;不能验证就明确说明未验证原因和风险。
- 常用验证命令以当前 `package.json` 为准,优先选和改动范围匹配的最小集合:`npm run type-check`、`npm run build`、`npm test -- <相关测试>`、`npm run lint`;对象存储用 `npm run storage:gc:audit` / `npm run storage:gc:apply`,主数据用 `npm run db:foundation:*` 相关只读或授权脚本。
- 前端体验类改动要结合浏览器实际操作和截图验证;涉及 3D/canvas 时要确认画面非空、尺寸正确、交互可用。
- 生产验证必须包含真实健康检查和日志/PM2/端口等现场证据,不能只说"本地构建通过"。
- 规范审查固定只查三件事:架构有没有被破坏、视觉有没有跑偏、这次是不是根因修复。结论只能是 `passed` / `passed with warnings` / `rejected`;只要架构违规、视觉违规或明显补丁式修复,就必须 `rejected`。非独立复审时必须写明"这是自审,不是独立复审"。统一清单:`furniture-3d-system/docs/workflow/规范审查清单.md`。没有规范审查结论,任务不算完成。
## 9. 敏感信息和交接
- 账号密码、访问密钥、数据库连接串、对象存储密钥等敏感信息只能放在本地安全材料里,不写入 Git、共享文档、普通交接文本或长期记忆;生产域名、PM2 名称、部署目录这类非密钥运行入口可以写进仓库文档,但必须保持最新。
- 仓库内协作文档只写非敏感入口、流程和职责;凭据类信息必须用 `[REDACTED_SECRET]` 或本地路径说明替代。
- 发现摘要、日志或交接文件混有明文凭据时,先提示风险,再决定是否拆成本地敏感版和仓库协作版。
- 给下一个 agent 或同事交接时,写清:本次只改了什么、没改什么、验证跑了什么、风险是什么、下一步要看哪些文件。
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。
- 未经用户明确要求,不主动 `commit / push`。
- 禁止破坏性操作,除非用户明确授权。

240
Dockerfile Normal file
View File

@ -0,0 +1,240 @@
# ============================================
# 3D家具安装展示系统 - Docker构建文件
# ============================================
# 基于 flnxi_sucai 项目的 Dockerfile 优化
# 支持本地缓存包,减少网络依赖
# ============================================
# 阶段 1: 基础镜像
# ============================================
FROM node:18-alpine AS base
# 使用阿里云镜像源加速(中国网络优化)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# ============================================
# 阶段 2: 依赖安装 (deps)
# ============================================
FROM base AS deps
# 安装构建时依赖
# Three.js 和 3D 相关包需要的系统依赖
RUN apk add --no-cache --virtual .build-deps \
libc6-compat \
openssl3 \
openssl-dev \
# 3D 图形处理相关依赖
mesa-dev \
mesa-gl \
# 编译工具(用于编译原生模块)
python3 \
make \
g++ \
pkgconfig
WORKDIR /app
# 复制依赖文件(利用 Docker 缓存层)
COPY package.json package-lock.json* ./
# 配置 npm 使用国内镜像源
RUN npm config set registry https://registry.npmmirror.com/
# 尝试使用本地缓存的 node_modules
# 如果本地缓存存在,直接复制;否则通过 npm 安装
COPY local-packages/npm-cache/node_modules* ./node_modules_cache/
RUN if [ -d "./node_modules_cache" ] && [ "$(ls -A ./node_modules_cache)" ]; then \
echo "使用本地缓存的 node_modules" && \
cp -r ./node_modules_cache/* ./node_modules/ 2>/dev/null || true && \
npm ci --prefer-offline --no-audit || npm install --prefer-offline --no-audit; \
else \
echo "本地缓存不可用,从网络安装依赖" && \
npm ci --prefer-offline --no-audit || npm install --prefer-offline --no-audit; \
fi
# 清理构建依赖,保留运行时库
RUN apk del .build-deps && \
npm config delete registry && \
apk add --no-cache \
libc6-compat \
ca-certificates \
openssl3 \
# 3D 渲染运行时库
mesa-gl
# ============================================
# 阶段 3: 构建应用 (builder)
# ============================================
FROM base AS builder
# 构建参数
ARG DATABASE_URL="postgresql://user:password@localhost:5432/placeholder_db?schema=public"
ARG NEXTAUTH_URL=""
ARG NEXT_PUBLIC_APP_URL=""
# Prisma 引擎缓存配置
ARG PRISMA_ENGINE_CACHE_FILE_NAME="libquery_engine-linux-musl-openssl-3.0.x.so.node"
ARG PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME="schema-engine-linux-musl-openssl-3.0.x"
# 环境变量设置
ENV PRISMA_ENGINE_CACHE_FILE_NAME=${PRISMA_ENGINE_CACHE_FILE_NAME}
ENV PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME=${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
ENV DATABASE_URL=${DATABASE_URL}
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1
WORKDIR /app
# 安装 OpenSSL 3(Prisma 需要)
RUN apk add --no-cache openssl3
# 从 deps 阶段复制 node_modules
COPY --from=deps /app/node_modules ./node_modules
# 复制应用源代码
COPY . .
# 配置 Prisma 引擎缓存路径
ENV PRISMA_ENGINE_CACHE_PATH=/app/local-packages/prisma-engines/${PRISMA_ENGINE_CACHE_FILE_NAME}
ENV PRISMA_SCHEMA_ENGINE_CACHE_PATH=/app/local-packages/prisma-engines/${PRISMA_SCHEMA_ENGINE_CACHE_FILE_NAME}
# 设置 Prisma 引擎缓存(如果本地缓存存在)
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
echo "检测到本地 Prisma 查询引擎缓存: ${PRISMA_ENGINE_CACHE_PATH}" && \
PRISMA_ENGINE_VERSION=$(node -p "require('@prisma/engines-version/package.json').prisma.enginesVersion") && \
TARGET_DIR="/root/.cache/prisma/master/${PRISMA_ENGINE_VERSION}/linux-musl-openssl-3.0.x" && \
mkdir -p "${TARGET_DIR}" && \
cp "${PRISMA_ENGINE_CACHE_PATH}" "${TARGET_DIR}/libquery_engine.so.node" && \
gzip -c "${PRISMA_ENGINE_CACHE_PATH}" > "${TARGET_DIR}/libquery_engine.so.node.gz" && \
sha256sum "${TARGET_DIR}/libquery_engine.so.node.gz" | awk '{print $1}' > "${TARGET_DIR}/libquery_engine.so.node.gz.sha256"; \
else \
echo "未找到本地 Prisma 查询引擎缓存,将在线下载"; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
echo "检测到本地 Prisma schema 引擎缓存: ${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" && \
PRISMA_ENGINE_VERSION=$(node -p "require('@prisma/engines-version/package.json').prisma.enginesVersion") && \
TARGET_DIR="/root/.cache/prisma/master/${PRISMA_ENGINE_VERSION}/linux-musl-openssl-3.0.x" && \
mkdir -p "${TARGET_DIR}" && \
cp "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" "${TARGET_DIR}/schema-engine" && \
chmod +x "${TARGET_DIR}/schema-engine" && \
gzip -c "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" > "${TARGET_DIR}/schema-engine.gz" && \
sha256sum "${TARGET_DIR}/schema-engine.gz" | awk '{print $1}' > "${TARGET_DIR}/schema-engine.gz.sha256"; \
else \
echo "未找到本地 Prisma schema 引擎缓存,将在线下载"; \
fi
# 生成 Prisma Client(构建阶段使用占位 DATABASE_URL,避免 prisma.config.ts 报错)
RUN set -ex && \
export DATABASE_URL="${DATABASE_URL:-postgresql://user:password@localhost:5432/placeholder_db?schema=public}" && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
export PRISMA_CLIENT_ENGINE_TYPE=library && \
export PRISMA_CLI_QUERY_ENGINE_TYPE=library && \
echo "使用本地 Prisma 查询引擎缓存生成 Client"; \
else \
echo "使用在线下载的 Prisma 引擎"; \
unset PRISMA_QUERY_ENGINE_LIBRARY PRISMA_CLIENT_ENGINE_TYPE PRISMA_CLI_QUERY_ENGINE_TYPE; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_SCHEMA_ENGINE_BINARY="${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" && \
echo "使用本地 Prisma schema 引擎缓存"; \
else \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
if npx prisma generate; then \
echo "Prisma Client 生成成功"; \
else \
echo "Prisma 官方源失败,尝试使用镜像源..." && \
PRISMA_ENGINES_MIRROR="https://registry.npmmirror.com/-/binary/prisma/" npx prisma generate; \
fi
# 验证 Prisma Client 生成
RUN if [ ! -d "node_modules/.prisma" ]; then \
echo "错误: Prisma Client 未生成" >&2 && exit 1; \
fi
# 构建 Next.js 应用(standalone 模式)
RUN set -ex && \
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
export PRISMA_CLIENT_ENGINE_TYPE=library && \
export PRISMA_CLI_QUERY_ENGINE_TYPE=library; \
else \
unset PRISMA_QUERY_ENGINE_LIBRARY PRISMA_CLIENT_ENGINE_TYPE PRISMA_CLI_QUERY_ENGINE_TYPE; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
export PRISMA_SCHEMA_ENGINE_BINARY="${PRISMA_SCHEMA_ENGINE_CACHE_PATH}"; \
else \
unset PRISMA_SCHEMA_ENGINE_BINARY; \
fi && \
npm run build
# 验证构建产物
RUN if [ ! -f ".next/standalone/server.js" ]; then \
echo "错误: standalone 输出未生成,请检查 next.config.js" >&2 && \
exit 1; \
fi
# ============================================
# 阶段 4: 生产运行 (runner)
# ============================================
FROM base AS runner
WORKDIR /app
# 设置生产环境变量
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 安装运行时依赖
RUN apk add --no-cache \
libc6-compat \
ca-certificates \
openssl3 \
# 3D 渲染运行时库
mesa-gl \
# 字体支持(3D 文本渲染)
fontconfig \
ttf-dejavu
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# 从 builder 阶段复制构建产物
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 复制必要的文件
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/src/lib ./src/lib
# 复制 Prisma 相关文件
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bin ./node_modules/.bin
# 创建上传目录
RUN mkdir -p /app/uploads && \
chown -R nextjs:nodejs /app/uploads && \
chmod -R 755 /app/uploads
# 切换到非 root 用户
USER nextjs
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
# 启动应用
CMD ["node", "server.js"]

192
Dockerfile.optimized Normal file
View File

@ -0,0 +1,192 @@
# ============================================
# 3D家具安装展示系统 - 优化版Dockerfile
# 优先使用本地缓存,支持离线构建
# ============================================
# ============================================
# 阶段 1: 基础镜像
# ============================================
FROM node:18-alpine AS base
# 使用阿里云镜像源加速(中国网络优化)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装基础运行时依赖
RUN apk add --no-cache \
libc6-compat \
ca-certificates \
openssl3
# ============================================
# 阶段 2: 依赖安装 (deps)
# ============================================
FROM base AS deps
# 安装构建时依赖
RUN apk add --no-cache --virtual .build-deps \
python3 \
make \
g++ \
pkgconfig \
openssl-dev
WORKDIR /app
# 复制package文件
COPY package.json package-lock.json* ./
# 检查是否有本地缓存的node_modules
COPY cache/node_modules* ./cache_node_modules/ 2>/dev/null || true
# 配置npm使用国内镜像源
RUN npm config set registry https://registry.npmmirror.com/
# 智能安装依赖:优先使用缓存,然后网络安装
RUN set -ex && \
# 如果有本地缓存,先复制关键包
if [ -d "./cache_node_modules" ]; then \
echo "🔄 发现本地依赖缓存,正在复制..." && \
mkdir -p node_modules && \
# 复制关键包
for pkg in "@prisma" ".prisma" "prisma" "sharp" "next" "react" "react-dom" "@aws-sdk" "three" "gsap"; do \
if [ -d "./cache_node_modules/$pkg" ]; then \
echo "📦 复制缓存包: $pkg" && \
cp -r "./cache_node_modules/$pkg" "./node_modules/" || true; \
fi; \
done; \
fi && \
# 执行npm install(会跳过已存在的包)
echo "📥 安装剩余依赖..." && \
npm ci --prefer-offline --no-audit || npm install --prefer-offline --no-audit
# 清理构建依赖
RUN apk del .build-deps && \
npm config delete registry && \
rm -rf ./cache_node_modules
# ============================================
# 阶段 3: 构建应用 (builder)
# ============================================
FROM base AS builder
# 构建参数
ARG DATABASE_URL=""
ARG NEXTAUTH_URL=""
ARG APP_PUBLIC_URL=""
# 环境变量
ENV DATABASE_URL=${DATABASE_URL}
ENV NEXTAUTH_URL=${NEXTAUTH_URL}
ENV APP_PUBLIC_URL=${APP_PUBLIC_URL}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
WORKDIR /app
# 安装OpenSSL(Prisma需要)
RUN apk add --no-cache openssl3
# 从deps阶段复制node_modules
COPY --from=deps /app/node_modules ./node_modules
# 复制应用源代码
COPY . .
# 复制本地Prisma引擎缓存(如果存在)
COPY cache/prisma-engines* ./prisma/engine-cache/ 2>/dev/null || true
# 配置Prisma引擎缓存路径
ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1
ENV PRISMA_ENGINE_CACHE_PATH=/app/prisma/engine-cache/libquery_engine-linux-musl-openssl-3.0.x.so.node
ENV PRISMA_SCHEMA_ENGINE_CACHE_PATH=/app/prisma/engine-cache/schema-engine-linux-musl-openssl-3.0.x
# 智能生成Prisma Client:优先使用本地引擎
RUN set -ex && \
# 检查本地引擎缓存
if [ -f "${PRISMA_ENGINE_CACHE_PATH}" ]; then \
echo "🔧 使用本地Prisma查询引擎缓存" && \
export PRISMA_QUERY_ENGINE_LIBRARY="${PRISMA_ENGINE_CACHE_PATH}" && \
export PRISMA_CLIENT_ENGINE_TYPE=library && \
export PRISMA_CLI_QUERY_ENGINE_TYPE=library; \
else \
echo "⚠️ 本地Prisma引擎缓存不可用,将在线下载"; \
fi && \
if [ -f "${PRISMA_SCHEMA_ENGINE_CACHE_PATH}" ]; then \
echo "🔧 使用本地Prisma schema引擎缓存" && \
export PRISMA_SCHEMA_ENGINE_BINARY="${PRISMA_SCHEMA_ENGINE_CACHE_PATH}"; \
fi && \
# 生成Prisma Client
if npx prisma generate; then \
echo "✅ Prisma Client生成成功"; \
else \
echo "⚠️ 官方源失败,尝试镜像源..." && \
PRISMA_ENGINES_MIRROR="https://registry.npmmirror.com/-/binary/prisma/" npx prisma generate; \
fi
# 构建Next.js应用
RUN set -ex && \
echo "🏗️ 构建Next.js应用..." && \
npm run build && \
echo "✅ 构建完成"
# 验证构建产物
RUN if [ ! -f ".next/standalone/server.js" ]; then \
echo "❌ standalone输出未生成,请检查next.config.js配置" >&2 && \
exit 1; \
fi
# ============================================
# 阶段 4: 生产运行 (runner)
# ============================================
FROM base AS runner
WORKDIR /app
# 生产环境变量
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 安装运行时依赖
RUN apk add --no-cache \
# 字体支持(3D渲染可能需要)
fontconfig \
ttf-dejavu \
# 图像处理(Sharp需要)
vips-dev
# 创建非root用户
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# 复制构建产物
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 复制必要文件
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# 复制关键运行时模块
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.bin ./node_modules/.bin
# 创建上传目录
RUN mkdir -p /app/uploads && \
chown -R nextjs:nodejs /app/uploads && \
chmod -R 755 /app/uploads
# 切换到非root用户
USER nextjs
EXPOSE 3000
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
# 启动应用
CMD ["node", "server.js"]

View File

@ -1,4 +1,4 @@
# 由壹点「产品开发平台」工作区
# 3D 家具开品平台工作区
> 最后更新:2026-04-07
> 当前主线:`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库`

View File

@ -20,7 +20,7 @@ namespace FurnitureExporter;
/// </summary>
public class FurnitureExporterApp : IExtensionApplication
{
private const string PluginVersion = "2.6.0";
private const string PluginVersion = "2.5.0";
public void Initialize()
{
@ -30,13 +30,12 @@ public class FurnitureExporterApp : IExtensionApplication
doc?.Editor.WriteMessage($"║ 家具导出插件 v{PluginVersion} 已加载 ║\n");
doc?.Editor.WriteMessage("║ ║\n");
doc?.Editor.WriteMessage("║ 命令: ║\n");
doc?.Editor.WriteMessage("║ FEXPORT - 单资产导出到资产中心 ║\n");
doc?.Editor.WriteMessage("║ FEXPORTASSET - 单资产导出别名 ║\n");
doc?.Editor.WriteMessage("║ FEXPORTPRODUCT - 整套家具兼容导出 ║\n");
doc?.Editor.WriteMessage("║ FEXPORT - 整套家具兼容导出 ║\n");
doc?.Editor.WriteMessage("║ FEXPORTASSET - 单资产导出到资产中心 ║\n");
doc?.Editor.WriteMessage("║ FINFO - 查看选中实体信息 ║\n");
doc?.Editor.WriteMessage("║ ║\n");
doc?.Editor.WriteMessage($"║ v{PluginVersion} 新功能: ║\n");
doc?.Editor.WriteMessage("║ · 默认进入资产中心单资产导出 ║\n");
doc?.Editor.WriteMessage("║ · 新增资产中心单资产导出模式 ║\n");
doc?.Editor.WriteMessage("║ · 新增 assembly 装配语义导出层 ║\n");
doc?.Editor.WriteMessage("║ · 语义优先用孔位/五金/配合面推导 ║\n");
doc?.Editor.WriteMessage("║ · 安装顺序仍保留给 Web 端人工设定 ║\n");
@ -55,7 +54,7 @@ public class FurnitureExporterApp : IExtensionApplication
/// </summary>
public class Commands
{
private const string PluginVersion = "2.6.0";
private const string PluginVersion = "2.5.0";
private sealed class SingleAssetExportMode
{
@ -72,9 +71,6 @@ public class Commands
public List<PanelInfo> Panels { get; } = new();
public List<HoleInfo> Holes { get; } = new();
public List<HoleGroupInfo> HoleGroups { get; } = new();
public List<MountPatternInfo> MountPatterns { get; } = new();
public List<MountPatternInfo> SnapPatterns { get; } = new();
public List<SnapSurfaceInfo> SnapSurfaces { get; } = new();
public int LogicalHoleCount { get; set; }
public List<EdgeFeatureInfo> EdgeFeatures { get; } = new();
public List<ExcludedHoleInfo> ExcludedHoles { get; } = new();
@ -340,9 +336,6 @@ public class Commands
var holeGroups = HoleGrouping.BuildEccentricScrewGroups(allHoles);
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
foreach (var hole in allHoles)
@ -360,9 +353,6 @@ public class Commands
result.Holes.AddRange(allHoles);
result.HoleGroups.AddRange(holeGroups);
result.MountPatterns.AddRange(mountPatterns);
result.SnapPatterns.AddRange(mountPatterns);
result.SnapSurfaces.AddRange(snapSurfaces);
result.LogicalHoleCount = logicalHoleCount;
result.EdgeFeatures.AddRange(allEdgeFeatures);
result.ExcludedHoles.AddRange(panelAnalyzer.ExcludedHoles);
@ -373,7 +363,7 @@ public class Commands
/// <summary>
/// 主导出命令 - 导出板材 GLB + 五金件位置 JSON
/// </summary>
[CommandMethod("FEXPORTPRODUCT")]
[CommandMethod("FEXPORT")]
public void ExportFurniture()
{
var doc = AcApp.DocumentManager.MdiActiveDocument;
@ -415,7 +405,7 @@ public class Commands
ed.WriteMessage(" 1. 输入命令 UNITS 打开单位设置\n");
ed.WriteMessage(" 2. 将 '插入时的缩放单位' 改为 '毫米'\n");
ed.WriteMessage(" 3. 点击确定\n");
ed.WriteMessage(" 4. 重新运行 FEXPORTPRODUCT 命令\n");
ed.WriteMessage(" 4. 重新运行 FEXPORT 命令\n");
ed.WriteMessage("\n");
// 询问是否继续(仅供高级用户)
@ -455,7 +445,7 @@ public class Commands
panelIds = FilterSolids(panelIds, db);
if (panelIds.Length == 0)
{
ed.WriteMessage(" ⚠️ 无法恢复板材(可能图纸已变更/实体被删除)。已清除会话,请重新运行 FEXPORTPRODUCT。\n");
ed.WriteMessage(" ⚠️ 无法恢复板材(可能图纸已变更/实体被删除)。已清除会话,请重新运行 FEXPORT。\n");
ExportSessionManager.Clear(docKey);
return;
}
@ -556,7 +546,7 @@ public class Commands
ed.WriteMessage($" ✓ 已记录 {panelIds.Length} 个板材\n");
ed.WriteMessage("\n 你现在可以自由调整图层显示/冻结/隔离等操作。\n");
ed.WriteMessage(" 调整完成后,再次运行 FEXPORTPRODUCT 继续第 2 步(选五金并导出)。\n");
ed.WriteMessage(" 调整完成后,再次运行 FEXPORT 继续第 2 步(选五金并导出)。\n");
var proceedOpt = new PromptKeywordOptions(
"\n是否现在直接继续第 2 步? [继续(C)/稍后(R)]",
@ -568,7 +558,7 @@ public class Commands
if (proceedResult.Status != PromptStatus.OK || proceedResult.StringResult != "Continue")
{
ed.WriteMessage("\n已暂停导出。提示:再次运行 FEXPORTPRODUCT 继续;运行 FEXPORTRESET 可清除暂存。\n");
ed.WriteMessage("\n已暂停导出。提示:再次运行 FEXPORT 继续;运行 FEXPORTRESET 可清除暂存。\n");
return;
}
}
@ -693,9 +683,6 @@ public class Commands
// 2) 偏心孔位分组:把“主孔+侧孔”合并为 1 个逻辑孔位(不改变 holes 的几何数据,仅提供分组与逻辑计数)
var holeGroups = HoleGrouping.BuildEccentricScrewGroups(allHoles);
HoleGrouping.AttachGroupsToPanels(panels, holeGroups);
var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups);
MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns);
var snapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(panels);
var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles);
@ -742,9 +729,6 @@ public class Commands
exportData.Panels = panels;
exportData.Holes = allHoles;
exportData.HoleGroups = holeGroups;
exportData.MountPatterns = mountPatterns;
exportData.SnapPatterns = mountPatterns;
exportData.SnapSurfaces = snapSurfaces;
exportData.LogicalHoleCount = logicalHoleCount;
if (panelAnalyzer.ExcludedHoles.Count > 0)
@ -858,25 +842,8 @@ public class Commands
}
}
[CommandMethod("FEXPORTBUNDLE")]
public void ExportFurnitureBundle()
{
ExportFurniture();
}
[CommandMethod("FEXPORT")]
public void ExportAssetDefault()
{
ExportSingleAssetCore();
}
[CommandMethod("FEXPORTASSET")]
public void ExportSingleAsset()
{
ExportSingleAssetCore();
}
private void ExportSingleAssetCore()
{
var doc = AcApp.DocumentManager.MdiActiveDocument;
if (doc == null)
@ -1018,9 +985,6 @@ public class Commands
exportData.Panels = panelAnalysis.Panels;
exportData.Holes = panelAnalysis.Holes;
exportData.HoleGroups = panelAnalysis.HoleGroups;
exportData.MountPatterns = panelAnalysis.MountPatterns;
exportData.SnapPatterns = panelAnalysis.SnapPatterns;
exportData.SnapSurfaces = panelAnalysis.SnapSurfaces;
exportData.LogicalHoleCount = panelAnalysis.LogicalHoleCount;
exportData.EdgeFeatures = panelAnalysis.EdgeFeatures;
exportData.PanelCount = panelAnalysis.Panels.Count;
@ -1060,11 +1024,21 @@ public class Commands
if (mode.AnalyzePanels && panelAnalysis.Panels.Count > 0)
{
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (single_asset 使用 snapPatterns/snapSurfaces)\n");
ed.WriteMessage(" [4/5] 生成装配语义...");
var assemblyAnalyzer = new AssemblySemanticAnalyzer();
exportData.Assembly = assemblyAnalyzer.Analyze(
panelAnalysis.Panels,
panelAnalysis.Holes,
panelAnalysis.HoleGroups,
exportData.HardwareTypes);
var highConfidenceCount = exportData.Assembly.PanelAssemblies.Count(item =>
item.Confidence >= 0.75 &&
!string.Equals(item.MountMode, "unknown", StringComparison.OrdinalIgnoreCase));
ed.WriteMessage($" ✓ ({exportData.Assembly.PanelAssemblies.Count} 个板件语义, {highConfidenceCount} 个高置信)\n");
}
else
{
ed.WriteMessage(" [4/5] 跳过旧装配语义... ✓ (当前模式无需生成)\n");
ed.WriteMessage(" [4/5] 生成装配语义... ✓ (当前模式无需生成)\n");
}
ed.WriteMessage(" [5/5] 打包文件...");
@ -1134,7 +1108,7 @@ public class Commands
var ed = doc.Editor;
var docKey = string.IsNullOrWhiteSpace(doc.Name) ? "Untitled" : doc.Name;
ExportSessionManager.Clear(docKey);
ed.WriteMessage("\n已清除整套兼容导出暂存会话。请重新运行 FEXPORTPRODUCT。\n");
ed.WriteMessage("\n已清除 FEXPORT 暂存会话。请重新运行 FEXPORT。\n");
}
private static List<string> GetEntityHandles(Database db, ObjectId[] ids)

View File

@ -1,236 +1,229 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Xunit;
namespace FurnitureExporter.Tests
namespace FurnitureExporter.Tests;
public class AssemblySemanticAnalyzerTests
{
public class AssemblySemanticAnalyzerTests
[Fact]
public void Analyze_BuildsAssemblySection_AndDoesNotEmitInstallOrderFields()
{
[Fact]
public void Analyze_BuildsAssemblySection_AndDoesNotEmitInstallOrderFields()
var exportData = new ExportData
{
var exportData = new ExportData
{
Panels = new List<PanelInfo>
{
CreatePanel(
"panel_001",
"side_panel",
new[] { 9.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
new[] { 0.0, 0.0, 0.0 },
new[] { 18.0, 560.0, 720.0 }),
CreatePanel(
"panel_002",
"shelf",
new[] { 200.0, 280.0, 359.0 },
new[] { 0.0, 0.0, 1.0 },
new[] { 18.0, 0.0, 350.0 },
new[] { 382.0, 560.0, 368.0 }),
},
};
exportData.Assembly = new AssemblySemanticAnalyzer().Analyze(
exportData.Panels,
exportData.Holes,
exportData.HoleGroups,
exportData.HardwareTypes);
var json = JsonConvert.SerializeObject(
exportData,
new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore,
});
var root = JObject.Parse(json);
Assert.Equal("1.0", root["assembly"]?["version"]?.Value<string>());
Assert.NotNull(root["assembly"]?["panelAssemblies"]);
Assert.NotNull(root["assembly"]?["connections"]);
Assert.DoesNotContain("installOrder", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_PrefersHardwareRelation_ForConnectedPanel()
{
var panels = new List<PanelInfo>
Panels = new List<PanelInfo>
{
CreatePanel(
"panel_001",
"side_panel",
new[] { 9.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
new[] { 0.0, 0.0, 0.0 },
new[] { 18.0, 560.0, 720.0 }),
position: new[] { 9.0, 280.0, 360.0 },
normal: new[] { 1.0, 0.0, 0.0 },
boundingBoxMin: new[] { 0.0, 0.0, 0.0 },
boundingBoxMax: new[] { 18.0, 560.0, 720.0 }),
CreatePanel(
"panel_002",
"shelf",
new[] { 200.0, 280.0, 359.0 },
new[] { 0.0, 0.0, 1.0 },
new[] { 18.0, 0.0, 350.0 },
new[] { 382.0, 560.0, 368.0 }),
};
position: new[] { 200.0, 280.0, 359.0 },
normal: new[] { 0.0, 0.0, 1.0 },
boundingBoxMin: new[] { 18.0, 0.0, 350.0 },
boundingBoxMax: new[] { 382.0, 560.0, 368.0 }),
},
};
var holes = new List<HoleInfo>
exportData.Assembly = AssemblySemanticAnalyzer.Analyze(
exportData.Panels,
exportData.Holes,
exportData.HoleGroups,
exportData.HardwareTypes);
var json = JsonConvert.SerializeObject(
exportData,
new JsonSerializerSettings
{
new HoleInfo
{
Id = "panel_001_hole_001",
PanelId = "panel_001",
Position = new[] { 9.0, 260.0, 360.0 },
Normal = new[] { 1.0, 0.0, 0.0 },
Diameter = 15.0,
Radius = 7.5,
Depth = 12.0,
Type = "blind",
},
new HoleInfo
{
Id = "panel_001_hole_002",
PanelId = "panel_001",
Position = new[] { 16.0, 260.0, 360.0 },
Normal = new[] { 0.0, 1.0, 0.0 },
Diameter = 8.0,
Radius = 4.0,
Depth = 10.0,
Type = "blind",
},
};
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore,
});
var holeGroups = new List<HoleGroupInfo>
var root = JObject.Parse(json);
Assert.Equal("1.0", root["assembly"]?["version"]?.Value<string>());
Assert.NotNull(root["assembly"]?["panelAssemblies"]);
Assert.NotNull(root["assembly"]?["connections"]);
Assert.DoesNotContain("installOrder", json, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Analyze_PrefersHoleGroupAndHardwareEvidence_OverPanelTypeFallback()
{
var panels = new List<PanelInfo>
{
CreatePanel(
"panel_001",
"side_panel",
position: new[] { 9.0, 280.0, 360.0 },
normal: new[] { 1.0, 0.0, 0.0 },
boundingBoxMin: new[] { 0.0, 0.0, 0.0 },
boundingBoxMax: new[] { 18.0, 560.0, 720.0 }),
CreatePanel(
"panel_002",
"shelf",
position: new[] { 200.0, 280.0, 359.0 },
normal: new[] { 0.0, 0.0, 1.0 },
boundingBoxMin: new[] { 18.0, 0.0, 350.0 },
boundingBoxMax: new[] { 382.0, 560.0, 368.0 }),
};
var holes = new List<HoleInfo>
{
new()
{
new HoleGroupInfo
{
Id = "panel_001_group_000",
PanelId = "panel_001",
GroupType = "eccentric_screw",
HoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
Position = new[] { 9.0, 260.0, 360.0 },
Normal = new[] { 1.0, 0.0, 0.0 },
Confidence = 0.96,
Reason = "偏心件孔组",
},
};
panels[0].Holes = holes.ToList();
panels[0].HoleGroups = holeGroups.ToList();
var hardwareTypes = new List<HardwareType>
Id = "panel_001_hole_001",
PanelId = "panel_001",
Position = new[] { 9.0, 260.0, 360.0 },
Normal = new[] { 1.0, 0.0, 0.0 },
Diameter = 15.0,
Radius = 7.5,
Depth = 12.0,
Type = "blind",
},
new()
{
new HardwareType
Id = "panel_001_hole_002",
PanelId = "panel_001",
Position = new[] { 16.0, 260.0, 360.0 },
Normal = new[] { 0.0, 1.0, 0.0 },
Diameter = 8.0,
Radius = 4.0,
Depth = 10.0,
Type = "blind",
},
};
var holeGroups = new List<HoleGroupInfo>
{
new()
{
Id = "panel_001_group_000",
PanelId = "panel_001",
GroupType = "eccentric_screw",
HoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
Position = new[] { 9.0, 260.0, 360.0 },
Normal = new[] { 1.0, 0.0, 0.0 },
Confidence = 0.96,
Reason = "偏心件孔组",
},
};
panels[0].Holes = holes.ToList();
panels[0].HoleGroups = holeGroups.ToList();
var hardwareTypes = new List<HardwareType>
{
new()
{
TypeId = "type_000",
InferredHardwareType = "connector",
Instances = new List<HardwareInstance>
{
TypeId = "type_000",
InferredHardwareType = "connector",
Instances = new List<HardwareInstance>
new()
{
new HardwareInstance
{
Position = new Position3D { X = 12.0, Y = 260.0, Z = 360.0 },
PrimaryPanelId = "panel_001",
SecondaryPanelId = "panel_002",
BoundHoleGroupId = "panel_001_group_000",
BoundHoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
BindingMode = "hole_group",
BindingConfidence = 0.97,
BindingReason = "优先按孔组绑定",
NearbyPanelIds = new[] { "panel_001", "panel_002" },
},
Position = new Position3D { X = 12.0, Y = 260.0, Z = 360.0 },
PrimaryPanelId = "panel_001",
SecondaryPanelId = "panel_002",
BoundHoleGroupId = "panel_001_group_000",
BoundHoleIds = new[] { "panel_001_hole_001", "panel_001_hole_002" },
BindingMode = "hole_group",
BindingConfidence = 0.97,
BindingReason = "优先按孔组绑定",
NearbyPanelIds = new[] { "panel_001", "panel_002" },
},
},
};
},
};
var assembly = new AssemblySemanticAnalyzer().Analyze(panels, holes, holeGroups, hardwareTypes);
var connection = Assert.Single(
assembly.Connections.Where(item =>
item.SourcePanelId == "panel_001" &&
item.TargetPanelId == "panel_002"));
var panelAssembly = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_001"));
var assembly = AssemblySemanticAnalyzer.Analyze(panels, holes, holeGroups, hardwareTypes);
var panelAssembly = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_001"));
Assert.Equal("hole_group_hardware", connection.ConnectionType);
Assert.Equal("slide_in", panelAssembly.MountMode);
Assert.Equal("left", panelAssembly.AssemblyDirection);
Assert.Equal("panel_002", panelAssembly.PrimarySupportPanelId);
Assert.Contains("panel_002", panelAssembly.TargetPanelIds);
Assert.Contains("panel_002", panelAssembly.DependsOn);
Assert.Contains("panel_002", panelAssembly.Mates.Right ?? new List<string>());
Assert.True(panelAssembly.Confidence >= 0.75);
}
Assert.Equal("connector_lock", panelAssembly.MountMode);
Assert.Equal("panel_002", panelAssembly.PrimarySupportPanelId);
Assert.Contains("panel_002", panelAssembly.TargetPanelIds);
Assert.Contains("panel_002", panelAssembly.DependsOn);
Assert.Contains(panelAssembly.Mates, mate => mate.MateType == "hole_group_hardware" && mate.HoleGroupId == "panel_001_group_000");
Assert.True(panelAssembly.Confidence >= 0.9);
Assert.DoesNotContain(panelAssembly.Warnings, warning => warning.Contains("仅按 panelType", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Analyze_FallsBackToShelfDirection_WhenNoHardwareBindingExists()
[Fact]
public void Analyze_FallsBackToFaceMate_WhenNoHardwareBindingExists()
{
var panels = new List<PanelInfo>
{
var panels = new List<PanelInfo>
{
CreatePanel(
"panel_left",
"side_panel",
new[] { 9.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
new[] { 0.0, 0.0, 0.0 },
new[] { 18.0, 560.0, 720.0 }),
CreatePanel(
"panel_right",
"side_panel",
new[] { 391.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
new[] { 382.0, 0.0, 0.0 },
new[] { 400.0, 560.0, 720.0 }),
CreatePanel(
"panel_shelf",
"shelf",
new[] { 200.0, 280.0, 359.0 },
new[] { 0.0, 0.0, 1.0 },
new[] { 18.0, 0.0, 350.0 },
new[] { 382.0, 560.0, 368.0 }),
};
CreatePanel(
"panel_left",
"side_panel",
position: new[] { 9.0, 280.0, 360.0 },
normal: new[] { 1.0, 0.0, 0.0 },
boundingBoxMin: new[] { 0.0, 0.0, 0.0 },
boundingBoxMax: new[] { 18.0, 560.0, 720.0 }),
CreatePanel(
"panel_right",
"side_panel",
position: new[] { 391.0, 280.0, 360.0 },
normal: new[] { 1.0, 0.0, 0.0 },
boundingBoxMin: new[] { 382.0, 0.0, 0.0 },
boundingBoxMax: new[] { 400.0, 560.0, 720.0 }),
CreatePanel(
"panel_shelf",
"shelf",
position: new[] { 200.0, 280.0, 359.0 },
normal: new[] { 0.0, 0.0, 1.0 },
boundingBoxMin: new[] { 18.0, 0.0, 350.0 },
boundingBoxMax: new[] { 382.0, 560.0, 368.0 }),
};
var assembly = new AssemblySemanticAnalyzer().Analyze(
panels,
new List<HoleInfo>(),
new List<HoleGroupInfo>(),
new List<HardwareType>());
var assembly = AssemblySemanticAnalyzer.Analyze(
panels,
new List<HoleInfo>(),
new List<HoleGroupInfo>(),
new List<HardwareType>());
var shelf = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_shelf"));
var shelf = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_shelf"));
Assert.Equal("drop_in", shelf.MountMode);
Assert.Equal("top", shelf.AssemblyDirection);
Assert.Contains("panel_left", shelf.TargetPanelIds);
Assert.Contains("panel_right", shelf.TargetPanelIds);
Assert.Contains("panel_left", shelf.Mates.Left ?? new List<string>());
Assert.Contains("panel_right", shelf.Mates.Right ?? new List<string>());
Assert.True(shelf.TravelDistance > 0);
}
Assert.Equal("slot_insert", shelf.MountMode);
Assert.Contains("panel_left", shelf.TargetPanelIds);
Assert.Contains("panel_right", shelf.TargetPanelIds);
Assert.Contains(shelf.Mates, mate => mate.MateType == "face_contact");
Assert.True(shelf.TravelDistance > 0);
Assert.True(System.Math.Abs(shelf.AssemblyAxis[0]) > 0.9);
}
private static PanelInfo CreatePanel(
string id,
string panelType,
double[] position,
double[] normal,
double[] boundingBoxMin,
double[] boundingBoxMax)
private static PanelInfo CreatePanel(
string id,
string panelType,
double[] position,
double[] normal,
double[] boundingBoxMin,
double[] boundingBoxMax)
{
return new PanelInfo
{
return new PanelInfo
Id = id,
PanelType = panelType,
Position = position,
Normal = normal,
BoundingBox = new BoundingBox3D
{
Id = id,
PanelType = panelType,
Position = position,
Normal = normal,
BoundingBox = new BoundingBox3D
{
Min = boundingBoxMin,
Max = boundingBoxMax,
},
Size = new[]
{
Math.Round(boundingBoxMax[0] - boundingBoxMin[0], 2),
Math.Round(boundingBoxMax[1] - boundingBoxMin[1], 2),
Math.Round(boundingBoxMax[2] - boundingBoxMin[2], 2),
},
};
}
Min = boundingBoxMin,
Max = boundingBoxMax,
},
Size = new[]
{
System.Math.Round(boundingBoxMax[0] - boundingBoxMin[0], 2),
System.Math.Round(boundingBoxMax[1] - boundingBoxMin[1], 2),
System.Math.Round(boundingBoxMax[2] - boundingBoxMin[2], 2),
},
};
}
}

View File

@ -1,42 +0,0 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
namespace FurnitureExporter.Tests
{
public class CommandRegistrationTests
{
[Fact]
public void Fexport_DefaultsToSingleAssetExport()
{
var source = ReadCommandsSource();
Assert.Matches(CommandMethodPattern("FEXPORT", "ExportAssetDefault"), source);
Assert.Matches(CommandMethodPattern("FEXPORTASSET", "ExportSingleAsset"), source);
Assert.DoesNotMatch(CommandMethodPattern("FEXPORT", "ExportFurniture"), source);
}
[Fact]
public void LegacyProductBundleExport_UsesExplicitCompatibilityCommands()
{
var source = ReadCommandsSource();
Assert.Matches(CommandMethodPattern("FEXPORTPRODUCT", "ExportFurniture"), source);
Assert.Matches(CommandMethodPattern("FEXPORTBUNDLE", "ExportFurnitureBundle"), source);
}
private static string ReadCommandsSource()
{
var path = Path.GetFullPath(
Path.Combine(AppContext.BaseDirectory, @"..\..\..\..\Commands.cs"));
return File.ReadAllText(path);
}
private static Regex CommandMethodPattern(string commandName, string methodName)
{
return new Regex(
$@"\[CommandMethod\(""{Regex.Escape(commandName)}""\)\]\s+public\s+void\s+{Regex.Escape(methodName)}\s*\(",
RegexOptions.Multiline);
}
}
}

View File

@ -1,89 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace FurnitureExporter.Tests
{
public class MountPatternAnalyzerTests
{
[Fact]
public void BuildMountPatterns_ForTwoHoleLine_RequiresTwoHoles()
{
var panel = CreatePanel("panel_line");
var holes = new List<HoleInfo>
{
CreateHole("hole_a", "panel_line", new[] { -40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
CreateHole("hole_b", "panel_line", new[] { 40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
};
var patterns = MountPatternAnalyzer.BuildMountPatterns(
new[] { panel },
holes,
new List<HoleGroupInfo>());
var pattern = Assert.Single(patterns);
Assert.Equal("line_2", pattern.PatternType);
Assert.Equal(2, pattern.MinRequiredHoles);
Assert.Equal(2, pattern.PreferredHoleCount);
Assert.Single(panel.SnapPatterns);
Assert.Equal(panel.MountPatterns, panel.SnapPatterns);
}
[Fact]
public void BuildMountPatterns_ForSymmetricRectangle_DefaultsToTwoRequiredHoles()
{
var panel = CreatePanel("panel_rect");
var holes = new List<HoleInfo>
{
CreateHole("hole_a", "panel_rect", new[] { -40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
CreateHole("hole_b", "panel_rect", new[] { 40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
CreateHole("hole_c", "panel_rect", new[] { -40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
CreateHole("hole_d", "panel_rect", new[] { 40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
};
var patterns = MountPatternAnalyzer.BuildMountPatterns(
new[] { panel },
holes,
new List<HoleGroupInfo>());
var pattern = Assert.Single(patterns);
Assert.Equal("rectangle_4", pattern.PatternType);
Assert.Equal(2, pattern.MinRequiredHoles);
Assert.Equal(4, pattern.PreferredHoleCount);
Assert.True(pattern.Symmetry);
Assert.Empty(pattern.RequiredHoleIds);
Assert.Equal(4, pattern.OptionalHoleIds.Length);
}
private static PanelInfo CreatePanel(string id)
{
return new PanelInfo
{
Id = id,
Position = new[] { 0.0, 0.0, 0.0 },
Normal = new[] { 0.0, 1.0, 0.0 },
BoundingBox = new BoundingBox3D
{
Min = new[] { -300.0, -9.0, -300.0 },
Max = new[] { 300.0, 9.0, 300.0 },
},
Size = new[] { 600.0, 18.0, 600.0 },
};
}
private static HoleInfo CreateHole(string id, string panelId, double[] position, double[] normal, string[] surfaceFaces)
{
return new HoleInfo
{
Id = id,
PanelId = panelId,
Position = position,
Normal = normal,
Diameter = 8.0,
Radius = 4.0,
Depth = 12.0,
Type = "blind",
SurfaceFaces = surfaceFaces,
};
}
}
}

View File

@ -1,73 +0,0 @@
namespace FurnitureExporter.Tests
{
public class PanelFaceSemanticResolverTests
{
[Fact]
public void ResolveSurfaceFaces_ForThroughHole_ReturnsOppositeFaces()
{
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
new BoundingBox3D
{
Min = new[] { 0.0, 0.0, 0.0 },
Max = new[] { 18.0, 560.0, 720.0 },
},
new[] { 9.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
18.0);
Assert.Contains("left", faces);
Assert.Contains("right", faces);
}
[Fact]
public void ResolveSurfaceFaces_ForBlindHole_ReturnsSingleExteriorFace()
{
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
new BoundingBox3D
{
Min = new[] { 0.0, 0.0, 0.0 },
Max = new[] { 18.0, 560.0, 720.0 },
},
new[] { 4.0, 280.0, 360.0 },
new[] { 1.0, 0.0, 0.0 },
8.0);
Assert.Single(faces);
Assert.Equal("left", faces[0]);
}
[Fact]
public void ResolveSurfaceFaces_ForTopBottomThroughHole_ReturnsTopAndBottom()
{
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
new BoundingBox3D
{
Min = new[] { 0.0, 0.0, 0.0 },
Max = new[] { 600.0, 720.0, 18.0 },
},
new[] { 300.0, 360.0, 9.0 },
new[] { 0.0, 0.0, 1.0 },
18.0);
Assert.Contains("top", faces);
Assert.Contains("bottom", faces);
}
[Fact]
public void ResolveSurfaceFaces_ForSideBlindHole_PrefersSingleSideFace()
{
var faces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
new BoundingBox3D
{
Min = new[] { 0.0, 0.0, 0.0 },
Max = new[] { 600.0, 18.0, 720.0 },
},
new[] { 300.0, 4.0, 360.0 },
new[] { 0.0, 1.0, 0.0 },
8.0);
Assert.Single(faces);
Assert.Equal("front", faces[0]);
}
}
}

View File

@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
namespace FurnitureExporter.Tests
{
public class SnapSurfaceBuilderTests
{
[Fact]
public void BuildSnapSurfaces_FromBoundingBox_CreatesSixAxisFaces()
{
var panel = CreatePanel("panel_001");
var surfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel });
Assert.Equal(6, surfaces.Count);
Assert.Equal(6, panel.SnapSurfaces.Count);
var top = Assert.Single(surfaces.Where(surface => surface.Face == "top"));
Assert.Equal("panel_001_snap_top", top.Id);
Assert.Equal("panel_001", top.PanelId);
Assert.Equal(new[] { 0.0, 0.0, 1.0 }, top.Normal);
Assert.Equal(new[] { 0.0, 0.0, 18.0 }, top.Center);
Assert.Equal(18.0, top.Bounds.Min[2]);
Assert.Equal(18.0, top.Bounds.Max[2]);
Assert.Equal(1.0, top.Confidence);
var front = Assert.Single(surfaces.Where(surface => surface.Face == "front"));
Assert.Equal(new[] { 0.0, -1.0, 0.0 }, front.Normal);
Assert.Equal(-280.0, front.Center[1]);
Assert.Equal(-280.0, front.Bounds.Min[1]);
Assert.Equal(-280.0, front.Bounds.Max[1]);
}
[Fact]
public void ExportData_ForSingleAsset_EmitsSnapFieldsAndOmitsAssembly()
{
var panel = CreatePanel("panel_001");
var pattern = new MountPatternInfo
{
Id = "panel_001_mount_top_000",
PanelId = "panel_001",
MountFace = "top",
PatternType = "line_2",
HoleIds = new[] { "hole_a", "hole_b" },
RequiredHoleIds = new[] { "hole_a", "hole_b" },
MinRequiredHoles = 2,
PreferredHoleCount = 2,
Normal = new[] { 0.0, 0.0, 1.0 },
Position = new[] { 0.0, 0.0, 18.0 },
Confidence = 0.95,
};
panel.MountPatterns = new List<MountPatternInfo> { pattern };
panel.SnapPatterns = panel.MountPatterns;
var exportData = new ExportData
{
ExportMode = "single_asset",
Panels = new List<PanelInfo> { panel },
MountPatterns = new List<MountPatternInfo> { pattern },
SnapPatterns = new List<MountPatternInfo> { pattern },
SnapSurfaces = SnapSurfaceBuilder.BuildSnapSurfaces(new[] { panel }),
};
var json = JsonConvert.SerializeObject(
exportData,
new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore,
});
var root = JObject.Parse(json);
Assert.Null(root["assembly"]);
Assert.DoesNotContain("\"mates\"", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"connections\"", json, StringComparison.OrdinalIgnoreCase);
Assert.Single(Assert.IsType<JArray>(root["mountPatterns"]));
Assert.Single(Assert.IsType<JArray>(root["snapPatterns"]));
Assert.Equal(6, Assert.IsType<JArray>(root["snapSurfaces"]).Count);
var panels = Assert.IsType<JArray>(root["panels"]);
Assert.Equal(6, Assert.IsType<JArray>(panels[0]?["snapSurfaces"]).Count);
}
private static PanelInfo CreatePanel(string id)
{
return new PanelInfo
{
Id = id,
Position = new[] { 0.0, 0.0, 9.0 },
Normal = new[] { 0.0, 0.0, 1.0 },
BoundingBox = new BoundingBox3D
{
Min = new[] { -300.0, -280.0, 0.0 },
Max = new[] { 300.0, 280.0, 18.0 },
},
Size = new[] { 600.0, 560.0, 18.0 },
};
}
}
}

View File

@ -11,7 +11,7 @@
<!-- AutoCAD 2024 插件配置 -->
<AssemblyName>FurnitureExporter</AssemblyName>
<RootNamespace>FurnitureExporter</RootNamespace>
<Version>2.6.0</Version>
<Version>2.5.0</Version>
<Authors>Furniture 3D System</Authors>
<Description>家具 3D 模型导出插件 - 导出 GLB 模型和五金件位置数据</Description>

View File

@ -162,31 +162,6 @@ public class HoleDetector
{
try
{
BoundingBox3D? panelBoundingBox = null;
try
{
var ext = solid.GeometricExtents;
panelBoundingBox = new BoundingBox3D
{
Min = new[]
{
Math.Round(ext.MinPoint.X + _originOffset.X, 2),
Math.Round(ext.MinPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MinPoint.Z + _originOffset.Z, 2),
},
Max = new[]
{
Math.Round(ext.MaxPoint.X + _originOffset.X, 2),
Math.Round(ext.MaxPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MaxPoint.Z + _originOffset.Z, 2),
},
};
}
catch
{
panelBoundingBox = null;
}
var radius = cylinder.Radius;
var diameter = radius * 2;
@ -228,12 +203,7 @@ public class HoleDetector
Depth = Math.Round(depth, 2),
PanelId = panelId,
Type = holeType,
Radius = Math.Round(radius, 2),
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
panelBoundingBox,
new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z },
new[] { axis.X, axis.Y, axis.Z },
depth)
Radius = Math.Round(radius, 2)
};
}
catch (System.Exception ex)

View File

@ -92,13 +92,7 @@ public static class HoleGrouping
Position = mainHole.Position,
Normal = mainHole.Normal,
Confidence = confidence,
Reason = $"主孔直径 {mainHole.Diameter:0.#}mm + 侧孔直径 {best.Diameter:0.#}mm,距离 {dist:0.#}mm,法向点积 {dot:0.00}",
MountFace = mainHole.SurfaceFaces.FirstOrDefault(face => !string.IsNullOrWhiteSpace(face)) ?? "unknown",
SurfaceFaces = mainHole.SurfaceFaces
.Concat(best.SurfaceFaces ?? Array.Empty<string>())
.Where(face => !string.IsNullOrWhiteSpace(face))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(),
Reason = $"主孔直径 {mainHole.Diameter:0.#}mm + 侧孔直径 {best.Diameter:0.#}mm,距离 {dist:0.#}mm,法向点积 {dot:0.00}"
};
groups.Add(group);

View File

@ -374,11 +374,6 @@ public sealed class HoleMarkerDetector
BindingSource = binding.BindingSource,
BindingConfidence = binding.Confidence,
BindingReason = binding.BindingReason,
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
binding.PanelCandidate.Panel.BoundingBox,
new[] { p.X, p.Y, p.Z },
new[] { axis.X, axis.Y, axis.Z },
depth),
});
}
@ -423,11 +418,6 @@ public sealed class HoleMarkerDetector
BindingSource = binding.BindingSource,
BindingConfidence = binding.Confidence,
BindingReason = binding.BindingReason,
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
binding.PanelCandidate.Panel.BoundingBox,
new[] { p.X, p.Y, p.Z },
new[] { axis.X, axis.Y, axis.Z },
depth),
});
}

View File

@ -10,7 +10,7 @@ public class ExportData
/// <summary>
/// 数据格式版本
/// </summary>
public string Version { get; set; } = "2.6";
public string Version { get; set; } = "2.4";
/// <summary>
/// 导出时间 (ISO 8601)
@ -46,13 +46,6 @@ public class ExportData
/// 图纸单位(毫米/厘米/米等)
/// </summary>
public string Units { get; set; } = "毫米";
/// <summary>
/// CAD 世界坐标系标识。
/// 当前导出统一按 AutoCAD 世界坐标:X 向右,Y 向前,Z 向上。
/// Web 端后续做孔位吸附时,不再需要靠启发式猜测坐标系。
/// </summary>
public string CoordinateSystem { get; set; } = "AUTOCAD_WORLD_Z_UP";
/// <summary>
/// 原点偏移 [X, Y, Z] (mm)
@ -110,24 +103,6 @@ public class ExportData
/// </summary>
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
/// <summary>
/// 安装图样(v2.5 兼容字段)。
/// 新方案主字段是 SnapPatterns;这里继续保留给旧 Web 端兼容。
/// </summary>
public List<MountPatternInfo> MountPatterns { get; set; } = new();
/// <summary>
/// 吸附图样(v2.6 新主字段)。
/// 内容与 MountPatterns 保持一致,Web 端后续优先读取 snapPatterns。
/// </summary>
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
/// <summary>
/// 吸附面(v2.6 新增)。
/// 每块板件至少提供 6 个轴向外表面,用于辅助装配阶段做面级吸附候选。
/// </summary>
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
/// <summary>
/// 逻辑孔位数量(v2.2.4 新增)
/// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。
@ -338,21 +313,6 @@ public class PanelInfo
/// 该板件上的逻辑孔位分组(v2.2.4 新增)
/// </summary>
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
/// <summary>
/// 该板件上的安装图样(v2.5 兼容字段)
/// </summary>
public List<MountPatternInfo> MountPatterns { get; set; } = new();
/// <summary>
/// 该板件上的吸附图样(v2.6 新主字段)
/// </summary>
public List<MountPatternInfo> SnapPatterns { get; set; } = new();
/// <summary>
/// 该板件的吸附面(v2.6 新增)
/// </summary>
public List<SnapSurfaceInfo> SnapSurfaces { get; set; } = new();
}
/// <summary>
@ -431,14 +391,6 @@ public class HoleInfo
/// </summary>
public double BindingConfidence { get; set; } = 1.0;
/// <summary>
/// 孔位整体可用置信度(0-1)
/// 说明:
/// - BindingConfidence 只表示“孔位和板件绑定得稳不稳”;
/// - Confidence 表示“这个孔位几何与语义整体是否可信”。
/// </summary>
public double Confidence { get; set; } = 1.0;
/// <summary>
/// 绑定原因说明(调试/回放)
/// </summary>
@ -466,24 +418,12 @@ public class HoleInfo
/// 例如:main(主孔)/ side(侧孔)
/// </summary>
public string? HoleGroupRole { get; set; }
/// <summary>
/// 当前孔实际打在板件外表面的面标签。
/// 盲孔通常只有 1 个面;通孔通常会有 2 个相对面。
/// 例如:top / bottom / left / right / front / back
/// </summary>
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 推断的用途(根据直径)
/// </summary>
public string? InferredUsage { get; set; }
/// <summary>
/// 低置信度或歧义提示
/// </summary>
public List<string> Warnings { get; set; } = new();
/// <summary>
/// 针对该孔位的紧固件建议(v2.3 新增)
/// 说明:
@ -571,140 +511,6 @@ public class HoleGroupInfo
/// 可读原因(调试/回放)
/// </summary>
public string? Reason { get; set; }
/// <summary>
/// 当前孔组默认作用的安装面。
/// 例如:top / bottom / left / right / front / back / unknown
/// </summary>
public string MountFace { get; set; } = "unknown";
/// <summary>
/// 逻辑孔位实际落在哪些板件外表面上。
/// 默认按主孔 + 组内成员孔合并去重。
/// </summary>
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 低置信度或歧义提示
/// </summary>
public List<string> Warnings { get; set; } = new();
}
/// <summary>
/// 安装图样(v2.5 新增)
/// </summary>
public class MountPatternInfo
{
/// <summary>
/// 图样 ID(如 panel_001_mount_top_001)
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// 所属板件 ID
/// </summary>
public string PanelId { get; set; } = "";
/// <summary>
/// 默认安装面:top / bottom / left / right / front / back / unknown
/// </summary>
public string MountFace { get; set; } = "unknown";
/// <summary>
/// 图样类型:single / line_2 / triangle_3 / rectangle_4 / generic
/// </summary>
public string PatternType { get; set; } = "generic";
/// <summary>
/// 属于该图样的几何孔位 ID
/// </summary>
public string[] HoleIds { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 必需命中的孔位 ID
/// </summary>
public string[] RequiredHoleIds { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 可选命中的孔位 ID
/// </summary>
public string[] OptionalHoleIds { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 吸附成立所需的最少命中孔数
/// </summary>
public int MinRequiredHoles { get; set; }
/// <summary>
/// 推荐命中孔数(越接近越稳)
/// </summary>
public int PreferredHoleCount { get; set; }
/// <summary>
/// 是否允许对称复用
/// </summary>
public bool Symmetry { get; set; }
/// <summary>
/// 图样参考点(默认取图样中心)
/// </summary>
public double[] Position { get; set; } = new double[3];
/// <summary>
/// 图样参考法向(默认取安装面法向)
/// </summary>
public double[] Normal { get; set; } = new double[3];
/// <summary>
/// 图样置信度(0-1)
/// </summary>
public double Confidence { get; set; }
/// <summary>
/// 低置信度或歧义提示
/// </summary>
public List<string> Warnings { get; set; } = new();
}
/// <summary>
/// 吸附面信息(v2.6 新增)
/// </summary>
public class SnapSurfaceInfo
{
/// <summary>
/// 吸附面 ID(如 panel_001_snap_top)
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// 面标签:left / right / front / back / bottom / top
/// </summary>
public string Face { get; set; } = "";
/// <summary>
/// 所属板件 ID
/// </summary>
public string PanelId { get; set; } = "";
/// <summary>
/// 外法向量 [X, Y, Z]
/// </summary>
public double[] Normal { get; set; } = new double[3];
/// <summary>
/// 面中心 [X, Y, Z] (mm)
/// </summary>
public double[] Center { get; set; } = new double[3];
/// <summary>
/// 面边界框。薄轴最小值和最大值相同,表示该外表面所在平面。
/// </summary>
public BoundingBox3D Bounds { get; set; } = new();
/// <summary>
/// 推导置信度(0-1)
/// </summary>
public double Confidence { get; set; }
}
/// <summary>

View File

@ -1,445 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace FurnitureExporter;
/// <summary>
/// 安装图样识别器。
///
/// 设计口径:
/// - 吸附面(mount face)先缩小候选范围;
/// - HoleGroup 负责把几何孔组织成业务孔位;
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
/// </summary>
public static class MountPatternAnalyzer
{
private const double DefaultClusterDistanceMm = 220.0;
public static List<MountPatternInfo> BuildMountPatterns(
IReadOnlyList<PanelInfo> panels,
IReadOnlyList<HoleInfo> holes,
IReadOnlyList<HoleGroupInfo> holeGroups)
{
var patterns = new List<MountPatternInfo>();
if (panels == null || panels.Count == 0)
{
return patterns;
}
var holesByPanel = holes
.Where(h => !string.IsNullOrWhiteSpace(h.PanelId))
.GroupBy(h => h.PanelId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
var groupsByPanel = holeGroups
.Where(group => !string.IsNullOrWhiteSpace(group.PanelId))
.GroupBy(group => group.PanelId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var panel in panels)
{
var panelHoles = holesByPanel.TryGetValue(panel.Id, out var panelHoleList)
? panelHoleList
: new List<HoleInfo>();
var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList)
? panelGroupList
: new List<HoleGroupInfo>();
var units = BuildUnits(panel.Id, panelHoles, panelGroups);
if (units.Count == 0)
{
panel.MountPatterns = new List<MountPatternInfo>();
panel.SnapPatterns = new List<MountPatternInfo>();
continue;
}
var panelPatterns = new List<MountPatternInfo>();
var unitsByFace = units
.SelectMany(unit =>
unit.MountFaces.Select(face => new { Face = face, Unit = unit }))
.GroupBy(item => item.Face, StringComparer.OrdinalIgnoreCase);
foreach (var faceGroup in unitsByFace)
{
var clusters = BuildClusters(faceGroup.Select(item => item.Unit).Distinct().ToList());
var patternIndex = 0;
foreach (var cluster in clusters)
{
var pattern = BuildPattern(panel.Id, faceGroup.Key, patternIndex++, cluster);
panelPatterns.Add(pattern);
}
}
panel.MountPatterns = panelPatterns;
panel.SnapPatterns = panelPatterns;
patterns.AddRange(panelPatterns);
}
return patterns;
}
public static void AttachPatternsToPanels(IReadOnlyList<PanelInfo> panels, IReadOnlyList<MountPatternInfo> patterns)
{
var map = patterns
.GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
foreach (var panel in panels)
{
var panelPatterns = map.TryGetValue(panel.Id, out var items)
? items
: new List<MountPatternInfo>();
panel.MountPatterns = panelPatterns;
panel.SnapPatterns = panelPatterns;
}
}
private static List<MountPatternUnit> BuildUnits(
string panelId,
IReadOnlyList<HoleInfo> holes,
IReadOnlyList<HoleGroupInfo> holeGroups)
{
var units = new List<MountPatternUnit>();
var groupedHoleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in holeGroups)
{
foreach (var holeId in group.HoleIds ?? Array.Empty<string>())
{
groupedHoleIds.Add(holeId);
}
var mountFaces = NormalizeMountFaces(group.SurfaceFaces, group.MountFace);
group.MountFace = mountFaces.FirstOrDefault() ?? "unknown";
units.Add(new MountPatternUnit
{
Id = group.Id,
PanelId = panelId,
HoleIds = group.HoleIds ?? Array.Empty<string>(),
Position = group.Position,
Normal = group.Normal,
GroupType = group.GroupType,
MountFaces = mountFaces,
Confidence = Clamp01(group.Confidence <= 0 ? 0.72 : group.Confidence),
Warnings = group.Warnings?.ToList() ?? new List<string>(),
Source = "hole-group",
});
}
foreach (var hole in holes)
{
if (groupedHoleIds.Contains(hole.Id))
{
continue;
}
units.Add(new MountPatternUnit
{
Id = hole.Id,
PanelId = panelId,
HoleIds = new[] { hole.Id },
Position = hole.Position,
Normal = hole.Normal,
GroupType = hole.HoleGroupType,
MountFaces = NormalizeMountFaces(hole.SurfaceFaces),
Confidence = Clamp01(hole.Confidence <= 0 ? 0.76 : hole.Confidence),
Warnings = hole.Warnings?.ToList() ?? new List<string>(),
Source = "single-hole",
});
}
return units;
}
private static List<List<MountPatternUnit>> BuildClusters(IReadOnlyList<MountPatternUnit> units)
{
var clusters = new List<List<MountPatternUnit>>();
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var unit in units)
{
if (!visited.Add(unit.Id))
{
continue;
}
var cluster = new List<MountPatternUnit>();
var queue = new Queue<MountPatternUnit>();
queue.Enqueue(unit);
while (queue.Count > 0)
{
var current = queue.Dequeue();
cluster.Add(current);
foreach (var candidate in units)
{
if (visited.Contains(candidate.Id))
{
continue;
}
if (Distance(current.Position, candidate.Position) > DefaultClusterDistanceMm)
{
continue;
}
visited.Add(candidate.Id);
queue.Enqueue(candidate);
}
}
clusters.Add(cluster.OrderBy(item => item.Position[0]).ThenBy(item => item.Position[1]).ThenBy(item => item.Position[2]).ToList());
}
return clusters;
}
private static MountPatternInfo BuildPattern(
string panelId,
string mountFace,
int patternIndex,
IReadOnlyList<MountPatternUnit> cluster)
{
var holeIds = cluster
.SelectMany(unit => unit.HoleIds)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var warnings = cluster
.SelectMany(unit => unit.Warnings)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var pointCount = cluster.Count;
var patternType = ResolvePatternType(cluster);
var isSymmetricRectangle = string.Equals(patternType, "rectangle_4", StringComparison.OrdinalIgnoreCase);
var minRequiredHoles = ResolveMinRequiredHoleCount(patternType, pointCount, isSymmetricRectangle);
var preferredHoleCount = pointCount;
var requiredHoleIds = Array.Empty<string>();
var optionalHoleIds = Array.Empty<string>();
if (pointCount <= 3 || !isSymmetricRectangle)
{
requiredHoleIds = holeIds;
}
else
{
optionalHoleIds = holeIds;
warnings.Add("四孔对称图样默认按 2 孔完成初始定位,剩余孔位作为增强固定。");
}
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
{
warnings.Add("当前图样由几何聚类自动生成,建议后续在资产录入阶段人工复核。");
}
return new MountPatternInfo
{
Id = $"{panelId}_mount_{mountFace}_{patternIndex:D3}",
PanelId = panelId,
MountFace = mountFace,
PatternType = patternType,
HoleIds = holeIds,
RequiredHoleIds = requiredHoleIds,
OptionalHoleIds = optionalHoleIds,
MinRequiredHoles = minRequiredHoles,
PreferredHoleCount = preferredHoleCount,
Symmetry = isSymmetricRectangle,
Position = AveragePosition(cluster),
Normal = ResolveMountFaceNormal(mountFace),
Confidence = ComputePatternConfidence(cluster, patternType, warnings.Count),
Warnings = warnings,
};
}
private static string ResolvePatternType(IReadOnlyList<MountPatternUnit> cluster)
{
return cluster.Count switch
{
1 => "single",
2 => "line_2",
3 => "triangle_3",
4 when IsRectangle(cluster) => "rectangle_4",
_ => "generic",
};
}
private static int ResolveMinRequiredHoleCount(string patternType, int pointCount, bool isSymmetricRectangle)
{
if (string.Equals(patternType, "single", StringComparison.OrdinalIgnoreCase))
{
return 1;
}
if (string.Equals(patternType, "line_2", StringComparison.OrdinalIgnoreCase))
{
return 2;
}
if (string.Equals(patternType, "triangle_3", StringComparison.OrdinalIgnoreCase))
{
return 3;
}
if (isSymmetricRectangle)
{
return 2;
}
if (pointCount <= 0)
{
return 1;
}
return pointCount <= 4 ? pointCount : Math.Min(pointCount, 4);
}
private static bool IsRectangle(IReadOnlyList<MountPatternUnit> cluster)
{
if (cluster.Count != 4)
{
return false;
}
var distances = new List<double>();
for (var i = 0; i < cluster.Count; i++)
{
for (var j = i + 1; j < cluster.Count; j++)
{
distances.Add(Distance(cluster[i].Position, cluster[j].Position));
}
}
distances.Sort();
if (distances.Count != 6)
{
return false;
}
var sideA = distances[0];
var sideB = distances[2];
var diagonalA = distances[4];
var diagonalB = distances[5];
return AreClose(sideA, distances[1], 0.12) &&
AreClose(sideB, distances[3], 0.12) &&
AreClose(diagonalA, diagonalB, 0.08);
}
private static double[] AveragePosition(IReadOnlyList<MountPatternUnit> cluster)
{
if (cluster.Count == 0)
{
return new[] { 0.0, 0.0, 0.0 };
}
return new[]
{
Math.Round(cluster.Average(item => item.Position[0]), 2),
Math.Round(cluster.Average(item => item.Position[1]), 2),
Math.Round(cluster.Average(item => item.Position[2]), 2),
};
}
private static double[] ResolveMountFaceNormal(string mountFace)
{
return mountFace.ToLowerInvariant() switch
{
"left" => new[] { -1.0, 0.0, 0.0 },
"right" => new[] { 1.0, 0.0, 0.0 },
"front" => new[] { 0.0, -1.0, 0.0 },
"back" => new[] { 0.0, 1.0, 0.0 },
"bottom" => new[] { 0.0, 0.0, -1.0 },
"top" => new[] { 0.0, 0.0, 1.0 },
_ => new[] { 0.0, 0.0, 1.0 },
};
}
private static double ComputePatternConfidence(
IReadOnlyList<MountPatternUnit> cluster,
string patternType,
int warningCount)
{
if (cluster.Count == 0)
{
return 0;
}
var baseConfidence = cluster.Average(item => item.Confidence);
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
{
baseConfidence = Math.Min(baseConfidence, 0.68);
}
if (warningCount > 0)
{
baseConfidence -= Math.Min(0.14, warningCount * 0.04);
}
return Math.Round(Clamp01(baseConfidence), 4);
}
private static string[] NormalizeMountFaces(IEnumerable<string>? faces, string? fallbackFace = null)
{
var normalized = (faces ?? Array.Empty<string>())
.Where(face => !string.IsNullOrWhiteSpace(face))
.Select(face => face.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count > 0)
{
return normalized.ToArray();
}
if (!string.IsNullOrWhiteSpace(fallbackFace))
{
return new[] { fallbackFace!.Trim().ToLowerInvariant() };
}
return new[] { "unknown" };
}
private static double Distance(IReadOnlyList<double> left, IReadOnlyList<double> right)
{
var dx = left[0] - right[0];
var dy = left[1] - right[1];
var dz = left[2] - right[2];
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
}
private static bool AreClose(double left, double right, double toleranceRatio)
{
var baseValue = Math.Max(Math.Abs(left), Math.Abs(right));
if (baseValue <= 1e-6)
{
return true;
}
return Math.Abs(left - right) / baseValue <= toleranceRatio;
}
private static double Clamp01(double value)
{
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
private sealed class MountPatternUnit
{
public string Id { get; set; } = "";
public string PanelId { get; set; } = "";
public string[] HoleIds { get; set; } = Array.Empty<string>();
public double[] Position { get; set; } = new double[3];
public double[] Normal { get; set; } = new double[3];
public string? GroupType { get; set; }
public string[] MountFaces { get; set; } = Array.Empty<string>();
public double Confidence { get; set; }
public List<string> Warnings { get; set; } = new();
public string Source { get; set; } = "single-hole";
}
}

View File

@ -210,24 +210,8 @@ public class PanelAnalyzer
// 判断板材方向(法向量)
var normal = DeterminePanelNormal(dims, size);
var panelBoundingBox = new BoundingBox3D
{
Min = new double[]
{
Math.Round(ext.MinPoint.X + _originOffset.X, 2),
Math.Round(ext.MinPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MinPoint.Z + _originOffset.Z, 2)
},
Max = new double[]
{
Math.Round(ext.MaxPoint.X + _originOffset.X, 2),
Math.Round(ext.MaxPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MaxPoint.Z + _originOffset.Z, 2)
}
};
// 提取孔位
var holes = ExtractHoles(solid, $"panel_{index:D3}", thickness, panelBoundingBox);
var holes = ExtractHoles(solid, $"panel_{index:D3}", thickness);
// v2.2.5:孔位过滤(与设计师“孔位口径”对齐)
// - 极小薄件(如脚垫/垫片)不作为板件孔位统计对象,避免误把“几何细节”当孔
@ -269,7 +253,21 @@ public class PanelAnalyzer
Math.Round(dims[2], 2) // 厚
},
Normal = normal,
BoundingBox = panelBoundingBox,
BoundingBox = new BoundingBox3D
{
Min = new double[]
{
Math.Round(ext.MinPoint.X + _originOffset.X, 2),
Math.Round(ext.MinPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MinPoint.Z + _originOffset.Z, 2)
},
Max = new double[]
{
Math.Round(ext.MaxPoint.X + _originOffset.X, 2),
Math.Round(ext.MaxPoint.Y + _originOffset.Y, 2),
Math.Round(ext.MaxPoint.Z + _originOffset.Z, 2)
}
},
Holes = holes
};
}
@ -301,11 +299,7 @@ public class PanelAnalyzer
/// <summary>
/// 从板材实体中提取孔位信息
/// </summary>
private List<HoleInfo> ExtractHoles(
Solid3d solid,
string panelId,
double panelThickness,
BoundingBox3D panelBoundingBox)
private List<HoleInfo> ExtractHoles(Solid3d solid, string panelId, double panelThickness)
{
var holes = new List<HoleInfo>();
@ -321,7 +315,7 @@ public class PanelAnalyzer
var surface = BrepSurfaceUtils.Unwrap(face.Surface);
if (surface is Cylinder cylinder)
{
var hole = ExtractHoleFromCylinder(cylinder, face, panelId, holeIndex, panelThickness, panelBoundingBox);
var hole = ExtractHoleFromCylinder(cylinder, face, panelId, holeIndex, panelThickness);
if (hole != null)
{
holes.Add(hole);
@ -332,7 +326,7 @@ public class PanelAnalyzer
{
// 柜脚/三角铁等五金件上常见“沉头孔/倒角孔”,其孔壁在 Brep 中可能表现为锥面(Cone),
// 仅识别 Cylinder 会漏掉这类孔位。
var hole = ExtractHoleFromCone(cone, face, panelId, holeIndex, panelThickness, panelBoundingBox);
var hole = ExtractHoleFromCone(cone, face, panelId, holeIndex, panelThickness);
if (hole != null)
{
holes.Add(hole);
@ -350,7 +344,7 @@ public class PanelAnalyzer
// 某些孔位(尤其是五金件的沉头孔/复杂倒角)在 AutoCAD Brep 中,孔壁面可能被表达为 NurbSurface/其它曲面,
// 导致 surface is Cylinder/Cone 判断失败。
// 这里用“闭合圆边界 + 相邻端面平面法向推断孔轴”的方式做一次兜底,尽量补齐漏孔。
var hole = ExtractHoleFromFallbackFace(face, panelId, holeIndex, panelThickness, panelBoundingBox);
var hole = ExtractHoleFromFallbackFace(face, panelId, holeIndex, panelThickness);
if (hole != null)
{
holes.Add(hole);
@ -380,8 +374,7 @@ public class PanelAnalyzer
Autodesk.AutoCAD.BoundaryRepresentation.Face face,
string panelId,
int index,
double panelThickness,
BoundingBox3D panelBoundingBox)
double panelThickness)
{
try
{
@ -465,12 +458,7 @@ public class PanelAnalyzer
DetectionSource = "brep",
BindingSource = "panel_brep",
BindingConfidence = 1.0,
BindingReason = "从当前板件实体 Brep 直接提取",
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
panelBoundingBox,
new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z },
new[] { axis.X, axis.Y, axis.Z },
depth)
BindingReason = "从当前板件实体 Brep 直接提取"
};
}
catch
@ -487,8 +475,7 @@ public class PanelAnalyzer
Autodesk.AutoCAD.BoundaryRepresentation.Face face,
string panelId,
int index,
double panelThickness,
BoundingBox3D panelBoundingBox)
double panelThickness)
{
try
{
@ -573,12 +560,7 @@ public class PanelAnalyzer
DetectionSource = "brep",
BindingSource = "panel_brep",
BindingConfidence = 1.0,
BindingReason = "从当前板件实体 Brep 直接提取",
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
panelBoundingBox,
new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z },
new[] { axis.X, axis.Y, axis.Z },
depth)
BindingReason = "从当前板件实体 Brep 直接提取"
};
}
catch
@ -595,8 +577,7 @@ public class PanelAnalyzer
BrepFace face,
string panelId,
int index,
double panelThickness,
BoundingBox3D panelBoundingBox)
double panelThickness)
{
try
{
@ -670,12 +651,7 @@ public class PanelAnalyzer
DetectionSource = "brep",
BindingSource = "panel_brep",
BindingConfidence = 1.0,
BindingReason = "从当前板件实体 Brep 兜底面直接提取",
SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces(
panelBoundingBox,
new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z },
new[] { axis.X, axis.Y, axis.Z },
depth)
BindingReason = "从当前板件实体 Brep 兜底面直接提取"
};
}
catch

View File

@ -1,226 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace FurnitureExporter;
public static class PanelFaceSemanticResolver
{
private const double SurfaceFaceToleranceMm = 3.0;
private const double InteriorFaceToleranceMm = 0.8;
public static string[] ResolveSurfaceFaces(
BoundingBox3D? boundingBox,
IReadOnlyList<double>? position,
IReadOnlyList<double>? normal,
double depth)
{
if (boundingBox?.Min?.Length != 3 || boundingBox.Max?.Length != 3)
{
return Array.Empty<string>();
}
var center = ToVector3(position);
var axis = Normalize(ToVector3(normal));
if (center == null || axis == null)
{
return Array.Empty<string>();
}
var faces = new List<string>();
var halfDepth = Math.Max(0, depth) / 2.0;
var dominantFaces = ResolveDominantAxisFaces(axis);
if (halfDepth > 0.01)
{
AddFace(
faces,
ResolveNearestSurfaceFace(
boundingBox,
Offset(center, axis, halfDepth),
dominantFaces.Positive,
dominantFaces.Negative,
SurfaceFaceToleranceMm));
AddFace(
faces,
ResolveNearestSurfaceFace(
boundingBox,
Offset(center, axis, -halfDepth),
dominantFaces.Negative,
dominantFaces.Positive,
SurfaceFaceToleranceMm));
}
if (faces.Count == 0)
{
AddFace(
faces,
ResolveNearestSurfaceFace(
boundingBox,
center,
dominantFaces.Positive,
dominantFaces.Negative,
SurfaceFaceToleranceMm + InteriorFaceToleranceMm));
}
return faces
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public static string OppositeFace(string? face)
{
return face?.Trim().ToLowerInvariant() switch
{
"left" => "right",
"right" => "left",
"front" => "back",
"back" => "front",
"top" => "bottom",
"bottom" => "top",
_ => "unknown",
};
}
private static void AddFace(ICollection<string> faces, string? face)
{
if (string.IsNullOrWhiteSpace(face))
{
return;
}
faces.Add(face!.Trim().ToLowerInvariant());
}
private static double[]? ToVector3(IReadOnlyList<double>? value)
{
if (value == null || value.Count < 3)
{
return null;
}
return new[] { value[0], value[1], value[2] };
}
private static double[]? Normalize(double[]? value)
{
if (value == null)
{
return null;
}
var length = Math.Sqrt(value[0] * value[0] + value[1] * value[1] + value[2] * value[2]);
if (length < 1e-9)
{
return null;
}
return new[]
{
value[0] / length,
value[1] / length,
value[2] / length,
};
}
private static double[] Offset(double[] origin, double[] direction, double distance)
{
return new[]
{
origin[0] + direction[0] * distance,
origin[1] + direction[1] * distance,
origin[2] + direction[2] * distance,
};
}
private static string? ResolveNearestSurfaceFace(BoundingBox3D box, IReadOnlyList<double> point)
{
return ResolveNearestSurfaceFace(box, point, null, null, SurfaceFaceToleranceMm);
}
private static string? ResolveNearestSurfaceFace(
BoundingBox3D box,
IReadOnlyList<double> point,
string? preferredFace,
string? secondaryFace,
double tolerance)
{
var candidates = new List<(string Face, double Distance)>();
if (IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance) &&
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
{
candidates.Add(("left", Math.Abs(point[0] - box.Min[0])));
candidates.Add(("right", Math.Abs(point[0] - box.Max[0])));
}
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
{
candidates.Add(("front", Math.Abs(point[1] - box.Min[1])));
candidates.Add(("back", Math.Abs(point[1] - box.Max[1])));
}
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance))
{
candidates.Add(("bottom", Math.Abs(point[2] - box.Min[2])));
candidates.Add(("top", Math.Abs(point[2] - box.Max[2])));
}
var best = candidates
.Where(item => item.Distance <= tolerance)
.OrderBy(item => GetFacePriority(item.Face, preferredFace, secondaryFace))
.ThenBy(item => item.Distance)
.FirstOrDefault();
return string.IsNullOrWhiteSpace(best.Face) ? null : best.Face;
}
private static (string Positive, string Negative) ResolveDominantAxisFaces(IReadOnlyList<double> axis)
{
var absX = Math.Abs(axis[0]);
var absY = Math.Abs(axis[1]);
var absZ = Math.Abs(axis[2]);
if (absX >= absY && absX >= absZ)
{
return axis[0] >= 0
? ("right", "left")
: ("left", "right");
}
if (absY >= absX && absY >= absZ)
{
return axis[1] >= 0
? ("back", "front")
: ("front", "back");
}
return axis[2] >= 0
? ("top", "bottom")
: ("bottom", "top");
}
private static int GetFacePriority(string face, string? preferredFace, string? secondaryFace)
{
if (!string.IsNullOrWhiteSpace(preferredFace) &&
string.Equals(face, preferredFace, StringComparison.OrdinalIgnoreCase))
{
return 0;
}
if (!string.IsNullOrWhiteSpace(secondaryFace) &&
string.Equals(face, secondaryFace, StringComparison.OrdinalIgnoreCase))
{
return 1;
}
return 2;
}
private static bool IsWithinRange(double value, double min, double max, double tolerance)
{
return value >= min - tolerance && value <= max + tolerance;
}
}

View File

@ -1,135 +0,0 @@
using System;
using System.Collections.Generic;
namespace FurnitureExporter;
/// <summary>
/// 从板件包围盒推导辅助装配吸附面。
/// </summary>
public static class SnapSurfaceBuilder
{
private static readonly FaceDefinition[] Faces =
{
new("left", 0, false, new[] { -1.0, 0.0, 0.0 }),
new("right", 0, true, new[] { 1.0, 0.0, 0.0 }),
new("front", 1, false, new[] { 0.0, -1.0, 0.0 }),
new("back", 1, true, new[] { 0.0, 1.0, 0.0 }),
new("bottom", 2, false, new[] { 0.0, 0.0, -1.0 }),
new("top", 2, true, new[] { 0.0, 0.0, 1.0 }),
};
public static List<SnapSurfaceInfo> BuildSnapSurfaces(IReadOnlyList<PanelInfo> panels)
{
var allSurfaces = new List<SnapSurfaceInfo>();
foreach (var panel in panels)
{
var panelSurfaces = BuildPanelSurfaces(panel);
panel.SnapSurfaces = panelSurfaces;
allSurfaces.AddRange(panelSurfaces);
}
return allSurfaces;
}
private static List<SnapSurfaceInfo> BuildPanelSurfaces(PanelInfo panel)
{
var (min, max, confidence) = ResolveBounds(panel);
var surfaces = new List<SnapSurfaceInfo>(Faces.Length);
foreach (var face in Faces)
{
var center = new[]
{
Round((min[0] + max[0]) / 2.0),
Round((min[1] + max[1]) / 2.0),
Round((min[2] + max[2]) / 2.0),
};
var boundsMin = new[] { min[0], min[1], min[2] };
var boundsMax = new[] { max[0], max[1], max[2] };
var planeValue = face.UseMax ? max[face.Axis] : min[face.Axis];
center[face.Axis] = planeValue;
boundsMin[face.Axis] = planeValue;
boundsMax[face.Axis] = planeValue;
surfaces.Add(new SnapSurfaceInfo
{
Id = $"{panel.Id}_snap_{face.Name}",
Face = face.Name,
PanelId = panel.Id,
Normal = face.Normal,
Center = center,
Bounds = new BoundingBox3D
{
Min = RoundAll(boundsMin),
Max = RoundAll(boundsMax),
},
Confidence = confidence,
});
}
return surfaces;
}
private static (double[] Min, double[] Max, double Confidence) ResolveBounds(PanelInfo panel)
{
if (panel.BoundingBox?.Min?.Length == 3 && panel.BoundingBox.Max?.Length == 3)
{
return (RoundAll(panel.BoundingBox.Min), RoundAll(panel.BoundingBox.Max), 1.0);
}
var center = panel.Position?.Length == 3
? panel.Position
: new[] { 0.0, 0.0, 0.0 };
var size = panel.Size?.Length == 3
? panel.Size
: new[] { 0.0, 0.0, 0.0 };
var min = new[]
{
center[0] - Math.Abs(size[0]) / 2.0,
center[1] - Math.Abs(size[1]) / 2.0,
center[2] - Math.Abs(size[2]) / 2.0,
};
var max = new[]
{
center[0] + Math.Abs(size[0]) / 2.0,
center[1] + Math.Abs(size[1]) / 2.0,
center[2] + Math.Abs(size[2]) / 2.0,
};
return (RoundAll(min), RoundAll(max), 0.6);
}
private static double[] RoundAll(IReadOnlyList<double> values)
{
return new[]
{
Round(values[0]),
Round(values[1]),
Round(values[2]),
};
}
private static double Round(double value)
{
return Math.Round(value, 2);
}
private sealed class FaceDefinition
{
public FaceDefinition(string name, int axis, bool useMax, double[] normal)
{
Name = name;
Axis = axis;
UseMax = useMax;
Normal = normal;
}
public string Name { get; }
public int Axis { get; }
public bool UseMax { get; }
public double[] Normal { get; }
}
}

View File

@ -1,13 +1,13 @@
# AutoCAD 家具导出插件
用于 AutoCAD 2024 的家具 3D 模型导出插件。当前主流程是按资产中心口径导出单板材、单组件或单五金件,用于后续辅助装配和规格款入库。
用于 AutoCAD 2024 的家具 3D 模型导出插件。导出板材模型和五金件安装位置,用于 Web 端 3D 展示和安装视频生成。
## 功能特点
- **单资产导出** - `FEXPORT` 默认导出单板材、单组件或单五金件
- **板材语义保留** - 导出孔位、孔组、吸附图样和吸附面,供辅助装配使用
- **五金规格提取** - 单五金模式保留几何签名、图层类型和实例信息
- **整套兼容保留** - 旧整套家具导出迁移到 `FEXPORTPRODUCT`
- **只导出板材模型** - 五金件使用位置引用,文件体积小(1-3MB vs 30-50MB)
- **自动分组五金件** - 按几何特征自动识别相同类型的五金件
- **精确位置记录** - 记录每个五金件的安装位置和旋转角度
- **服务端自动匹配** - 上传后自动匹配五金件库
## 系统要求
@ -20,7 +20,7 @@
### 方法 1:手动安装发版包(推荐给产品/实施)
1. 完全关闭 AutoCAD 2024
2. 解压 `dist/家具导出插件-v2.6.0-一键安装版.zip`
2. 解压 `dist/家具导出插件-v2.3.1-一键安装版.zip`
3. 将整个 `FurnitureExporter.bundle` 文件夹复制到:
```text
@ -39,7 +39,7 @@
### 方法 2:一键安装
双击 `dist/家具导出插件-v2.6.0-一键安装版/安装.bat`
双击 `dist/家具导出插件-v2.3.1-一键安装版/安装.bat`
脚本会自动:
1. 复制插件到 `%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle`
@ -90,7 +90,7 @@ dotnet build -c Release
## 使用方法
### FEXPORT - 导出单资产到资产中心
### FEXPORT - 导出家具模型
```
命令: FEXPORT
@ -98,21 +98,13 @@ dotnet build -c Release
**操作步骤:**
1. **选择资产类型** - 板材 / 组件 / 五金
2. **选择实体** - 框选该单资产包含的 3D Solid 或图块,按 Enter 确认
3. **保存文件** - 选择 ZIP 保存位置
1. **选择板材** - 框选所有板材实体,按 Enter 确认
2. **选择五金件** - 框选所有五金件,按 Enter 确认(可跳过)
3. **保存文件** - 选择保存位置
**输出文件:** `xxx_board_asset.zip` / `xxx_component_asset.zip` / `xxx_hardware_asset.zip`,包含:
- `model.obj` - 单资产模型
- `hardware.json` - 单资产结构化数据,包含 `exportMode: "single_asset"` 与 `assetTypeHint`
### FEXPORTPRODUCT - 整套家具兼容导出
```
命令: FEXPORTPRODUCT
```
仅用于旧产品链路兼容。新资产中心和辅助装配测试请优先使用 `FEXPORT`。
**输出文件:** `xxx_export.zip`,包含:
- `model.glb` - 板材 3D 模型
- `hardware.json` - 五金件位置数据
### FINFO - 查看实体信息
@ -128,19 +120,36 @@ dotnet build -c Release
```json
{
"version": "2.6",
"exportedAt": "2026-05-11T10:30:00Z",
"sourceFile": "桌腿.dwg",
"version": "1.0",
"exportedAt": "2024-01-15T10:30:00Z",
"sourceFile": "衣柜.dwg",
"software": "AutoCAD 2024",
"pluginVersion": "2.6.0",
"modelFile": "model.obj",
"exportMode": "single_asset",
"assetTypeHint": "COMPONENT",
"sourceEntityCount": 1,
"panelCount": 1,
"snapPatterns": [],
"snapSurfaces": [],
"hardwareTypes": []
"pluginVersion": "1.0.0",
"modelFile": "model.glb",
"panelCount": 12,
"hardwareTypes": [
{
"typeId": "type_000",
"signature": {
"width": 35.0,
"height": 12.0,
"depth": 12.0,
"volume": 4032.5
},
"instances": [
{
"position": { "x": 100.0, "y": 50.0, "z": 18.0 },
"rotation": { "x": 0, "y": 0, "z": 1.5708 },
"sourceLayer": "五金件"
},
{
"position": { "x": 100.0, "y": 150.0, "z": 18.0 },
"rotation": { "x": 0, "y": 0, "z": 1.5708 },
"sourceLayer": "五金件"
}
]
}
]
}
```
@ -151,17 +160,17 @@ dotnet build -c Release
─────── ──────
1. 打开 DWG 文件
2. FEXPORT
3. 选择资产类型(板材/组件/五金)
4. 框选单个资产实体 ───────→
5. 保存 ZIP ────────────────→ 上传到资产中心
3. 框选板材 ────────────────→
4. 框选五金件 ──────────────→
5. 保存 ZIP ────────────────→ 上传 ZIP
↓
自动解析
↓
创建/更新资产族规格款
匹配五金件库
↓
保存到数据库
↓
辅助装配可用 ✓
3D 预览 ✓
```
## 开发

View File

@ -49,7 +49,7 @@ Write-Host ""
# 路径设置
$PluginName = "FurnitureExporter"
$Version = "2.6.0"
$Version = "2.5.0"
$SourceDir = Join-Path $PSScriptRoot "FurnitureExporter"
$InstallerDir = Join-Path $PSScriptRoot "installer"
$OutputDir = Join-Path $PSScriptRoot "dist"
@ -90,7 +90,7 @@ $PackageXml = @"
SchemaVersion="1.0"
AutodeskProduct="AutoCAD"
Name="$PluginName"
Description="家具导出插件 v$Version - 默认资产中心单资产导出,保留整套兼容导出"
Description="家具导出插件 v$Version - 支持整套兼容导出与资产中心单资产导出"
Author="Furniture 3D System"
AppVersion="$Version"
FriendlyVersion="$Version"
@ -119,7 +119,7 @@ $PackageXml = @"
AppName="$PluginName"
Version="$Version"
ModuleName="./Contents/$PluginName.dll"
AppDescription="家具导出插件 - 默认资产中心单资产导出,保留整套兼容导出"
AppDescription="家具导出插件 - 支持整套兼容导出与资产中心单资产导出"
LoadOnAutoCADStartup="True"
AppType="Net" />
</Components>
@ -306,7 +306,7 @@ New-ItemProperty -Path $appKey -Name "DESCRIPTION" -PropertyType String -Value "
Write-Step "[3/3] 完成"
Write-Host ""
Write-Host "安装完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
Write-Host "启动后命令行输入:FEXPORT(单资产)或 FEXPORTPRODUCT(整套兼容)" -ForegroundColor Green
Write-Host "启动后命令行输入:FEXPORTASSET(单资产)或 FEXPORT(整套兼容)" -ForegroundColor Green
Write-Host ""
'@
Write-TextFileUtf8Bom -Path (Join-Path $InstallerDir "install.ps1") -Content $InstallPs1
@ -480,10 +480,8 @@ $Readme = @"
使用方法
───────────────────────────────────────────────────────────────────────
打开 AutoCAD 后,命令行输入:
FEXPORT -> 单资产导出到资产中心(板材 / 组件 / 五金)
FEXPORTASSET -> 单资产导出别名
FEXPORTPRODUCT -> 整套家具兼容导出
FEXPORTBUNDLE -> 整套家具兼容导出别名
FEXPORTASSET -> 单资产导出到资产中心(板材 / 组件 / 五金)
FEXPORT -> 整套家具兼容导出
FINFO -> 查看实体信息
安装脚本做了什么(便于 IT/排查)
@ -497,7 +495,7 @@ $Readme = @"
常见问题
───────────────────────────────────────────────────────────────────────
Q: 提示“未知命令 FEXPORT / FEXPORTPRODUCT”
Q: 提示“未知命令 FEXPORTASSET / FEXPORT”
A: 1) 确认已重启 AutoCAD(关闭再打开)
2) 确认插件目录存在:
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll

View File

@ -75,8 +75,7 @@ if %ERRORLEVEL% EQU 0 (
echo 1. 打开 AutoCAD 2024
echo 2. 输入命令: NETLOAD
echo 3. 选择上述 DLL 文件
echo 4. 使用命令: FEXPORT 导出单资产模型
echo 旧整套兼容导出: FEXPORTPRODUCT
echo 4. 使用命令: FEXPORT 导出模型
echo.
) else (
echo.

View File

@ -84,8 +84,7 @@ try {
Write-Host " 1. 打开 AutoCAD 2024"
Write-Host " 2. 输入命令: NETLOAD"
Write-Host " 3. 选择上述 DLL 文件"
Write-Host " 4. 使用命令: FEXPORT 导出单资产模型"
Write-Host " 旧整套兼容导出: FEXPORTPRODUCT"
Write-Host " 4. 使用命令: FEXPORT 导出模型"
Write-Host ""
}
else {

View File

@ -3,12 +3,12 @@
SchemaVersion="1.0"
AutodeskProduct="AutoCAD"
Name="FurnitureExporter"
Description="家具导出插件 v2.6.0 - 默认资产中心单资产导出,保留整套兼容导出"
Description="家具导出插件 v2.5.0 - 支持整套兼容导出与资产中心单资产导出"
Author="Furniture 3D System"
AppVersion="2.6.0"
FriendlyVersion="2.6.0"
AppVersion="2.5.0"
FriendlyVersion="2.5.0"
ProductType="Application"
Version="2.6.0"
Version="2.5.0"
ProductCode="{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}">
<CompanyDetails
@ -30,9 +30,9 @@
<ComponentEntry
AppName="FurnitureExporter"
Version="2.6.0"
Version="2.5.0"
ModuleName="./Contents/FurnitureExporter.dll"
AppDescription="家具导出插件 - 默认资产中心单资产导出,保留整套兼容导出"
AppDescription="家具导出插件 - 支持整套兼容导出与资产中心单资产导出"
LoadOnAutoCADStartup="True"
AppType="Net" />
</Components>

View File

@ -174,5 +174,5 @@ New-ItemProperty -Path $appKey -Name "DESCRIPTION" -PropertyType String -Value "
Write-Step "[3/3] 完成"
Write-Host ""
Write-Host "安装完成。请完全关闭 AutoCAD 后重新启动。" -ForegroundColor Green
Write-Host "启动后命令行输入:FEXPORT(单资产)或 FEXPORTPRODUCT(整套兼容)" -ForegroundColor Green
Write-Host "启动后命令行输入:FEXPORTASSET(单资产)或 FEXPORT(整套兼容)" -ForegroundColor Green
Write-Host ""

View File

@ -1,5 +1,5 @@
═══════════════════════════════════════════════════════════════════════
家具导出插件 v2.6.0 - 安装指南
家具导出插件 v2.5.0 - 安装指南
═══════════════════════════════════════════════════════════════════════
安装步骤(只需30秒)
@ -16,10 +16,8 @@
使用方法
───────────────────────────────────────────────────────────────────────
打开 AutoCAD 后,命令行输入:
FEXPORT -> 单资产导出到资产中心(板材 / 组件 / 五金)
FEXPORTASSET -> 单资产导出别名
FEXPORTPRODUCT -> 整套家具兼容导出
FEXPORTBUNDLE -> 整套家具兼容导出别名
FEXPORTASSET -> 单资产导出到资产中心(板材 / 组件 / 五金)
FEXPORT -> 整套家具兼容导出
FINFO -> 查看实体信息
安装脚本做了什么(便于 IT/排查)
@ -33,10 +31,10 @@
常见问题
───────────────────────────────────────────────────────────────────────
Q: 提示“未知命令 FEXPORT / FEXPORTPRODUCT”
Q: 提示“未知命令 FEXPORTASSET / FEXPORT”
A: 1) 确认已重启 AutoCAD(关闭再打开)
2) 确认插件目录存在:
%AppData%\Autodesk\ApplicationPlugins\FurnitureExporter.bundle\Contents\FurnitureExporter.dll
3) 如果 SECURELOAD=2,脚本会将其降到 1;如仍无效请截图安装日志给开发排查
版本: v2.6.0
版本: v2.5.0

View File

@ -0,0 +1,13 @@
{
"timestamp": "2025-12-18T22:40:21.8958346+08:00",
"root": ".\\backups\\full\\20251218-224021",
"components": {
"database": null,
"files": null,
"config": [
".env",
"docker-compose*.yml",
"prisma/schema.prisma"
]
}
}

View File

@ -0,0 +1,160 @@
# ============================================
# 3D家具安装展示系统 - 缓存优化版Docker Compose
# 使用本地缓存加速构建和部署
# ============================================
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-3d-postgres
environment:
POSTGRES_DB: furniture_3d
POSTGRES_USER: furniture_user
POSTGRES_PASSWORD: furniture_pass_2024
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./cache/postgres-init:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
networks:
- furniture-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U furniture_user -d furniture_3d"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: furniture-3d-minio
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_BROWSER_REDIRECT_URL: http://localhost:9001
volumes:
- minio_data:/data
- ./cache/minio-config:/root/.minio:ro
ports:
- "9000:9000"
- "9001:9001"
networks:
- furniture-network
command: server /data --console-address ":9001"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# 3D家具展示应用
app:
build:
context: .
dockerfile: Dockerfile.optimized
args:
DATABASE_URL: postgresql://furniture_user:furniture_pass_2024@postgres:5432/furniture_3d
NEXTAUTH_URL: http://localhost:3000
APP_PUBLIC_URL: http://localhost:3000
# 使用缓存挂载加速构建
cache_from:
- node:18-alpine
target: runner
container_name: furniture-3d-app
environment:
# 数据库配置
DATABASE_URL: postgresql://furniture_user:furniture_pass_2024@postgres:5432/furniture_3d
# 认证配置
NEXTAUTH_SECRET: furniture-3d-secret-key-2024
NEXTAUTH_URL: http://localhost:3000
APP_PUBLIC_URL: http://localhost:3000
# MinIO/S3配置
S3_ENDPOINT: http://minio:9000
S3_BUCKET: furniture-3d-models
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_REGION: us-east-1
S3_FORCE_PATH_STYLE: "true"
# 应用配置
NODE_ENV: production
PORT: 3000
# 3D渲染优化
NEXT_SHARP: 0
NEXT_TELEMETRY_DISABLED: 1
volumes:
- app_uploads:/app/uploads
# 挂载本地缓存(只读)
- ./cache/prisma-engines:/app/prisma/engine-cache:ro
ports:
- "3000:3000"
networks:
- furniture-network
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# MinIO 初始化服务(创建存储桶等)
minio-init:
image: minio/mc:latest
container_name: furniture-3d-minio-init
environment:
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
networks:
- furniture-network
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
echo '🔧 配置MinIO客户端...'
mc alias set furniture http://minio:9000 minioadmin minioadmin
echo '📦 创建存储桶...'
mc mb furniture/furniture-3d-models --ignore-existing
mc mb furniture/furniture-3d-thumbnails --ignore-existing
mc mb furniture/furniture-3d-cache --ignore-existing
echo '🔒 设置存储桶策略...'
mc anonymous set public furniture/furniture-3d-models
mc anonymous set public furniture/furniture-3d-thumbnails
echo '✅ MinIO初始化完成'
"
restart: "no"
volumes:
postgres_data:
driver: local
name: furniture-3d-postgres-data
minio_data:
driver: local
name: furniture-3d-minio-data
app_uploads:
driver: local
name: furniture-3d-app-uploads
networks:
furniture-network:
driver: bridge
name: furniture-3d-network

View File

@ -0,0 +1,43 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
environment:
POSTGRES_USER: furniture
POSTGRES_PASSWORD: furniture123
POSTGRES_DB: furniture_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: furniture-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: furniture-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
minio_data:

View File

@ -0,0 +1,163 @@
# 3D 家具安装展示系统 - 轻量级模式
# 适用于: 4-8GB 内存的设备 (Intel N100 等低功耗处理器)
# 性能目标: 优化资源使用,保证基本功能
version: '3.8'
services:
# 前端服务 - Nginx + Next.js 静态资源
frontend:
image: furniture-3d-frontend:latest
container_name: furniture-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
environment:
- NGINX_WORKER_PROCESSES=2
- NGINX_WORKER_CONNECTIONS=512
- ENABLE_GZIP_COMPRESSION=true
volumes:
- nginx-cache:/var/cache/nginx
depends_on:
backend:
condition: service_healthy
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
# 后端服务 - Next.js API + Prisma
backend:
image: furniture-3d-backend:latest
container_name: furniture-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET}
- MINIO_USER=${MINIO_USER}
- MINIO_PASSWORD=${MINIO_PASSWORD}
# 轻量级性能配置
- DB_POOL_SIZE=10
- ENABLE_COMPRESSION=true
- MAX_CONCURRENT_REQUESTS=20
- MODEL_CACHE_SIZE=100
- ENABLE_MEMORY_OPTIMIZATION=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
minio:
condition: service_started
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# PostgreSQL 数据库 - 轻量级配置
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=128MB
-c max_connections=50
-c work_mem=8MB
-c maintenance_work_mem=64MB
-c effective_cache_size=256MB
-c checkpoint_completion_target=0.9
-c wal_buffers=8MB
-c default_statistics_target=50
-c random_page_cost=1.1
-c effective_io_concurrency=200
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储 - 轻量级配置
minio:
image: minio/minio:latest
container_name: furniture-minio
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
- MINIO_BROWSER_REDIRECT_URL=http://localhost:9001
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- furniture-network
mem_limit: 512m
cpus: 0.5
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存 - 轻量级配置
redis:
image: redis:7-alpine
container_name: furniture-redis
restart: unless-stopped
command: >
redis-server
--maxmemory 200mb
--maxmemory-policy allkeys-lru
--save 900 1
--save 300 10
--appendonly no
volumes:
- redis-data:/data
networks:
- furniture-network
mem_limit: 256m
cpus: 0.5
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
furniture-network:
driver: bridge
volumes:
postgres-data:
driver: local
minio-data:
driver: local
redis-data:
driver: local
nginx-cache:
driver: local

View File

@ -0,0 +1,160 @@
# 3D 家具安装展示系统 - 标准模式
# 适用于: 8GB+ 内存的服务器/NAS
# 性能目标: 支持中等并发,完整功能
version: '3.8'
services:
# 前端服务 - Nginx + Next.js 静态资源
frontend:
image: furniture-3d-frontend:latest
container_name: furniture-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
environment:
- NGINX_WORKER_PROCESSES=4
- NGINX_WORKER_CONNECTIONS=1024
- ENABLE_GZIP_COMPRESSION=true
volumes:
- nginx-cache:/var/cache/nginx
depends_on:
backend:
condition: service_healthy
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
# 后端服务 - Next.js API + Prisma
backend:
image: furniture-3d-backend:latest
container_name: furniture-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET}
- MINIO_USER=${MINIO_USER}
- MINIO_PASSWORD=${MINIO_PASSWORD}
# 性能配置
- DB_POOL_SIZE=20
- ENABLE_COMPRESSION=true
- MAX_CONCURRENT_REQUESTS=50
- MODEL_CACHE_SIZE=200
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
minio:
condition: service_started
networks:
- furniture-network
mem_limit: 2g
cpus: 4.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=256MB
-c max_connections=100
-c work_mem=16MB
-c maintenance_work_mem=128MB
-c effective_cache_size=512MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c default_statistics_target=100
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储 - 3D 模型文件
minio:
image: minio/minio:latest
container_name: furniture-minio
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
- MINIO_BROWSER_REDIRECT_URL=http://localhost:9001
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- furniture-network
mem_limit: 1g
cpus: 1.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存
redis:
image: redis:7-alpine
container_name: furniture-redis
restart: unless-stopped
command: >
redis-server
--maxmemory 400mb
--maxmemory-policy allkeys-lru
--save 900 1
--save 300 10
--save 60 10000
volumes:
- redis-data:/data
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
furniture-network:
driver: bridge
volumes:
postgres-data:
driver: local
minio-data:
driver: local
redis-data:
driver: local
nginx-cache:
driver: local

View File

@ -0,0 +1,111 @@
# 3D 家具安装展示系统 - 测试配置
# 用于测试部署脚本,使用简化的服务配置
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-postgres-test
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD:-test_password_123}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=128MB
-c max_connections=50
volumes:
- postgres-test-data:/var/lib/postgresql/data
networks:
- furniture-test-network
ports:
- "5432:5432"
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: furniture-minio-test
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER:-admin}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD:-test_minio_password_123}
command: server /data --console-address ":9001"
volumes:
- minio-test-data:/data
networks:
- furniture-test-network
ports:
- "9000:9000"
- "9001:9001"
mem_limit: 512m
cpus: 0.5
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存
redis:
image: redis:7-alpine
container_name: furniture-redis-test
restart: unless-stopped
command: >
redis-server
--maxmemory 200mb
--maxmemory-policy allkeys-lru
volumes:
- redis-test-data:/data
networks:
- furniture-test-network
ports:
- "6379:6379"
mem_limit: 256m
cpus: 0.5
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
# Nginx 测试服务 (模拟前端)
nginx:
image: nginx:alpine
container_name: furniture-nginx-test
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
networks:
- furniture-test-network
mem_limit: 128m
cpus: 0.5
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
volumes:
- ./test-html:/usr/share/nginx/html:ro
networks:
furniture-test-network:
driver: bridge
volumes:
postgres-test-data:
driver: local
minio-test-data:
driver: local
redis-test-data:
driver: local

View File

@ -0,0 +1,43 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: furniture3d-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: furniture3d
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: furniture3d-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
volumes:
postgres_data:
minio_data:

View File

@ -0,0 +1,440 @@
// 新的 Prisma schema - Product-SKU 结构
// 针对电商企业的产品管理需求设计
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============================================
// 产品管理(Product-SKU结构)
// ============================================
// 产品(型号)
model Product {
id String @id @default(cuid())
modelNumber String @unique // 型号,如 "A001"
name String // 产品名称,如 "现代简约沙发"
description String? @db.Text // 产品描述
category String? // 产品分类
// 3D模型相关(型号级别的基础模型)
modelUrl String? // 基础3D模型URL (glTF)
thumbnailUrl String? // 缩略图
// 归属信息(型号级别 - 多人协作)
createdBy String // 创建者
creator User @relation("ProductCreator", fields: [createdBy], references: [id])
// 协作者列表(多人可编辑)
collaborators Json? // [{ userId: "xxx", role: "editor", addedAt: "..." }]
// 部门归属
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
// 可见性
visibility String @default("TEAM") // PRIVATE, TEAM, DEPARTMENT, PUBLIC
// SKU列表
skus ProductSKU[]
// 装配步骤(型号级别,所有SKU共享)
assemblySteps AssemblyStep[]
// 板件(型号级别)
panels Panel[]
// 分享链接
shareLinks ShareLink[]
// 问题反馈
feedbacks Feedback[]
// 元数据
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // 软删除
@@index([modelNumber])
@@index([category])
@@index([createdBy])
@@index([departmentId])
}
// 产品SKU(库存单位)
model ProductSKU {
id String @id @default(cuid())
productId String // 关联的产品ID
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
// SKU标识
skuCode String @unique // SKU编码,如 "A001-RED-3S"
// 规格属性(灵活的JSON结构)
// 示例: { "color": "红色", "size": "三人座", "material": "布艺" }
attributes Json
// 价格和库存
price Decimal @db.Decimal(10, 2) // 售价
costPrice Decimal? @db.Decimal(10, 2) // 成本价
stock Int @default(0) // 库存数量
// 图片(SKU特定的图片)
images String[] // 图片URL数组
// 3D模型(如果SKU有特定的3D模型变体,否则使用Product的基础模型)
modelUrl String? // SKU特定的3D模型URL
// 归属信息(SKU 级别 - 单人负责)
ownerId String // 负责人ID
owner User @relation("SKUOwner", fields: [ownerId], references: [id])
// 交接记录
transferHistory Json? // [{ fromUserId, toUserId, transferredAt, reason, operatorId }]
// 状态
isActive Boolean @default(true) // 是否上架
// 元数据
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // 软删除
@@index([productId])
@@index([skuCode])
@@index([isActive])
@@index([ownerId])
}
// ============================================
// 装配相关
// ============================================
// 装配步骤(属于Product,所有SKU共享)
model AssemblyStep {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
order Int // 步骤顺序
title String // 步骤标题
description String? @db.Text // 步骤说明
// 动画数据(JSON)
partIds String[] // 参与的部件 ID
transform Json // 目标变换 {position, rotation, scale}
duration Int // 动画时长(ms)
easing String // 缓动函数
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId, order])
}
// 板件(属于Product)
model Panel {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
code String // 编号
name String // 名称
gltfNodeName String // glTF 节点名
// 尺寸
length Float
width Float
thickness Float
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId])
}
// ============================================
// 分享功能
// ============================================
// 分享链接
model ShareLink {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
token String @unique // 分享令牌
expiresAt DateTime? // 过期时间(可选)
viewCount Int @default(0) // 查看次数
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId])
@@index([token])
}
// ============================================
// 用户管理
// ============================================
// 角色枚举
enum Role {
SUPER_ADMIN // 超级管理员
ADMIN // 管理员
DEPARTMENT_MANAGER // 部门管理员
TEAM_LEADER // 组长
DESIGNER // 设计师
VIEWER // 查看者
}
// 用户状态枚举
enum UserStatus {
ACTIVE // 在职
INACTIVE // 离职
SUSPENDED // 暂停
}
// 用户
model User {
id String @id @default(cuid())
// 登录凭证
email String @unique // 邮箱(用于登录)
name String @unique // 姓名/花名(用于登录和显示)
password String
// 首次登录标识
isFirstLogin Boolean @default(true) // 超级管理员首次登录需强制修改信息
// 其他信息
avatar String?
phone String?
// 角色与权限
role Role @default(DESIGNER)
// 组织架构
departmentId String?
department Department? @relation("DepartmentMembers", fields: [departmentId], references: [id])
teamId String?
team Team? @relation("TeamMembers", fields: [teamId], references: [id])
// 管理关系
managedDepartment Department? @relation("DepartmentManager")
managedTeam Team? @relation("TeamLeader")
// 状态
status UserStatus @default(ACTIVE)
// 关联
createdProducts Product[] @relation("ProductCreator")
ownedSKUs ProductSKU[] @relation("SKUOwner")
feedbacks Feedback[] @relation("FeedbackSubmitter")
assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
// 操作日志
activityLogs ActivityLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@index([email])
@@index([name])
@@index([departmentId])
@@index([teamId])
@@index([status])
}
// 部门
model Department {
id String @id @default(cuid())
name String @unique
fullPath String // 完整路径,如 "设计部-3D组-建模小组"
// 层级关系
parentId String?
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
// 部门负责人
managerId String? @unique
manager User? @relation("DepartmentManager", fields: [managerId], references: [id])
// 成员
members User[] @relation("DepartmentMembers")
// 小组
teams Team[]
// 产品
products Product[]
// 排序
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
}
// 小组
model Team {
id String @id @default(cuid())
name String
description String?
// 所属部门
departmentId String
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
// 组长
leaderId String? @unique
leader User? @relation("TeamLeader", fields: [leaderId], references: [id])
// 成员
members User[] @relation("TeamMembers")
// 排序
orderIndex Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([departmentId, name])
@@index([departmentId])
}
// ============================================
// 问题反馈
// ============================================
// 反馈状态枚举
enum FeedbackStatus {
PENDING // 待处理
IN_PROGRESS // 处理中
RESOLVED // 已解决
CLOSED // 已关闭
}
// 反馈优先级枚举
enum FeedbackPriority {
LOW // 低
MEDIUM // 中
HIGH // 高
URGENT // 紧急
}
// 问题反馈
model Feedback {
id String @id @default(cuid())
// 关联产品
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
skuId String? // 可选:具体的 SKU
// 反馈内容
title String
description String @db.Text
images String[] // 问题截图
// 问题分类
category String // 模型错误、尺寸错误、材质错误、装配步骤错误等
priority FeedbackPriority @default(MEDIUM)
// 提交者
submitterId String
submitter User @relation("FeedbackSubmitter", fields: [submitterId], references: [id])
// 处理者
assigneeId String?
assignee User? @relation("FeedbackAssignee", fields: [assigneeId], references: [id])
// 状态
status FeedbackStatus @default(PENDING)
// 处理记录
comments Json? // [{ userId, content, createdAt }]
// 解决方案
resolution String? @db.Text
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([productId])
@@index([submitterId])
@@index([assigneeId])
@@index([status])
}
// ============================================
// 操作日志
// ============================================
// 操作类型枚举
enum ActivityType {
USER_REGISTER
USER_LOGIN
USER_LOGIN_FAILED
USER_LOGOUT
USER_FIRST_LOGIN_UPDATE
USER_CREATE
USER_UPDATE
USER_DELETE
PRODUCT_CREATE
PRODUCT_UPDATE
PRODUCT_DELETE
SKU_CREATE
SKU_UPDATE
SKU_DELETE
SKU_TRANSFER
FILE_UPLOAD
FILE_DOWNLOAD
SHARE_LINK_CREATE
SHARE_LINK_ACCESS
FEEDBACK_CREATE
FEEDBACK_UPDATE
DATA_EXPORT
}
// 操作日志
model ActivityLog {
id String @id @default(cuid())
// 操作者
userId String
user User @relation(fields: [userId], references: [id])
// 操作类型
type ActivityType
// 操作对象
targetType String? // Product, ProductSKU, User, Feedback
targetId String?
// 操作详情
action String // 具体操作描述
changes Json? // 变更内容
metadata Json? // 额外信息(IP、设备等)
// 时间
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([targetType, targetId])
@@index([type, createdAt])
}

View File

@ -0,0 +1,2 @@
[2025-12-18 22:40:21] INFO: 检查依赖...
[2025-12-18 22:40:21] ERROR: pg_dump 未安装,请安装 PostgreSQL 客户端工具

View File

@ -0,0 +1,4 @@
[2025-12-18 22:40:21] INFO: 检查依赖...
[2025-12-18 22:40:21] SUCCESS: ✅ 依赖检查通过
[2025-12-18 22:40:21] INFO: 验证 MinIO 数据目录...
[2025-12-18 22:40:21] ERROR: MinIO 数据目录不存在: .\minio-data

26
check-product.ts Normal file
View File

@ -0,0 +1,26 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
const product = await prisma.product.findFirst({
where: {
assemblySteps: {
some: {},
},
},
select: {
id: true,
name: true,
modelNumber: true,
modelUrl: true,
thumbnailUrl: true,
},
})
console.log('Product:', JSON.stringify(product, null, 2))
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())

160
docker-compose.cached.yml Normal file
View File

@ -0,0 +1,160 @@
# ============================================
# 3D家具安装展示系统 - 缓存优化版Docker Compose
# 使用本地缓存加速构建和部署
# ============================================
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-3d-postgres
environment:
POSTGRES_DB: furniture_3d
POSTGRES_USER: furniture_user
POSTGRES_PASSWORD: furniture_pass_2024
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./cache/postgres-init:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
networks:
- furniture-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U furniture_user -d furniture_3d"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: furniture-3d-minio
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_BROWSER_REDIRECT_URL: http://localhost:9001
volumes:
- minio_data:/data
- ./cache/minio-config:/root/.minio:ro
ports:
- "9000:9000"
- "9001:9001"
networks:
- furniture-network
command: server /data --console-address ":9001"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# 3D家具展示应用
app:
build:
context: .
dockerfile: Dockerfile.optimized
args:
DATABASE_URL: postgresql://furniture_user:furniture_pass_2024@postgres:5432/furniture_3d
NEXTAUTH_URL: http://localhost:3000
APP_PUBLIC_URL: http://localhost:3000
# 使用缓存挂载加速构建
cache_from:
- node:18-alpine
target: runner
container_name: furniture-3d-app
environment:
# 数据库配置
DATABASE_URL: postgresql://furniture_user:furniture_pass_2024@postgres:5432/furniture_3d
# 认证配置
NEXTAUTH_SECRET: furniture-3d-secret-key-2024
NEXTAUTH_URL: http://localhost:3000
APP_PUBLIC_URL: http://localhost:3000
# MinIO/S3配置
S3_ENDPOINT: http://minio:9000
S3_BUCKET: furniture-3d-models
S3_ACCESS_KEY: minioadmin
S3_SECRET_KEY: minioadmin
S3_REGION: us-east-1
S3_FORCE_PATH_STYLE: "true"
# 应用配置
NODE_ENV: production
PORT: 3000
# 3D渲染优化
NEXT_SHARP: 0
NEXT_TELEMETRY_DISABLED: 1
volumes:
- app_uploads:/app/uploads
# 挂载本地缓存(只读)
- ./cache/prisma-engines:/app/prisma/engine-cache:ro
ports:
- "3000:3000"
networks:
- furniture-network
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# MinIO 初始化服务(创建存储桶等)
minio-init:
image: minio/mc:latest
container_name: furniture-3d-minio-init
environment:
MINIO_ENDPOINT: http://minio:9000
MINIO_ACCESS_KEY: minioadmin
MINIO_SECRET_KEY: minioadmin
networks:
- furniture-network
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
echo '🔧 配置MinIO客户端...'
mc alias set furniture http://minio:9000 minioadmin minioadmin
echo '📦 创建存储桶...'
mc mb furniture/furniture-3d-models --ignore-existing
mc mb furniture/furniture-3d-thumbnails --ignore-existing
mc mb furniture/furniture-3d-cache --ignore-existing
echo '🔒 设置存储桶策略...'
mc anonymous set public furniture/furniture-3d-models
mc anonymous set public furniture/furniture-3d-thumbnails
echo '✅ MinIO初始化完成'
"
restart: "no"
volumes:
postgres_data:
driver: local
name: furniture-3d-postgres-data
minio_data:
driver: local
name: furniture-3d-minio-data
app_uploads:
driver: local
name: furniture-3d-app-uploads
networks:
furniture-network:
driver: bridge
name: furniture-3d-network

66
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,66 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
environment:
POSTGRES_USER: furniture
POSTGRES_PASSWORD: furniture123
POSTGRES_DB: furniture_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: furniture-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: furniture-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
restart: unless-stopped
# DWG 转换服务(ODA 方案)
#
# 使用方法:
# 1. 构建镜像:docker-compose -f docker-compose.dev.yml --profile dwg build dwg-converter
# 2. 启动服务:docker-compose -f docker-compose.dev.yml --profile dwg up -d dwg-converter
#
# ODA File Converter 会自动从官方下载(约 100MB)
#
dwg-converter:
build:
context: .
dockerfile: docker/Dockerfile.oda
container_name: furniture-dwg-converter
ports:
- "3001:3001"
volumes:
- ./public/uploads:/app/uploads
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
profiles:
- dwg # 使用 --profile dwg 启动此服务
volumes:
postgres_data:
redis_data:
minio_data:

View File

@ -0,0 +1,163 @@
# 3D 家具安装展示系统 - 轻量级模式
# 适用于: 4-8GB 内存的设备 (Intel N100 等低功耗处理器)
# 性能目标: 优化资源使用,保证基本功能
version: '3.8'
services:
# 前端服务 - Nginx + Next.js 静态资源
frontend:
image: furniture-3d-frontend:latest
container_name: furniture-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
environment:
- NGINX_WORKER_PROCESSES=2
- NGINX_WORKER_CONNECTIONS=512
- ENABLE_GZIP_COMPRESSION=true
volumes:
- nginx-cache:/var/cache/nginx
depends_on:
backend:
condition: service_healthy
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
# 后端服务 - Next.js API + Prisma
backend:
image: furniture-3d-backend:latest
container_name: furniture-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET}
- MINIO_USER=${MINIO_USER}
- MINIO_PASSWORD=${MINIO_PASSWORD}
# 轻量级性能配置
- DB_POOL_SIZE=10
- ENABLE_COMPRESSION=true
- MAX_CONCURRENT_REQUESTS=20
- MODEL_CACHE_SIZE=100
- ENABLE_MEMORY_OPTIMIZATION=true
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
minio:
condition: service_started
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# PostgreSQL 数据库 - 轻量级配置
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=128MB
-c max_connections=50
-c work_mem=8MB
-c maintenance_work_mem=64MB
-c effective_cache_size=256MB
-c checkpoint_completion_target=0.9
-c wal_buffers=8MB
-c default_statistics_target=50
-c random_page_cost=1.1
-c effective_io_concurrency=200
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储 - 轻量级配置
minio:
image: minio/minio:latest
container_name: furniture-minio
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
- MINIO_BROWSER_REDIRECT_URL=http://localhost:9001
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- furniture-network
mem_limit: 512m
cpus: 0.5
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存 - 轻量级配置
redis:
image: redis:7-alpine
container_name: furniture-redis
restart: unless-stopped
command: >
redis-server
--maxmemory 200mb
--maxmemory-policy allkeys-lru
--save 900 1
--save 300 10
--appendonly no
volumes:
- redis-data:/data
networks:
- furniture-network
mem_limit: 256m
cpus: 0.5
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
furniture-network:
driver: bridge
volumes:
postgres-data:
driver: local
minio-data:
driver: local
redis-data:
driver: local
nginx-cache:
driver: local

177
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,177 @@
# ============================================
# 生产环境 Docker Compose 配置
# ============================================
# 使用方法:
# docker-compose -f docker-compose.prod.yml up -d
# ============================================
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture3d-postgres
restart: always
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-furniture3d}
# 性能优化参数
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backups/database:/backups
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-furniture3d}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
networks:
- furniture3d-network
# Redis 缓存
redis:
image: redis:7-alpine
container_name: furniture3d-redis
restart: always
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
cpus: '0.5'
memory: 512M
reservations:
cpus: '0.1'
memory: 128M
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
networks:
- furniture3d-network
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: furniture3d-minio
restart: always
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
# 生产环境建议启用 TLS
# MINIO_SERVER_URL: https://minio.yourdomain.com
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
- ./backups/files:/backups
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
start_period: 30s
deploy:
resources:
limits:
cpus: '1'
memory: 1G
reservations:
cpus: '0.25'
memory: 256M
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
networks:
- furniture3d-network
# Next.js 应用(可选,也可以单独部署)
app:
build:
context: .
dockerfile: Dockerfile
container_name: furniture3d-app
restart: always
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-furniture3d}
S3_ENDPOINT: http://minio:9000
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET: ${S3_BUCKET:-furniture-3d-models}
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
REDIS_URL: redis://redis:6379
ports:
- "${APP_PORT:-3000}:3000"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health?mode=simple"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
networks:
- furniture3d-network
volumes:
postgres_data:
driver: local
redis_data:
driver: local
minio_data:
driver: local
networks:
furniture3d-network:
driver: bridge

160
docker-compose.standard.yml Normal file
View File

@ -0,0 +1,160 @@
# 3D 家具安装展示系统 - 标准模式
# 适用于: 8GB+ 内存的服务器/NAS
# 性能目标: 支持中等并发,完整功能
version: '3.8'
services:
# 前端服务 - Nginx + Next.js 静态资源
frontend:
image: furniture-3d-frontend:latest
container_name: furniture-frontend
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
environment:
- NGINX_WORKER_PROCESSES=4
- NGINX_WORKER_CONNECTIONS=1024
- ENABLE_GZIP_COMPRESSION=true
volumes:
- nginx-cache:/var/cache/nginx
depends_on:
backend:
condition: service_healthy
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/health"]
interval: 30s
timeout: 10s
retries: 3
# 后端服务 - Next.js API + Prisma
backend:
image: furniture-3d-backend:latest
container_name: furniture-backend
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
- REDIS_URL=${REDIS_URL}
- S3_ENDPOINT=${S3_ENDPOINT}
- S3_BUCKET=${S3_BUCKET}
- MINIO_USER=${MINIO_USER}
- MINIO_PASSWORD=${MINIO_PASSWORD}
# 性能配置
- DB_POOL_SIZE=20
- ENABLE_COMPRESSION=true
- MAX_CONCURRENT_REQUESTS=50
- MODEL_CACHE_SIZE=200
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
minio:
condition: service_started
networks:
- furniture-network
mem_limit: 2g
cpus: 4.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-postgres
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=256MB
-c max_connections=100
-c work_mem=16MB
-c maintenance_work_mem=128MB
-c effective_cache_size=512MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c default_statistics_target=100
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- furniture-network
mem_limit: 1g
cpus: 2.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储 - 3D 模型文件
minio:
image: minio/minio:latest
container_name: furniture-minio
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD}
- MINIO_BROWSER_REDIRECT_URL=http://localhost:9001
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- furniture-network
mem_limit: 1g
cpus: 1.0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存
redis:
image: redis:7-alpine
container_name: furniture-redis
restart: unless-stopped
command: >
redis-server
--maxmemory 400mb
--maxmemory-policy allkeys-lru
--save 900 1
--save 300 10
--save 60 10000
volumes:
- redis-data:/data
networks:
- furniture-network
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
furniture-network:
driver: bridge
volumes:
postgres-data:
driver: local
minio-data:
driver: local
redis-data:
driver: local
nginx-cache:
driver: local

111
docker-compose.test.yml Normal file
View File

@ -0,0 +1,111 @@
# 3D 家具安装展示系统 - 测试配置
# 用于测试部署脚本,使用简化的服务配置
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:15-alpine
container_name: furniture-postgres-test
restart: unless-stopped
environment:
- POSTGRES_DB=furniture_db
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD:-test_password_123}
- PGDATA=/var/lib/postgresql/data/pgdata
command: >
postgres
-c shared_buffers=128MB
-c max_connections=50
volumes:
- postgres-test-data:/var/lib/postgresql/data
networks:
- furniture-test-network
ports:
- "5432:5432"
mem_limit: 512m
cpus: 1.0
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d furniture_db"]
interval: 10s
timeout: 5s
retries: 5
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: furniture-minio-test
restart: unless-stopped
environment:
- MINIO_ROOT_USER=${MINIO_USER:-admin}
- MINIO_ROOT_PASSWORD=${MINIO_PASSWORD:-test_minio_password_123}
command: server /data --console-address ":9001"
volumes:
- minio-test-data:/data
networks:
- furniture-test-network
ports:
- "9000:9000"
- "9001:9001"
mem_limit: 512m
cpus: 0.5
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# Redis 缓存
redis:
image: redis:7-alpine
container_name: furniture-redis-test
restart: unless-stopped
command: >
redis-server
--maxmemory 200mb
--maxmemory-policy allkeys-lru
volumes:
- redis-test-data:/data
networks:
- furniture-test-network
ports:
- "6379:6379"
mem_limit: 256m
cpus: 0.5
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
# Nginx 测试服务 (模拟前端)
nginx:
image: nginx:alpine
container_name: furniture-nginx-test
restart: unless-stopped
ports:
- "${WEB_PORT:-3000}:80"
networks:
- furniture-test-network
mem_limit: 128m
cpus: 0.5
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
volumes:
- ./test-html:/usr/share/nginx/html:ro
networks:
furniture-test-network:
driver: bridge
volumes:
postgres-test-data:
driver: local
minio-test-data:
driver: local
redis-test-data:
driver: local

59
docker-compose.yml Normal file
View File

@ -0,0 +1,59 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: furniture3d-postgres
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: furniture3d
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: furniture3d-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
container_name: furniture3d-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
volumes:
postgres_data:
redis_data:
minio_data:

79
docker/Dockerfile.oda Normal file
View File

@ -0,0 +1,79 @@
# ODA File Converter DWG 转换服务
#
# 使用 ODA File Converter 将 DWG 转换为 GLB
# 镜像大小约 300MB(比 FreeCAD 的 2GB+ 小很多)
#
# 构建:
# docker build -f docker/Dockerfile.oda -t dwg-converter .
#
# 运行:
# docker run -p 3001:3001 -v ./uploads:/app/uploads dwg-converter
FROM node:18-slim
# 安装系统依赖(包括虚拟显示服务器)
RUN apt-get update && apt-get install -y \
wget \
curl \
python3 \
python3-pip \
xvfb \
libgl1-mesa-glx \
libglu1-mesa \
libfreetype6 \
fontconfig \
libxrender1 \
libsm6 \
libice6 \
libxcb-icccm4 \
libxcb-image0 \
libxcb-keysyms1 \
libxcb-render-util0 \
libxcb-xinerama0 \
libxkbcommon-x11-0 \
libxcb-cursor0 \
libxcb-shape0 \
&& rm -rf /var/lib/apt/lists/*
# 安装 Node.js 转换工具
RUN npm install -g obj2gltf gltf-pipeline
# 安装 Python 依赖
RUN pip3 install --break-system-packages \
flask \
gunicorn \
ezdxf \
trimesh \
numpy
# 创建工作目录
WORKDIR /app
# 下载并安装 ODA File Converter (v26.12)
RUN wget -q "https://www.opendesign.com/guestfiles/get?filename=ODAFileConverter_QT6_lnxX64_8.3dll_26.12.deb" \
-O /tmp/ODAFileConverter.deb \
&& dpkg -i /tmp/ODAFileConverter.deb || apt-get install -f -y \
&& rm -f /tmp/ODAFileConverter.deb \
&& echo "ODA File Converter 安装成功"
# 复制转换服务脚本
COPY docker/oda-converter-server.py /app/
# 创建目录
RUN mkdir -p /app/uploads /app/outputs
# 环境变量
ENV PYTHONUNBUFFERED=1
ENV ODA_CONVERTER_PATH=/usr/bin/ODAFileConverter
ENV DISPLAY=:99
ENV QT_QPA_PLATFORM=xcb
# 暴露端口
EXPOSE 3001
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3001/health || exit 1
# 启动脚本:先启动 Xvfb,再启动服务
CMD Xvfb :99 -screen 0 1024x768x24 & sleep 1 && gunicorn -w 2 -b 0.0.0.0:3001 oda-converter-server:app

View File

@ -0,0 +1,404 @@
"""
DWG 转换服务 REST API(ODA 方案)
使用 ODA File Converter 将 DWG 转换为 OBJ,再用 obj2gltf 转为 GLB
端点:
POST /convert - 上传 DWG 文件并转换
GET /health - 健康检查
依赖:
- ODA File Converter (需手动安装)
- obj2gltf (npm install -g obj2gltf)
"""
import os
import sys
import uuid
import json
import subprocess
import tempfile
import shutil
from pathlib import Path
from flask import Flask, request, jsonify, send_file
from werkzeug.utils import secure_filename
app = Flask(__name__)
# 配置
UPLOAD_FOLDER = '/app/uploads'
OUTPUT_FOLDER = '/app/outputs'
ALLOWED_EXTENSIONS = {'dwg', 'stl', 'obj'}
MAX_CONTENT_LENGTH = 100 * 1024 * 1024 # 100MB
# ODA File Converter 路径(Linux)
ODA_CONVERTER = os.environ.get('ODA_CONVERTER_PATH', '/usr/bin/ODAFileConverter')
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
def allowed_file(filename):
"""检查文件扩展名"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def check_oda_available():
"""检查 ODA File Converter 是否可用"""
return os.path.exists(ODA_CONVERTER) or shutil.which('ODAFileConverter')
def convert_dwg_to_glb(input_path: str, output_path: str) -> dict:
"""
使用 ODA 将 DWG 转换为 GLB
流程: DWG -> DXF (ODA) -> 解析所有实体类型 -> OBJ -> GLB (obj2gltf)
"""
result = {
'success': False,
'error': None,
'stats': {}
}
temp_dir = tempfile.mkdtemp(prefix='dwg_convert_')
try:
input_dir = os.path.dirname(input_path)
input_name = os.path.basename(input_path)
# Step 1: DWG -> DXF (使用 ODA File Converter)
oda_path = ODA_CONVERTER if os.path.exists(ODA_CONVERTER) else shutil.which('ODAFileConverter')
if not oda_path:
result['error'] = 'ODA File Converter 未安装'
return result
cmd = [
oda_path,
input_dir,
temp_dir,
'ACAD2018',
'DXF',
'0',
'1',
input_name
]
print(f'[ODA] 执行命令: {" ".join(cmd)}')
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if proc.returncode != 0:
result['error'] = f'ODA 转换失败: {proc.stderr or proc.stdout}'
return result
# 查找生成的 DXF 文件
dxf_files = list(Path(temp_dir).glob('*.dxf'))
if not dxf_files:
result['error'] = 'ODA 未生成 DXF 文件'
return result
dxf_path = str(dxf_files[0])
print(f'[ODA] DXF 文件生成: {dxf_path}')
# Step 2: DXF -> OBJ (使用 ezdxf 提取所有 3D 实体)
obj_path = os.path.join(temp_dir, 'model.obj')
try:
import ezdxf
from ezdxf.addons import meshex
import trimesh
import numpy as np
doc = ezdxf.readfile(dxf_path)
msp = doc.modelspace()
all_meshes = []
entity_stats = {}
# 遍历所有实体
for entity in msp:
etype = entity.dxftype()
entity_stats[etype] = entity_stats.get(etype, 0) + 1
try:
if etype == '3DFACE':
pts = [entity.dxf.vtx0, entity.dxf.vtx1, entity.dxf.vtx2]
if hasattr(entity.dxf, 'vtx3') and entity.dxf.vtx3 != entity.dxf.vtx2:
pts.append(entity.dxf.vtx3)
verts = [[p[0], p[1], p[2] if len(p) > 2 else 0] for p in pts]
if len(pts) == 3:
faces = [[0, 1, 2]]
else:
faces = [[0, 1, 2], [0, 2, 3]]
mesh = trimesh.Trimesh(vertices=verts, faces=faces)
all_meshes.append(mesh)
elif etype == '3DSOLID':
# 3D 实体 - 尝试使用 ezdxf 的 meshex 提取
try:
from ezdxf.render import MeshBuilder
builder = MeshBuilder()
entity.render_mesh(builder)
if builder.vertices:
mesh = trimesh.Trimesh(
vertices=np.array(builder.vertices),
faces=np.array(builder.faces) if builder.faces else []
)
all_meshes.append(mesh)
except Exception as e:
print(f'[ODA] 3DSOLID 提取失败: {e}')
elif etype == 'MESH':
try:
verts = list(entity.vertices)
faces_data = list(entity.faces)
if verts and faces_data:
mesh = trimesh.Trimesh(
vertices=np.array(verts),
faces=np.array([f[:3] for f in faces_data if len(f) >= 3])
)
all_meshes.append(mesh)
except Exception as e:
print(f'[ODA] MESH 提取失败: {e}')
elif etype == 'POLYFACE':
# Polyface mesh
try:
from ezdxf.entities import Polyface
if hasattr(entity, 'vertices') and hasattr(entity, 'faces'):
verts = [list(v.dxf.location) for v in entity.vertices]
faces_data = [list(f.indices()) for f in entity.faces]
if verts and faces_data:
mesh = trimesh.Trimesh(
vertices=np.array(verts),
faces=np.array([f[:3] for f in faces_data if len(f) >= 3])
)
all_meshes.append(mesh)
except Exception as e:
print(f'[ODA] POLYFACE 提取失败: {e}')
elif etype in ('SURFACE', 'PLANESURFACE'):
# 曲面实体
try:
from ezdxf.render import MeshBuilder
builder = MeshBuilder()
entity.render_mesh(builder)
if builder.vertices:
mesh = trimesh.Trimesh(
vertices=np.array(builder.vertices),
faces=np.array(builder.faces) if builder.faces else []
)
all_meshes.append(mesh)
except Exception as e:
print(f'[ODA] SURFACE 提取失败: {e}')
except Exception as e:
print(f'[ODA] 实体 {etype} 处理异常: {e}')
continue
print(f'[ODA] 实体统计: {entity_stats}')
print(f'[ODA] 成功提取 {len(all_meshes)} 个网格')
if not all_meshes:
# 检查是否有 3DSOLID 实体
if entity_stats.get('3DSOLID', 0) > 0:
result['error'] = (
f'检测到 {entity_stats["3DSOLID"]} 个 3DSOLID 实体(布尔运算模型)。\n'
'3DSOLID 格式暂不支持自动转换,请在 AutoCAD 中执行以下操作:\n'
'1. 选择所有实体 → 输入命令 EXPORT → 选择 STL 或 OBJ 格式导出\n'
'2. 或使用 FACETRES 命令调整精度后导出\n'
'3. 然后上传导出的 STL/GLB 文件'
)
else:
result['error'] = f'DXF 文件中没有可转换的 3D 几何体。实体类型: {entity_stats}'
return result
# 合并所有网格
combined = trimesh.util.concatenate(all_meshes)
combined.export(obj_path)
print(f'[ODA] OBJ 文件生成: {obj_path}')
result['stats']['mesh_count'] = len(all_meshes)
result['stats']['vertex_count'] = len(combined.vertices)
result['stats']['face_count'] = len(combined.faces)
except ImportError as e:
result['error'] = f'依赖库未安装: {e}'
return result
except Exception as e:
result['error'] = f'DXF 解析失败: {str(e)}'
import traceback
print(traceback.format_exc())
return result
# Step 3: OBJ -> GLB (使用 obj2gltf)
obj2gltf_path = shutil.which('obj2gltf')
if not obj2gltf_path:
obj2gltf_path = 'npx'
cmd = [obj2gltf_path, 'obj2gltf', '-i', obj_path, '-o', output_path, '-b']
else:
cmd = [obj2gltf_path, '-i', obj_path, '-o', output_path, '-b']
print(f'[ODA] 执行 obj2gltf: {" ".join(cmd)}')
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if proc.returncode != 0 or not os.path.exists(output_path):
result['error'] = f'obj2gltf 转换失败: {proc.stderr or proc.stdout}'
return result
output_size = os.path.getsize(output_path)
result['stats']['output_size_bytes'] = output_size
result['stats']['output_size_mb'] = round(output_size / (1024 * 1024), 2)
result['success'] = True
print(f'[ODA] 转换成功: {output_path} ({result["stats"]["output_size_mb"]} MB)')
except subprocess.TimeoutExpired:
result['error'] = '转换超时'
except Exception as e:
result['error'] = f'转换过程出错: {str(e)}'
import traceback
print(traceback.format_exc())
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
return result
@app.route('/health', methods=['GET'])
def health_check():
"""健康检查"""
oda_available = check_oda_available()
return jsonify({
'status': 'healthy' if oda_available else 'degraded',
'service': 'dwg-converter-oda',
'version': '1.0.0',
'oda_available': oda_available,
'oda_path': ODA_CONVERTER if os.path.exists(ODA_CONVERTER) else shutil.which('ODAFileConverter')
})
def convert_stl_obj_to_glb(input_path: str, output_path: str) -> dict:
"""
将 STL/OBJ 文件转换为 GLB
"""
result = {
'success': False,
'error': None,
'stats': {}
}
try:
file_ext = os.path.splitext(input_path)[1].lower()
# 使用 obj2gltf 转换
obj2gltf_path = shutil.which('obj2gltf')
if file_ext == '.stl':
# STL -> OBJ -> GLB (使用 trimesh)
import trimesh
mesh = trimesh.load(input_path)
temp_obj = input_path.replace('.stl', '.obj')
mesh.export(temp_obj)
if obj2gltf_path:
cmd = [obj2gltf_path, '-i', temp_obj, '-o', output_path, '-b']
else:
cmd = ['npx', 'obj2gltf', '-i', temp_obj, '-o', output_path, '-b']
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
os.remove(temp_obj)
elif file_ext == '.obj':
# OBJ -> GLB
if obj2gltf_path:
cmd = [obj2gltf_path, '-i', input_path, '-o', output_path, '-b']
else:
cmd = ['npx', 'obj2gltf', '-i', input_path, '-o', output_path, '-b']
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if os.path.exists(output_path):
output_size = os.path.getsize(output_path)
result['stats']['output_size_bytes'] = output_size
result['stats']['output_size_mb'] = round(output_size / (1024 * 1024), 2)
result['success'] = True
print(f'[Converter] 转换成功: {output_path}')
else:
result['error'] = f'转换失败: {proc.stderr or proc.stdout}'
except Exception as e:
result['error'] = f'转换出错: {str(e)}'
import traceback
print(traceback.format_exc())
return result
@app.route('/convert', methods=['POST'])
def convert():
"""上传 DWG/STL/OBJ 文件并转换为 GLB"""
# 检查文件
if 'file' not in request.files:
return jsonify({'error': '未提供文件'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': '未选择文件'}), 400
if not allowed_file(file.filename):
return jsonify({'error': '不支持的文件格式,支持 .dwg/.stl/.obj'}), 400
# 生成文件路径
task_id = str(uuid.uuid4())
filename = secure_filename(file.filename)
input_path = os.path.join(UPLOAD_FOLDER, f'{task_id}_{filename}')
output_path = os.path.join(OUTPUT_FOLDER, f'{task_id}.glb')
# 保存上传文件
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
file.save(input_path)
file_ext = os.path.splitext(filename)[1].lower()
try:
# 根据文件类型选择转换方法
if file_ext == '.dwg':
# 检查 ODA 是否可用
if not check_oda_available():
return jsonify({
'error': 'ODA File Converter 未安装'
}), 503
result = convert_dwg_to_glb(input_path, output_path)
else:
# STL/OBJ 直接转换
result = convert_stl_obj_to_glb(input_path, output_path)
if result['success']:
return send_file(
output_path,
mimetype='model/gltf-binary',
as_attachment=True,
download_name='converted.glb'
)
else:
return jsonify({'error': result['error']}), 500
finally:
# 清理输入文件
if os.path.exists(input_path):
os.remove(input_path)
if __name__ == '__main__':
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
os.makedirs(OUTPUT_FOLDER, exist_ok=True)
app.run(host='0.0.0.0', port=3001, debug=True)

281
docs/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,281 @@
# 3D 家具系统 - 部署指南
## 目录
1. [环境要求](#环境要求)
2. [快速部署](#快速部署)
3. [环境变量配置](#环境变量配置)
4. [数据库迁移](#数据库迁移)
5. [服务管理](#服务管理)
6. [备份与恢复](#备份与恢复)
7. [常见问题](#常见问题)
---
## 环境要求
### 最低配置
- CPU: 2 核
- 内存: 4GB
- 磁盘: 20GB SSD
### 推荐配置
- CPU: 4 核
- 内存: 8GB
- 磁盘: 50GB SSD
### 软件依赖
- Docker 20.10+
- Docker Compose 2.0+
- Node.js 20+ (仅开发环境)
---
## 快速部署
### 1. 克隆项目
```bash
git clone <repository-url>
cd furniture-3d-system
```
### 2. 配置环境变量
```bash
# 复制配置模板
cp .env.example .env
# 编辑配置(重要!必须修改默认密码)
nano .env
```
### 3. 启动服务
```bash
# 标准模式(推荐,8GB+ 内存)
docker-compose -f docker-compose.prod.yml up -d
# 轻量级模式(4-8GB 内存)
docker-compose -f docker-compose.lightweight.yml up -d
```
### 4. 初始化数据库
```bash
# 进入应用容器执行迁移
docker exec -it furniture3d-app npx prisma db push
# 或在本地执行(需要数据库可访问)
npx prisma db push
```
### 5. 访问系统
- 应用地址: http://localhost:3000
- MinIO 控制台: http://localhost:9001
---
## 环境变量配置
### 必须修改的变量
| 变量 | 说明 | 安全要求 |
|------|------|----------|
| `DB_PASSWORD` | 数据库密码 | 至少 16 位,包含大小写字母和数字 |
| `MINIO_PASSWORD` | MinIO 密码 | 至少 16 位 |
| `JWT_SECRET` | JWT 密钥 | 至少 32 位随机字符串 |
| `REDIS_PASSWORD` | Redis 密码 | 至少 16 位(如启用认证) |
### 生成安全密码
```bash
# Linux/Mac
openssl rand -base64 32
# 或使用 Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
```
### 完整配置示例
```env
# ============================================
# 数据库配置
# ============================================
DB_USER=postgres
DB_PASSWORD=<生成的强密码>
DB_NAME=furniture3d
DB_PORT=5432
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
# ============================================
# Redis 缓存配置
# ============================================
REDIS_URL=redis://redis:6379
# 如需密码认证:
# REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
# ============================================
# MinIO 对象存储配置
# ============================================
MINIO_ROOT_USER=admin
MINIO_ROOT_PASSWORD=<生成的强密码>
S3_ENDPOINT=http://minio:9000
S3_BUCKET=furniture-3d-models
S3_ACCESS_KEY=${MINIO_ROOT_USER}
S3_SECRET_KEY=${MINIO_ROOT_PASSWORD}
# ============================================
# 应用配置
# ============================================
NODE_ENV=production
APP_PORT=3000
JWT_SECRET=<生成的32位以上随机字符串>
JWT_EXPIRES_IN=7d
```
---
## 数据库迁移
### 首次部署
```bash
# 推送 schema 到数据库
npx prisma db push
# 生成 Prisma Client
npx prisma generate
```
### 后续更新(Schema 变更)
```bash
# 开发环境:直接推送
npx prisma db push
# 生产环境:使用迁移(推荐)
npx prisma migrate deploy
```
### 回滚(如需)
```bash
# 查看迁移历史
npx prisma migrate status
# 回滚到上一个版本(手动操作数据库)
# 注意:Prisma 不支持自动回滚,需要手动处理
```
---
## 服务管理
### 启动/停止服务
```bash
# 启动所有服务
docker-compose -f docker-compose.prod.yml up -d
# 停止所有服务
docker-compose -f docker-compose.prod.yml down
# 重启单个服务
docker-compose -f docker-compose.prod.yml restart app
```
### 查看日志
```bash
# 查看所有服务日志
docker-compose -f docker-compose.prod.yml logs -f
# 查看特定服务日志
docker-compose -f docker-compose.prod.yml logs -f app
docker-compose -f docker-compose.prod.yml logs -f postgres
docker-compose -f docker-compose.prod.yml logs -f redis
```
### 服务状态
```bash
# 查看服务状态
docker-compose -f docker-compose.prod.yml ps
# 查看资源使用
docker stats
```
---
## 备份与恢复
### 数据库备份
```bash
# 创建备份
docker exec furniture3d-postgres pg_dump -U postgres furniture3d > backup_$(date +%Y%m%d).sql
# 恢复备份
docker exec -i furniture3d-postgres psql -U postgres furniture3d < backup_20240101.sql
```
### MinIO 文件备份
```bash
# 使用 mc 命令行工具
mc alias set myminio http://localhost:9000 minioadmin minioadmin
mc mirror myminio/furniture-3d-models ./backup/files/
```
### 自动备份脚本
参见 `scripts/backup.sh`
---
## 常见问题
### Q: 数据库连接失败
```
Error: Can't reach database server
```
**解决**:检查 PostgreSQL 容器是否正常运行,网络是否通畅
```bash
docker-compose -f docker-compose.prod.yml ps postgres
docker-compose -f docker-compose.prod.yml logs postgres
```
### Q: Redis 连接失败
```
Error: Redis connection refused
```
**解决**:
1. 检查 Redis 容器状态
2. 系统会自动降级为内存缓存,不影响核心功能
3. 生产环境建议确保 Redis 正常运行
### Q: MinIO 上传失败
```
Error: Access Denied
```
**解决**:
1. 检查 MinIO 凭证配置
2. 确认 Bucket 已创建
3. 检查 Bucket 权限策略
### Q: 内存不足
**解决**:使用轻量级模式
```bash
docker-compose -f docker-compose.lightweight.yml up -d
```
---
## 版本更新
### 更新应用
```bash
# 拉取最新代码
git pull
# 重新构建镜像
docker-compose -f docker-compose.prod.yml build app
# 重启应用
docker-compose -f docker-compose.prod.yml up -d app
# 执行数据库迁移(如有)
docker exec -it furniture3d-app npx prisma db push
```
### 重要提示
- 更新前请先备份数据库
- 测试环境验证后再更新生产环境
- 保留旧版本镜像以便回滚

522
docs/PHASES_8_9_SUMMARY.md Normal file
View File

@ -0,0 +1,522 @@
# Phase 8-9 实施总结
## 📋 项目概述
本文档总结了 Phase 8(数据安全与日志)和 Phase 9(测试与优化)的实施情况。
**完成时间**:2025-12-12
**项目状态**:Phase 8 完成,Phase 9 规划完成
---
## ✅ Phase 8:数据安全与日志(已完成)
### 核心成果
成功实现了完整的操作日志系统和审计报表功能,为系统提供了全面的审计追踪能力。
### 已完成的功能
#### 1. 日志查询系统
- ✅ **日志查询 API** (`GET /api/logs`)
- 支持多维度查询(时间、用户、操作类型、实体类型)
- 实现权限隔离(部门管理员只能查看本部门)
- 分页功能
- 响应时间优化
- ✅ **日志导出 API** (`POST /api/logs/export`)
- CSV 格式导出
- JSON 格式导出
- 自动过滤敏感信息
- 限制导出数量(10000 条)
- 记录导出操作
#### 2. 审计报表系统
- ✅ **用户活动报表** (`GET /api/reports/user-activity`)
- 登录次数统计
- 操作次数统计
- 活跃用户分析
- 按活跃度排序
- ✅ **产品变更报表** (`GET /api/reports/product-changes`)
- 按日期分组统计
- 创建/更新/删除分类
- 趋势分析(最近 30 天)
- ✅ **SKU 交接报表** (`GET /api/reports/sku-transfers`)
- 交接记录详情
- 按部门统计
- 交接历史追踪
- ✅ **反馈处理报表** (`GET /api/reports/feedback-stats`)
- 平均处理时长
- 按优先级统计
- 按状态统计
- 每日趋势分析
#### 3. 日志记录工具
- ✅ **统一日志接口** (`src/lib/logger.ts`)
- 自动过滤敏感信息
- 便捷的日志记录函数
- 元数据提取(IP、User Agent)
- 支持所有操作类型
### 技术亮点
1. **完整的权限控制**
- 基于角色的访问控制
- 数据访问隔离
- 部门级别的数据过滤
2. **性能优化**
- 数据库索引优化
- 使用 `groupBy` 聚合查询
- 原始 SQL 优化复杂查询
- 查询结果限制
3. **安全措施**
- 敏感信息自动过滤
- 操作审计追踪
- 权限严格检查
### 交付物清单
**API 端点**(6 个):
1. `src/app/api/logs/route.ts`
2. `src/app/api/logs/export/route.ts`
3. `src/app/api/reports/user-activity/route.ts`
4. `src/app/api/reports/product-changes/route.ts`
5. `src/app/api/reports/sku-transfers/route.ts`
6. `src/app/api/reports/feedback-stats/route.ts`
**工具函数**(1 个):
1. `src/lib/logger.ts`
**文档**(2 个):
1. `docs/PHASE8_COMPLETE.md`
2. `docs/PHASES_8_9_SUMMARY.md`(本文档)
### 代码统计
- **新增文件**:8 个
- **新增代码行数**:约 1500 行
- **API 端点**:6 个
- **代码通过类型检查**:✅
---
## 📝 Phase 9:测试与优化(进行中)
### 实施概述
Phase 9 已经开始实施,目前完成了前端性能优化和安全测试。
### 规划文档
1. **需求文档** (`../../.kiro/specs/历史归档/Phase9-测试与优化/requirements.md`)
- 功能测试需求
- 性能优化需求
- 安全测试需求
- 用户体验优化需求
2. **设计文档** (`../../.kiro/specs/历史归档/Phase9-测试与优化/design.md`)
- 测试策略(单元测试、集成测试、E2E 测试)
- 性能优化方案
- 安全加固措施
- 用户体验改进方案
3. **任务清单** (`../../.kiro/specs/历史归档/Phase9-测试与优化/tasks.md`)
- 7 天详细任务分解
- 每日任务清单
- 验收标准
### 已完成的任务
#### ✅ 第 4 天:前端性能优化(2025-12-13)
**上午:代码分割和懒加载**
- ✅ 创建性能优化工具库 (`src/lib/performance.ts`)
- 防抖、节流函数
- 懒加载、预加载工具
- 动态导入辅助函数
- ✅ 创建懒加载包装组件 (`src/components/common/LazyLoadWrapper.tsx`)
- ✅ 创建懒加载组件配置 (`src/lib/lazy-components.ts`)
- ✅ 创建性能监控 Hooks (`src/hooks/usePerformance.ts`)
- ✅ 创建图片懒加载组件 (`src/components/common/LazyImage.tsx`)
**下午:渲染性能优化**
- ✅ 优化 `HomeClient.tsx`
- 添加搜索功能
- 使用 useMemo、useCallback、useDebounce
- ✅ 优化 `UsersPage.tsx`
- 优化 13 个回调函数
- 使用防抖搜索
- ✅ 优化 `UserListItem.tsx`
- 使用 memo、useMemo、useCallback
- ✅ 优化 `ProductListItem.tsx`
- 使用 memo、useMemo、useCallback
- ✅ 创建性能测试 (`tests/performance/rendering.test.tsx`)
- 12 个测试用例,100% 通过
**性能提升**:
- 渲染 100 个用户列表项:259ms
- 渲染 50 个产品列表项:98ms
- 首屏加载时间减少 30-40%
- 路由切换时间减少 50%
**代码量**:约 1320 行(660 行工具 + 660 行优化)
---
#### ✅ 第 5 天下午:安全测试(2025-12-13)
**依赖漏洞扫描**
- ✅ 运行 `npm audit`
- ✅ 检查结果:0 个实际影响的漏洞
- ✅ Next.js 15.1.3 不在受影响范围内
**SQL 注入测试**
- ✅ 创建测试文件 (`tests/security/sql-injection.test.ts`)
- ✅ 17 个测试用例
- ✅ 测试覆盖:用户查询、产品搜索、复杂查询、批量操作
- ✅ 结论:Prisma ORM 提供完善的 SQL 注入防护
**XSS 测试**
- ✅ 创建测试文件 (`tests/security/xss.test.ts`)
- ✅ 14 个测试用例
- ✅ 测试覆盖:React 转义、URL 参数、表单输入、CSS 注入、SVG XSS
- ✅ 结论:React 自动转义机制完善
**权限绕过测试**
- ✅ 创建测试文件 (`tests/security/authorization.test.ts`)
- ✅ 33 个测试用例
- ✅ 测试覆盖:未授权访问、产品权限、用户管理、横向越权、纵向越权
- ✅ 结论:权限系统设计完善
**安全测试报告**
- ✅ 创建文档 (`docs/安全测试报告.md`)
- ✅ 总体安全评分:⭐⭐⭐⭐⭐ (5/5)
**测试统计**:
- 测试用例:64 个
- 测试代码:约 800 行
- 文档:约 600 行
- 总计:约 1400 行
---
### 待完成的任务
#### ⏳ 第 1-3 天:测试框架和核心测试(可选)
- 安装测试依赖(Jest, Testing Library, Supertest)
- 配置测试环境
- 权限系统单元测试
- API 集成测试
#### ⏳ 第 5 天上午:3D 性能优化(可选)
- 模型压缩(Draco)
- LOD(细节层次)
- 纹理优化
- 渲染器配置优化
#### ⏳ 第 6 天:用户体验优化(可选)
- 界面优化
- 交互优化
- 响应式设计
- 无障碍支持
#### ⏳ 第 7 天:E2E 测试和文档(可选)
- E2E 测试
- 文档完善
- 最终验收
### 性能目标
| 指标 | 目标值 | 当前状态 | 达成情况 |
|------|--------|---------|---------|
| API 响应时间 | < 200ms | 待测试 | ⏳ |
| 页面加载时间 | < 2s | 减少 30-40% | ✅ |
| 路由切换时间 | < 500ms | 减少 50% | ✅ |
| 列表渲染 | 流畅 | 100 项 259ms | ✅ |
| 3D 模型加载 | < 3s | 待测试 | ⏳ |
| 动画帧率 | 60fps | 待测试 | ⏳ |
| 内存使用 | < 500MB | 待测试 | ⏳ |
| 测试覆盖率 | > 80% | 安全测试 100% | 🟡 |
### 安全目标
- ✅ 无高危安全漏洞(已验证)
- ✅ 无中危安全漏洞(已验证)
- ✅ 低危漏洞 < 5 个(0 个)
- ✅ SQL 注入防护(17 个测试通过)
- ✅ XSS 防护(14 个测试通过)
- ✅ 权限控制(33 个测试通过)
- ✅ 总体安全评分:⭐⭐⭐⭐⭐ (5/5)
---
## 🎯 整体项目进度
### 已完成的阶段
| 阶段 | 状态 | 完成时间 | 主要成果 |
|------|------|---------|---------|
| Phase 1 | ✅ 完成 | Week 1 | 数据库模型重构 |
| Phase 2 | ✅ 完成 | Week 2-3 | 认证系统实现 |
| Phase 3 | ✅ 完成 | Week 4-5 | 权限系统实现 |
| Phase 4 | ✅ 完成 | Week 6 | 组织架构实现 |
| Phase 5 | ✅ 完成 | Week 7-8 | 产品归属实现 |
| Phase 6 | ✅ 完成 | Week 9-10 | 离职交接实现 |
| Phase 7 | ✅ 完成 | Week 11 | 问题反馈实现 |
| Phase 8 | ✅ 完成 | Week 12 | 数据安全与日志 |
| Phase 9 | 🚧 进行中 | Week 13 | 测试与优化(Day 4-5 完成)|
### 功能完成度
**核心功能**:
- ✅ 用户认证系统(100%)
- ✅ 权限管理系统(100%)
- ✅ 组织架构管理(100%)
- ✅ 产品管理系统(100%)
- ✅ SKU 管理系统(100%)
- ✅ 离职交接系统(100%)
- ✅ 问题反馈系统(100%)
- ✅ 操作日志系统(100%)
- ✅ 审计报表系统(100%)
**Phase 9 进展**:
- ✅ 前端性能优化(100%)
- ✅ 安全测试(100%)
- ⏳ 3D 性能优化(0%)
- ⏳ 用户体验优化(0%)
- ⏳ E2E 测试(0%)
### 代码统计
**总体统计**:
- **总文件数**:约 50+ 个
- **总代码行数**:约 8000+ 行
- **API 端点数**:约 30+ 个
- **数据库模型**:11 个
**Phase 8 贡献**:
- **新增文件**:8 个
- **新增代码**:约 1500 行
- **新增 API**:6 个
**Phase 9 贡献(Day 4-5)**:
- **新增文件**:13 个
- **新增代码**:约 2720 行
- 性能优化工具:约 660 行
- 组件优化:约 660 行
- 安全测试:约 800 行
- 文档:约 600 行
- **测试用例**:76 个(12 个性能 + 64 个安全)
---
## 🚀 下一步建议
### 立即可以做的事情
1. **运行类型检查**
```bash
cd furniture-3d-system
npm run type-check
```
2. **启动开发服务器测试**
```bash
npm run dev
```
3. **测试 API 端点**
- 使用 Postman 或 curl 测试新的日志和报表 API
- 验证权限控制是否正确
- 检查响应格式是否符合预期
### Phase 9 实施建议
#### 选项 1:完整实施(推荐)
按照规划文档完整实施 Phase 9,包括:
- 搭建测试框架
- 编写单元测试和集成测试
- 进行性能优化
- 进行安全测试
- 优化用户体验
**预计时间**:5-7 天
**收益**:系统质量有保障,可以正式投入使用
#### 选项 2:最小化实施
只实施最关键的部分:
- 核心功能的集成测试
- 基本的性能优化
- 关键的安全检查
**预计时间**:2-3 天
**收益**:快速验证核心功能,尽早发现问题
#### 选项 3:渐进式实施
边使用边优化:
- 先部署到测试环境
- 收集实际使用反馈
- 根据反馈逐步优化
**预计时间**:持续进行
**收益**:基于实际需求优化,避免过度工程
### 技术债务管理
**已知的技术债务**:
1. 部分旧代码有类型错误(不影响 Phase 8-9)
2. 缺少自动化测试
3. 性能未经过压力测试
4. 安全性未经过专业审计
**建议优先级**:
1. 🔴 高优先级:核心功能测试
2. 🟡 中优先级:性能优化
3. 🟢 低优先级:用户体验优化
---
## 📚 相关文档
### Phase 8 文档
- [Phase 8 完成文档](./PHASE8_COMPLETE.md)
- [Phase 8 需求文档](../.kiro/specs/data-security-logging/requirements.md)
- [Phase 8 设计文档](../.kiro/specs/data-security-logging/design.md)
- [Phase 8 任务清单](../.kiro/specs/data-security-logging/tasks.md)
### Phase 9 文档
- [Phase 9 需求文档](../../.kiro/specs/历史归档/Phase9-测试与优化/requirements.md)
- [Phase 9 设计文档](../../.kiro/specs/历史归档/Phase9-测试与优化/design.md)
- [Phase 9 任务清单](../../.kiro/specs/历史归档/Phase9-测试与优化/tasks.md)
### 总览文档
- [Phase 8-9 规划总览](../../.kiro/specs/历史归档/PHASES_8_9_OVERVIEW.md)
- [项目总览](./项目总览.md)
- [文档导航](./README.md)
### 历史文档
- [Phase 1-7 完成总结](./PHASES_5_6_7_SUMMARY.md)
- [Phase 1 完成文档](./PHASE1_COMPLETE.md)
- [Phase 2 完成文档](./PHASE2_COMPLETE.md)
- [Phase 3 完成文档](./PHASE3_COMPLETE.md)
- [Phase 4 完成文档](./PHASE4_COMPLETE.md)
- [Phase 5 完成文档](./PHASE5_COMPLETE.md)
- [Phase 6 完成文档](./PHASE6_COMPLETE.md)
- [Phase 7 完成文档](./PHASE7_COMPLETE.md)
---
## 🎉 成就总结
### Phase 8 成就
1. **完整的审计系统**
- 6 个 API 端点
- 全面的日志记录
- 多维度的报表分析
2. **企业级功能**
- 权限隔离
- 数据安全
- 操作追踪
3. **高质量代码**
- 类型安全
- 错误处理完善
- 代码结构清晰
### 项目整体成就
1. **功能完整性**
- ✅ 9 个核心模块全部完成
- ✅ 30+ 个 API 端点
- ✅ 完整的权限体系
2. **技术栈现代化**
- Next.js 14 + React 18
- TypeScript 严格模式
- Prisma ORM
- PostgreSQL
3. **企业级特性**
- 6 种用户角色
- 部门/小组管理
- 完整的审计追踪
- 数据安全保护
---
## 💡 经验总结
### 成功经验
1. **系统化的规划**
- 详细的 spec 文档
- 清晰的任务分解
- 明确的验收标准
2. **渐进式开发**
- 按阶段实施
- 每个阶段都有明确的目标
- 及时记录和总结
3. **代码质量保障**
- TypeScript 类型检查
- 统一的代码风格
- 完善的错误处理
### 改进建议
1. **测试驱动开发**
- 应该更早引入测试
- 边开发边测试
- 自动化测试覆盖
2. **性能监控**
- 应该更早关注性能
- 建立性能基准
- 持续监控优化
3. **文档先行**
- API 文档应该更详细
- 使用示例应该更丰富
- 部署文档应该更完善
---
## 📞 需要帮助?
### 如果遇到问题
1. **查看文档**
- 查看相关的设计文档
- 查看 API 文档
- 查看代码注释
2. **运行测试**
- 运行类型检查
- 测试 API 端点
- 查看错误日志
3. **参考示例**
- 查看已完成的代码
- 参考类似的实现
- 查看测试用例
---
**Phase 8 完成,Phase 9 规划完成!** 🎉
系统现在具备了完整的核心功能和审计能力。接下来可以根据实际需求选择合适的方式实施 Phase 9,进一步提升系统质量。
**预计完成时间**:如果完整实施 Phase 9,预计 2025-12-19 完成
**当前状态**:系统核心功能已完成,可以开始测试和优化
---
**创建时间**:2025-12-12
**最后更新**:2025-12-12

181
docs/Phase10完成报告.md Normal file
View File

@ -0,0 +1,181 @@
# Phase 10: 生产就绪 - 完成报告
> **完成日期**:2025-12-22
> **状态**:✅ 已完成
---
## 📋 完成概览
Phase 10 生产就绪阶段已全部完成,系统现已具备完整的生产环境部署能力。
### 主要成果
| 功能模块 | 状态 | 说明 |
|---------|------|------|
| 数据库备份 | ✅ 完成 | 支持本地和 Docker 环境 |
| 文件存储备份 | ✅ 完成 | 支持 MinIO Docker 容器 |
| 完整系统备份 | ✅ 完成 | 一键备份所有数据 |
| 系统恢复 | ✅ 完成 | 支持完整恢复和恢复前备份 |
| 健康检查 API | ✅ 完成 | 检测数据库、MinIO、资源使用 |
| 健康检查页面 | ✅ 完成 | 实时监控系统状态 |
| 配置验证 | ✅ 完成 | 验证环境变量和依赖服务 |
| 生产环境配置 | ✅ 完成 | docker-compose.prod.yml |
| 部署文档 | ✅ 完成 | 完整的部署和运维指南 |
---
## 🔧 备份恢复系统
### 备份脚本
1. **数据库备份** (`scripts/backup-db.ps1`)
- 自动检测 Docker 环境
- 支持 PostgreSQL 容器备份
- 生成压缩备份文件 (.sql.gz)
- SHA256 校验和验证
- 保留最近 5 份备份
2. **文件存储备份** (`scripts/backup-files.ps1`)
- 支持 MinIO Docker 容器
- 使用 docker cp 复制数据
- 本地压缩为 tar.gz
- 保留最近 5 份备份
3. **完整备份** (`scripts/backup-all.ps1`)
- 一键备份数据库和文件
- 备份配置文件 (.env, docker-compose.yml)
- 生成备份清单 (manifest.json)
- 自动清理过期备份(默认 5 天)
### 恢复脚本
- **系统恢复** (`scripts/restore.ps1`)
- 恢复前自动备份当前数据
- 支持数据库恢复
- 支持文件存储恢复
- 交互式确认防止误操作
---
## 🏥 健康检查系统
### API 端点
- **GET /api/health** - 系统健康检查
- 简单模式:返回 ok/error 状态
- 详细模式:返回所有服务状态和资源使用
### 检查项目
- ✅ 数据库连接状态
- ✅ API 服务状态
- ✅ CPU 使用率
- ✅ 内存使用率
- ✅ 磁盘使用率(可选)
### 健康检查页面
- 路径:`/system-health`
- 实时显示系统状态
- 支持手动/自动刷新
- 可配置刷新间隔
---
## 🐳 Docker 配置
### 生产环境配置 (`docker-compose.prod.yml`)
- 资源限制(CPU、内存)
- 健康检查配置
- 自动重启策略
- 日志轮转配置
- 安全配置
### 配置验证 (`scripts/validate-config.ps1`)
- 验证环境变量
- 检查端口可用性
- 验证依赖服务
- 检查开发环境
- 检查 Docker 状态
---
## 📚 文档
### 已创建文档
1. **部署指南** (`docs/部署指南.md`)
- 环境要求
- 详细部署步骤
- 配置参数说明
- 部署检查清单
2. **运维指南** (`docs/运维指南.md`)
- 备份恢复操作
- 监控指南
- 故障排查
- 日常维护
3. **README.md** - 更新项目状态和快速开始指南
---
## ✅ 验收测试结果
### 功能验收
| 测试项 | 结果 | 备注 |
|-------|------|------|
| 数据库备份 | ✅ 通过 | Docker 环境正常 |
| 文件存储备份 | ✅ 通过 | MinIO 容器正常 |
| 完整备份 | ✅ 通过 | 所有组件正常 |
| 数据库恢复 | ✅ 通过 | 数据完整恢复 |
| 健康检查 API | ✅ 通过 | 响应时间 < 1s |
| 配置验证 | ✅ 通过 | 所有检查通过 |
### 性能验收
| 指标 | 目标 | 实际 | 状态 |
|-----|------|------|------|
| 数据库备份时间 | < 5分钟 | ~1秒 | ✅ |
| 文件备份时间 | < 10分钟 | ~1秒 | ✅ |
| 健康检查响应 | < 1秒 | ~200ms | ✅ |
---
## 🔮 后续建议
1. **定时备份**:配置 cron/任务计划程序定时执行备份
2. **远程备份**:将备份文件同步到远程存储
3. **监控告警**:集成监控系统,异常时发送告警
4. **负载测试**:进行生产环境负载测试
---
## 📁 相关文件
```
furniture-3d-system/
├── scripts/
│ ├── backup-db.ps1 # 数据库备份
│ ├── backup-files.ps1 # 文件存储备份
│ ├── backup-all.ps1 # 完整备份
│ ├── restore.ps1 # 系统恢复
│ └── validate-config.ps1 # 配置验证
├── docker-compose.prod.yml # 生产环境配置
├── docs/
│ ├── 部署指南.md
│ ├── 运维指南.md
│ └── Phase10完成报告.md
└── src/app/
├── api/health/route.ts # 健康检查 API
└── system-health/ # 健康检查页面
```
---
**报告生成时间**:2025-12-22 18:17
**项目状态**:生产就绪 ✅

View File

@ -212,69 +212,3 @@
| `src/app/warehouse/WarehouseClient.tsx` | 移除 headerTools/eyebrow |
| `src/app/HomeClient.workspace.tsx` | 移除 topTools 传参 |
| ~20 个业务文件 | 硬编码颜色替换 |
---
## 第三阶段:管理基础设施视觉升级 (2026-04-16)
### 10. 管理端专属视觉模式 (Management UI Patterns)
在重构 `/admin` 和 `/users` 页面时,引入了针对“高密度控制类页面”的增强视觉模式,以平衡“工业精密感”与“视觉通透度”。
**1. 沉浸式页头 (Immersive Page Header)**
- **视觉特征**:
- 采用 `bg-white/60` + `backdrop-blur-2xl`。
- 强化左侧 Icon 视觉,使用 `rounded-[2rem] bg-black` 黑色底座,并叠加状态徽章(如 `ShieldCheck`)。
- **排版建议**:Manrope 3XL 标题 + 紧跟其后的 `text-apple-blue` 高亮关键词。
- **副标题**:使用 `text-[10px] tracking-[0.4em] uppercase` 的精密说明文字。
**2. 精密控制条 (Precision Control Bar)**
- **定义**:将原本分散的“搜索框”与“筛选器”整合进一个逻辑整体。
- **视觉实现**:
- 容器:`rounded-[2rem]` 极圆圆角,`border-white/80` 微光边框,`bg-white/40` 背景。
- 搜索框:沉浸式设计,背景为 `bg-white/80`,聚焦时通过 `ring-apple-blue/5` 产生柔和晕染。
- 筛选器:图标 (Lucide Icon) + Select 组件的紧凑组合,图标作为装饰前缀。
**3. 玻璃态卡片增强 (Enhanced Glass Cards)**
- **改动**:在 `rounded-[2rem]` 的基础上,引入了“装饰线”动效。
- **动效逻辑**:`group-hover:w-full` 的底部 1.5px 强调线,颜色与模块 Icon 色调一致。
- **背景文字装饰**:绝对定位的 `text-[80px]` 背景大字(如模块 path),设置 `opacity-[0.03]` 极低透明度,增加“空间感”。
**4. 状态指标看板 (System Health Dashboard)**
- 应用场景:`/admin` 首页。
- 组成:Icon 容器 + 上下两行文本(上行为微缩标签,下行为状态值)。
- 配色:动态使用 `emerald-500` (正常), `blue-500` (活动), `amber-500` (注意)。
**5. 三栏工作区 (Three-Column Workspace)**
- 应用场景:`/asset-center/material-library`。
- 定义:左侧极简分类 (Navigation) + 中间响应式网格 (Grid) + 右侧实时定义面板 (Inspector)。
- 优势:极大降低页面跳转成本,实现“即时反馈”。
**6. 精密滑块交互 (Precision Sliders)**
- 视觉表现:细条轨道 + 圆形 Thumb + 右侧数值百分比徽章。
- 应用场景:物理参数调节 (Metallic/Roughness/Opacity)。
- 规范:取代原有的输入框,滑动步长固定为 0.01 (1%)。
**7. 悬浮坞系统 (Floating Dock System)**
- 应用场景:`/assisted-assembly`。
- 定义:左侧布局的交互式入口,采用 `bg-black/60` + `backdrop-blur-3xl`。
- 动效:点击后向右水平展开列表。
- 逻辑:通过视觉发光 (Glow) 和计数徽标反馈当前分类的选择状态。
**8. 环境 HUD (Ambient HUD)**
- 定义:底部的非侵入式信息条。
- 样式:极圆胶囊型,`backdrop-blur-xl`。
- 作用:提供当前操作环境的实时状态(如“捕捉已就绪”)。
### 11. 改动文件清单
| 文件 | 改动类型 | 备注 |
|------|----------|------|
| `src/app/admin/page.tsx` | 重构 | 系统总控制台首页视觉重载 |
| `src/app/users/page.tsx` | 重构 | 身份控制中心 (用户管理) 三栏重写 |
| `src/app/asset-center/material-library/MaterialLibraryClient.tsx` | 重构 | 材质库首页视觉重写 (草稿图 12.png) |
| `src/app/assisted-assembly/AssistedAssemblyClient.tsx` | 重构 | 拼装工作台沉浸式重写 & 引入 Floating Dock |
| `src/components/platform/PlatformSplitLayout.tsx` | 基础重构 | 统一加装 backHref 导航标准 |

183
docs/README.md Normal file
View File

@ -0,0 +1,183 @@
# 家具开品平台 - 文档导航
> **最后更新**:2026-03-31
> **项目状态**:新平台一期骨架已落地,旧 `CAD -> 装配步骤` 链继续作为兼容链保留
---
## 📖 快速导航
### 🎯 了解项目进度
- **[项目总览](./项目总览.md)** - 查看整体进度、功能完成度、下一步计划
- **[长期决策日志](./agent-memory/decision-log.md)** - 查看最近做过的重要技术与流程决策
- **[长期待办与阻塞](./agent-memory/open-issues.md)** - 查看当前仍在收口的问题
### 🏗️ 架构设计
- **[认证系统设计](./架构设计/auth-system-design.md)** - 用户认证和首次登录流程
- **[权限系统设计](./架构设计/permission-design-v2.md)** - 6 种角色的权限体系
### 📚 开发指南
- **[设计系统](./开发指南/设计指南.md)** - Apple 风格 UI 组件使用
- **[组件使用指南](./开发指南/组件使用指南.md)** - 常用组件的使用方法
- **[上传 API 指南](./开发指南/上传API指南.md)** - 文件上传功能使用
- **[性能优化指南](./开发指南/性能优化指南.md)** - 性能优化最佳实践
- **[数据库优化](./开发指南/数据库优化.md)** - 数据库查询优化
- **[快速测试指南](./开发指南/快速测试指南.md)** - 测试相关指南
- **[项目上下文](./agent-memory/project-context.md)** - 当前高频回归点、默认工作流、项目目标
### 🤝 团队协作
- **[团队协作模式](./workflow/团队协作模式.md)** - 当前推荐分工、分支规则、提交流程
- **[高频协作五条铁律](./workflow/高频协作五条铁律.md)** - 双 Codex 高频协作时的 5 条固定规则
- **[主线同步操作手册](./workflow/主线同步操作手册.md)** - 主线持续更新时,同事如何安全同步自己的分支
- **[同事本地 AGENTS 使用说明](./workflow/同事本地AGENTS使用说明.md)** - 如何给同事单独加 AI 约束,而不污染仓库共享规则
- **[同事本地 AGENTS 模板](./workflow/同事本地AGENTS模板.md)** - 可直接复制到同事电脑外层工作区的 `AGENTS.md` 模板
- **[模块边界说明](./workflow/模块边界说明.md)** - 新平台主链、包装协作、旧 CAD 兼容链的边界
- **[任务单模板](./workflow/任务单模板.md)** - 给人和 Codex 共用的任务模板
- **[提测与验收清单](./workflow/提测与验收清单.md)** - 提交前必须过的最小检查
- **[同事交接模板](./workflow/同事交接模板.md)** - 给非程序员同事和 Codex 的交接模板
### 🧠 长期记忆与问题收口
- **[长期记忆导航](./agent-memory/README.md)** - 当前长期记录入口
- **[长期决策日志](./agent-memory/decision-log.md)** - 近期关键决策
- **[长期待办与阻塞](./agent-memory/open-issues.md)** - 当前问题收口列表
### 📊 阶段完成报告(历史阶段)
- **[Phase 1: 数据库模型重构](./阶段报告/PHASE1_COMPLETE.md)**
- **[Phase 2: 认证系统](./阶段报告/PHASE2_COMPLETE.md)**
- **[Phase 3: 权限系统](./阶段报告/PHASE3_COMPLETE.md)**
- **[Phase 4: 组织架构](./阶段报告/PHASE4_COMPLETE.md)**
- **[Phase 5: 产品归属](./阶段报告/PHASE5_COMPLETE.md)**
- **[Phase 6: 离职交接](./阶段报告/PHASE6_COMPLETE.md)**
- **[Phase 7: 问题反馈](./阶段报告/PHASE7_COMPLETE.md)**
- **[Phase 8: 数据安全与日志](./阶段报告/PHASE8_COMPLETE.md)**
- **[Phase 9: 测试与优化](./阶段报告/Phase9总结.md)**
- **[Phase 10 完成报告](./Phase10完成报告.md)**
### 🧪 测试报告
- **[安全测试报告](./测试报告/安全测试报告.md)** - SQL 注入、XSS、权限测试
- **[性能测试报告](./测试报告/性能测试报告.md)** - 前端性能优化结果
- **[登录测试报告](./测试报告/登录测试报告.md)** - 登录流程测试
- **[主页测试报告](./测试报告/主页测试报告.md)** - 主页功能测试
---
## 🚀 快速开始
### 1. 查看项目进度
```bash
# 查看项目总览
cat docs/项目总览.md
```
### 2. 了解架构设计
```bash
# 查看权限系统设计
cat docs/架构设计/permission-design-v2.md
```
### 3. 开始开发
```bash
# 查看快速开始指南
cat ../README.md
```
---
## 📂 目录结构
```
docs/
├── README.md # 📖 本文档(导航入口)
├── 项目总览.md # 🎯 项目整体情况
├── PHASES_8_9_SUMMARY.md # 📊 最新阶段总结
├── workflow/ # 🤝 团队协作规则
│ ├── 团队协作模式.md
│ ├── 高频协作五条铁律.md
│ ├── 主线同步操作手册.md
│ ├── 同事本地AGENTS使用说明.md
│ ├── 同事本地AGENTS模板.md
│ ├── 模块边界说明.md
│ ├── 任务单模板.md
│ ├── 提测与验收清单.md
│ ├── 同事交接模板.md
│ └── 规范审查清单.md
│
├── 架构设计/ # 🏗️ 架构相关文档
│ ├── auth-system-design.md # 认证系统设计
│ └── permission-design-v2.md # 权限系统设计
│
├── 开发指南/ # 📚 开发相关指南
│ ├── 设计指南.md
│ ├── 组件使用指南.md
│ ├── 上传API指南.md
│ ├── 性能优化指南.md
│ ├── 数据库优化.md
│ └── 快速测试指南.md
│
├── 阶段报告/ # 📊 各阶段完成文档
│ ├── PHASE1_COMPLETE.md
│ ├── PHASE2_COMPLETE.md
│ ├── ...
│ ├── PHASE8_COMPLETE.md
│ ├── Phase9总结.md # ⭐ 当前阶段
│ ├── 第9阶段_第4天_上午完成.md
│ ├── 第9阶段_第4天_下午完成.md
│ ├── 第9阶段_第4天_总结.md
│ └── 第9阶段_第5天_下午完成.md
│
├── 测试报告/ # 🧪 测试相关报告
│ ├── 安全测试报告.md
│ ├── 性能测试报告.md
│ ├── 登录测试报告.md
│ └── 主页测试报告.md
│
└── 历史归档/ # 📦 过时的文档归档
├── database-redesign-product-sku.md
├── LOGIN_FLOW_FIXED.md
├── NEXT_STEPS.md
├── PHASES_5_6_7_SUMMARY.md
├── theme-adaptation-fix.md
├── troubleshooting-theme.md
├── 迁移指南.md
├── 用户指南.md
├── 主题测试指南.md
└── 实施计划.md
```
---
## 🎯 当前状态
### 当前重点
- 新主线已切到:`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库`
- 旧 `CAD 导入 -> SKU 级结构数据 -> 装配编辑 -> 3D 回放` 继续保留为兼容链
- 当前重点不是继续堆页面,而是先把资产精度、模块边界和团队协作规则立住
### 当前建议查看顺序
1. **[项目总览](./项目总览.md)**
2. **[项目上下文](./agent-memory/project-context.md)**
3. **[长期待办与阻塞](./agent-memory/open-issues.md)**
4. **[长期决策日志](./agent-memory/decision-log.md)**
---
## 📞 需要帮助?
### 常见问题
1. **如何查看项目进度?** → 查看 [项目总览](./项目总览.md)
2. **如何了解权限系统?** → 查看 [权限系统设计](./架构设计/permission-design-v2.md)
3. **如何了解认证系统?** → 查看 [认证系统设计](./架构设计/auth-system-design.md)
4. **如何优化性能?** → 查看 [性能优化指南](./开发指南/性能优化指南.md)
### 联系方式
- 查看代码注释
- 查看相关文档
- 参考已完成的代码示例
---
**文档版本**:v2.2
**最后更新**:2026-03-31
**维护者**:开发团队

View File

@ -0,0 +1,44 @@
# Agent 长期记忆目录
> 目的:避免“换 Agent / 重启会话后失忆”,把关键事实沉淀为仓库文档。
## 文件说明
- `project-context.md`:项目现状、硬约束、当前优先级。
- `decision-log.md`:关键技术决策与原因(按日期追加)。
- `open-issues.md`:未完成项、阻塞项、下一步动作。
- `regression-checklist.md`:高风险功能回归清单(通过/失败状态)。
## 使用规则(强制)
1. 每轮任务开始前:先读上述 4 个文件。
2. 每轮任务结束后:至少更新 `decision-log.md` 与 `open-issues.md`。
3. 声称“已修复/已完成”前:更新 `regression-checklist.md` 的验证结果。
4. 长期事实只认本目录,不依赖临时 Agent 会话记忆。
## 容量上限(强制)
- `project-context.md`:不超过 220 行。
- `decision-log.md`:不超过 150 条决策或 24KB。
- `open-issues.md`:不超过 120 行。
- `regression-checklist.md`:不超过 220 行。
- 本目录总大小建议不超过 180KB(不含 `archive/`)。
超过上限时,先执行“压缩+归档”再继续新任务。
## 归档规则
- 归档目录:`docs/agent-memory/archive/`
- 归档命名:
- `decision-log-YYYY-MM.md`
- `open-issues-YYYY-MM.md`
- `regression-checklist-YYYY-MM.md`
- 当前文件顶部需保留:
- 最新摘要(3~10 行)
- 最近归档文件路径(便于追溯)
## 记录格式建议
- 日期:`YYYY-MM-DD`
- 状态:`待处理 / 处理中 / 已完成 / 阻塞`
- 每条记录尽量包含:问题、动作、证据(命令或文件路径)

View File

@ -0,0 +1,17 @@
# Agent 记忆归档目录
用途:存放从 `docs/agent-memory/` 主文件压缩出来的历史记录。
## 命名规则
- `decision-log-YYYY-MM.md`
- `open-issues-YYYY-MM.md`
- `regression-checklist-YYYY-MM.md`
## 归档流程(建议)
1. 从主文件剪切较旧内容到对应归档文件。
2. 在主文件顶部保留“当前摘要 + 归档路径”。
3. 确认主文件回到容量上限以内。
> 说明:归档文件默认不作为每轮必读内容,仅在追溯历史时查阅。

View File

@ -0,0 +1,15 @@
# 决策日志(长期)
| 日期 | 决策 | 原因 | 影响范围 | 状态 |
|---|---|---|---|---|
| 2026-03-13 | 协作流程收敛为“直接开发 + 规范审查闸门” | 避免运行时协作策略干扰主线开发,把硬规则稳定落到每次交付的固定步骤里 | 全项目默认协作方式、交付口径、审查流程 | 已生效 |
| 2026-03-05 | SKU详情页/装配编辑器节点解析统一到“模型真实节点映射 + canonical 别名解析”,并将 3D 拾取容差改为“中心命中优先 + 最近命中优先” | 修复 SKU 详情与装配编辑器点选规则不一致、折角线锚点错位,以及点选漂移导致的误选/漏选 | `SKUDetailClient.workspace`、`install-order-node-resolution`、`model-viewer-pick-tolerance` | 已生效 |
| 2026-02-27 | 装配编辑器本轮收口:修复可见乱码文案、步骤编辑器响应式布局、孔位高亮坐标解算改为“panel/modelRoot 双坐标根候选择优” | 解决页面可用性问题并降低孔位错绑/爆炸状态漂移 | `AssemblyEditorWorkspace`、`AssemblyStepEditor`、`HoleOverlay2D` | 已生效 |
| 2026-02-26 | 安装顺序链路后端收口:在 Service 新增“保存顺序+生成步骤+返回最新步骤”编排方法 | 符合企业级分层,避免前端承载业务编排 | `assembly.service`、`generate-from-order route` | 已生效 |
| 2026-02-26 | 生成失败后增加安装顺序补偿回滚,且 Route/Service 双层限制板件上限 300 | 降低部分成功状态与 DoS 风险 | `assembly.service`、`panel.repository`、`generate-from-order route` | 已生效 |
| 2026-02-26 | 安装顺序“保存+生成”前端改为调用 `/api/products/[id]/assembly/generate-from-order` 单接口,并直接消费响应 `steps` 刷新步骤区 | 减少一次网络往返,避免额外 `GET /assembly` 带来的时序抖动 | `AssemblyEditorWorkspace` 安装顺序提交流程 | 已完成 |
| 2026-02-26 | Agent 长期记忆从“会话上下文”切换为“仓库文档持久化” | 避免换会话或重启后丢失关键项目背景 | 全项目开发上下文与决策沉淀 | 已生效 |
| 2026-02-26 | 开发阶段默认采用“批量闭环修复”,不再以最小改动为目标 | 当前处于快速开发与清债阶段 | 任务拆分、修复策略、审查节奏 | 已生效 |
| 2026-02-26 | 安装顺序编辑器移除 `key` 强制重挂载,改为内部状态同步 | 降低重挂载性能损耗,保持数据同步 | `AssemblyEditorWorkspace`、`AssemblyStepEditor` | 已完成 |
> 追加规则:新决策按时间倒序追加到表格顶部。

View File

@ -0,0 +1,61 @@
# 待办与阻塞(长期)
最后更新:2026-03-11
## P0(高优先级)
- 问题:SKU 详情页与 SKU 装配页“部分板材不可选/折角线贴合异常”已完成代码修复,但仍需基于真实模型做人工终验(自动化仅验证到开关/基础交互)。
- 责任角色:QA 测试员 / 3D 工程师 / 前端工程师
- 下一步:在 `3000` 端口对固定 SKU 执行全量点选回归(非抽样),逐板件验证:可选中、安装顺序可入列、折角线贴边。
- 证据:`src/app/products/[id]/skus/[skuId]/SKUDetailClient.workspace.tsx`、`src/components/3d/model-viewer-pick-tolerance.ts`、`output/playwright/sku-regression-progress-2026-03-05T02-36-59-091Z/report.json`
- 状态:处理中
- 问题:`HoleOverlay2D` 在 `requestAnimationFrame` 内仍有较重计算(每帧分组聚合与多次对象创建),孔位多时存在掉帧风险。
- 责任角色:3D 工程师
- 下一步:抽离“静态分组/标签聚合”到 `useMemo`,绘制环节仅做投影与绘制;减少帧内临时对象创建。
- 证据:`src/components/3d/HoleOverlay2D.tsx`
- 状态:待处理
## P1(中优先级)
- 问题:`generate-from-order` 仍缺“接口限频 + 幂等键”治理,网络抖动下存在重复生成与写放大风险。
- 责任角色:后端工程师
- 下一步:为该路由增加用户维度限频,并设计幂等键(同一次提交在短窗口只执行一次)。
- 证据:`src/app/api/products/[id]/assembly/generate-from-order/route.ts`
- 状态:待处理
- 问题:当前补偿回滚仅覆盖安装顺序,生成前创建版本的写放大仍需评估(失败时可能产生额外版本记录)。
- 责任角色:后端工程师
- 下一步:评估“版本创建时机后移”或“失败标记版本”策略,避免无效版本增长。
- 证据:`src/server/services/assembly.service.ts`
- 状态:待处理
- 问题:安装顺序单接口 `generate-from-order` 已接入,仍需联调确认异常场景与 `warnings` 展示策略。
- 责任角色:前端工程师 / 后端工程师
- 下一步:用真实产品执行“点选顺序 -> 保存并生成”,确认失败提示与步骤回填一致;评估是否补充 `warnings` UI。
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- 状态:待处理
- 问题:`AssemblyEditorWorkspace` 仍有历史视觉 token 不统一(如部分 `bg-white/text-white`)。
- 责任角色:前端工程师
- 下一步:按页面分区批量替换为语义 token,不改业务逻辑
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- 状态:待处理
- 问题:`AssemblyEditorWorkspace` 仍存在组件内直写 `fetch` 的历史实现,未完全收口到 hooks/数据层。
- 责任角色:前端工程师
- 下一步:按接口链路分批迁移到 `src/hooks/api`,先迁移装配页高频接口,保持行为不变。
- 证据:`src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- 状态:待处理
## 阻塞项
> 暂无
## 记录模板
- 问题:
- 责任角色:
- 下一步:
- 证据(文件/命令):
- 状态:待处理 / 处理中 / 已完成 / 阻塞

View File

@ -0,0 +1,34 @@
# 项目上下文(长期)
最后更新:2026-02-26
## 项目目标
- 核心:CAD 导入后,在装配编辑器中设置安装顺序并自动生成装配步骤。
- 重点链路:`装配编辑器(3D) -> 安装顺序 -> 自动生成步骤 -> 回放验证`。
## 当前硬约束
- 后端必须遵循:`API Route -> Service -> Repository -> Database`。
- 前端必须优先复用:`src/components/ui/` 元组件。
- 开发阶段策略:批量闭环修复,不以“最小改动”为默认目标。
- 交付前必须完成:规范审查;安全 / 性能 / QA 按任务风险追加。
- 当前总规则入口:根目录 `AGENTS.md` 与 `docs/workflow/*.md`。
## 用户关注的高频回归点
- 孔位高亮位置与爆炸程度同步。
- 折角线显示、文本与板材关联、边界不越出预览区。
- 白膜模式灰度正确(不是改背景色)。
- 板材可选中与高亮稳定。
- 右键菜单层级、位置、可见区域裁剪、材质预览交互。
- 爆炸程度滑块流畅性(无明显卡顿)。
- 中文 UI 文案与样式规范一致。
## 当前默认工作流
- 默认由当前开发流程直接推进任务。
- 开始前先明确:目标、验收标准、适用标准、根因判断。
- 开发后先做最小必要验证,再进入规范审查阶段。
- 规范审查固定检查:企业级架构标准、统一视觉标准、是否为根因修复。
- 遇到安全、性能、关键用户链路时,再补安全 / 性能 / QA 审查。

View File

@ -0,0 +1,29 @@
# 回归检查清单(长期)
最后更新:2026-02-26
## 装配编辑器核心交互
| 检查项 | 状态 | 备注 |
|---|---|---|
| 孔位高亮与真实孔位贴合 | 待验证 | 含爆炸程度 0%/45%/100% |
| 孔位高亮随爆炸程度同步 | 待验证 | 拖动滑块时不飘逸 |
| 折角线可见且随爆炸程度同步 | 待验证 | 文字不越界 |
| 折角线文字与板材名称关联 | 待验证 | 示例:`横隔板_panel 1` |
| 白膜模式显示为灰膜(非背景变灰) | 待验证 | 需在 3D 与预览区都确认 |
| 板材点选与高亮稳定 | 待验证 | 单选/多选都验证 |
| 鼠标中键平移符合“始终面对摄像机” | 待验证 | 默认中键行为无冲突 |
| 右键菜单位置不越出可视区域 | 待验证 | 含子菜单 |
| 右键菜单层级移动不丢焦点 | 待验证 | 材质二级/三级菜单 |
| 材质项小图+悬浮大图预览正常 | 待验证 | 不遮挡关键操作 |
| 爆炸程度滑块拖动流畅 | 已通过 | 已去除明显卡顿 |
| 中文文案与字体样式合规 | 待验证 | 开关、菜单、提示文案 |
## 安装顺序链路
| 检查项 | 状态 | 备注 |
|---|---|---|
| 点击板材按顺序追加步骤 | 已通过 | 重复点击不重复加入 |
| 保存顺序并自动生成步骤 | 待验证 | 已改为单接口 `generate-from-order` 且直接消费响应 `steps`;`lint/type-check` 已通过,待真实联调 |
| 步骤编辑器与外部步骤同步 | 已通过 | 移除 `key remount` |
| 安装顺序操作区使用元组件 | 已通过 | `Button` 已替换 |

34
docs/solutions/README.md Normal file
View File

@ -0,0 +1,34 @@
# 解决方案沉淀库
本目录用于沉淀“已修复且已验证”的问题模式,目标是让后续同类问题更快定位、更稳修复。
## 目录规则
- `pattern-registry.yaml`:模式索引,维护状态与验证信息。
- `<category>/<slug>-YYYYMMDD.md`:单条问题模式文档。
## 分类建议
- `build-errors`
- `test-failures`
- `runtime-errors`
- `performance-issues`
- `database-issues`
- `security-issues`
- `ui-bugs`
- `integration-issues`
- `logic-errors`
- `workflow-issues`
- `developer-experience`
- `documentation-gaps`
## 文档命名
- 建议格式:`<slug>-YYYYMMDD.md`
- 例:`service-repository-layering-user-service-20260215.md`
## 使用流程
1. 修复并验证通过后,新增/更新对应模式文档。
2. 同步更新 `pattern-registry.yaml` 的状态和验证时间。
3. 若旧模式被替代,不直接删除,标记为 `deprecated` 或 `merged`。

View File

@ -0,0 +1,172 @@
patterns:
- id: WF-SVC-REPO-USER-001
file: docs/solutions/workflow-issues/service-repository-layering-user-service-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-PANEL-PERM-001
file: docs/solutions/security-issues/panel-write-permission-guard-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-HOLE-PERM-001
file: docs/solutions/security-issues/hole-permission-guard-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-HOLE-ROUTE-SERVICE-001
file: docs/solutions/workflow-issues/hole-route-service-layering-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-PRODHW-PERM-001
file: docs/solutions/security-issues/product-hardware-permission-guard-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-PRODHW-ROUTE-SERVICE-001
file: docs/solutions/workflow-issues/product-hardware-route-layering-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-PANEL-BOUNDARY-001
file: docs/solutions/security-issues/panel-nested-product-boundary-20260215.md
problem_type: security_issue
status: deprecated
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: [SEC-PANEL-HOLE-BOUNDARY-001]
- id: SEC-COLLAB-PII-001
file: docs/solutions/security-issues/collaborator-email-minimization-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-HOLE-MATCH-GUARD-001
file: docs/solutions/security-issues/hole-match-access-throttle-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: SEC-PANEL-HOLE-BOUNDARY-001
file: docs/solutions/security-issues/panel-hole-delete-boundary-20260215.md
problem_type: security_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: [SEC-PANEL-BOUNDARY-001]
superseded_by: []
- id: WF-PANEL-ROUTE-SVC-001
file: docs/solutions/workflow-issues/panel-route-service-permission-unification-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-DWG-TASK-CLEANUP-001
file: docs/solutions/workflow-issues/dwg-task-lifecycle-cleanup-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-UI-TOKEN-RAF-001
file: docs/solutions/workflow-issues/ui-token-and-raf-optimization-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-CONTROLLER-LAYERING-002
file: docs/solutions/workflow-issues/controller-overload-layering-batch-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-COLLAB-RELATION-LIBDS-001
file: docs/solutions/workflow-issues/collaborator-relation-and-lib-datasource-20260215.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-15
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-UI-WORKSPACE-MODULAR-001
file: docs/solutions/workflow-issues/ui-monolith-workspace-modularization-batch-20260216.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-16
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-ASSEMBLY-INTERACTION-002
file: docs/solutions/workflow-issues/assembly-3d-interaction-regression-20260223.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-23
usage_count: 2
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-ASSEMBLY-EDITOR-SYNC-003
file: docs/solutions/workflow-issues/assembly-install-order-editor-sync-20260226.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-26
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []
- id: WF-ASSEMBLY-ORDER-ORCH-004
file: docs/solutions/workflow-issues/assembly-generate-from-order-orchestration-20260226.md
problem_type: workflow_issue
status: active
last_verified_at: 2026-02-26
usage_count: 1
success_rate: 1.0
supersedes: []
superseded_by: []

View File

@ -0,0 +1,37 @@
---
title: 协作者列表默认暴露邮箱,需最小化返回字段
date: 2026-02-15
module: product-collaborator
problem_type: security_issue
severity: high
symptoms:
- 协作者 GET 对有查看权限用户默认返回创建者与协作者邮箱
- 造成组织敏感信息过度暴露
root_cause: 数据最小化策略缺失,读取权限与敏感字段权限未分离
resolution_type: code_fix
status: active
confidence: high
tags: [security, privacy, collaborators, pii]
related_patterns: [SEC-COLLAB-PII-001]
---
## 现象与影响
即使用户仅具备产品可见权限,也能读取所有协作者邮箱,扩大了员工账号信息暴露面。
## 修复动作
1. 保留 `canViewProduct` 作为协作者列表读取前置条件。
2. 新增 `canManage` 判定,仅当通过 `canManageCollaborators` 时返回邮箱字段。
3. 默认返回中将创建者与协作者 `email` 置为 `null`,满足“最小必要信息”原则。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
## 预防策略
- API 设计区分“可查看资源”与“可查看敏感字段”两层权限。
- 新增返回字段时默认走最小化策略,敏感字段须显式授权后释放。

View File

@ -0,0 +1,42 @@
---
title: holes/match 权限与滥用防护收口
date: 2026-02-15
module: hole-match
problem_type: security_issue
severity: critical
symptoms:
- autoSave=true 仅校验登录态,未校验产品编辑权限
- autoSave=false 可匿名读取非公开产品匹配结果
- 匿名高频调用会触发大规模匹配计算
root_cause: 匹配链路缺少产品级权限判断与接口级节流,且计算规模没有上限
resolution_type: code_fix
status: active
confidence: high
tags: [security, permission, rate-limit, holes, match]
related_patterns: [SEC-HOLE-PERM-001]
---
## 现象与影响
`/api/holes/match` 存在“已登录即可写任意产品”和“匿名可读非公开产品”的边界缺口,同时高频请求会放大 CPU/DB 消耗。
## 修复动作
1. 在 `src/server/services/hole.service.ts` 的 `matchHardware` 增加产品解析与权限判断:
- 保存结果:`canEditProduct`
- 预览结果:`canViewProduct`
- 匿名访问:仅允许 `PUBLIC` 产品
2. 增加匹配规模上限(匿名/登录/保存分级)和五金库查询上限(1500)。
3. 在 `src/app/api/holes/match/route.ts` 增加按用户/IP 的窗口限流。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
## 预防策略
- 任何“预览 + 保存”双模式接口都要在 Service 层拆分权限模型,而不是只在 Route 判断登录态。
- 计算型接口默认要有“节流 + 规模上限”双保险。

View File

@ -0,0 +1,36 @@
---
title: Hole 写/读操作缺少 Service 层产品权限护栏
date: 2026-02-15
module: hole-service
problem_type: security_issue
severity: critical
symptoms:
- /api/holes 与 /api/holes/[id] 依赖登录态,缺少基于产品归属的 canEditProduct/canViewProduct 校验
- Route 可被绕过时,Service 直接写孔位数据,存在跨产品越权风险
root_cause: 权限策略停留在 Route 入参层,Service 未对 hole/panel -> product 归属做统一鉴权
resolution_type: code_fix
status: active
confidence: high
tags: [security, idor, permission, holes, service-layer]
related_patterns: [WF-HOLE-ROUTE-SERVICE-001]
---
## 现象与影响
孔位写接口在“已登录”之后直接执行写库,读接口允许匿名访问精确结构参数。缺少产品级权限校验时,攻击者可通过可猜测 ID 读取或修改不属于自己的产品孔位数据。
## 修复动作
1. 在 `src/server/services/hole.service.ts` 新增 `requireUser/getProductForPermission/assertCanViewProduct/assertCanEditProduct`。
2. 将权限护栏接入 `listByFilter/findById/findByPanelId`(查看)与 `create/createMany/update/delete/deleteMany/deleteByPanelId/deleteByFilter`(写入)。
3. 所有写入前都先解析 `hole/panel -> product` 归属,再执行 `canEditProduct`。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- 所有“资源隶属产品”的接口统一执行“Route 鉴权入口 + Service 产品权限护栏”。
- Service 层禁止依赖 Route 先验假设(例如“已经做过权限校验”)。

View File

@ -0,0 +1,35 @@
---
title: 面板级孔位批量删除越界防护
date: 2026-02-15
module: panel-hole-route
problem_type: security_issue
severity: high
symptoms:
- products/[id]/panels/[panelId]/holes?ids= 删除时未校验 hole 是否属于 panelId
root_cause: Service 批量删除只校验“同一产品”,缺少“同一面板”边界约束
resolution_type: code_fix
status: active
confidence: high
tags: [security, idor, panel, holes]
related_patterns: [SEC-PANEL-BOUNDARY-001]
---
## 现象与影响
攻击者可以在面板级接口传入其他面板的 holeId,实现越界删除。
## 修复动作
1. `holeService.deleteMany` 新增 `expectedPanelId` 参数。
2. 删除前逐条验证 `hole.panelId === expectedPanelId`,不符合直接拒绝。
3. 路由 `src/app/api/products/[id]/panels/[panelId]/holes/route.ts` 在 DELETE 分支传入 `expectedPanelId`,并补充空 ids 校验。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- 嵌套路由的批量接口,必须做“URL 资源边界”与“请求体资源边界”双向一致性校验。

View File

@ -0,0 +1,37 @@
---
title: 嵌套路由 panelId 缺少 productId 归属校验(IDOR)
date: 2026-02-15
module: panel-nested-routes
problem_type: security_issue
severity: critical
symptoms:
- /api/products/[id]/panels/[panelId] 与 /holes 子路由按 panelId 直接读写
- 未校验 panel.productId 与 URL 中 productId 一致
root_cause: 路由层默认信任路径参数,未建立资源归属约束
resolution_type: code_fix
status: active
confidence: high
tags: [security, idor, route, panel, product]
related_patterns: [SEC-PANEL-BOUNDARY-001]
---
## 现象与影响
攻击者可构造“任意 productId + 目标 panelId”请求,在嵌套路由下越权读取或修改其他产品下的板件/孔位。
## 修复动作
1. 在 `panel.service.ts` 新增 `assertPanelBelongsToProduct(panelId, productId)`。
2. 在 `products/[id]/panels/[panelId]/route.ts` 与 `products/[id]/panels/[panelId]/holes/route.ts` 所有读写入口统一先做归属校验。
3. `panel.service.findById` 增加 `canViewProduct` 权限护栏,并将板件详情 GET 改为认证访问。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
## 预防策略
- 所有形如 `/products/:productId/.../:resourceId` 的接口,必须执行“resource -> productId 与路径一致”校验。
- 归属校验与权限校验都应在 Service 层有可复用方法,Route 只负责调用。

View File

@ -0,0 +1,36 @@
---
title: Panel 写操作缺少 Service 层权限护栏
date: 2026-02-15
module: panel-service
problem_type: security_issue
severity: critical
symptoms:
- 板件创建路由做了权限校验,但 Service 层写操作未做 canEditProduct 兜底
- 可通过绕过 Route 的调用路径触发越权写入
root_cause: 权限逻辑集中在 Route,Service 层缺少防线
resolution_type: code_fix
status: active
confidence: high
tags: [security, idor, permission, panel, service-layer]
related_patterns: []
---
## 现象与影响
板件创建接口在 Route 层做了登录+编辑权限校验,但 `panel.service.ts` 的 `create/update/delete/importFromZip` 在写库前不校验产品编辑权限。
一旦存在绕过 Route 的调用(内部复用、脚本、后续新增路由等),会造成跨产品越权写入。
## 修复动作
1. 在 Service 层新增 `assertCanEditProduct` 权限护栏。
2. 新增 `parseCollaborators` 解析 JSON 协作者列表,兼容权限函数输入。
3. 将护栏接入 `create/update/delete/importFromZip` 四个写操作。
## 验证
- `npm run type-check -- --pretty false` 通过
## 预防策略
- 所有写操作必须“Route + Service 双层权限校验”。
- 新增写接口时,优先复用统一权限护栏方法,避免每个方法重复拼装权限对象。

View File

@ -0,0 +1,38 @@
---
title: Product Hardware 接口缺少产品级权限护栏
date: 2026-02-15
module: product-hardware
problem_type: security_issue
severity: critical
symptoms:
- /api/product-hardware/[id] GET 允许匿名读取
- PATCH/DELETE 只校验登录,未校验当前用户是否有目标产品权限
root_cause: 接口按资源 ID 直接读写,缺少 productId 归属权限校验
resolution_type: code_fix
status: active
confidence: high
tags: [security, idor, permission, product-hardware]
related_patterns: [WF-PRODHW-ROUTE-SERVICE-001]
---
## 现象与影响
攻击者只要拿到 `productHardware.id`,就可能越权读取产品结构细节或修改/删除不属于自己的五金件关联记录。
## 修复动作
1. 新增 `src/server/services/product-hardware.service.ts`,在 `findById/update/delete` 统一执行:
- 解析 `productHardware.productId`
- 基于产品快照执行 `canViewProduct/canEditProduct`
2. 新增 `src/server/repositories/product-hardware.repository.ts`,由 Repository 承担查询与更新细节。
3. `src/app/api/product-hardware/[id]/route.ts` 全面改为通过 Service 调用,GET 不再匿名开放。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- 所有“按资源 ID 直接读写”的接口,必须在 Service 层做“资源 -> productId -> 权限”链路校验。
- Route 层不得直接承担权限规则分支与数据库读写。

View File

@ -0,0 +1,530 @@
# CAD 插件装配语义导出改造说明(2026-03-25)
## 目标
本次任务的目标,不是优化爆炸图表现,而是改造 `CAD` 插件的导出能力,让导出数据可以支撑“真实安装方向”的自动化安装动画。
要解决的问题是:
- 当前爆炸图使用“从模型中心向外的球形爆炸”。
- 这个展示逻辑本身没问题。
- 真正的问题在于:安装步骤还原时,错误复用了爆炸方向。
- 结果是,本应沿固定轴插入、垂直装配、或从上往下压装的板件,被错误地做成了沿斜向飞回去。
本次必须按根因修复处理,不能继续在展示方向上打补丁。
---
## 根因结论
当前系统把两类完全不同的语义混在了一起:
1. 展示语义:为了让模型更容易看清,各零件从模型中心向外散开。
2. 安装语义:零件在真实装配中应该沿哪个方向进入、从哪里进入、依赖谁先装。
现在缺的不是“更复杂的爆炸算法”,而是“独立的装配语义数据层”。
一句话概括:
`展示方向` 和 `安装方向` 必须彻底分开。
---
## 当前现状确认
在动手前,先确认下面这些事实成立:
### 插件侧现状
插件源码目录:
`D:\AI Code\3danzhuang\autocad-plugin\FurnitureExporter`
插件发布目录:
`D:\AI Code\3danzhuang\autocad-plugin\dist\家具导出插件-v2.3.1-一键安装版`
插件当前已经能导出这些核心数据:
- `panels`
- `holes`
- `holeGroups`
- `hardwareTypes`
- `hardwareSummary`
插件当前已具备的基础能力包括:
- 板件位置、尺寸、法向、包围盒提取
- 板件类型推断
- 孔位检测与用途推断
- 逻辑孔组
- 五金实例与板件、孔位、孔组的绑定关系
但插件当前还没有显式导出这些数据:
- 板件真实安装方向
- 安装方式
- 板件与板件的配合面关系
- 起装偏移和装配行程
- 板件装配依赖关系
### 主项目侧现状
主项目目录:
`D:\AI Code\3danzhuang\furniture-3d-system`
当前与问题强相关的文件:
- `D:\AI Code\3danzhuang\furniture-3d-system\src\lib\sku-cad-data.ts`
- `D:\AI Code\3danzhuang\furniture-3d-system\src\lib\assembly-generator.ts`
- `D:\AI Code\3danzhuang\furniture-3d-system\src\components\assembly\AssemblyAnimationPlayer.tsx`
- `D:\AI Code\3danzhuang\furniture-3d-system\src\lib\3d\engine.ts`
- `D:\AI Code\3danzhuang\furniture-3d-system\src\lib\3d\explosion-math.ts`
当前主项目实际上只消费了这类信息:
- `panelType`
- `explosionDirection`
- `cadPosition`
- `cadNormal`
- `cadBoundingBox`
当前主项目没有真正保存这类安装语义:
- `assemblyAxis`
- `installDirection`
- `mountMode`
- `preAssembleOffset`
- `travelDistance`
- `dependsOn`
- `mates`
当前播放层的板件动画仍然属于演示级飞入,不是真实装配路径。
---
## 本次修改边界
### 优先级
1. 优先改 `CAD` 插件导出层。
2. 如有必要,只做最小范围的主项目类型补充和解析补充。
3. 不要先大改前端播放逻辑。
### 明确禁止
- 不要把“真实安装方向”继续塞进 `explosionDirection`。
- 不要删除、重命名、破坏现有导出字段。
- 不要为了追求快速可见效果,写死某几类板件的方向当成通用方案。
- 不要把问题继续做成“根据 `panelType` 猜一个方向”的补丁式方案。
---
## 必须实现的结果
插件需要额外导出一层“装配语义数据”,至少要回答下面这些问题:
- 这块板最终沿哪个方向装进去?
- 它是平移插入、下压就位,还是旋转后就位?
- 它依赖哪些板先装?
- 它最终和哪块板的哪一面贴合?
- 动画起点应该从目标位往哪个方向退多远?
- 这次自动推导的结果置信度是多少?
目标不是一上来做到所有复杂场景全覆盖,而是:
- 对大多数标准柜体板件自动推导正确方向
- 对异常件保留清晰兜底
- 保持向后兼容
---
## 推荐的数据结构
建议在现有 `hardware.json` 根对象中新增一个可选字段:
`assembly`
不要破坏任何已有字段。
### ExportData 新增字段
```csharp
public AssemblyData? Assembly { get; set; }
```
### AssemblyData 建议结构
```json
{
"assembly": {
"version": "1.0",
"panelAssemblies": [],
"connections": []
}
}
```
### PanelAssemblyInfo 最少字段
- `panelId`
- `mountMode`
- `assemblyAxis`
- `assemblyDirection`
- `preAssembleOffset`
- `travelDistance`
- `dependsOn`
- `primarySupportPanelId`
- `targetPanelIds`
- `mates`
- `confidence`
- `warnings`
- `autoGenerated`
### 推荐语义
#### mountMode
至少支持:
- `slide_in`
- `drop_in`
- `rotate_into_place`
- `unknown`
#### assemblyAxis
长度为 `3` 的单位向量,例如:
```json
[1, 0, 0]
```
#### assemblyDirection
离散标签,例如:
- `+X`
- `-X`
- `+Y`
- `-Y`
- `+Z`
- `-Z`
#### preAssembleOffset
从最终安装位往外退的偏移向量,单位 `mm`。
#### travelDistance
沿安装方向需要走的距离,单位 `mm`。
### AssemblyMateInfo 最少字段
- `targetPanelId`
- `selfFace`
- `targetFace`
- `connectionType`
- `confidence`
### 可选增强字段
如果你判断后续会用到,可以增加:
- `rotationAxis`
- `pivotPoint`
- `inferenceReason`
---
## 推导逻辑要求
### 总原则
严禁再使用“模型中心到板中心”的方向来推安装方向。
真实安装方向必须优先从以下两类信息中推导:
1. 连接关系
2. 配合关系
### 优先级
#### 第一优先级:孔位、孔组、五金绑定
如果一块板与另一块板之间存在明显的孔组配对、连接件绑定、木榫、三合一、偏心件等关系,则安装方向优先由这些连接关系推导。
要求:
- 优先使用孔轴线、孔法向、孔组法向判断推进方向。
- 若连接关系明显指向某一主轴,则该方向优先于 `panel.normal`。
- `panel.normal` 只能视为板厚方向参考,不能直接当安装方向。
#### 第二优先级:板件配合面
通过板件的 `boundingBox` 与 `position`,寻找相邻板件和配合面关系。
要求:
- 找出哪两个轴重叠明显、哪一个轴面距最小。
- 明确导出 `selfFace` 和 `targetFace`。
- 安装方向应取“朝向配合面的反向推进”。
#### 第三优先级:板件类型和空间位置兜底
当连接关系不够强时,再使用以下信息兜底:
- `panelType`
- 板件在整体空间中的位置
- 是否位于顶部、底部、侧边、中间层
这部分只能做兜底,不能成为主逻辑。
---
## 建议的判定规则
建议把这些常量集中管理,不要散落在多个文件里:
```text
FACE_GAP_TOLERANCE_MM = 3
FACE_OVERLAP_RATIO_MIN = 0.25
HOLE_ASSOCIATION_TOLERANCE_MM = 5
OUTSIDE_MARGIN_MM = 30
MIN_PREASSEMBLE_DISTANCE_MM = 80
```
### 相邻板候选判定
建议规则:
- 两块板在两个轴上的投影重叠比例大于等于 `0.25`
- 在剩余一个轴上的最近面间距小于等于 `3 mm`
- 满足后,视为存在面接触候选
### 配合面识别
建议规则:
- 若最近面在 `X` 轴方向,则候选面为 `left / right`
- 若最近面在 `Y` 轴方向,则候选面为 `front / back`
- 若最近面在 `Z` 轴方向,则候选面为 `top / bottom`
注意:
- `selfFace` 和 `targetFace` 都要导出
- 不要只导一个方向标签
### 安装方向识别
建议规则:
- 有明确孔组或连接件时,优先取孔轴线或连接件主方向
- 有明确配合面时,取“指向配合面的反方向推进”
- 无强连接时,再用 `panelType` 兜底
- 严禁直接使用“模型中心 -> 板中心”
### MountMode 判定
`V1` 先覆盖大多数标准柜体板件,不要过度设计。
建议规则:
- `assemblyAxis` 近似 `X` 或 `Y` 主轴,优先判为 `slide_in`
- `assemblyAxis` 近似 `Z` 主轴,且板件明显从上往下落位,优先判为 `drop_in`
- 若单轴平移无法解释主要配合关系,再考虑 `rotate_into_place`
- `V1` 对 `rotate_into_place` 要谨慎输出,宁可少判,不要乱判
- 无法高置信推断时,输出 `unknown`,并给出 `warnings`
### DependsOn 推导
建议规则:
- 与当前板存在主要配合或支撑关系的板,进入 `dependsOn`
- `primarySupportPanelId` 选主要承载或主要配合对象
- `targetPanelIds` 保存所有主要装配目标板
本轮不要求做绝对完美的全局装配排序,但至少要导出局部依赖关系。
### PreAssembleOffset 与 TravelDistance
严禁直接写死固定值。
建议规则:
1. 取当前板的目标包围盒
2. 取 `dependsOn` 或 `targetPanelIds` 对应参考板的合并包围盒
3. 沿 `assemblyAxis` 的反方向,将当前板虚拟退到“完全脱离参考包围盒 + 30 mm 安全边距”的位置
4. 该退出来的向量即为 `preAssembleOffset`
5. 该向量长度即为 `travelDistance`
6. 若结果过小,则至少保证 `80 mm` 的最小预装距离
---
## 覆盖与兜底机制
目标是最大化自动化,但不要假设所有模型都能 `100%` 自动判断。
必须预留人工覆盖机制,但默认不要求设计师参与。
原则:
- 没有覆盖标记时,插件必须全自动运行
- 有覆盖标记时,以覆盖结果为准
- 被覆盖的结果也必须写回导出 `JSON`
- 被覆盖的结果必须标记 `autoGenerated = false`
推荐支持的覆盖字段:
- `AssemblyDirectionOverride`
- `MountModeOverride`
- `DependsOnOverride`
- `PreAssembleOffsetOverride`
覆盖承载方式可自行选择一种稳定方案,例如:
- 图层命名约定
- 块属性
- `XData`
- `Extension Dictionary`
---
## 实现建议
建议新增独立分析器,不要把全部逻辑硬塞进现有 `PanelAnalyzer`。
推荐结构:
- `AssemblySemanticAnalyzer.cs`
- `AssemblyInferenceHelpers.cs`
- `BoundingBoxMath.cs`
- `FaceRelationHelper.cs`
最低建议是新增:
- `AssemblySemanticAnalyzer.cs`
建议输入:
- `List<PanelInfo> panels`
- `List<HoleInfo> holes`
- `List<HoleGroupInfo> holeGroups`
- `List<HardwareType> hardwareTypes`
建议输出:
- `AssemblyData`
导出链路建议:
1. 先按现有流程完成几何与五金数据导出
2. 再执行装配语义分析
3. 将结果挂到 `ExportData.Assembly`
4. 最终仍输出到同一个 `hardware.json`
---
## 主项目侧的最小改动原则
如果为了验证新导出结构,需要顺带让主项目先能读取这些字段,只做最小必要改动:
- 更新 `sku-cad-data.ts` 的类型定义
- 增加 `assembly` 字段的解析与归一化
- 使用可选字段保证旧数据不报错
这次不要优先做:
- 大改 `assembly-generator.ts`
- 大改 `AssemblyAnimationPlayer.tsx`
- 把临时规则直接写死进播放器
本次主战场是插件导出层,不是前端动画层。
---
## 向后兼容要求
必须满足以下兼容条件:
- 原有 `panels / holes / holeGroups / hardwareTypes / hardwareSummary` 不破坏
- 原有导出样例仍能导出成功
- 新增字段为空时,不影响旧链路
- 老版本 `hardware.json` 没有 `assembly` 字段时,主项目仍可读取旧数据
- 如果补了主项目类型定义,必须使用可选字段处理
---
## 验证要求
必须使用这个样例做最小必要验证:
`D:\AI Code\3danzhuang\素材\BJ0124(1)_export.zip`
验证至少包括:
1. 解压并检查当前 `hardware.json`
2. 修改插件后,重新生成或模拟生成新的导出结构
3. 确认新增 `assembly` 字段结构完整
4. 至少输出每个 `panel` 的以下结果:
- `panelId`
- `mountMode`
- `assemblyDirection`
- `dependsOn` 数量
- `travelDistance`
- `confidence`
5. 对明显标准板件,确认安装方向不是“从模型中心往回飞”的结果
6. 明确哪些板件属于高置信自动推导,哪些属于兜底推导
如果当前样例缺少真实五金实例,必须明确写出:
- 本轮验证主要覆盖板件装配语义
- 五金参与的复杂装配仍需后续拿带真实五金实例的样例继续验证
不能假装已经完整验证复杂五金场景。
---
## 验收标准
只有同时满足以下条件,才算本次任务完成:
1. 插件可以额外导出装配语义,而不只有几何数据
2. 真实安装方向不再依赖球形爆炸方向
3. 安装方向优先基于连接关系和配合面推导
4. 对大多数标准柜体板件,能自动给出合理的 `slide_in / drop_in` 方向
5. 导出结果保留置信度和警告信息
6. 对异常件具备兜底能力
7. 对现有结构保持向后兼容
8. 做了最小但有效的验证,而不是只改代码不验证
---
## 交付要求
完成后请按下面顺序汇报:
1. 改了什么
2. 验证结果是什么
3. 还剩什么风险
4. 哪些场景已经覆盖
5. 哪些场景还需要更多样例验证
6. 是否改动了主项目消费层
7. 是否生成了新的插件产物
除非明确要求,不要主动 `commit`。
---
## 一句最终提醒
这次要解决的是“安装语义缺失”,不是“爆炸动画不够漂亮”。
不要把问题做成:
- 继续强化 `explosionDirection`
- 继续按 `panelType` 猜方向
- 在前端播放器里堆临时特判
必须把“展示方向”和“安装方向”彻底分层。

View File

@ -0,0 +1,72 @@
---
title: 装配页 3D 交互回归(选中/右键材质/孔位/爆炸拖动)
date: 2026-02-23
module: assembly-editor
problem_type: workflow_issue
severity: high
symptoms:
- 中键平移偶发触发浏览器默认行为,平移手感异常
- 板件点击命中率下降,选中高亮不稳定
- 右键材质菜单缺少完整层级与材质预览
- 孔位高亮与模型孔位存在偏移,爆炸拖动时错位更明显
- 爆炸程度滑块拖动时卡顿
root_cause: 交互链路在多轮重构后出现能力回退,菜单结构、拾取兜底、覆盖层坐标映射与渲染节流未统一
resolution_type: code_fix
status: active
confidence: high
tags: [assembly, threejs, context-menu, picking, hole-overlay, performance]
related_patterns: [WF-UI-TOKEN-RAF-001, WF-UI-WORKSPACE-MODULAR-001]
---
## 现象与影响
装配页多个核心交互同时回退:板件难选中、材质菜单不完整、孔位高亮不准、爆炸拖动卡顿,直接影响装配演示与编辑效率。
## 排查路径
1. 先复跑类型检查与核心测试,确保没有基础语法/类型噪音干扰。
2. 对交互链路逐层定位:`AssemblyEditorWorkspace -> ModelViewer -> ThreeDEngine -> HoleOverlay2D -> ContextMenu`。
3. 对比当前行为与预期能力,确认回退点主要集中在:
- 拾取失败时没有兜底路径;
- 右键菜单缺少材质三级结构与预览;
- 爆炸滑块每次输入都触发重计算;
- 孔位候选坐标选择策略过松,导致对齐偏差。
## 最终修复
1. `src/components/3d/ModelViewer.tsx`
- 增加拾取兜底:候选名拾取失败时自动执行全场拾取,再做节点名规范化映射。
- 中键按下补 `preventDefault`,避免浏览器默认中键行为干扰平移。
2. `src/lib/3d/engine.ts`
- `applyMaterialToMesh` 补充 `normalizeTooBrightMaterial`,动态换材后依然执行近白灰化。
3. `src/components/3d/HoleOverlay2D.tsx`
- 引入多坐标候选择优(原始坐标/模型矩阵/板件矩阵),并收紧阈值,减少孔位偏移。
4. `src/components/ui/ContextMenu.tsx`
- `ContextMenuItem` 增加缩略图与预览图字段。
- 子菜单支持材质小图和悬停大图气泡。
5. `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- 恢复右键三级材质菜单(材质、复制/粘贴、UV ±90°、材质分类、具体材质项)。
- 爆炸滑块改为 UI 值和渲染值分离,渲染值通过 `requestAnimationFrame` 合帧提交。
- 关键文案统一中文化(孔位高亮、板材折角线、爆炸程度、菜单标题)。
## 2026-02-23 二次回归补丁
1. `src/components/ui/ContextMenu.tsx`
- 右键菜单改为 `createPortal` 渲染到 `document.body`,修复父级 transform 场景下菜单偏移。
2. `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- `showPanelLeaders` 改为跟随 `showFoldLines`,修复板材折角线开关无效。
3. `src/components/3d/HoleOverlay2D.tsx`
- 对有 `panelId` 的孔位优先面板解算,失败不再回落到宽松模型兜底,避免孔位聚集到底部错位。
- 吸附仅在近距离候选启用,并将 `transformVersion` 量化到 30fps,缓解拖动爆炸滑块卡顿。
4. `src/lib/3d/engine.ts`
- 爆炸进度增加小幅变化跳过与最近进度缓存,减少高频重复重算。
5. `src/lib/3d/engine-highlight-resources.ts`
- 近白灰化逻辑扩展到通用颜色材质,修复白膜材质仍偏白的回归场景。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
- `npm test -- tests/lib/zip-parser.test.ts --runInBand` 通过
- `npm test -- tests/services/sku-upload.service.test.ts --runInBand` 通过
- `npm test -- tests/lib/engine-events.test.ts --runInBand` 通过
## 预防策略
- 装配页交互变更必须按“拾取 -> 菜单 -> 覆盖层 -> 性能”四段链路回归。
- 右键菜单能力作为固定清单项(层级、复制粘贴、UV、缩略图、悬停预览)纳入 UI 回归检查。
- 爆炸滑块与孔位覆盖层同时变更时,强制检查拖动帧率和孔位贴合度,避免单点优化引入联动退化。

View File

@ -0,0 +1,47 @@
# 安装顺序后端编排收口(2026-02-26)
## 问题
- 前端执行“保存安装顺序 -> 生成步骤 -> 再拉取步骤”三段串行请求。
- 业务编排落在前端,不符合 `API -> Service -> Repository` 分层。
- 生成失败时存在“安装顺序已落库、步骤未生成”的部分成功风险。
## 根因
1. 缺少后端单接口编排能力,前端不得不串行调用多个接口。
2. 安装顺序更新与步骤生成在服务层没有补偿策略。
3. 路由层仅做最小校验,缺少输入上限防护。
## 方案
1. 新增接口:`POST /api/products/[id]/assembly/generate-from-order`。
2. Route 仅做参数校验(含重复 `panelId/order`)与统一响应处理。
3. Service 新增 `saveInstallOrderAndGenerateStepsForUser`:
- 先保存安装顺序;
- 再生成步骤(`save=true`);
- 返回最新步骤列表供前端直接渲染。
4. 失败补偿:
- 保存前快照原安装顺序;
- 生成失败时回滚安装顺序,避免半成功状态。
5. 风险收口:
- Route 增加 `panels` 数量上限;
- Service 同步增加数量上限,防止多入口规则漂移;
- 请求体增加 `content-length` 防护上限。
## 改动文件
- `src/app/api/products/[id]/assembly/generate-from-order/route.ts`
- `src/server/services/assembly.service.ts`
- `src/server/repositories/panel.repository.ts`
- `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
## 验证
- `npx eslint src/server/services/assembly.service.ts src/server/repositories/panel.repository.ts src/app/api/products/[id]/assembly/generate-from-order/route.ts src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx` 通过
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/install-order-rules.test.ts --runInBand` 通过(41/41)
## 剩余事项
- 仍需真实联调验证 `warnings` 展示策略。
- 建议后续补充接口限频与幂等键,进一步降低重复写放大。

View File

@ -0,0 +1,36 @@
# 安装顺序编辑器同步与规范收口(2026-02-26)
## 问题现象
- 装配编辑器切换“安装顺序模式/普通模式”后,步骤编辑器显示状态偶发不同步。
- 右侧安装顺序操作区使用原生按钮,且有硬编码颜色,不符合项目 UI 元组件与设计 token 规范。
## 根因分析
1. `AssemblyStepEditor` 内部维护本地 `steps` 与 `selectedStepId`,但仅在初次挂载时初始化。
2. 之前依赖父组件传 `key` 强制重挂载做同步,导致每次步骤变化都触发整体重建,性能与交互稳定性差。
3. 安装顺序操作区未统一使用 `@/components/ui/Button`。
## 修复方案
1. **去除强制重挂载**:移除 `AssemblyEditorWorkspace` 中 `stepEditorSyncKey` 与 `key` 方案。
2. **改为受控同步**:在 `AssemblyStepEditor` 内新增 `useEffect` 监听 `initialSteps`,同步本地步骤、选中项与高亮状态。
3. **组件规范收口**:安装顺序操作按钮统一改为 `Button` 元组件。
4. **视觉规范收口**:本轮改动范围内的安装顺序提示色改为语义类(`text-apple-blue`/`text-apple-red`),步骤编辑器局部颜色改为 `apple-*` token。
5. **复用元组件**:步骤详情中的部件勾选改为 `Checkbox` 元组件。
## 影响范围
- `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- `src/components/assembly/AssemblyStepEditor.tsx`
## 验证记录
- `npm run lint -- src/components/assembly/AssemblyStepEditor.tsx src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx` 通过
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/install-order-rules.test.ts --runInBand` 通过(41/41)
## 复用建议
- 当组件需要“外部数据刷新 + 内部编辑态并存”时,优先使用 **受控同步 + 保留选中状态**,不要用 `key` 重挂载兜底。
- UI 收口优先检查:原生控件、硬编码颜色、交互状态(disabled/loading)是否已落在元组件能力内。

View File

@ -0,0 +1,46 @@
---
title: 协作者关系表与 lib 数据源注入收口
date: 2026-02-15
module: product-collaboration-and-lib-layering
problem_type: workflow_issue
severity: high
symptoms:
- Product 协作者依赖 JSON 字段,查询和交接链路存在全表扫描与类型不稳定
- lib 层直接访问数据库,绕过 Service -> Repository 分层
root_cause: 数据访问边界未统一,历史兼容字段与新能力叠加后缺少关系化模型与运行时注入层
resolution_type: code_fix
status: active
confidence: high
tags: [product-collaborator, repository-layering, datasource-injection, architecture]
related_patterns: []
---
## 现象与影响
- `Product.collaborators` 采用 JSON 存储协作者,导致筛选和交接只能依赖应用层逻辑,缺少数据库级约束。
- `src/lib` 多个模块直接读写 Prisma,API/Service 分层难以维持,后续审计与复用成本高。
## 排查路径
1. 先用 `npm run type-check -- --pretty false` 固定错误集合,确认问题集中在协作者链路和 3D/装配链路重构中间态。
2. 对 `product.repository`、`handover.repository`、`product-collaborator.service` 做调用链回溯,确认 JSON 与关系表并存阶段缺少统一读取策略。
3. 对 `src/lib/assembly-generator.ts`、`src/lib/hardware-matcher.ts`、`src/lib/permissions.ts` 做依赖扫描,定位直连 Prisma 的边界点。
## 根因分析
- 协作者数据模型长期停留在 JSON,缺少 FK 和索引,导致权限、交接、查询无法在 Repository 层稳定下沉。
- lib 层承担了运行时数据访问职责,破坏了“lib 纯算法、server 负责 IO”边界,拆分后容易出现类型和注入断层。
## 最终修复
- 新增 `ProductCollaborator` 关系表并建立索引/FK,新增迁移 `20260215093000_add_product_collaborator_relation`,并在迁移中回填历史 JSON 数据。
- Repository/Service 改为优先读写关系表,同时保留 JSON 镜像同步,保障存量接口兼容。
- `assembly-generator`、`hardware-matcher` 改为 data source 注入和 server runtime 封装;`permissions` 通过 repository 读取权限上下文,移除 lib 直连 Prisma。
## 预防策略
- 新增跨层守护检查:lib 模块默认禁止 `@/lib/prisma` 依赖,统一走 runtime/service 注入。
- 协作者相关新需求默认只增量扩展关系表,JSON 仅作为兼容镜像,避免再次回到“应用层全扫”。
- 验证命令固定为:
- `npm run prisma:generate`
- `npm run type-check -- --pretty false`

View File

@ -0,0 +1,50 @@
---
title: Controller 过载拆分与批处理事务收口
date: 2026-02-15
module: api/products/upload + api/users/[id]/handover + auth/share/panel services
problem_type: workflow_issue
severity: high
symptoms:
- Route 文件承担解析、存储、事务、日志等多重职责
- Service 直连 Prisma,分层边界失效
- 离职交接单事务内长循环,存在锁等待风险
root_cause: Controller 与 Service 职责未按分层规范收口,数据访问逻辑散落在业务层
resolution_type: code_fix
status: active
confidence: high
tags: [route-layering, service-repository, batch-processing, transaction-scope]
related_patterns:
- WF-SVC-REPO-USER-001
- WF-PANEL-ROUTE-SVC-001
---
## 1) 现象与影响
- `/api/products/upload` 既做鉴权又做文件转换、上传、解析与事务写入,Route 过载。
- `/api/users/[id]/handover` 在 Route 内执行多段查询、循环更新、日志落库,难以维护且不利于并发稳定性。
- `auth/share/panel` 的 Service 存在直连 Prisma,导致分层审计无法闭环。
## 2) 排查路径
1. 先定位台账中同类问题(ARCH-009/013/015/017/DB-004)。
2. 用 `type-check` 兜底验证改造后类型边界。
3. 用认证和安全回归测试确认关键链路未回归。
## 3) 根因分析
- Route 层未被限制为“参数+鉴权+统一返回”,业务编排和数据细节长期堆积。
- Service 缺少配套 Repository 原子方法,导致为求开发速度直接写 Prisma。
- 交接链路追求“全成功事务”,把多项循环更新捆成单大事务,放大锁持有时间。
## 4) 最终修复
- 上传链路:新增 `product-upload.service + product-upload.repository`,Route 仅保留入口职责。
- 离职交接:新增 `user-handover.service + handover.repository`,采用分批提交与短事务策略。
- 认证/分享/板件:新增或扩展 `auth/share/panel` Repository 原子方法,Service 仅做权限和编排。
- 产品服务:将软删/恢复/CAD 创建事务下沉到 `product.repository`,减少业务层数据细节泄漏。
## 5) 预防策略
- 新增 API 一律先定义 Service/Repository 边界,再写 Route。
- 对“循环更新 + 事务”场景默认评估分批策略,避免单长事务。
- 复核清单加入“Service 是否仍引用 Prisma”自动检索项。

View File

@ -0,0 +1,36 @@
---
title: DWG 异步任务生命周期清理模式
date: 2026-02-15
module: dwg-converter
problem_type: workflow_issue
severity: high
symptoms:
- activeTasks 持续增长
- temp/uploads 原始文件长期残留
root_cause: 异步任务缺少完成态回收机制(内存与磁盘)
resolution_type: code_fix
status: active
confidence: high
tags: [memory, cleanup, async-task, dwg]
related_patterns: []
---
## 现象与影响
DWG 异步转换完成后未清理任务状态和输入文件,服务长期运行后会出现内存与磁盘增长。
## 修复动作
1. `src/lib/dwg/converter.ts` 增加 `scheduleTaskCleanup`,完成后延迟移除 `activeTasks`。
2. 增加 `cleanupInputFile`,在成功/失败的 `finally` 阶段统一删除输入文件。
3. 异常兜底分支也执行相同清理逻辑。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
## 预防策略
- 异步任务必须明确“创建 -> 执行 -> 完成 -> 回收”生命周期,避免状态和文件双泄漏。

View File

@ -0,0 +1,39 @@
---
title: Hole Route 去 Repository 直连并收口到 Service
date: 2026-02-15
module: holes-route
problem_type: workflow_issue
severity: high
symptoms:
- /api/holes Route 直接调用 holeRepository/panelRepository 承担筛选、数量校验、删除编排
- Route 过重,分层边界不清晰,权限与删除策略散落在接口层
root_cause: 历史实现为了快速交付在 Route 层叠加了数据访问与业务编排
resolution_type: code_fix
status: active
confidence: high
tags: [architecture, route-layer, service-layer, holes]
related_patterns: [SEC-HOLE-PERM-001]
---
## 现象与影响
`/api/holes` 同时承担参数解析、数据访问、业务编排,导致:
- 违反 `Route -> Service -> Repository` 分层约束。
- 相同规则(筛选、删除限额、权限)难以复用,后续接口容易漂移。
## 修复动作
1. `src/app/api/holes/route.ts` 移除 `holeRepository/panelRepository` 直连。
2. 新增 `holeService.listByFilter` 与 `holeService.deleteByFilter`,将筛选、权限、数量校验与删除编排统一下沉到 Service。
3. Route 仅保留参数解析、输入校验与统一响应封装。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- 新增 API 时先定义 Service 方法,再编写 Route;Route 不允许直接引入 Repository。
- Code Review 对 Route 层增加规则:若出现 `*Repository` 依赖需给出例外说明,否则驳回。

View File

@ -0,0 +1,36 @@
---
title: 产品板件路由权限收口到 Service
date: 2026-02-15
module: panel-route
problem_type: workflow_issue
severity: high
symptoms:
- Route 与 Service 同时维护产品权限判断
- Route 直连 Prisma,权限策略容易漂移
root_cause: 分层迁移不彻底,Controller 仍承担数据访问与业务判断
resolution_type: code_fix
status: active
confidence: high
tags: [architecture, route-service, panel]
related_patterns: [WF-HOLE-ROUTE-SERVICE-001]
---
## 现象与影响
`products/[id]/panels` 在 Route 层重复做查询和权限,Service 也有权限逻辑,导致规则双轨并行。
## 修复动作
1. `src/app/api/products/[id]/panels/route.ts` 移除 Prisma 和 `canView/canEdit` 分支。
2. `src/server/services/panel.service.ts` 的 `findByProductId` 接入权限校验(`ctx.user` 存在时执行 `canViewProduct`)。
3. Route 仅保留参数解析、调用 Service 与统一响应。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/api/auth-me.test.ts --runInBand` 通过
## 预防策略
- Route 层只做入口编排;权限与业务判断统一放在 Service 层,避免策略漂移。

View File

@ -0,0 +1,40 @@
---
title: Product Hardware Route 分层收口到 Service/Repository
date: 2026-02-15
module: product-hardware-route
problem_type: workflow_issue
severity: high
symptoms:
- /api/product-hardware/[id] Route 直接操作 Prisma
- Route 叠加参数解析、权限判断、数据访问与更新逻辑
root_cause: 历史实现未遵循 Route -> Service -> Repository 分层约束
resolution_type: code_fix
status: active
confidence: high
tags: [architecture, layering, route, service, repository, product-hardware]
related_patterns: [SEC-PRODHW-PERM-001]
---
## 现象与影响
Route 层直接写 Prisma 会导致:
- 权限逻辑散落,重复实现容易漂移。
- 业务规则不可复用,后续接口扩展成本高。
- 架构审查难以确认边界是否一致。
## 修复动作
1. 新增 `product-hardware.repository.ts` 统一承接 ProductHardware 的查询/更新。
2. 新增 `product-hardware.service.ts` 统一承接权限校验、业务校验与编排。
3. `src/app/api/product-hardware/[id]/route.ts` 仅保留参数校验和统一响应,不再直连 Prisma。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- Route 文件出现 `prisma.` 直接访问时,默认判定为架构违规。
- 新接口先设计 Service 方法和 Repository 原子方法,再接入 Route。

View File

@ -0,0 +1,65 @@
---
title: User Service 直连 Prisma 导致分层失效
date: 2026-02-15
module: user-service
problem_type: workflow_issue
severity: high
symptoms:
- user.service.ts 中直接调用 prisma.user/prisma.department/prisma.team
- Service 层同时承担数据访问与业务编排,破坏企业分层边界
root_cause: 历史实现未统一收口到 Repository 层
resolution_type: code_fix
status: active
confidence: high
tags: [architecture, service-layer, repository-layer, user-service]
related_patterns: []
---
## 现象与影响
用户服务在列表、详情、创建、更新、删除、登录校验等场景直接访问 Prisma,导致:
- Service 层和 Repository 层职责重叠,后续治理难度上升。
- 权限/规则与数据查询耦合,复用困难。
- 审查时难以快速确认“Route -> Service -> Repository”是否被遵守。
## 排查路径
1. 在 `src/server/services/user.service.ts` 全量搜索 `prisma.`。
2. 将命中的查询按“读/写/校验”分类,映射到应由 Repository 承担的方法。
3. 检查 `src/server/repositories/user.repository.ts` 现有能力是否可复用,不足则补充原子方法。
## 根因分析
用户模块早期实现以功能闭环优先,缺少统一分层约束落地,随着功能堆叠形成了 Service 直连数据库的路径依赖。
## 最终修复
### 代码改动
- 重写 `src/server/services/user.service.ts`,移除全部 Prisma 直连,Service 仅保留:
- 输入校验
- 业务规则判定
- 调用 Repository 编排
- 扩展 `src/server/repositories/user.repository.ts`:
- `findByEmailWithRelations`
- `findByNameWithRelations`
- `createWithScalars`
- `updateWithScalars`
- `departmentIds` 过滤支持
### 验证命令
- `npm run type-check -- --pretty false`(通过)
- `npm test -- tests/api/auth-me.test.ts --runInBand`(通过)
## 预防策略
- 新增 Service 逻辑时,先在 Repository 层补齐原子数据方法,再编写 Service 编排。
- Code Review 增加硬性检查:Service 文件若出现 `prisma.` 直接判为架构违规。
- 对已存在的 Service 分批治理,优先处理 P0/P1 的权限与数据一致性链路。
## 剪枝与冲突检查
- 本次未发现可直接剪枝的旧模式(首次沉淀该类模式)。
- 本次未发现同触发条件下的冲突模式。

View File

@ -0,0 +1,37 @@
# UI 大文件拆分为工作区模块(批量案例)
- 日期:2026-02-16
- 适用范围:前端超大组件(页面/3D 编辑器/弹窗)因“壳文件转发”被审查驳回,需做实质拆分。
## 问题特征
1. 单文件超过 500~800 行,状态、请求、渲染全部混在一起。
2. 之前只做了 `*.container.tsx -> *.workspace.tsx` 的壳层迁移,审查仍判定为未拆分。
3. 复核关注点不是“文件名变化”,而是“职责分层是否可见”。
## 解决方案(本次落地)
1. 先补齐可编译基线,避免在红灯状态做结构重构。
2. 按“容器编排 + 业务 hooks + 视图区块组件”拆分:
- Assembly:`hooks/useAssemblyEditorState.ts` + 顶栏/预览/步骤面板组件。
- Home:`home/hooks/*` + `home/components/HomeModelsSection.tsx`。
- ProductDetail:顶部栏、信息卡、创建分享弹窗拆成独立组件。
- SKUDetail:顶部动作区、只读侧栏、规格切换抽屉拆分。
- HoleViewer:渲染层 hook + 控制层 hook + 图例统计组件。
- AddSKUModal:schema + submit hook + 字段分组组件。
- HoleEditor:types + validation + interaction hook。
3. 每个拆分点都保持原有行为不变,Workspace 只做组装。
## 验证方式
1. `npm run type-check -- --pretty false`
2. `npm test -- tests/api/auth-me.test.ts --runInBand`
3. `npm test -- tests/lib/zip-parser.test.ts --runInBand`
4. `npm test -- tests/services/sku-upload.service.test.ts --runInBand`
## 复用清单
1. 先找“状态管理块”和“大段 JSX 区块”,优先抽这两类。
2. hooks 命名按能力域(`use-xxx`),组件命名按区域(TopBar/Sidebar/Modal)。
3. 重构后保留统一入口(container/workspace),避免页面导入路径抖动。
4. 台账同步标注“已修复待复核”,并写清新增模块文件路径。

View File

@ -0,0 +1,39 @@
---
title: UI 令牌收敛与按需渲染优化
date: 2026-02-15
module: ui-foundation
problem_type: workflow_issue
severity: medium
symptoms:
- 元组件存在硬编码色值与固定过渡时长
- 无可视标注场景仍持续 requestAnimationFrame 循环
- explodeFactor 变化触发模型重复加载
root_cause: 动画/样式令牌与渲染生命周期缺少统一约束
resolution_type: code_fix
status: active
confidence: high
tags: [ui, token, animation, raf, performance]
related_patterns: []
---
## 现象与影响
样式令牌不统一会导致视觉漂移;无效渲染循环和重复模型加载会产生不必要的 CPU/GPU 开销与卡顿。
## 修复动作
1. `Button/Input/Select` 统一过渡令牌,移除硬编码十六进制色值。
2. `HoleOverlay2D` 在无标注时停止 RAF,仅保留一次清屏。
3. `ExplodedViewSelector` 将 `explodeFactor` 与模型加载解耦,改为仅更新已加载 mesh 位移。
4. 材质缩略图兜底 SVG 接入语义色令牌并保持统一主题。
## 验证
- `npm run type-check -- --pretty false` 通过
- `npm test -- tests/lib/cad-panel-linking.test.ts --runInBand` 通过
## 预防策略
- 元组件层禁止硬编码主题色和动效参数,统一走 design tokens。
- 任何渲染循环都要有“空数据停机”策略,避免无意义帧消耗。

View File

@ -0,0 +1,141 @@
# 主线同步操作手册
适用对象:会用 Codex 协作、但不一定熟悉 Git 的同事。
目标:当 `main` 持续更新时,你仍然能把自己的分支保持在最新状态,不把代码做旧,不把冲突拖到最后一天。
---
## 1. 先记住一句话
你不要直接改 `main`。
你只在自己的分支里开发,然后经常把最新的 `main` 同步进来。
当前推荐分工:
- `WuYongJie` 负责主线 `main`
- 你负责自己的功能分支,例如 `codex/xuxiake-dev`
---
## 2. 你每天就做这 3 件事
### 开工前
先同步一次最新主线。
### 主负责人刚推了重要改动后
再同步一次,不要继续拿旧代码往下写。
### 你准备提交前
最后再同步一次,避免你推上去的分支已经落后很多。
---
## 3. 默认操作步骤
把下面这 5 行命令原样交给 Codex 执行就行:
```bash
git checkout main
git pull origin main
git checkout <你的分支名>
git rebase main
git push --force-with-lease origin <你的分支名>
```
示例:
```bash
git checkout main
git pull origin main
git checkout codex/xuxiake-dev
git rebase main
git push --force-with-lease origin codex/xuxiake-dev
```
这组命令的白话意思:
1. 先把本地 `main` 拉到最新
2. 切回你自己的分支
3. 把你的改动“搬到最新主线后面”
4. 再把你的分支推上去
---
## 4. 如果你不想用 `rebase`
可以先用更稳一点的版本:
```bash
git checkout main
git pull origin main
git checkout <你的分支名>
git merge main
git push origin <你的分支名>
```
它的特点:
- 好处:不容易把人绕晕
- 代价:提交记录会更乱一点
怎么选最简单:
- 这个分支只有你一个人在用,就优先用 `rebase`
- 你如果看不懂冲突,先用 `merge`
---
## 5. 什么时候说明你已经落后主线了
出现下面任意一种情况,就不要继续写了,先同步:
- 主负责人刚改了你也会碰到的模块
- Codex 提示分支落后
- 你打开页面后,发现和主线最新效果不一样
- 你准备提交,但已经隔了半天或一天没同步
---
## 6. 如果同步时出现冲突
不要慌,按这个顺序处理:
1. 先停下来,不要乱点保存
2. 看冲突文件是不是你这次本来就在改的模块
3. 如果不是你负责的模块,先问主负责人
4. 如果是你负责的模块,就保留你的目标改动,再把主线的新内容接进去
5. 处理完后再继续让 Codex 帮你完成 `rebase` 或 `merge`
可以直接把下面这段话发给 Codex:
```text
我在同步 main 到我的分支时遇到冲突了。
请只帮我处理这几个冲突文件,不要扩范围,不要改无关模块。
优先保留主线最新结构,再把我这个分支的目标改动接进去。
处理后请告诉我还需要执行什么命令。
```
---
## 7. 绝对不要这样做
- 不要直接在 `main` 上开发
- 不要长时间不同步主线
- 不要等“全部做完了”才第一次同步
- 不要看到冲突就随手全选自己的版本
- 不要在没看懂的情况下执行 `git reset --hard`
---
## 8. 给同事最短口令版
如果你懒得记,只记这一段:
```text
每天开工前,先把 main 拉最新,再把自己的分支 rebase 到 main 上。
如果不会 rebase,就先 merge main。
如果冲突了,不要硬处理,先让 Codex 只处理冲突文件。
```

View File

@ -0,0 +1,149 @@
# 任务单模板
这个模板既给人看,也给 Codex 看。
原则:不要只写“帮我改一下”,要把边界、目标和验收写明白。
---
## 1. 任务基本信息
- 任务名称:
- 所属模块:
- 负责人:
- 所在分支:
- 计划开始时间:
---
## 2. 这次要解决什么问题
用人话写:
- 当前现象:
- 希望变成什么样:
- 为什么现在必须做:
---
## 3. 本次只做什么
列 3 到 5 条最核心内容:
-
-
-
---
## 4. 本次明确不做什么
这个部分一定要写,不然 AI 和人都容易顺手扩范围:
-
-
-
---
## 5. 影响范围
### 页面 / 入口
-
### API
-
### 服务 / 仓库 / 数据表
-
### 文档
-
---
## 6. 验收标准
至少写到“看见什么算完成”:
- [ ]
- [ ]
- [ ]
---
## 7. 验证方式
把能跑的命令和要人工点的页面都写出来:
- 命令:
- `npm run type-check`
- `npm test -- --runTestsByPath <按需补>`
- `npm run build`
- 人工验证:
-
-
---
## 8. 禁止改动区
把这次不应该碰的目录直接写死:
-
-
-
---
## 9. 风险和依赖
- 风险:
- 依赖谁继续接:
- 是否需要后续补文档:
---
## 10. 直接发给 Codex 的提示词模板
```text
请在当前仓库处理下面这个任务。
任务名称:<任务名称>
所属模块:<模块名>
目标:<一句话说清楚>
本次只做:
1. <事项 1>
2. <事项 2>
3. <事项 3>
本次不做:
1. <事项 1>
2. <事项 2>
关键目录:
- <目录 1>
- <目录 2>
禁止改动:
- <目录或文件 1>
- <目录或文件 2>
验收标准:
1. <标准 1>
2. <标准 2>
3. <标准 3>
验证要求:
- 至少跑 `npm run type-check`
- 按需跑相关测试
- 能跑就跑 `npm run build`
需要同步更新的文档:
- <文档路径 1>
- <文档路径 2>
```

View File

@ -0,0 +1,99 @@
# 同事交接模板
适用对象:不一定是程序员,但会用 Codex 协作的人。
目标:让接手的人不用先读完整仓库,也能知道从哪里开始、哪里不能乱改。
---
## 1. 先把这 5 句话说清楚
- 这次要做的模块是什么
- 现在做到哪一步了
- 你接下来只需要做什么
- 哪些目录不能碰
- 做完后怎么验证
---
## 2. 交接卡片模板
```text
任务名称:<名称>
当前状态:<已完成到哪里>
你接手的部分:<只写这次要做的内容>
先看这几个文件:
- <文件 1>
- <文件 2>
- <文件 3>
不要改这些地方:
- <目录或文件 1>
- <目录或文件 2>
验收标准:
1. <标准 1>
2. <标准 2>
3. <标准 3>
验证方式:
- `npm run type-check`
- `npm test -- --runTestsByPath <补具体文件>`
- `npm run build`
- 人工打开:<页面路径>
```
---
## 3. 直接发给 Codex 的交接提示词
```text
请接手这个仓库里的一个局部任务,不要扩范围。
任务名称:<任务名称>
你现在只负责:<一句话>
当前背景:
<用 3 到 5 行说清楚现在已经做到哪一步>
先看这些文件:
- <文件 1>
- <文件 2>
- <文件 3>
禁止改动:
- <目录或文件 1>
- <目录或文件 2>
本次验收标准:
1. <标准 1>
2. <标准 2>
3. <标准 3>
验证要求:
- 至少跑 `npm run type-check`
- 按需跑相关测试
- 能跑就跑 `npm run build`
如果主线 `main` 最近有更新,请先同步主线再继续开发:
- `git checkout main`
- `git pull origin main`
- `git checkout <你的分支>`
- `git rebase main`
完成后请按下面格式回报:
1. 现在你要做什么
2. 是否完成
3. 还有没有风险
```
---
## 4. 交接时最容易漏掉的事
- 没说清楚这次只做什么
- 没说清楚哪些目录不能碰
- 没说清楚新平台主链和旧兼容链的边界
- 没写验证方式
- 没提醒对方先同步最新 `main`
- 没同步文档,导致下一个人继续靠猜

View File

@ -0,0 +1,159 @@
# 同事本地 AGENTS 使用说明
适用对象:在自己电脑上用 Codex 协作、但不想改动仓库共享规则的同事。
目标:让每个协作者都可以有自己的 AI 约束文档,同时不污染仓库里大家共用的 `AGENTS.md`。
---
## 1. 先说结论
可以有。
但不要直接去改仓库里已经提交的那份 `AGENTS.md`。
正确做法是:
- 仓库里的 `AGENTS.md` 继续保留为团队共享规则
- 你同事在自己电脑上,再单独放一份“本地工作区级”的 `AGENTS.md`
这样就能做到:
- 团队共用规则不乱
- 同事自己的 AI 还有额外约束
- 不会因为一人改规则,把所有人的 Codex 一起带偏
---
## 2. 为什么不能直接在仓库根目录再放一份
因为 `AGENTS.md` 是按目录生效的。
白话理解:
- 同一个目录层级,只能有一套这一级的规则
- 仓库根目录那份 `AGENTS.md`,本来就是给整个项目共用的
- 如果你同事直接把它改成自己的版本,就等于把团队共享规则一起改了
所以:
- 团队规则放仓库里
- 个人规则放自己电脑上的外层工作区
---
## 3. 最推荐的放法
让你同事在自己电脑上,为这个项目单独建一个外层工作区目录。
示意结构:
```text
<同事自己的工作区目录>/
├─ AGENTS.md
└─ furniture-3d-system/
├─ AGENTS.md
├─ src/
├─ docs/
└─ ...
```
这里的含义是:
- 外层 `AGENTS.md`:只约束你同事自己的 Codex
- 仓库里的 `AGENTS.md`:继续约束项目共享规则
Codex 会同时看到这两层规则。
这样最稳,也最适合团队协作。
---
## 4. 为什么这种方式最好
它同时解决了 3 个问题:
1. 不需要改仓库里的共享规则
2. 不需要让你同事长期带着一个“已修改但不敢提交”的根目录 `AGENTS.md`
3. 以后你同事换电脑、重装环境,也能直接复制这份本地规则继续用
---
## 5. 你同事具体怎么做
### 第一步:准备一个只给这个项目用的外层目录
目录名随便,但建议一看就知道是给这个项目单独准备的。
例如:
```text
<任意父目录>/furniture-3d-system-codex-workspace/
```
### 第二步:把仓库放到这个目录下面
结构变成:
```text
furniture-3d-system-codex-workspace/
├─ AGENTS.md
└─ furniture-3d-system/
```
### 第三步:把模板内容复制到外层的 `AGENTS.md`
模板见:`docs/workflow/同事本地AGENTS模板.md`
### 第四步:以后让 Codex 从这个外层工作区进入项目
这样它既能吃到团队规则,也能吃到你同事自己的规则。
---
## 6. 这份本地 AGENTS 应该写什么
建议只写“个人工作方式”和“模块边界”,不要重写项目真相。
适合放进去的内容:
- 默认用中文,面向非程序员表达
- 先看哪些文档
- 这位同事当前只负责哪些模块
- 哪些目录绝对不要碰
- 开工前先同步 `main`
- 结果汇报格式要简单
不适合放进去的内容:
- 项目数据库真相
- 会影响所有人的共享分层规则
- 和仓库主 `AGENTS.md` 相冲突的内容
---
## 7. 推荐边界
团队共享规则,继续放仓库根目录 `AGENTS.md`:
- 企业级架构要求
- 统一视觉标准
- 根因修复原则
- 项目主链与兼容链边界
同事本地规则,放自己工作区外层 `AGENTS.md`:
- 他当前只负责包装相关模块
- 先同步主线再开发
- 不扩范围
- 不擅自改主导航、自动组合、仓库主链
- 回报格式尽量简单
---
## 8. 最后给你的建议
最稳妥的团队做法就是下面这句:
```text
共享 AGENTS 放仓库里,个人 AGENTS 放各自电脑的外层工作区里。
```
这样以后你换同事、换模块、换机器,都不会乱。

View File

@ -0,0 +1,111 @@
# 同事本地 AGENTS 模板
使用方式:把下面内容复制到你同事自己电脑上的“项目外层工作区” `AGENTS.md` 中。
不要直接覆盖仓库里已经提交的那份 `AGENTS.md`。
---
```md
# AGENTS.md
适用范围:当前这个工作区下的 `furniture-3d-system` 项目。
## 1. 默认沟通方式
- 默认中文回复。
- 默认面向非程序员表达,先说结论,再说必要解释。
- 默认只说“现在你要做什么、是否完成、还有没有风险”。
## 2. 先做什么
开始任务前,先看下面这些文件:
- `docs/README.md`
- `docs/workflow/团队协作模式.md`
- `docs/workflow/模块边界说明.md`
- `docs/workflow/主线同步操作手册.md`
如果任务和包装有关,再看:
- `docs/workflow/同事交接模板.md`
## 3. 你的默认职责
你当前默认只负责包装相关模块,以及与包装对接的预留能力。
优先关注:
- `src/app/api/pre-skus/[id]/packaging`
- `src/app/api/products/[id]/packaging`
- `src/server/services/packaging.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/draft-group.repository.ts`
## 4. 默认不要改的地方
除非任务明确要求,否则不要主动改下面这些地方:
- `src/app/asset-center`
- `src/app/auto-combinations`
- `src/app/intake`
- `src/app/warehouse`
- `src/server/services/combination.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/warehouse.service.ts`
- 旧 CAD 兼容链的大范围逻辑
## 5. 开发前的固定动作
- 开工前先同步最新 `main`
- 主负责人刚推了重要改动后,再同步一次
- 自己准备提交前,再同步一次
高频协作固定铁律见:
- `docs/workflow/高频协作五条铁律.md`
默认同步步骤:
```bash
git checkout main
git pull origin main
git checkout <你的分支名>
git rebase main
git push --force-with-lease origin <你的分支名>
```
如果不会处理 `rebase`,先改用:
```bash
git checkout main
git pull origin main
git checkout <你的分支名>
git merge main
git push origin <你的分支名>
```
## 6. 开发规则
- 先修根因,不做表面补丁。
- 不顺手扩范围,不把自己的任务做成整个项目大改。
- Route / Page 不直接写 Prisma 业务规则。
- 先复用现有实现,不重复造新结构。
- 遇到冲突或边界不清,先停下来说明,不要硬改。
- 主导航、主流程骨架、公共数据结构,不要擅自大改。
## 7. 前端与文档
- 涉及前端时,优先保持现有统一视觉,不要自己另起一套样式。
- 涉及文档时,优先写短、直白、可执行的内容。
- 不要在文档里写你自己电脑上的绝对路径。
## 8. 完成后默认回报格式
完成后默认按下面格式回报:
1. 现在你做了什么
2. 是否完成
3. 跑了什么验证
4. 还有没有风险或待确认项
```

View File

@ -0,0 +1,296 @@
# 团队协作模式
适用范围:`furniture-3d-system` 仓库。
目标:把当前项目从“单人连续开发”切换成“多人并行协作,但主链不打架”的模式。
---
## 1. 先记住一句话
这个仓库现在默认按下面这条主链协作:
`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 成品展示 -> 正式入库 -> 轻仓库`
旧的 `CAD -> 装配步骤` 链继续保留,但它是**历史兼容链**,不是新开品主链。
---
## 2. 当前推荐分工
### 平台主线负责人
负责新平台一期主链:
- 资产中心
- 自动组合
- 草稿组
- 正式入库
- 轻仓库
- 新平台公共导航与页面骨架
默认涉及目录:
- `src/app/asset-center`
- `src/app/auto-combinations`
- `src/app/draft-groups`
- `src/app/intake`
- `src/app/warehouse`
- `src/app/api/asset-center`
- `src/app/api/auto-combinations`
- `src/app/api/draft-groups`
- `src/app/api/intake`
- `src/app/api/warehouse`
- `src/components/platform`
- `src/lib/open-product`
- `src/lib/planning`
- `src/server/services/asset-center.service.ts`
- `src/server/services/combination.service.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/planning.service.ts`
- `src/server/services/warehouse.service.ts`
### 包装负责人
负责包装方案增强,以及未来对接新平台的包装结果。
默认涉及目录:
- `src/app/api/pre-skus/[id]/packaging`
- `src/app/api/products/[id]/packaging`
- `src/server/services/packaging.service.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `src/server/repositories/draft-group.repository.ts`
- `docs/开发指南/包装方案增强`
默认边界:
- 可以增强包装对象、包装结果、包装展示。
- 不直接改自动组合规则。
- 不直接改正式入库规则。
- 不把包装流程做成独立顶层系统入口。
### 兼容链负责人
负责旧产品详情、旧装配编辑器、CAD 导入链、板件/孔位/五金/装配步骤兼容维护。
默认涉及目录:
- `src/app/products/[id]`
- `src/app/products/[id]/assembly`
- `src/app/products/[id]/skus/[skuId]`
- `src/app/products/[id]/skus/[skuId]/assembly`
- `src/app/api/products/[id]/assembly`
- `src/app/api/products/[id]/panels`
- `src/app/api/products/[id]/holes`
- `src/app/api/products/[id]/import-hardware`
- `src/app/api/holes`
- `src/app/api/hardware`
- `src/components/assembly`
- `src/components/3d`
- `src/lib/assembly-generator.ts`
- `src/lib/panel-assembly-semantic.ts`
- `src/lib/sku-cad-data.ts`
- `src/server/services/assembly.service.ts`
- `src/server/services/panel.service.ts`
- `src/server/services/sku-cad-import.helper.ts`
默认边界:
- 只做兼容维护、语义接入、步骤生成和旧链体验修正。
- 不把旧产品详情页重新拉回成新平台主入口。
- 不在旧链里偷偷新增新平台业务真相。
---
## 3. 分支规则
### 主分支
- `main` 继续保持受保护状态,但当前只允许 `WuYongJie` 直接 push。
- 其他协作者默认不能直接 push `main`,只能走自己的功能分支。
- 这样做的目的不是放开主线,而是保证主负责人可以快速收口,同事不会误把半成品直接推上主线。
### 开发分支
统一使用:
- `codex/<负责人>-<模块>`
示例:
- `codex/wuyongjie-open-platform`
- `codex/xuxiake-packaging`
- `codex/team-cad-compat`
### 合并方式
- 主负责人可以直接在 `main` 收口、修主线、推主线。
- 其他协作者默认先推自己的功能分支。
- 同事的改动进入主线前,默认先由主负责人检查和收口。
- 没有过验收清单,不合并,不并线。
### 同事和主线怎么保持同步
当前项目默认采用这个口径:
- `WuYongJie` 负责持续更新 `main`
- 同事长期在自己的分支开发,例如 `codex/xuxiake-dev`
- 同事不能等做完了再一次性同步,而是要高频同步 `main`
默认同步时机:
- 每天开始开发前同步一次
- 主负责人刚推了重要改动后同步一次
- 自己准备提交前再同步一次
默认推荐方式:
- 个人协作分支优先使用 `rebase main`
- 如果同事暂时不熟悉 `rebase`,允许先用 `merge main`
- 但不要一边长期不同步,一边继续堆改动
同事日常同步固定步骤:
```bash
git checkout main
git pull origin main
git checkout <自己的分支>
git rebase main
git push --force-with-lease origin <自己的分支>
```
如果同事当前只想用更稳、更不容易出心理压力的方式,可以先用:
```bash
git checkout main
git pull origin main
git checkout <自己的分支>
git merge main
git push origin <自己的分支>
```
怎么选:
- 分支只有自己在用,用 `rebase`
- 分支已经多人共用,先用 `merge`
详细操作说明见:`docs/workflow/主线同步操作手册.md`
高频协作总规则见:`docs/workflow/高频协作五条铁律.md`
---
## 4. 每个任务都按这个流程走
1. 先建一张任务单。
2. 写清楚本次只做什么、不做什么、验收标准是什么。
3. 明确归属模块,不跨模块顺手乱改。
4. 开发前先读对应模块边界说明。
5. 改完后同步必要文档。
6. 跑最小验证。
7. 做一次规范自审,再推分支。
---
## 5. 人和 AI 都要遵守的几条硬规则
### 先修根因,不打补丁
- 影响 3D 拼装、命名、包装、BOM、安装步骤的数据,优先在数据源修。
- 不允许只在页面上压报错、绕校验。
### 不跨层
- Page / Route 不直接写 Prisma 业务逻辑。
- Route 负责收参、鉴权、返回。
- Service 负责业务规则。
- Repository 负责数据访问。
### 不混主链和兼容链
- 新平台主链的数据真相在 `AssetFamily / AssetVariant / DraftGroup / PreSku`。
- 旧 `Product / SKU / Panel / Hole / ProductHardware` 继续服务兼容链。
- 包装本期要能挂接 `PreSku`,但不直接跳过草稿组去造正式产品。
### 不破坏统一视觉
- 新平台页面优先复用 `src/components/platform`。
- 旧装配编辑器继续走现有视觉体系,不另起一套杂样式。
---
## 6. 本地目录管理规则
### 需要长期保留的
- 源码
- 迁移
- 测试
- 正式文档
- 确认要进仓库的设计稿或说明
### 默认视为本地产物的
- `.dev-*.log`
- `.qa-*.log`
- `.start-*.log`
- `.next-start-*.log`
- `dev-server.log`
- `.codex-logs/`
- `_logs/`
- `output/`
- `temp/`
- `public/uploads/`
### 推荐习惯
- 提交前先运行:`npm run clean:local`
- 然后再看:`git status --short`
---
## 7. 提交前最少要交代什么
- 这次改的是哪个模块
- 本次只做了什么
- 哪些地方明确没做
- 跑了哪些验证
- 有没有风险或依赖别人继续接
详细清单见:`docs/workflow/提测与验收清单.md`
---
## 8. 给 Codex 的一句话协作口径
如果要把任务转给 AI,建议在任务里直接写明:
- 当前目标
- 所属模块
- 禁止改动的目录
- 验收标准
- 需要同步更新的文档
模板见:`docs/workflow/任务单模板.md` 和 `docs/workflow/同事交接模板.md`
---
## 9. 团队共享 AGENTS 和个人 AGENTS 怎么分
当前默认规则:
- 仓库里的 `AGENTS.md` 是团队共享规则
- 个人额外规则不要直接改仓库根目录那份
- 谁要给自己的 Codex 加私有约束,就在自己电脑上的项目外层工作区再放一份本地 `AGENTS.md`
推荐说明:
- 共享规则:继续放仓库里,大家共用
- 个人规则:放各自机器上的外层工作区,不进仓库
对应说明和模板见:
- `docs/workflow/同事本地AGENTS使用说明.md`
- `docs/workflow/同事本地AGENTS模板.md`
- `docs/workflow/高频协作五条铁律.md`

View File

@ -0,0 +1,108 @@
# 提测与验收清单
目标:让“做完了”变成有证据,不靠感觉。
---
## 1. 提测前先过这 6 项
- [ ] 任务范围没有失控,没有顺手改无关模块
- [ ] 关键文档已同步
- [ ] 先跑过 `npm run clean:local`
- [ ] `git status --short` 里没有不该提交的日志、缓存、临时文件
- [ ] 改动文件和任务单一致
- [ ] 自己能用一句话说清根因和修复方式
---
## 2. 最小验证命令
### 默认必跑
```bash
npm run type-check
```
### 按需补跑
```bash
npm test -- --runTestsByPath <相关测试文件>
```
### 能跑就跑
```bash
npm run build
```
---
## 3. 页面 / 接口人工确认
至少确认与任务直接相关的入口:
- [ ] 对应页面能打开
- [ ] 关键按钮/表单/保存动作能走通
- [ ] 关键接口返回正常
- [ ] 没有把别的入口改坏
---
## 4. 文档确认
下面这些情况要同步文档:
- 改了模块边界
- 改了页面入口
- 改了 API 入参或返回
- 改了数据模型
- 改了交接方式
最少同步到:
- `docs/workflow/团队协作模式.md`
- `docs/workflow/模块边界说明.md`
- 对应模块的专项说明文档
---
## 5. 规范自审
这是自审,不是独立复审。
结论只能写:
- `passed`
- `passed with warnings`
- `rejected`
自审固定只看 3 件事:
- 架构有没有被破坏
- 视觉有没有跑偏
- 这次是不是根因修复
如果不通过,就不要推 `main` 合并链路。
---
## 6. 提测说明模板
```text
现在要做什么:
<一句话说明这次改动解决了什么问题>
是否完成:
<已完成 / 部分完成>
验证结果:
- type-check:<通过 / 未跑>
- test:<通过 / 未跑>
- build:<通过 / 未跑>
- 人工验证:<写页面和结果>
风险:
- <没有风险 / 写明确风险>
规范审查结论:
这是自审,不是独立复审;<passed / passed with warnings / rejected>
```

View File

@ -0,0 +1,228 @@
# 模块边界说明
目标:让人和 AI 在开工前先知道“哪里是新平台主链,哪里是旧兼容链,哪里不能乱碰”。
---
## 1. 页面与入口边界
### 新平台主链入口
- `/`:新平台首页导航
- `/asset-center`:资产中心
- `/auto-combinations`:自动组合
- `/draft-groups`:草稿组列表
- `/draft-groups/[id]`:草稿组详情
- `/intake`:正式入库
- `/warehouse`:轻仓库
### 旧兼容链入口
- `/products`
- `/products/[id]`
- `/products/[id]/assembly`
- `/products/[id]/skus/[skuId]`
- `/products/[id]/skus/[skuId]/assembly`
### 不要混的原则
- 新平台入口负责“规划、预览、入库前整理”。
- 旧兼容链负责“历史产品、CAD 导入、板件/孔位/五金/装配步骤”。
- 不要把旧产品详情页重新改成新平台首页。
---
## 2. 新平台主链边界
### 前端目录
- `src/app/page.tsx`
- `src/app/asset-center`
- `src/app/auto-combinations`
- `src/app/draft-groups`
- `src/app/intake`
- `src/app/warehouse`
- `src/components/platform`
### API 目录
- `src/app/api/asset-center`
- `src/app/api/auto-combinations`
- `src/app/api/draft-groups`
- `src/app/api/intake`
- `src/app/api/planning`
- `src/app/api/pre-skus`
- `src/app/api/series-templates`
- `src/app/api/warehouse`
### 服务与仓库
- `src/server/services/asset-center.service.ts`
- `src/server/services/combination.service.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/services/intake.service.ts`
- `src/server/services/planning.service.ts`
- `src/server/services/series-template.service.ts`
- `src/server/services/warehouse.service.ts`
- `src/server/repositories/asset-center.repository.ts`
- `src/server/repositories/draft-group.repository.ts`
- `src/server/repositories/planning.repository.ts`
- `src/server/repositories/series-template.repository.ts`
- `src/server/repositories/warehouse.repository.ts`
### 纯规则与类型
- `src/lib/open-product`
- `src/lib/planning`
### 这里的业务真相是什么
- 资产族:`AssetFamily`
- 规格款:`AssetVariant`
- 草稿组:`DraftGroup`
- 准 SKU:`PreSku`
- 入库结果:正式 `Product / SKU`
### 不要做的事
- 不直接从自动组合跳过草稿组生成正式产品。
- 不把旧 `Product / SKU` 当作新平台组合阶段的数据真相。
- 不把包装做成绕过草稿组的独立系统。
---
## 3. 包装协作边界
### 当前包装入口分两条
- 新平台承接口:`src/app/api/pre-skus/[id]/packaging/route.ts`
- 旧正式产品口:`src/app/api/products/[id]/packaging`
### 主要服务
- `src/server/services/packaging.service.ts`
- `src/server/services/draft-group.service.ts`
- `src/server/repositories/packaging.repository.ts`
- `src/server/repositories/draft-group.repository.ts`
### 当前约定
- 包装结果最终按单个 `PreSku` 保存。
- 正式产品包装链保留,作为兼容与发布后使用。
- 工艺图组合本期只预留接口,不在主线里单独展开。
### 包装负责人不要乱碰的地方
- 自动组合生成规则
- 草稿组转正式 SKU 的规则
- 旧 CAD 的板件/孔位/五金推导链
---
## 4. 旧 CAD 兼容链边界
### 页面目录
- `src/app/products/[id]`
- `src/app/products/[id]/assembly`
- `src/app/products/[id]/skus/[skuId]`
- `src/app/products/[id]/skus/[skuId]/assembly`
- `src/components/assembly`
- `src/components/3d`
### API 目录
- `src/app/api/products/[id]/assembly`
- `src/app/api/products/[id]/panels`
- `src/app/api/products/[id]/holes`
- `src/app/api/products/[id]/import-hardware`
- `src/app/api/holes`
- `src/app/api/hardware`
- `src/app/api/product-hardware`
### 服务与库
- `src/server/services/assembly.service.ts`
- `src/server/services/panel.service.ts`
- `src/server/services/hole.service.ts`
- `src/server/services/hardware.service.ts`
- `src/server/services/sku-cad-import.helper.ts`
- `src/server/repositories/panel.repository.ts`
- `src/server/repositories/hole.repository.ts`
- `src/server/repositories/hardware.repository.ts`
- `src/server/repositories/product-hardware.repository.ts`
### 纯规则与共享链路
- `src/lib/assembly-generator.ts`
- `src/lib/panel-assembly-semantic.ts`
- `src/lib/sku-cad-data.ts`
- `src/lib/3d/engine.ts`
### 当前约定
- 装配语义是旧链增强能力,不是新平台主数据源。
- 旧链可以继续生成安装步骤和预览,但不要反向主导新平台资产模型。
---
## 5. 共享层边界
### 所有人都可能会碰到,但不要随手改
- `prisma/schema.prisma`
- `prisma/migrations`
- `src/server/services/index.ts`
- `src/server/repositories/index.ts`
- `src/app/layout.tsx`
- `src/app/api/platform/schemas.ts`
### 共享层规则
- 任何 Prisma 模型调整,都必须带迁移。
- 任何新接口,都要有明确输入输出 DTO。
- 任何跨模块字段变更,都要先同步文档,再改代码。
---
## 6. 看到这些情况要先停一下
- 一个任务同时改新平台主链和旧兼容链,但任务单里没写原因。
- Route 里开始直接写 Prisma 查询。
- 包装开始自己造一套 `Product / SKU`。
- 页面样式开始绕开 `src/components/platform` 和既有视觉标准。
- 改动影响命名、BOM、包装、安装步骤,但没说明数据来源。
---
## 7. 开工前最少要看什么
### 做新平台主链
先看:
- `docs/workflow/团队协作模式.md`
- `src/components/platform/platform-navigation.ts`
- `src/lib/open-product/types.ts`
- `src/server/services/planning.service.ts`
- `src/server/services/draft-group.service.ts`
### 做包装
先看:
- `docs/workflow/团队协作模式.md`
- `docs/开发指南/包装方案增强/包装方案增强-同事交接说明.md`
- `src/app/api/pre-skus/[id]/packaging/route.ts`
- `src/server/services/packaging.service.ts`
- `src/server/services/draft-group.service.ts`
### 做旧 CAD 兼容链
先看:
- `docs/solutions/workflow-issues/CAD插件装配语义导出改造说明-20260325.md`
- `src/app/products/[id]/assembly/AssemblyEditorWorkspace.tsx`
- `src/components/assembly/AssemblyEditorClient.tsx`
- `src/server/services/assembly.service.ts`
- `src/lib/assembly-generator.ts`

Some files were not shown because too many files have changed in this diff Show More