diff --git a/.gitignore b/.gitignore index 0cf13421..eb1ad053 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,16 @@ _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/ diff --git a/AGENTS.md b/AGENTS.md index faa2de45..15e5109c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,15 @@ # AGENTS.md -适用范围:`D:\AI Code\3danzhuang` 全仓库。 +适用范围:`D:\AI Code\3danzhuang` 全仓库。 +未单独说明时,先遵循全局规则:`C:\Users\54972\.codex\AGENTS.md`。 +这份文件只补充本仓库特有规则,不重复全局通用要求。 -## 1. 默认方式 +## 1. 默认执行口径 -- 默认中文沟通,面向非程序员表达。 -- 默认回复尽量简短、直白、好理解,非必要不展开。 -- 默认先说“现在你要做什么 / 结论是什么”;技术细节、文件路径、命令和长解释只在用户要求时展开。 -- 默认按当前会话直接开发。 +- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。 +- 当任务发生在 `furniture-3d-system/` 时,默认按当前会话直接开发。 +- 未经用户明确要求,不主动 `commit / push`。 +- 禁止破坏性操作,除非用户明确授权。 - 没有“规范审查结论”,任务不算完成。 ## 2. 三条硬规则 @@ -16,48 +18,47 @@ - 必须符合统一视觉标准。 - 必须优先做根因修复,不能拿补丁冒充完成。 -## 3. 固定流程 +## 3. 固定协作入口 -1. 开始前先用 1-2 句说明:目标、验收标准、根因判断。 -2. 开发时优先复用现有实现,不顺手改无关问题。 -3. 改完先做最小必要验证。 -4. 交付前必须单独做一次规范审查。 -5. 最终默认只交代:现在你要做什么、是否完成、有没有风险;技术细节只在用户要求时展开。 - -### 团队协作固定入口 - -当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面这些协作文档,再开始改代码: +当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面 3 份协作文档,再开始改代码: - `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` - `furniture-3d-system/docs/workflow/同事本地AGENTS模板.md` - `furniture-3d-system/docs/开发指南/包装方案增强/新平台包装底座-同事AI对接说明-20260331.md` -固定协作口径: +## 4. 协作与分支口径 -- `main` 只作为 `WuYongJie` 的主线收口分支,其他协作者默认不能直接推主线 -- `xuxiake` 的长期包装分支固定为 `codex/xuxiake-packaging` -- `xuxiake` 的临时任务分支固定为 `codex/xuxiake-packaging-<任务简称>` -- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>` -- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名 -- 长期包装分支同步 `main` 时固定使用 `merge main` -- 临时个人任务分支同步 `main` 时才使用 `rebase 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. 规范审查 +## 5. Gemini 前端协作口径 + +- 如项目已指定 `Gemini(前端专家)` 负责前端,则前端代码、UI 视觉、排版动效和 `3D(立体)` 表现层默认由 Gemini 主导。 +- 我不主动改 Gemini 已定稿的前端样式;我默认负责底层逻辑、接口、数据、数据库、部署和非前端部分。 +- 与 Gemini 联调时,必须优先遵守 Gemini 已确定的数据结构和类型口径,避免因为字段或格式不一致导致前端渲染异常。 +- `docs/前端UI改动清单.md` 只作为当前轮次的轻量对接板使用,不作为长期规范来源、历史总账或唯一协作入口。 + +## 6. 规范审查 规范审查固定只查三件事: + - 架构有没有被破坏 - 视觉有没有跑偏 - 这次是不是根因修复 结论只能是: + - `passed` - `passed with warnings` - `rejected` @@ -67,14 +68,8 @@ 统一清单:`docs/workflow/规范审查清单.md` -## 5. 标准来源 +## 7. 标准来源 - `furniture-3d-system/.cursor/rules/*.mdc` - `furniture-3d-system/docs/设计规范/苹果风格视觉标准.md` - `docs/workflow/规范审查清单.md` - -## 6. 边界 - -- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。 -- 未经用户明确要求,不主动 `commit / push`。 -- 禁止破坏性操作,除非用户明确授权。 diff --git a/autocad-plugin/FurnitureExporter/Commands.cs b/autocad-plugin/FurnitureExporter/Commands.cs index 2031c2f1..a5b97b74 100644 --- a/autocad-plugin/FurnitureExporter/Commands.cs +++ b/autocad-plugin/FurnitureExporter/Commands.cs @@ -71,6 +71,7 @@ public class Commands public List Panels { get; } = new(); public List Holes { get; } = new(); public List HoleGroups { get; } = new(); + public List MountPatterns { get; } = new(); public int LogicalHoleCount { get; set; } public List EdgeFeatures { get; } = new(); public List ExcludedHoles { get; } = new(); @@ -336,6 +337,8 @@ public class Commands var holeGroups = HoleGrouping.BuildEccentricScrewGroups(allHoles); HoleGrouping.AttachGroupsToPanels(panels, holeGroups); + var mountPatterns = MountPatternAnalyzer.BuildMountPatterns(panels, allHoles, holeGroups); + MountPatternAnalyzer.AttachPatternsToPanels(panels, mountPatterns); var logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles); foreach (var hole in allHoles) @@ -353,6 +356,7 @@ public class Commands result.Holes.AddRange(allHoles); result.HoleGroups.AddRange(holeGroups); + result.MountPatterns.AddRange(mountPatterns); result.LogicalHoleCount = logicalHoleCount; result.EdgeFeatures.AddRange(allEdgeFeatures); result.ExcludedHoles.AddRange(panelAnalyzer.ExcludedHoles); @@ -683,6 +687,8 @@ 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 logicalHoleCount = HoleGrouping.ComputeLogicalHoleCount(allHoles); @@ -729,6 +735,7 @@ public class Commands exportData.Panels = panels; exportData.Holes = allHoles; exportData.HoleGroups = holeGroups; + exportData.MountPatterns = mountPatterns; exportData.LogicalHoleCount = logicalHoleCount; if (panelAnalyzer.ExcludedHoles.Count > 0) @@ -985,6 +992,7 @@ public class Commands exportData.Panels = panelAnalysis.Panels; exportData.Holes = panelAnalysis.Holes; exportData.HoleGroups = panelAnalysis.HoleGroups; + exportData.MountPatterns = panelAnalysis.MountPatterns; exportData.LogicalHoleCount = panelAnalysis.LogicalHoleCount; exportData.EdgeFeatures = panelAnalysis.EdgeFeatures; exportData.PanelCount = panelAnalysis.Panels.Count; diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/AssemblySemanticAnalyzerTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/AssemblySemanticAnalyzerTests.cs index 10981d9f..3104db75 100644 --- a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/AssemblySemanticAnalyzerTests.cs +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/AssemblySemanticAnalyzerTests.cs @@ -1,229 +1,236 @@ +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; - -public class AssemblySemanticAnalyzerTests +namespace FurnitureExporter.Tests { - [Fact] - public void Analyze_BuildsAssemblySection_AndDoesNotEmitInstallOrderFields() + public class AssemblySemanticAnalyzerTests { - var exportData = new ExportData + [Fact] + public void Analyze_BuildsAssemblySection_AndDoesNotEmitInstallOrderFields() { - Panels = new List + var exportData = new ExportData + { + Panels = new List + { + 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()); + 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 { 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 }), + 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", - 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 }), - }, - }; + 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 = AssemblySemanticAnalyzer.Analyze( - exportData.Panels, - exportData.Holes, - exportData.HoleGroups, - exportData.HardwareTypes); - - var json = JsonConvert.SerializeObject( - exportData, - new JsonSerializerSettings + var holes = new List { - ContractResolver = new CamelCasePropertyNamesContractResolver(), - NullValueHandling = NullValueHandling.Ignore, - }); - - var root = JObject.Parse(json); - Assert.Equal("1.0", root["assembly"]?["version"]?.Value()); - 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 - { - 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 - { - new() - { - 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() - { - 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 - { - 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 - { - new() - { - TypeId = "type_000", - InferredHardwareType = "connector", - Instances = new List + new HoleInfo { - new() + 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", + }, + }; + + var holeGroups = new List + { + 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 + { + new HardwareType + { + TypeId = "type_000", + InferredHardwareType = "connector", + Instances = new List { - 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" }, + 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" }, + }, }, }, - }, - }; + }; - var assembly = AssemblySemanticAnalyzer.Analyze(panels, holes, holeGroups, hardwareTypes); - var panelAssembly = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_001")); + 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")); - 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)); - } + 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()); + Assert.True(panelAssembly.Confidence >= 0.75); + } - [Fact] - public void Analyze_FallsBackToFaceMate_WhenNoHardwareBindingExists() - { - var panels = new List + [Fact] + public void Analyze_FallsBackToShelfDirection_WhenNoHardwareBindingExists() { - 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 panels = new List + { + 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 }), + }; - var assembly = AssemblySemanticAnalyzer.Analyze( - panels, - new List(), - new List(), - new List()); + var assembly = new AssemblySemanticAnalyzer().Analyze( + panels, + new List(), + new List(), + new List()); - 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("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); - } + 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()); + Assert.Contains("panel_right", shelf.Mates.Right ?? new List()); + Assert.True(shelf.TravelDistance > 0); + } - private static PanelInfo CreatePanel( - string id, - string panelType, - double[] position, - double[] normal, - double[] boundingBoxMin, - double[] boundingBoxMax) - { - return new PanelInfo + private static PanelInfo CreatePanel( + string id, + string panelType, + double[] position, + double[] normal, + double[] boundingBoxMin, + double[] boundingBoxMax) { - Id = id, - PanelType = panelType, - Position = position, - Normal = normal, - BoundingBox = new BoundingBox3D + return new PanelInfo { - 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), - }, - }; + 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), + }, + }; + } } } diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs new file mode 100644 index 00000000..b3ad97a4 --- /dev/null +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/MountPatternAnalyzerTests.cs @@ -0,0 +1,87 @@ +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 + { + 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()); + + var pattern = Assert.Single(patterns); + Assert.Equal("line_2", pattern.PatternType); + Assert.Equal(2, pattern.MinRequiredHoles); + Assert.Equal(2, pattern.PreferredHoleCount); + } + + [Fact] + public void BuildMountPatterns_ForSymmetricRectangle_DefaultsToTwoRequiredHoles() + { + var panel = CreatePanel("panel_rect"); + var holes = new List + { + 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()); + + 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, + }; + } + } +} diff --git a/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/PanelFaceSemanticResolverTests.cs b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/PanelFaceSemanticResolverTests.cs new file mode 100644 index 00000000..906b0675 --- /dev/null +++ b/autocad-plugin/FurnitureExporter/FurnitureExporter.Tests/PanelFaceSemanticResolverTests.cs @@ -0,0 +1,73 @@ +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]); + } + } +} diff --git a/autocad-plugin/FurnitureExporter/HoleDetector.cs b/autocad-plugin/FurnitureExporter/HoleDetector.cs index f754d843..cdede0b6 100644 --- a/autocad-plugin/FurnitureExporter/HoleDetector.cs +++ b/autocad-plugin/FurnitureExporter/HoleDetector.cs @@ -162,6 +162,31 @@ 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; @@ -203,7 +228,12 @@ public class HoleDetector Depth = Math.Round(depth, 2), PanelId = panelId, Type = holeType, - Radius = Math.Round(radius, 2) + Radius = Math.Round(radius, 2), + SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces( + panelBoundingBox, + new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z }, + new[] { axis.X, axis.Y, axis.Z }, + depth) }; } catch (System.Exception ex) diff --git a/autocad-plugin/FurnitureExporter/HoleGrouping.cs b/autocad-plugin/FurnitureExporter/HoleGrouping.cs index daab7617..716ee15e 100644 --- a/autocad-plugin/FurnitureExporter/HoleGrouping.cs +++ b/autocad-plugin/FurnitureExporter/HoleGrouping.cs @@ -92,7 +92,13 @@ 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}" + 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()) + .Where(face => !string.IsNullOrWhiteSpace(face)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(), }; groups.Add(group); diff --git a/autocad-plugin/FurnitureExporter/HoleMarkerDetector.cs b/autocad-plugin/FurnitureExporter/HoleMarkerDetector.cs index 5c129d88..32ad0373 100644 --- a/autocad-plugin/FurnitureExporter/HoleMarkerDetector.cs +++ b/autocad-plugin/FurnitureExporter/HoleMarkerDetector.cs @@ -374,6 +374,11 @@ 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), }); } @@ -418,6 +423,11 @@ 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), }); } diff --git a/autocad-plugin/FurnitureExporter/Models.cs b/autocad-plugin/FurnitureExporter/Models.cs index 1be8eb27..15580f2c 100644 --- a/autocad-plugin/FurnitureExporter/Models.cs +++ b/autocad-plugin/FurnitureExporter/Models.cs @@ -46,6 +46,13 @@ public class ExportData /// 图纸单位(毫米/厘米/米等) /// public string Units { get; set; } = "毫米"; + + /// + /// CAD 世界坐标系标识。 + /// 当前导出统一按 AutoCAD 世界坐标:X 向右,Y 向前,Z 向上。 + /// Web 端后续做孔位吸附时,不再需要靠启发式猜测坐标系。 + /// + public string CoordinateSystem { get; set; } = "AUTOCAD_WORLD_Z_UP"; /// /// 原点偏移 [X, Y, Z] (mm) @@ -103,6 +110,15 @@ public class ExportData /// public List HoleGroups { get; set; } = new(); + /// + /// 安装图样(v2.5 新增) + /// 说明: + /// - 吸附面先缩小范围; + /// - HoleGroup 负责把几何孔组织成业务孔位; + /// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。 + /// + public List MountPatterns { get; set; } = new(); + /// /// 逻辑孔位数量(v2.2.4 新增) /// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。 @@ -313,6 +329,11 @@ public class PanelInfo /// 该板件上的逻辑孔位分组(v2.2.4 新增) /// public List HoleGroups { get; set; } = new(); + + /// + /// 该板件上的安装图样(v2.5 新增) + /// + public List MountPatterns { get; set; } = new(); } /// @@ -391,6 +412,14 @@ public class HoleInfo /// public double BindingConfidence { get; set; } = 1.0; + /// + /// 孔位整体可用置信度(0-1) + /// 说明: + /// - BindingConfidence 只表示“孔位和板件绑定得稳不稳”; + /// - Confidence 表示“这个孔位几何与语义整体是否可信”。 + /// + public double Confidence { get; set; } = 1.0; + /// /// 绑定原因说明(调试/回放) /// @@ -418,12 +447,24 @@ public class HoleInfo /// 例如:main(主孔)/ side(侧孔) /// public string? HoleGroupRole { get; set; } + + /// + /// 当前孔实际打在板件外表面的面标签。 + /// 盲孔通常只有 1 个面;通孔通常会有 2 个相对面。 + /// 例如:top / bottom / left / right / front / back + /// + public string[] SurfaceFaces { get; set; } = System.Array.Empty(); /// /// 推断的用途(根据直径) /// public string? InferredUsage { get; set; } + /// + /// 低置信度或歧义提示 + /// + public List Warnings { get; set; } = new(); + /// /// 针对该孔位的紧固件建议(v2.3 新增) /// 说明: @@ -511,6 +552,99 @@ public class HoleGroupInfo /// 可读原因(调试/回放) /// public string? Reason { get; set; } + + /// + /// 当前孔组默认作用的安装面。 + /// 例如:top / bottom / left / right / front / back / unknown + /// + public string MountFace { get; set; } = "unknown"; + + /// + /// 逻辑孔位实际落在哪些板件外表面上。 + /// 默认按主孔 + 组内成员孔合并去重。 + /// + public string[] SurfaceFaces { get; set; } = System.Array.Empty(); + + /// + /// 低置信度或歧义提示 + /// + public List Warnings { get; set; } = new(); +} + +/// +/// 安装图样(v2.5 新增) +/// +public class MountPatternInfo +{ + /// + /// 图样 ID(如 panel_001_mount_top_001) + /// + public string Id { get; set; } = ""; + + /// + /// 所属板件 ID + /// + public string PanelId { get; set; } = ""; + + /// + /// 默认安装面:top / bottom / left / right / front / back / unknown + /// + public string MountFace { get; set; } = "unknown"; + + /// + /// 图样类型:single / line_2 / triangle_3 / rectangle_4 / generic + /// + public string PatternType { get; set; } = "generic"; + + /// + /// 属于该图样的几何孔位 ID + /// + public string[] HoleIds { get; set; } = System.Array.Empty(); + + /// + /// 必需命中的孔位 ID + /// + public string[] RequiredHoleIds { get; set; } = System.Array.Empty(); + + /// + /// 可选命中的孔位 ID + /// + public string[] OptionalHoleIds { get; set; } = System.Array.Empty(); + + /// + /// 吸附成立所需的最少命中孔数 + /// + public int MinRequiredHoles { get; set; } + + /// + /// 推荐命中孔数(越接近越稳) + /// + public int PreferredHoleCount { get; set; } + + /// + /// 是否允许对称复用 + /// + public bool Symmetry { get; set; } + + /// + /// 图样参考点(默认取图样中心) + /// + public double[] Position { get; set; } = new double[3]; + + /// + /// 图样参考法向(默认取安装面法向) + /// + public double[] Normal { get; set; } = new double[3]; + + /// + /// 图样置信度(0-1) + /// + public double Confidence { get; set; } + + /// + /// 低置信度或歧义提示 + /// + public List Warnings { get; set; } = new(); } /// diff --git a/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs new file mode 100644 index 00000000..3612634d --- /dev/null +++ b/autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FurnitureExporter; + +/// +/// 安装图样识别器。 +/// +/// 设计口径: +/// - 吸附面(mount face)先缩小候选范围; +/// - HoleGroup 负责把几何孔组织成业务孔位; +/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。 +/// +public static class MountPatternAnalyzer +{ + private const double DefaultClusterDistanceMm = 220.0; + + public static List BuildMountPatterns( + IReadOnlyList panels, + IReadOnlyList holes, + IReadOnlyList holeGroups) + { + var patterns = new List(); + 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(); + var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList) + ? panelGroupList + : new List(); + + var units = BuildUnits(panel.Id, panelHoles, panelGroups); + if (units.Count == 0) + { + panel.MountPatterns = new List(); + continue; + } + + var panelPatterns = new List(); + 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; + patterns.AddRange(panelPatterns); + } + + return patterns; + } + + public static void AttachPatternsToPanels(IReadOnlyList panels, IReadOnlyList patterns) + { + var map = patterns + .GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase); + + foreach (var panel in panels) + { + panel.MountPatterns = map.TryGetValue(panel.Id, out var items) + ? items + : new List(); + } + } + + private static List BuildUnits( + string panelId, + IReadOnlyList holes, + IReadOnlyList holeGroups) + { + var units = new List(); + var groupedHoleIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var group in holeGroups) + { + foreach (var holeId in group.HoleIds ?? Array.Empty()) + { + 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(), + 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(), + 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(), + Source = "single-hole", + }); + } + + return units; + } + + private static List> BuildClusters(IReadOnlyList units) + { + var clusters = new List>(); + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var unit in units) + { + if (!visited.Add(unit.Id)) + { + continue; + } + + var cluster = new List(); + var queue = new Queue(); + 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 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(); + var optionalHoleIds = Array.Empty(); + + 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 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 cluster) + { + if (cluster.Count != 4) + { + return false; + } + + var distances = new List(); + 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 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 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? faces, string? fallbackFace = null) + { + var normalized = (faces ?? Array.Empty()) + .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 left, IReadOnlyList 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(); + 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(); + public double Confidence { get; set; } + public List Warnings { get; set; } = new(); + public string Source { get; set; } = "single-hole"; + } +} diff --git a/autocad-plugin/FurnitureExporter/PanelAnalyzer.cs b/autocad-plugin/FurnitureExporter/PanelAnalyzer.cs index 907afbb7..8da2f7f6 100644 --- a/autocad-plugin/FurnitureExporter/PanelAnalyzer.cs +++ b/autocad-plugin/FurnitureExporter/PanelAnalyzer.cs @@ -210,8 +210,24 @@ 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); + var holes = ExtractHoles(solid, $"panel_{index:D3}", thickness, panelBoundingBox); // v2.2.5:孔位过滤(与设计师“孔位口径”对齐) // - 极小薄件(如脚垫/垫片)不作为板件孔位统计对象,避免误把“几何细节”当孔 @@ -253,21 +269,7 @@ public class PanelAnalyzer Math.Round(dims[2], 2) // 厚 }, Normal = normal, - 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) - } - }, + BoundingBox = panelBoundingBox, Holes = holes }; } @@ -299,7 +301,11 @@ public class PanelAnalyzer /// /// 从板材实体中提取孔位信息 /// - private List ExtractHoles(Solid3d solid, string panelId, double panelThickness) + private List ExtractHoles( + Solid3d solid, + string panelId, + double panelThickness, + BoundingBox3D panelBoundingBox) { var holes = new List(); @@ -315,7 +321,7 @@ public class PanelAnalyzer var surface = BrepSurfaceUtils.Unwrap(face.Surface); if (surface is Cylinder cylinder) { - var hole = ExtractHoleFromCylinder(cylinder, face, panelId, holeIndex, panelThickness); + var hole = ExtractHoleFromCylinder(cylinder, face, panelId, holeIndex, panelThickness, panelBoundingBox); if (hole != null) { holes.Add(hole); @@ -326,7 +332,7 @@ public class PanelAnalyzer { // 柜脚/三角铁等五金件上常见“沉头孔/倒角孔”,其孔壁在 Brep 中可能表现为锥面(Cone), // 仅识别 Cylinder 会漏掉这类孔位。 - var hole = ExtractHoleFromCone(cone, face, panelId, holeIndex, panelThickness); + var hole = ExtractHoleFromCone(cone, face, panelId, holeIndex, panelThickness, panelBoundingBox); if (hole != null) { holes.Add(hole); @@ -344,7 +350,7 @@ public class PanelAnalyzer // 某些孔位(尤其是五金件的沉头孔/复杂倒角)在 AutoCAD Brep 中,孔壁面可能被表达为 NurbSurface/其它曲面, // 导致 surface is Cylinder/Cone 判断失败。 // 这里用“闭合圆边界 + 相邻端面平面法向推断孔轴”的方式做一次兜底,尽量补齐漏孔。 - var hole = ExtractHoleFromFallbackFace(face, panelId, holeIndex, panelThickness); + var hole = ExtractHoleFromFallbackFace(face, panelId, holeIndex, panelThickness, panelBoundingBox); if (hole != null) { holes.Add(hole); @@ -374,7 +380,8 @@ public class PanelAnalyzer Autodesk.AutoCAD.BoundaryRepresentation.Face face, string panelId, int index, - double panelThickness) + double panelThickness, + BoundingBox3D panelBoundingBox) { try { @@ -458,7 +465,12 @@ public class PanelAnalyzer DetectionSource = "brep", BindingSource = "panel_brep", BindingConfidence = 1.0, - BindingReason = "从当前板件实体 Brep 直接提取" + BindingReason = "从当前板件实体 Brep 直接提取", + SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces( + panelBoundingBox, + new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z }, + new[] { axis.X, axis.Y, axis.Z }, + depth) }; } catch @@ -475,7 +487,8 @@ public class PanelAnalyzer Autodesk.AutoCAD.BoundaryRepresentation.Face face, string panelId, int index, - double panelThickness) + double panelThickness, + BoundingBox3D panelBoundingBox) { try { @@ -560,7 +573,12 @@ public class PanelAnalyzer DetectionSource = "brep", BindingSource = "panel_brep", BindingConfidence = 1.0, - BindingReason = "从当前板件实体 Brep 直接提取" + BindingReason = "从当前板件实体 Brep 直接提取", + SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces( + panelBoundingBox, + new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z }, + new[] { axis.X, axis.Y, axis.Z }, + depth) }; } catch @@ -577,7 +595,8 @@ public class PanelAnalyzer BrepFace face, string panelId, int index, - double panelThickness) + double panelThickness, + BoundingBox3D panelBoundingBox) { try { @@ -651,7 +670,12 @@ public class PanelAnalyzer DetectionSource = "brep", BindingSource = "panel_brep", BindingConfidence = 1.0, - BindingReason = "从当前板件实体 Brep 兜底面直接提取" + BindingReason = "从当前板件实体 Brep 兜底面直接提取", + SurfaceFaces = PanelFaceSemanticResolver.ResolveSurfaceFaces( + panelBoundingBox, + new[] { offsetPosition.X, offsetPosition.Y, offsetPosition.Z }, + new[] { axis.X, axis.Y, axis.Z }, + depth) }; } catch diff --git a/autocad-plugin/FurnitureExporter/PanelFaceSemanticResolver.cs b/autocad-plugin/FurnitureExporter/PanelFaceSemanticResolver.cs new file mode 100644 index 00000000..2afd2512 --- /dev/null +++ b/autocad-plugin/FurnitureExporter/PanelFaceSemanticResolver.cs @@ -0,0 +1,226 @@ +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? position, + IReadOnlyList? normal, + double depth) + { + if (boundingBox?.Min?.Length != 3 || boundingBox.Max?.Length != 3) + { + return Array.Empty(); + } + + var center = ToVector3(position); + var axis = Normalize(ToVector3(normal)); + if (center == null || axis == null) + { + return Array.Empty(); + } + + var faces = new List(); + 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 faces, string? face) + { + if (string.IsNullOrWhiteSpace(face)) + { + return; + } + + faces.Add(face!.Trim().ToLowerInvariant()); + } + + private static double[]? ToVector3(IReadOnlyList? 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 point) + { + return ResolveNearestSurfaceFace(box, point, null, null, SurfaceFaceToleranceMm); + } + + private static string? ResolveNearestSurfaceFace( + BoundingBox3D box, + IReadOnlyList 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 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; + } +} diff --git a/docs/前端UI改动清单.md b/docs/前端UI改动清单.md index c74c5bc4..b829b458 100644 --- a/docs/前端UI改动清单.md +++ b/docs/前端UI改动清单.md @@ -1,31 +1,15 @@ -# 前端 UI 改动清单 +# 协作任务板 -> 这份文档只保留"正在进行 / 未完成 / 待联调"的前端交接项。 -> 已完成且已确认正确的内容,直接删除,不保留历史记录。 -> 前端 UI agent 只按本文档执行;如果联调后确认是接口、数据、保存链路、状态口径问题,再回传后端 agent。 +## Gemini -## 处理归属说明 +- `[TODO]` `/admin`、`/users`:全量 UI 汉化收口 + - 负责人:`Gemini` + - 说明:清理当前页面里残留的英文文案 -- `前端负责`:视觉、布局、交互、动效、前端展示层接线。 -- `联调确认`:先按当前实现点流程;若发现根因在接口、数据、保存失败、状态不一致,再转后端。 -- **重要边界**: - - 前端 `agent` 只负责前端层,不负责拍板业务真相,不负责改接口,不负责改数据库,不负责处理保存链路真相。 - - 只要问题已经超出“展示层接线 / 复用现有前端能力”的边界,就停止继续扩写,并回传后端 `agent`。 +## Codex ---- +- `[TODO]` `assisted-assembly(辅助装配)`:推进准确吸附数据主链 + - 负责人:`Codex` -## 当前未完成项 - -- 当前无未完成前端交接项。 -- 2026-03-26 已完成交互重构: - - 背景改为淡灰底色+40px网格线(匹配图2资产中心) - - 物料区样式按Stitch设计稿重实现: - - dock_2风格:垂直56x56方块,渐变背景,极简阴影 - - dock_1风格:点击打开规格款选择面板,2x2网格布局 - - 右侧面板简化:合并为2个卡片(资产选择+保存),删除多余提示文字 - - 类型检查通过,测试通过 -- 2026-04-10 已确认: - - `type-check(类型检查)` 通过 - - 辅助装配相关单元测试通过 - - 测试环境已完成部署 - - 真实页面已完成最小联调:登录后进入 `辅助装配(assisted assembly)`,勾选资产族后左侧物料区可正常出现 `加入工作台` +- `[TODO]` `users(用户管理)`:继续收口离职交接后端逻辑与联调 + - 负责人:`Codex` diff --git a/furniture-3d-system/.gitignore b/furniture-3d-system/.gitignore index 8870a99e..612eeb24 100644 --- a/furniture-3d-system/.gitignore +++ b/furniture-3d-system/.gitignore @@ -58,4 +58,6 @@ nul dev-server.log trace-deprecation-build.log typecheck-latest.log +.local-test-*.log +tsconfig.typecheck.generated.json local-packages/npm-cache/cache/_logs/ diff --git a/furniture-3d-system/README.md b/furniture-3d-system/README.md index d2a41067..7f4aef79 100644 --- a/furniture-3d-system/README.md +++ b/furniture-3d-system/README.md @@ -1,10 +1,11 @@ # 家具开品平台 -> 最后更新:2026-04-07 -> 当前默认主线:`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库` +> 最后更新:2026-04-15 +> 当前默认主线:`资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库` 这是当前正在持续开发的主项目。 旧 `CAD -> 装配编辑 -> 装配步骤 -> 3D 回放` 链继续保留,但它属于兼容链,不再代表当前业务主线。 +包装结果当前挂在草稿组、`PreSku` 和轻仓库承接口里,不作为独立顶层页面阶段。 ## 开始前先读 @@ -16,8 +17,8 @@ ## 当前项目目标 - 用资产族和规格款作为开品基础数据 -- 按系列规则生成自动组合候选 -- 让用户在草稿组里确认组合、预览、包装承接 +- 在辅助装配工作台完成组合、吸附、3D 预览和保存前校验 +- 让用户在草稿组里确认组合、预览,并承接包装相关信息 - 再进入正式入库和轻仓库 - 保留旧 CAD 链服务历史数据、兼容导入、装配编辑和回放 @@ -50,7 +51,7 @@ npm test ### 新平台主链 - `/asset-center` -- `/auto-combinations` +- `/assisted-assembly` - `/draft-groups` - `/intake` - `/warehouse` @@ -74,11 +75,11 @@ npm test - `docs/项目总览.md` - `docs/README.md` -- `docs/agent-memory/README.md` - `docs/部署指南.md` - `docs/运维指南.md` ## 说明 - 一次性阶段报告、测试报告和旧设计稿已清理,不再占用项目入口。 -- 前端交接单以工作区根目录 `docs/前端UI改动清单.md` 为准,不看仓库内旧副本。 +- 项目内不再维护前端交接单、会话交接或 `agent-memory` 这类台账入口。 +- 历史文档里如果还出现 `auto-combinations`、`自动组合`、`自动拼装`,统一按旧称呼理解,不再代表当前主线入口。 diff --git a/furniture-3d-system/docs/README.md b/furniture-3d-system/docs/README.md index d861cbb0..43243904 100644 --- a/furniture-3d-system/docs/README.md +++ b/furniture-3d-system/docs/README.md @@ -1,7 +1,7 @@ # 家具开品平台文档导航 -> 最后更新:2026-04-07 -> 这份导航只突出当前有效入口,历史阶段文档不再和现行入口混排。 +> 最后更新:2026-04-15 +> 当前主线入口:`/asset-center -> /assisted-assembly -> /draft-groups -> /intake -> /warehouse` ## 当前必读入口 @@ -16,15 +16,12 @@ ### 项目现状 - [项目总览](./项目总览.md) -- [长期记忆导航](./agent-memory/README.md) -- [长期决策日志](./agent-memory/decision-log.md) -- [长期待办与阻塞](./agent-memory/open-issues.md) ### 业务与架构 - [认证系统设计](./架构设计/auth-system-design.md) - [权限系统设计](./架构设计/permission-design-v2.md) -- [新平台一期实施计划](./架构设计/新平台一期-资产中心与自动拼装实施计划-20260331.md) +- [新平台一期实施计划(历史文件名保留“自动拼装”字样)](./架构设计/新平台一期-资产中心与自动拼装实施计划-20260331.md) ### 开发与运维 @@ -53,4 +50,9 @@ 2. [团队协作模式](./workflow/团队协作模式.md) 3. [模块边界说明](./workflow/模块边界说明.md) 4. [主线同步操作手册](./workflow/主线同步操作手册.md) -5. [agent-memory/README](./agent-memory/README.md) + +## 当前说明 + +- 部分历史文档或文件名仍保留“自动拼装 / 自动组合 / auto-combinations”等旧称呼;当前一律按“辅助装配(assisted-assembly)”理解。 +- 这份导航只保留有效入口;具体任务边界以当前任务单、交接摘要、已确认协作信息和实际代码现状为准。 +- `main` 继续由 `WuYongJie` 负责收口。 diff --git a/furniture-3d-system/docs/workflow/主线同步操作手册.md b/furniture-3d-system/docs/workflow/主线同步操作手册.md index 0ff6fbd5..1ce12d50 100644 --- a/furniture-3d-system/docs/workflow/主线同步操作手册.md +++ b/furniture-3d-system/docs/workflow/主线同步操作手册.md @@ -13,13 +13,13 @@ 当前推荐分工: - `WuYongJie` 负责主线 `main` -- `xuxiake` 负责固定包装分支 `codex/xuxiake-packaging` +- 其他协作者在自己的长期共享分支或临时任务分支开发 分支命名固定规则: - 新分支统一用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>` - `main` 只由 `WuYongJie` 收口,其他协作者只在自己的分支里同步和开发 -- `codex/xuxiake-packaging` 是固定长期包装分支 +- 长期共享分支和临时任务分支都要把当前工作域写进分支名 - 不再使用 `codex/xuxiake-dev` 这种看不出职责的泛分支名 --- @@ -57,28 +57,28 @@ git status --short --- -如果你现在在长期共享分支 `codex/xuxiake-packaging`,就把下面这 5 行命令原样交给 Codex 执行: +如果你现在在长期共享分支,就把下面这 5 行命令原样交给 Codex 执行: ```bash git checkout main git pull origin main -git checkout codex/xuxiake-packaging +git checkout <你的长期共享分支> git merge main -git push origin codex/xuxiake-packaging +git push origin <你的长期共享分支> ``` 这组命令的白话意思: 1. 先把本地 `main` 拉到最新 -2. 切回固定包装分支 -3. 把最新主线合进包装分支 -4. 再把包装分支推上去 +2. 切回你的长期共享分支 +3. 把最新主线合进这个共享分支 +4. 再把共享分支推上去 --- ## 4. 如果你临时开了个人任务分支 -只有在你临时从 `codex/xuxiake-packaging` 再开出个人任务分支时,才用下面这套: +只有在你从长期共享分支再开出个人任务分支时,才用下面这套: ```bash git checkout main @@ -95,7 +95,7 @@ git push --force-with-lease origin <你的分支名> 怎么选最简单: -- 长期包装分支 `codex/xuxiake-packaging` 固定用 `merge` +- 长期共享分支固定用 `merge` - 只有你一个人在用的短期任务分支,才用 `rebase` - 如果这个分支会被别人继续接手或反复同步,就按长期共享分支处理,不能为了图直线历史去改写公共提交 @@ -174,8 +174,12 @@ git push --force-with-lease origin <你的分支名> 如果你懒得记,只记这一段: ```text -包装线默认只用 codex/xuxiake-packaging。 -每天开工前,先把 main 拉最新,再把 main merge 进 codex/xuxiake-packaging。 +每天开工前,先把 main 拉最新,再把 main merge 进你的长期共享分支。 只有你临时开的个人任务分支才用 rebase。 如果冲突了,不要硬处理,先让 Codex 只处理冲突文件。 ``` + +补充说明: + +- 历史文档里如果还出现固定包装分支名,那是旧协作阶段留下的例子,不再代表当前唯一允许的长期分支。 +- 当前哪条分支算“长期共享分支”,以最新交接、当前任务边界和实际协作方式为准。 diff --git a/furniture-3d-system/docs/workflow/团队协作模式.md b/furniture-3d-system/docs/workflow/团队协作模式.md index 7509194d..843508b5 100644 --- a/furniture-3d-system/docs/workflow/团队协作模式.md +++ b/furniture-3d-system/docs/workflow/团队协作模式.md @@ -17,70 +17,44 @@ 这个仓库现在默认按下面这条主链协作: -`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 成品展示 -> 正式入库 -> 轻仓库` +`资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库` -旧的 `CAD -> 装配步骤` 链继续保留,但它是**历史兼容链**,不是新开品主链。 +- 包装相关能力当前挂在 `DraftGroup / PreSku / Warehouse` 承接口里,不作为独立顶层主线入口。 +- 旧的 `CAD -> 装配步骤` 链继续保留,但它是**历史兼容链**,不是新开品主链。 +- 历史文档里如果还出现 `auto-combinations`、`自动组合`、`自动拼装`,统一按旧称呼理解,不再代表当前页面入口。 --- -## 2. 当前推荐分工 +## 2. 当前协作口径 -### 平台主线负责人 +### 先固定两件事 -负责新平台一期主链: +- `main` 继续只由 `WuYongJie` 直接 push 和收口。 +- 这份文档现在只固定 Git 协作方式、主线收口规则和共享接缝处理原则,不再把当前模块职责长期写死在这里。 -- 资产中心 -- 自动组合 -- 草稿组 -- 正式入库 -- 新平台公共导航与页面骨架 - -默认涉及目录: +### 当前主线入口 - `src/app/asset-center` -- `src/app/auto-combinations` +- `src/app/assisted-assembly` - `src/app/draft-groups` - `src/app/intake` +- `src/app/warehouse` - `src/app/api/asset-center` -- `src/app/api/auto-combinations` +- `src/app/api/assisted-assembly` - `src/app/api/draft-groups` - `src/app/api/intake` +- `src/app/api/pre-skus` +- `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/app/warehouse` -- `src/app/api/warehouse` -- `src/app/api/pre-skus/[id]/packaging` -- `src/app/api/products/[id]/packaging` -- `src/server/services/warehouse.service.ts` -- `src/server/repositories/warehouse.repository.ts` -- `src/server/services/packaging.service.ts` -- `src/server/repositories/packaging.repository.ts` -- `docs/开发指南/包装方案增强` - -默认边界: - -- 可以增强包装对象、包装结果、包装展示。 -- 可以增强轻仓库里的包装成品、库存展示、库位展示和仓库工作区交互。 -- 不直接改自动组合规则。 -- 不直接改正式入库规则。 -- 不把包装流程做成独立顶层系统入口。 -- 不把轻仓库重新拉回成独立主数据源。 -- 草稿组详情页当前出现的包装区,只是主线最小联通版占位入口,不是最终包装工作区。 - -需要先和主线负责人对齐后再改的共享接缝: +### 哪些地方仍然算共享接缝 - `src/server/services/draft-group.service.ts` - `src/server/repositories/draft-group.repository.ts` @@ -89,37 +63,6 @@ - `prisma/schema.prisma` - `src/app/draft-groups/[id]` -### 兼容链负责人 - -负责旧产品详情、旧装配编辑器、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. 分支规则 @@ -141,8 +84,7 @@ - 负责人必须进分支名,不能只用 `feature/...`、`packaging/...`、`dev` 这类泛名字 - `WuYongJie` 主线相关分支统一放 `codex/wuyongjie-mainline...` -- `xuxiake` 包装/仓库长期分支固定为 `codex/xuxiake-packaging` -- `xuxiake` 的包装/仓库临时任务分支统一放 `codex/xuxiake-packaging-<任务简称>` +- 长期共享分支和临时任务分支都要把当前工作域写进分支名,不再把人长期绑定在单一旧分支名上 - 其他负责人也按同一模式命名,不再额外发明新前缀 - 历史分支名不强制重命名,但从现在开始新分支全部按这套规则建 @@ -151,7 +93,7 @@ - `codex/wuyongjie-mainline` - `codex/wuyongjie-mainline-asset-center` - `codex/xuxiake-packaging` -- `codex/xuxiake-packaging-costing` +- `codex/xuxiake-intake-warehouse-clean-20260415` - `codex/zhangsan-cad-compat` ### 合并方式 @@ -193,7 +135,7 @@ - 优先 `cherry-pick` - 适用:同一个分支里既有可收的目标提交,也混入了暂时不该进主线的提交 - - 常见场景:包装分支里夹带了共享接缝改动、调试提交、试错提交 + - 常见场景:同一个协作分支里夹带了共享接缝改动、调试提交、试错提交 - 手工整合 - 适用:目标功能要收,但提交历史太脏、冲突太多,或者改动虽然合理但不能整段直接拿 @@ -208,7 +150,7 @@ 当前项目默认采用这个口径: - `WuYongJie` 负责持续更新和收口 `main` -- `xuxiake` 长期在 `codex/xuxiake-packaging` 开发包装与仓库线 +- 协作者长期在自己的长期共享分支或当前任务分支开发 - 同事不能等做完了再一次性同步,而是要高频同步 `main` 默认同步时机: @@ -219,7 +161,7 @@ 默认推荐方式: -- 长期协作分支 `codex/xuxiake-packaging` 固定优先使用 `merge main` +- 长期协作分支固定优先使用 `merge main` - 只有临时个人任务分支才优先使用 `rebase main` - 但不要一边长期不同步,一边继续堆改动 @@ -229,29 +171,29 @@ - 临时个人任务分支只服务当前任务,为了保持提交线清楚,才用 `rebase main` - 如果你拿不准自己现在是不是长期共享分支,就先按长期共享分支处理,不要擅自改写共享历史 -`xuxiake` 日常同步固定步骤: +长期共享分支日常同步固定步骤: ```bash git checkout main git pull origin main -git checkout codex/xuxiake-packaging +git checkout <你的长期共享分支> git merge main -git push origin codex/xuxiake-packaging +git push origin <你的长期共享分支> ``` -如果 `xuxiake` 临时开了个人任务分支,例如 `codex/xuxiake-packaging-costing`,再用: +如果你临时开了个人任务分支,再用: ```bash git checkout main git pull origin main -git checkout codex/xuxiake-packaging-costing +git checkout <你的临时任务分支> git rebase main -git push --force-with-lease origin codex/xuxiake-packaging-costing +git push --force-with-lease origin <你的临时任务分支> ``` 怎么选: -- 长期包装/仓库分支,用 `merge` +- 长期共享分支,用 `merge` - 临时个人任务分支,用 `rebase` 详细操作说明见:`docs/workflow/主线同步操作手册.md` @@ -271,11 +213,11 @@ git push --force-with-lease origin codex/xuxiake-packaging-costing 额外固定动作: -- 包装线涉及共享接缝时,先在任务单里写清“为什么必须碰共享层”。 -- `xuxiake` 推包装/仓库分支后,要附一段交接说明,明确本次只改了什么。 -- `WuYongJie` 收口包装/仓库改动时,优先按小任务合并,不一次性吞并长期积压改动。 -- 如果任务只是要把包装或轻仓库工作区做完整,默认不要直接在 `src/app/draft-groups/[id]` 或 `src/app/intake` 里堆实现;先确认是否该独立在包装/仓库域内收口。 -- 如果这次改动会影响 `intake / warehouse / Product / SKU` 承接链,收口时额外确认:有没有把包装或仓库逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。 +- 任务涉及共享接缝时,先在任务单里写清“为什么必须碰共享层”。 +- 协作者推分支后,要附一段交接说明,明确本次只改了什么、没改什么、风险是什么。 +- `WuYongJie` 收口同事改动时,优先按小任务合并,不一次性吞并长期积压改动。 +- 如果任务只是要把局部工作区做完整,默认不要直接在 `src/app/draft-groups/[id]`、`src/app/intake` 这类共享接缝里堆实现;先确认是否该在当前工作域内独立收口。 +- 如果这次改动会影响 `intake / warehouse / Product / SKU` 承接链,收口时额外确认:有没有把局部协作逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。 --- diff --git a/furniture-3d-system/docs/workflow/模块边界说明.md b/furniture-3d-system/docs/workflow/模块边界说明.md index c3cda2dd..20f14a47 100644 --- a/furniture-3d-system/docs/workflow/模块边界说明.md +++ b/furniture-3d-system/docs/workflow/模块边界说明.md @@ -7,6 +7,7 @@ - 先读 `docs/workflow/团队协作模式.md`,确认你属于哪个工作域、该用什么分支名 - 再读 `docs/workflow/主线同步操作手册.md`,确认你当前分支该用 `merge main` 还是 `rebase main` - 这份文档只负责讲边界,不额外创造新的角色体系或新的协作入口 +- 这份文档只固定系统边界,不固定当前负责人;具体任务归属以当前任务单、交接摘要和已确认协作信息为准 --- @@ -53,6 +54,7 @@ ### API 目录 - `src/app/api/asset-center` +- `src/app/api/assisted-assembly` - `src/app/api/draft-groups` - `src/app/api/intake` - `src/app/api/planning` @@ -98,7 +100,7 @@ ## 3. 包装与仓库协作边界 -### 当前同事协作入口分四条 +### 当前包装与仓库主要入口分四条 - 轻仓库页面:`src/app/warehouse` - 轻仓库接口:`src/app/api/warehouse` @@ -116,7 +118,7 @@ - 包装结果最终按单个 `PreSku` 保存。 - 正式产品包装链保留,作为兼容与发布后使用。 -- 轻仓库工作区由同事负责,但轻仓库承接的正式 `Product / SKU` 仍然属于主线真相,不在仓库侧重复造数。 +- 轻仓库工作区可以独立协作推进,但轻仓库承接的正式 `Product / SKU` 仍然属于主线真相,不在仓库侧重复造数。 - 工艺图组合本期只预留接口,不在主线里单独展开。 - 草稿组详情页里的包装区只负责主线最小联通版占位,不是最终包装工作区。 - 轻仓库可以增强包装成品、库存、库位和展示交互,但不能绕过正式入库单独制造主链结果。 diff --git a/furniture-3d-system/docs/项目总览.md b/furniture-3d-system/docs/项目总览.md index d51602c5..75d93ea2 100644 --- a/furniture-3d-system/docs/项目总览.md +++ b/furniture-3d-system/docs/项目总览.md @@ -1,15 +1,16 @@ # 家具开品平台项目总览 -> 最后更新:2026-04-07 +> 最后更新:2026-04-15 > 当前状态:新平台主线已进入真实业务测试与收口阶段 ## 一句话说明 这个项目现在的真主线不是旧 CAD 装配编辑器,而是: -`资产中心 -> 自动组合 -> 草稿组 -> 包装方案 -> 正式入库 -> 轻仓库` +`资产中心 -> 辅助装配 -> 草稿组 -> 正式入库 -> 轻仓库` 旧 `CAD -> 装配编辑 -> 装配步骤 -> 回放` 继续保留,但属于兼容链。 +包装相关能力当前挂在草稿组、`PreSku` 和轻仓库承接口里,不作为独立顶层主链页面。 ## 当前主线目标 @@ -17,29 +18,31 @@ - 管理板件、组件、五金、包装材料等资产族和规格款 - 承接 CAD ZIP 导入 -- 为后续自动组合提供稳定、干净的资产基础数据 +- 为后续辅助装配提供稳定、干净的资产基础数据 -### 2. 自动组合 +### 2. 辅助装配 -- 用户选系列、选资产后,系统按规则生成候选组合 -- 用户点某个候选后,左侧 3D 区直接预览真实组合结果 +- 用户在工作台选择和组合资产 +- 系统按 `mountFace / holeGroup / mountPattern` 承接准确吸附与 3D 预览 +- 通过保存前校验后,再进入草稿组 ### 3. 草稿组 -- 承接候选组合 -- 汇总 BOM、预览、包装占位、后续展示承接 +- 承接辅助装配结果 +- 汇总 BOM、预览、包装占位和后续承接口 ### 4. 正式入库与轻仓库 - 从草稿组转正式产品和 SKU - 承接库存、库位、包装成品等业务结果 +- 包装结果当前通过 `PreSku` 与仓库承接口进入后续链路,而不是独立顶层主线阶段 ## 当前模块边界 ### 新平台主链 - `/asset-center` -- `/auto-combinations` +- `/assisted-assembly` - `/draft-groups` - `/intake` - `/warehouse` @@ -52,18 +55,19 @@ - `/products/[id]/skus/[skuId]` - `/products/[id]/skus/[skuId]/assembly` -### 包装协作 +### 当前协作口径 -- 当前包装与轻仓库由同事负责 -- 主线只保证包装在草稿组和 `PreSku` 上有稳定承接口,并保证轻仓库承接正式入库结果 -- 不把包装或轻仓库重新做成独立顶层主入口 +- `main` 继续由 `WuYongJie` 收口 +- 具体模块职责不再由这份总览固定分配;以当前任务单、交接摘要、已确认协作信息和实际代码现状为准 +- 包装与轻仓库仍保留承接口,但不再作为独立顶层主链入口 +- 共享接缝改动仍需先对齐影响范围,再决定谁来收口 ## 已落地的基础能力 - 登录、权限、组织架构 - 资产族 / 规格款管理 - CAD ZIP 导入基础链路 -- 自动组合候选生成 +- 辅助装配工作台、3D 预览与草稿组承接基础链路 - 草稿组详情与预览承接 - 正式入库和轻仓库基础页 - 旧装配编辑器、装配步骤、回放兼容能力 @@ -72,9 +76,10 @@ ## 当前最需要关注的事 - 资产数据精度是否稳定 -- 连接角色、命名、系列规则是否继续产生脏数据 -- 自动组合和 3D 预览是否真实一致 -- 包装结果是否稳定挂到主线承接口 +- `mountFace / holeGroup / mountPattern` 这条准确吸附链是否稳定 +- 辅助装配和 3D 预览是否真实一致 +- 包装结果是否稳定挂到 `PreSku / Warehouse` 承接口 +- 用户系统与权限链路是否完成真人闭环验收 - 前端视觉是否统一,不再混多套旧风格 ## 协作入口 @@ -86,4 +91,5 @@ ## 其他说明 - 一次性阶段报告、完成报告和旧 UI 方案已经从活跃入口清理,不再作为默认入口。 -- 前端交接单以工作区根目录 `docs/前端UI改动清单.md` 为准。 +- 项目内不再维护前端交接单、会话交接或 `agent-memory` 这类台账文件。 +- 历史文档里如果还保留 `auto-combinations`、`自动组合`、`自动拼装` 等旧称呼,统一按历史口径理解,不再代表当前主线页面入口。 diff --git a/furniture-3d-system/next.config.js b/furniture-3d-system/next.config.js index f84e545e..45485f59 100644 --- a/furniture-3d-system/next.config.js +++ b/furniture-3d-system/next.config.js @@ -1,6 +1,75 @@ /** @type {import('next').NextConfig} */ const isProduction = process.env.NODE_ENV === 'production' +function trimToNull(value) { + const normalized = value?.trim() + return normalized ? normalized : null +} + +function buildRemotePatternFromUrl(rawUrl) { + const normalized = trimToNull(rawUrl) + if (!normalized) { + return null + } + + try { + const parsed = new URL(normalized) + const pathname = parsed.pathname.replace(/\/+$/, '') + return { + protocol: parsed.protocol.replace(':', ''), + hostname: parsed.hostname, + ...(parsed.port ? { port: parsed.port } : {}), + pathname: pathname ? `${pathname}/**` : '/**', + } + } catch { + return null + } +} + +const remotePatterns = [ + { + protocol: 'http', + hostname: 'localhost', + port: '9000', + pathname: '/**', + }, + { + protocol: 'http', + hostname: 'minio', + port: '9000', + pathname: '/**', + }, + { + protocol: 'https', + hostname: 'threejs.org', + pathname: '/**', + }, +] + +for (const candidate of [ + process.env.S3_PUBLIC_DOMAIN, + process.env.S3_PUBLIC_ENDPOINT, + process.env.S3_PUBLIC_URL, + process.env.S3_ENDPOINT, +]) { + const pattern = buildRemotePatternFromUrl(candidate) + if (!pattern) { + continue + } + + const duplicated = remotePatterns.some( + (existing) => + existing.protocol === pattern.protocol && + existing.hostname === pattern.hostname && + (existing.port || '') === (pattern.port || '') && + existing.pathname === pattern.pathname, + ) + + if (!duplicated) { + remotePatterns.push(pattern) + } +} + const nextConfig = { // 启用standalone输出,用于Docker部署 output: 'standalone', @@ -21,25 +90,7 @@ const nextConfig = { // 图像优化配置 images: { - remotePatterns: [ - { - protocol: 'http', - hostname: 'localhost', - port: '9000', - pathname: '/**', - }, - { - protocol: 'http', - hostname: 'minio', - port: '9000', - pathname: '/**', - }, - { - protocol: 'https', - hostname: 'threejs.org', - pathname: '/**', - } - ], + remotePatterns, unoptimized: true, // 在容器环境中禁用优化以提高性能 formats: ['image/webp', 'image/avif'] }, diff --git a/furniture-3d-system/src/app/admin/materials/page.tsx b/furniture-3d-system/src/app/admin/materials/page.tsx index e874a4a1..251223ba 100644 --- a/furniture-3d-system/src/app/admin/materials/page.tsx +++ b/furniture-3d-system/src/app/admin/materials/page.tsx @@ -1,42 +1,31 @@ -/** - * 材质管理后台页面 - * 管理员可以查看、创建、编辑、删除材质 - */ - -import { cookies } from 'next/headers' import { redirect } from 'next/navigation' -import { verifyToken } from '@/lib/jwt' -import MaterialManagementClient from './MaterialManagementClient' -import QueryProvider from './QueryProvider' -export const metadata = { - title: '材质管理 - 后台管理', - description: '管理预置材质和自定义材质', -} +export default async function LegacyMaterialManagementPage({ + searchParams, +}: { + searchParams?: Promise> +}) { + const resolvedSearchParams = searchParams ? await searchParams : undefined + const query = new URLSearchParams() -export default async function MaterialManagementPage() { - // 验证用户权限 - const cookieStore = await cookies() - const token = cookieStore.get('auth-token')?.value + Object.entries(resolvedSearchParams ?? {}).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => { + if (item) { + query.append(key, item) + } + }) + return + } - if (!token) { - redirect('/login?redirect=/admin/materials') - } + if (value) { + query.set(key, value) + } + }) - const payload = verifyToken(token) - if (!payload) { - redirect('/login?redirect=/admin/materials') - } - - // 检查是否有管理权限 - const allowedRoles = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] - if (!allowedRoles.includes(payload.role)) { - redirect('/?error=unauthorized') - } - - return ( - - - + redirect( + query.size > 0 + ? `/asset-center/material-library?${query.toString()}` + : '/asset-center/material-library', ) } diff --git a/furniture-3d-system/src/app/admin/page.tsx b/furniture-3d-system/src/app/admin/page.tsx new file mode 100644 index 00000000..1ee7997b --- /dev/null +++ b/furniture-3d-system/src/app/admin/page.tsx @@ -0,0 +1,134 @@ +import Link from 'next/link' +import { cookies } from 'next/headers' +import { redirect } from 'next/navigation' +import { Users, Palette, SwatchBook, Grid, ArrowRight, ShieldCheck } from 'lucide-react' +import { cn } from '@/lib/utils' +import { verifyToken } from '@/lib/jwt' + +const adminModules = [ + { + title: '用户管理', + description: '管理系统访问权限、组织架构及账号激活。', + href: '/users', + icon: Users, + color: 'text-apple-blue', + bgColor: 'bg-apple-blue/5', + }, + { + title: '主题管理', + description: '配置系统全局视觉变量、品牌色及 UI 调性。', + href: '/admin/themes', + icon: Palette, + color: 'text-indigo-500', + bgColor: 'bg-indigo-50', + }, + { + title: '材质管理', + description: '维护 3D 渲染核心材质球及物理参数库。', + href: '/admin/materials', + icon: SwatchBook, + color: 'text-emerald-500', + bgColor: 'bg-emerald-50', + }, + { + title: '分类管理', + description: '定义产品资产的标准化层级与归类规则。', + href: '/admin/categories', + icon: Grid, + color: 'text-amber-500', + bgColor: 'bg-amber-50', + }, +] + +export const metadata = { + title: '管理总控制台', + description: '后台管理总入口', +} + +export default async function AdminPortalPage() { + const cookieStore = await cookies() + const token = cookieStore.get('auth-token')?.value + + if (!token) { + redirect('/login?redirect=/admin') + } + + const payload = verifyToken(token) + if (!payload) { + redirect('/login?redirect=/admin') + } + + const allowedRoles = ['SUPER_ADMIN', 'ADMIN'] + if (!allowedRoles.includes(payload.role)) { + redirect('/?error=unauthorized') + } + + return ( +
+
+ +
+
+
+
+ +
+ + 管理终端 + +
+

+ 系统总控制台 +

+

+ 欢迎进入后台管理中心。在这里可以快速进入权限、主题、材质和分类等核心管理模块。 +

+
+ +
+ {adminModules.map((module) => ( + +
+
+ +
+ +
+

+ {module.title} +

+

+ {module.description} +

+ + {module.href.split('/').pop()} + + + ))} +
+ +
+

+ Precision Atelier · 管理基础设施 +

+
+
+
+ ) +} diff --git a/furniture-3d-system/src/app/api/admin/materials/[id]/route.test.ts b/furniture-3d-system/src/app/api/admin/materials/[id]/route.test.ts new file mode 100644 index 00000000..216f6c4b --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/materials/[id]/route.test.ts @@ -0,0 +1,116 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonSuccess = jest.fn() +const mockMaterialFindByIdWithUsage = jest.fn() +const mockMaterialUpdate = jest.fn() +const mockMaterialDelete = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/services', () => ({ + materialService: { + findByIdWithUsage: (...args: unknown[]) => mockMaterialFindByIdWithUsage(...args), + update: (...args: unknown[]) => mockMaterialUpdate(...args), + delete: (...args: unknown[]) => mockMaterialDelete(...args), + }, +})) + +const { GET, PUT, DELETE } = require('./route') + +function createMockRequest(url: string, body?: unknown) { + return { + url, + method: body === undefined ? 'GET' : 'PUT', + headers: { + get: () => null, + }, + json: async () => body, + } as any +} + +describe('GET/PUT/DELETE /api/admin/materials/[id]', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-material-id', + user: { + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + }) + + it('GET 应返回材质详情', async () => { + mockMaterialFindByIdWithUsage.mockResolvedValue({ id: 'material-1', name: '浅木纹' }) + + const request = createMockRequest('http://localhost/api/admin/materials/material-1') + const response = await GET(request as any, { params: Promise.resolve({ id: 'material-1' }) }) + + expect(mockMaterialFindByIdWithUsage).toHaveBeenCalledWith('material-1') + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { id: 'material-1', name: '浅木纹' }, + }), + ) + }) + + it('PUT 应更新材质', async () => { + mockMaterialUpdate.mockResolvedValue({ id: 'material-1', name: '更新后材质' }) + + const requestBody = { + name: '更新后材质', + color: '#123456', + } + const request = createMockRequest('http://localhost/api/admin/materials/material-1', requestBody) + const response = await PUT(request as any, { params: Promise.resolve({ id: 'material-1' }) }) + + expect(mockMaterialUpdate).toHaveBeenCalledWith('material-1', requestBody) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { + material: { id: 'material-1', name: '更新后材质' }, + message: '材质更新成功', + }, + }), + ) + }) + + it('DELETE 应执行软删除', async () => { + mockMaterialDelete.mockResolvedValue(undefined) + + const request = createMockRequest('http://localhost/api/admin/materials/material-1') + const response = await DELETE(request as any, { params: Promise.resolve({ id: 'material-1' }) }) + + expect(mockMaterialDelete).toHaveBeenCalledWith('material-1', false) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { + message: '材质已禁用', + }, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/admin/materials/route.test.ts b/furniture-3d-system/src/app/api/admin/materials/route.test.ts new file mode 100644 index 00000000..839a83b7 --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/materials/route.test.ts @@ -0,0 +1,110 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonSuccess = jest.fn() +const mockMaterialFindAll = jest.fn() +const mockMaterialCreate = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/services', () => ({ + materialService: { + findAll: (...args: unknown[]) => mockMaterialFindAll(...args), + create: (...args: unknown[]) => mockMaterialCreate(...args), + }, +})) + +const { GET, POST } = require('./route') + +function createMockRequest(url: string, body?: unknown) { + return { + url, + method: body === undefined ? 'GET' : 'POST', + headers: { + get: () => null, + }, + json: async () => body, + } as any +} + +describe('GET/POST /api/admin/materials', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-material', + user: { + id: 'admin-1', + email: 'admin@example.com', + name: '管理员', + role: 'ADMIN', + status: 'ACTIVE', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + }) + + it('GET 应按查询参数获取材质列表', async () => { + mockMaterialFindAll.mockResolvedValue([ + { id: 'material-1', name: '浅木纹' }, + ]) + + const request = createMockRequest('http://localhost/api/admin/materials?categoryId=cat-1&includeInactive=true') + const response = await GET(request as any) + + expect(mockMaterialFindAll).toHaveBeenCalledWith({ + categoryId: 'cat-1', + includeInactive: true, + }) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: [{ id: 'material-1', name: '浅木纹' }], + }), + ) + }) + + it('POST 应创建材质', async () => { + mockMaterialCreate.mockResolvedValue({ id: 'material-2', name: '胡桃木' }) + + const requestBody = { + name: '胡桃木', + categoryId: 'cat-1', + color: '#AA7744', + metalness: 0.1, + roughness: 0.8, + opacity: 1, + textureRepeat: 1, + textureRotation: 0, + textureSizeMm: 600, + isPreset: false, + isActive: true, + } + const request = createMockRequest('http://localhost/api/admin/materials', requestBody) + const response = await POST(request as any) + + expect(mockMaterialCreate).toHaveBeenCalledWith(requestBody) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 201, + data: { + material: { id: 'material-2', name: '胡桃木' }, + message: '材质创建成功', + }, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.test.ts b/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.test.ts new file mode 100644 index 00000000..287c8894 --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/materials/upload-texture/route.test.ts @@ -0,0 +1,114 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest: any = jest.fn() +const mockJsonSuccess: any = jest.fn() +const mockJsonError: any = jest.fn() +const mockWriteFile: any = jest.fn() +const mockMkdir: any = jest.fn() +const mockExistsSync: any = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), +})) + +jest.mock('fs/promises', () => ({ + writeFile: (...args: unknown[]) => mockWriteFile(...args), + mkdir: (...args: unknown[]) => mockMkdir(...args), +})) + +jest.mock('fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), +})) + +const { POST } = require('./route') + +describe('POST /api/admin/materials/upload-texture', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-test', + user: { + id: 'admin-1', + role: 'ADMIN', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + success: false, + code, + message, + status, + requestId, + })) + + mockExistsSync.mockReturnValue(true) + }) + + it('会保存上传文件并返回公共地址', async () => { + const file = { + name: 'oak-texture.png', + type: 'image/png', + size: 3, + arrayBuffer: jest.fn(async () => new Uint8Array([1, 2, 3]).buffer), + } + + const response = await POST( + { + formData: jest.fn(async () => { + const values = new Map([ + ['file', file], + ['type', 'texture'], + ['category', 'wood'], + ]) + return { + get: (key: string) => values.get(key) ?? null, + } + }), + } as any, + ) + + expect(mockWriteFile).toHaveBeenCalled() + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 201, + data: expect.objectContaining({ + url: expect.stringContaining('/materials/textures/wood/'), + type: 'texture', + size: 3, + }), + }), + ) + }) + + it('缺少文件时会返回验证错误', async () => { + const response = await POST( + { + formData: jest.fn(async () => new FormData()), + } as any, + ) + + expect(mockWriteFile).not.toHaveBeenCalled() + expect(response).toEqual( + expect.objectContaining({ + success: false, + message: '请选择要上传的文件', + status: 400, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/admin/themes/[id]/activate/route.test.ts b/furniture-3d-system/src/app/api/admin/themes/[id]/activate/route.test.ts new file mode 100644 index 00000000..717adff4 --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/themes/[id]/activate/route.test.ts @@ -0,0 +1,104 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest: any = jest.fn() +const mockJsonSuccess: any = jest.fn() +const mockActivateTheme: any = jest.fn() +const mockDeactivateTheme: any = jest.fn() +const mockSetUserId: any = jest.fn() +const mockComplete: any = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/services/theme.service', () => ({ + themeService: { + activateTheme: (...args: unknown[]) => mockActivateTheme(...args), + deactivateTheme: (...args: unknown[]) => mockDeactivateTheme(...args), + }, +})) + +jest.mock('@/server/middleware/request-logger', () => ({ + createRequestContext: jest.fn(() => ({ + setUserId: (...args: unknown[]) => mockSetUserId(...args), + complete: (...args: unknown[]) => mockComplete(...args), + })), +})) + +const { PUT, DELETE } = require('./route') + +describe('PUT/DELETE /api/admin/themes/[id]/activate', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-test', + user: { + id: 'super-admin-1', + role: 'SUPER_ADMIN', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + }) + + it('PUT 会激活主题', async () => { + mockActivateTheme.mockResolvedValue({ id: 'theme-1', isActive: true }) + + const response = await PUT({} as any, { + params: Promise.resolve({ id: 'theme-1' }), + }) + + expect(mockActivateTheme).toHaveBeenCalledWith( + 'theme-1', + expect.objectContaining({ + user: expect.objectContaining({ id: 'super-admin-1' }), + requestId: 'req-test', + }), + ) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: expect.objectContaining({ + message: '主题已激活', + }), + }), + ) + }) + + it('DELETE 会停用主题', async () => { + mockDeactivateTheme.mockResolvedValue({ id: 'theme-1', isActive: false }) + + const response = await DELETE({} as any, { + params: Promise.resolve({ id: 'theme-1' }), + }) + + expect(mockDeactivateTheme).toHaveBeenCalledWith( + 'theme-1', + expect.objectContaining({ + user: expect.objectContaining({ id: 'super-admin-1' }), + requestId: 'req-test', + }), + ) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: expect.objectContaining({ + message: '主题已停用', + }), + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/admin/themes/[id]/route.test.ts b/furniture-3d-system/src/app/api/admin/themes/[id]/route.test.ts new file mode 100644 index 00000000..d29fb6ed --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/themes/[id]/route.test.ts @@ -0,0 +1,153 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonSuccess = jest.fn() +const mockCreateRequestContext = jest.fn() +const mockGetThemeById = jest.fn() +const mockUpdateTheme = jest.fn() +const mockDeleteTheme = jest.fn() +const mockSetUserId = jest.fn() +const mockComplete = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/middleware/request-logger', () => ({ + createRequestContext: (...args: unknown[]) => mockCreateRequestContext(...args), +})) + +jest.mock('@/server/services/theme.service', () => ({ + themeService: { + getThemeById: (...args: unknown[]) => mockGetThemeById(...args), + updateTheme: (...args: unknown[]) => mockUpdateTheme(...args), + deleteTheme: (...args: unknown[]) => mockDeleteTheme(...args), + }, +})) + +const { GET, PUT, DELETE } = require('./route') + +function createMockRequest(url: string, body?: unknown) { + return { + url, + method: body === undefined ? 'GET' : 'PUT', + headers: { + get: () => null, + }, + json: async () => body, + } as any +} + +describe('GET/PUT/DELETE /api/admin/themes/[id]', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockCreateRequestContext.mockReturnValue({ + setUserId: mockSetUserId, + complete: mockComplete, + }) + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-theme-id', + user: { + id: 'admin-1', + email: 'admin@example.com', + name: '超级管理员', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + }) + + it('GET 应返回主题详情', async () => { + mockGetThemeById.mockResolvedValue({ id: 'theme-1', name: '默认主题' }) + + const request = createMockRequest('http://localhost/api/admin/themes/theme-1') + const response = await GET(request as any, { params: Promise.resolve({ id: 'theme-1' }) }) + + expect(mockCreateRequestContext).toHaveBeenCalledWith(request) + expect(mockGetThemeById).toHaveBeenCalledWith( + 'theme-1', + { + user: expect.objectContaining({ id: 'admin-1', role: 'SUPER_ADMIN' }), + requestId: 'req-theme-id', + }, + ) + expect(mockSetUserId).toHaveBeenCalledWith('admin-1') + expect(mockComplete).toHaveBeenCalledWith(200) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { id: 'theme-1', name: '默认主题' }, + }), + ) + }) + + it('PUT 应更新主题', async () => { + mockUpdateTheme.mockResolvedValue({ id: 'theme-1', name: '更新后主题' }) + + const requestBody = { name: '更新后主题' } + const request = createMockRequest('http://localhost/api/admin/themes/theme-1', requestBody) + const response = await PUT(request as any, { params: Promise.resolve({ id: 'theme-1' }) }) + + expect(mockCreateRequestContext).toHaveBeenCalledWith(request) + expect(mockUpdateTheme).toHaveBeenCalledWith( + 'theme-1', + requestBody, + { + user: expect.objectContaining({ id: 'admin-1', role: 'SUPER_ADMIN' }), + requestId: 'req-theme-id', + }, + ) + expect(mockSetUserId).toHaveBeenCalledWith('admin-1') + expect(mockComplete).toHaveBeenCalledWith(200) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { + data: { id: 'theme-1', name: '更新后主题' }, + message: '主题更新成功', + }, + }), + ) + }) + + it('DELETE 应删除主题', async () => { + mockDeleteTheme.mockResolvedValue(undefined) + + const request = createMockRequest('http://localhost/api/admin/themes/theme-1') + const response = await DELETE(request as any, { params: Promise.resolve({ id: 'theme-1' }) }) + + expect(mockCreateRequestContext).toHaveBeenCalledWith(request) + expect(mockDeleteTheme).toHaveBeenCalledWith( + 'theme-1', + { + user: expect.objectContaining({ id: 'admin-1', role: 'SUPER_ADMIN' }), + requestId: 'req-theme-id', + }, + ) + expect(mockSetUserId).toHaveBeenCalledWith('admin-1') + expect(mockComplete).toHaveBeenCalledWith(200) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { + message: '主题删除成功', + }, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/admin/themes/route.test.ts b/furniture-3d-system/src/app/api/admin/themes/route.test.ts new file mode 100644 index 00000000..d2ef04f3 --- /dev/null +++ b/furniture-3d-system/src/app/api/admin/themes/route.test.ts @@ -0,0 +1,130 @@ +export {} + +const mockHandleRequest = jest.fn() +const mockJsonSuccess = jest.fn() +const mockCreateRequestContext = jest.fn() +const mockGetAllThemes = jest.fn() +const mockCreateTheme = jest.fn() +const mockSetUserId = jest.fn() +const mockComplete = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/middleware/request-logger', () => ({ + createRequestContext: (...args: unknown[]) => mockCreateRequestContext(...args), +})) + +jest.mock('@/server/services/theme.service', () => ({ + themeService: { + getAllThemes: (...args: unknown[]) => mockGetAllThemes(...args), + createTheme: (...args: unknown[]) => mockCreateTheme(...args), + }, +})) + +const { GET, POST } = require('./route') + +function createMockRequest(url: string, body?: unknown) { + return { + url, + method: body === undefined ? 'GET' : 'POST', + headers: { + get: () => null, + }, + json: async () => body, + } as any +} + +describe('GET/POST /api/admin/themes', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockCreateRequestContext.mockReturnValue({ + setUserId: mockSetUserId, + complete: mockComplete, + }) + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-theme', + user: { + id: 'admin-1', + email: 'admin@example.com', + name: '超级管理员', + role: 'SUPER_ADMIN', + status: 'ACTIVE', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + }) + + it('GET 应返回主题列表并写入请求日志', async () => { + mockGetAllThemes.mockResolvedValue([ + { id: 'theme-1', name: '默认主题' }, + ]) + + const request = createMockRequest('http://localhost/api/admin/themes') + const response = await GET(request as any) + + expect(mockCreateRequestContext).toHaveBeenCalledWith(request) + expect(mockGetAllThemes).toHaveBeenCalledWith( + { + user: expect.objectContaining({ id: 'admin-1', role: 'SUPER_ADMIN' }), + requestId: 'req-theme', + }, + ) + expect(mockSetUserId).toHaveBeenCalledWith('admin-1') + expect(mockComplete).toHaveBeenCalledWith(200) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: [{ id: 'theme-1', name: '默认主题' }], + }), + ) + }) + + it('POST 应创建主题并写入请求日志', async () => { + mockCreateTheme.mockResolvedValue({ + id: 'theme-2', + name: '新主题', + }) + + const requestBody = { + name: '新主题', + isDefault: true, + } + const request = createMockRequest('http://localhost/api/admin/themes', requestBody) + const response = await POST(request as any) + + expect(mockCreateRequestContext).toHaveBeenCalledWith(request) + expect(mockCreateTheme).toHaveBeenCalledWith( + requestBody, + { + user: expect.objectContaining({ id: 'admin-1', role: 'SUPER_ADMIN' }), + requestId: 'req-theme', + }, + ) + expect(mockSetUserId).toHaveBeenCalledWith('admin-1') + expect(mockComplete).toHaveBeenCalledWith(201) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 201, + data: { + data: { id: 'theme-2', name: '新主题' }, + message: '主题创建成功', + }, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts new file mode 100644 index 00000000..ef5e34d5 --- /dev/null +++ b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.test.ts @@ -0,0 +1,93 @@ +export {} + +const mockResolveRedirectPath = jest.fn() +const mockAuthenticate = jest.fn() +const mockCookieOptions = { httpOnly: true, path: '/' } +const mockRedirect = jest.fn() + +jest.mock('@/server/auth/auth-cookie', () => ({ + getAuthTokenCookieOptions: () => mockCookieOptions, +})) + +jest.mock('next/server', () => ({ + NextResponse: { + redirect: (...args: unknown[]) => mockRedirect(...args), + }, +})) + +jest.mock('@/server/services/dingtalk-auth.service', () => ({ + resolveDingtalkCallbackOrigin: jest.fn(() => 'https://cp.jiansun.vip'), + dingtalkAuthService: { + resolveRedirectPath: (...args: unknown[]) => mockResolveRedirectPath(...args), + authenticate: (...args: unknown[]) => mockAuthenticate(...args), + }, +})) + +const { GET } = require('./route') + +function createMockRequest(url: string) { + return { + nextUrl: new URL(url), + } as const +} + +function createRedirectResponse(target: string) { + return { + status: 307, + headers: { + get: (name: string) => (name.toLowerCase() === 'location' ? target : null), + }, + cookies: { + set: jest.fn(), + }, + } +} + +describe('GET /api/auth/dingtalk/callback', () => { + beforeEach(() => { + jest.clearAllMocks() + mockResolveRedirectPath.mockResolvedValue('/draft-groups') + mockAuthenticate.mockResolvedValue({ + token: 'token-123', + isFirstLogin: false, + }) + mockRedirect.mockImplementation((url: string | URL) => + createRedirectResponse(typeof url === 'string' ? url : url.toString()), + ) + }) + + it('应支持官方回调里的 authCode 参数', async () => { + const response = await GET( + createMockRequest('http://180.97.215.57:5246/api/auth/dingtalk/callback?authCode=auth-code-1&state=state-1'), + ) + + expect(mockResolveRedirectPath).toHaveBeenCalledWith('state-1') + expect(mockAuthenticate).toHaveBeenCalledWith('auth-code-1') + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://cp.jiansun.vip/draft-groups') + }) + + it('钉钉报错时应回跳正式域名登录页,而不是 0.0.0.0', async () => { + const response = await GET( + createMockRequest('http://0.0.0.0:3001/api/auth/dingtalk/callback?error=access_denied'), + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://cp.jiansun.vip/login?dingtalkError=%E9%92%89%E9%92%89%E6%8E%88%E6%9D%83%E5%B7%B2%E5%8F%96%E6%B6%88%E6%88%96%E5%A4%B1%E8%B4%A5', + ) + }) + + it('待激活账号应回跳登录页并提示管理员激活', async () => { + mockAuthenticate.mockRejectedValue(new Error('账号待管理员激活')) + + const response = await GET( + createMockRequest('https://cp.jiansun.vip/api/auth/dingtalk/callback?authCode=auth-code-2&state=state-2'), + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://cp.jiansun.vip/login?dingtalkError=%E8%B4%A6%E5%8F%B7%E5%BE%85%E7%AE%A1%E7%90%86%E5%91%98%E6%BF%80%E6%B4%BB', + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts index 8d88beb7..0b05caf9 100644 --- a/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts +++ b/furniture-3d-system/src/app/api/auth/dingtalk/callback/route.ts @@ -1,9 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' import { getAuthTokenCookieOptions } from '@/server/auth/auth-cookie' -import { dingtalkAuthService } from '@/server/services/dingtalk-auth.service' +import { + dingtalkAuthService, + resolveDingtalkCallbackOrigin, +} from '@/server/services/dingtalk-auth.service' function buildLoginRedirect(request: NextRequest, message?: string) { - const url = new URL('/login', request.nextUrl.origin) + const appOrigin = resolveDingtalkCallbackOrigin(request.nextUrl.origin) + const url = new URL('/login', appOrigin) if (message) { url.searchParams.set('dingtalkError', message) } @@ -11,7 +15,7 @@ function buildLoginRedirect(request: NextRequest, message?: string) { } export async function GET(request: NextRequest) { - const code = request.nextUrl.searchParams.get('code') + const code = request.nextUrl.searchParams.get('authCode') || request.nextUrl.searchParams.get('code') const state = request.nextUrl.searchParams.get('state') const error = request.nextUrl.searchParams.get('error') @@ -27,7 +31,8 @@ export async function GET(request: NextRequest) { const redirectPath = await dingtalkAuthService.resolveRedirectPath(state) const result = await dingtalkAuthService.authenticate(code) const finalPath = result.isFirstLogin ? '/first-login' : redirectPath - const response = NextResponse.redirect(new URL(finalPath, request.nextUrl.origin)) + const appOrigin = resolveDingtalkCallbackOrigin(request.nextUrl.origin) + const response = NextResponse.redirect(new URL(finalPath, appOrigin)) response.cookies.set('auth-token', result.token, getAuthTokenCookieOptions()) diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/start/route.test.ts b/furniture-3d-system/src/app/api/auth/dingtalk/start/route.test.ts new file mode 100644 index 00000000..05fbd10a --- /dev/null +++ b/furniture-3d-system/src/app/api/auth/dingtalk/start/route.test.ts @@ -0,0 +1,76 @@ +export {} + +const mockBuildAuthorizationUrl = jest.fn() +const mockRedirect = jest.fn() + +jest.mock('next/server', () => ({ + NextResponse: { + redirect: (...args: unknown[]) => mockRedirect(...args), + }, +})) + +jest.mock('@/server/services/dingtalk-auth.service', () => ({ + dingtalkAuthService: { + buildAuthorizationUrl: (...args: unknown[]) => mockBuildAuthorizationUrl(...args), + }, +})) + +const { GET } = require('./route') + +function createMockRequest(url: string) { + return { + nextUrl: new URL(url), + } as const +} + +function createRedirectResponse(target: string) { + return { + status: 307, + headers: { + get: (name: string) => (name.toLowerCase() === 'location' ? target : null), + }, + } +} + +describe('GET /api/auth/dingtalk/start', () => { + beforeEach(() => { + jest.clearAllMocks() + mockRedirect.mockImplementation((url: string | URL) => + createRedirectResponse(typeof url === 'string' ? url : url.toString()), + ) + }) + + it('应跳转到钉钉授权地址', async () => { + mockBuildAuthorizationUrl.mockReturnValue('https://login.dingtalk.com/oauth2/auth?foo=bar') + + const response = await GET( + createMockRequest('http://180.97.215.57:5246/api/auth/dingtalk/start?redirect=%2Fdraft-groups'), + ) + + expect(mockBuildAuthorizationUrl).toHaveBeenCalledWith({ + requestOrigin: 'http://180.97.215.57:5246', + redirectPath: '/draft-groups', + }) + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://login.dingtalk.com/oauth2/auth?foo=bar') + }) + + it('缺少钉钉配置时应回退到登录页并保留跳转地址', async () => { + mockBuildAuthorizationUrl.mockImplementation(() => { + throw new Error('钉钉登录未配置 AppKey') + }) + + const response = await GET( + createMockRequest('http://180.97.215.57:5246/api/auth/dingtalk/start?redirect=%2Fdraft-groups%2Fabc'), + ) + + const location = response.headers.get('location') + expect(response.status).toBe(307) + expect(location).toBeTruthy() + + const redirectUrl = new URL(location!) + expect(redirectUrl.pathname).toBe('/login') + expect(redirectUrl.searchParams.get('redirect')).toBe('/draft-groups/abc') + expect(redirectUrl.searchParams.get('dingtalkError')).toBe('钉钉登录未配置 AppKey') + }) +}) diff --git a/furniture-3d-system/src/app/api/auth/dingtalk/start/route.ts b/furniture-3d-system/src/app/api/auth/dingtalk/start/route.ts index 55133784..6863338d 100644 --- a/furniture-3d-system/src/app/api/auth/dingtalk/start/route.ts +++ b/furniture-3d-system/src/app/api/auth/dingtalk/start/route.ts @@ -1,12 +1,29 @@ import { NextRequest, NextResponse } from 'next/server' import { dingtalkAuthService } from '@/server/services/dingtalk-auth.service' -export async function GET(request: NextRequest) { +function buildLoginRedirect(request: NextRequest, message?: string) { + const url = new URL('/login', request.nextUrl.origin) const redirectPath = request.nextUrl.searchParams.get('redirect') - const authUrl = dingtalkAuthService.buildAuthorizationUrl({ - requestOrigin: request.nextUrl.origin, - redirectPath, - }) - - return NextResponse.redirect(authUrl) + if (redirectPath?.startsWith('/')) { + url.searchParams.set('redirect', redirectPath) + } + if (message) { + url.searchParams.set('dingtalkError', message) + } + return url +} + +export async function GET(request: NextRequest) { + try { + const redirectPath = request.nextUrl.searchParams.get('redirect') + const authUrl = dingtalkAuthService.buildAuthorizationUrl({ + requestOrigin: request.nextUrl.origin, + redirectPath, + }) + + return NextResponse.redirect(authUrl) + } catch (error) { + const message = error instanceof Error ? error.message : '钉钉登录暂不可用,请联系管理员检查配置' + return NextResponse.redirect(buildLoginRedirect(request, message)) + } } diff --git a/furniture-3d-system/src/app/api/categories/[id]/route.test.ts b/furniture-3d-system/src/app/api/categories/[id]/route.test.ts new file mode 100644 index 00000000..86caf87d --- /dev/null +++ b/furniture-3d-system/src/app/api/categories/[id]/route.test.ts @@ -0,0 +1,162 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { AppError, ErrorCode } from '@/server/core/errors' + +const mockHandleRequest: any = jest.fn() +const mockJsonSuccess: any = jest.fn() +const mockJsonError: any = jest.fn() +const mockGetById: any = jest.fn() +const mockUpdateCategory: any = jest.fn() +const mockDeleteCategory: any = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), +})) + +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + getById: (...args: unknown[]) => mockGetById(...args), + update: (...args: unknown[]) => mockUpdateCategory(...args), + delete: (...args: unknown[]) => mockDeleteCategory(...args), + }, +})) + +const { GET, PUT, DELETE } = require('./route') + +function createRequest(body?: unknown) { + return { + json: jest.fn(async () => body), + } as any +} + +describe('GET/PUT/DELETE /api/categories/[id]', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-test', + user: { + id: 'admin-1', + role: 'ADMIN', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ + success: true, + data, + status, + requestId, + })) + + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + success: false, + code, + message, + status, + requestId, + })) + }) + + it('GET 会返回分类详情并序列化日期', async () => { + mockGetById.mockResolvedValue({ + id: 'category-1', + name: '桌类', + createdAt: new Date('2026-04-15T00:00:00.000Z'), + updatedAt: new Date('2026-04-16T00:00:00.000Z'), + }) + + const response = await GET(createRequest(), { + params: Promise.resolve({ id: 'category-1' }), + }) + + expect(mockGetById).toHaveBeenCalledWith('category-1') + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { + data: expect.objectContaining({ + id: 'category-1', + createdAt: '2026-04-15T00:00:00.000Z', + updatedAt: '2026-04-16T00:00:00.000Z', + }), + }, + }), + ) + }) + + it('PUT 会更新分类', async () => { + mockUpdateCategory.mockResolvedValue({ + id: 'category-1', + name: '新桌类', + isActive: true, + }) + + const response = await PUT( + createRequest({ + name: '新桌类', + description: '更新后的描述', + icon: 'table', + sortOrder: 1, + isActive: true, + }), + { + params: Promise.resolve({ id: 'category-1' }), + }, + ) + + expect(mockUpdateCategory).toHaveBeenCalledWith('category-1', { + name: '新桌类', + description: '更新后的描述', + icon: 'table', + sortOrder: 1, + isActive: true, + }) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + }), + ) + }) + + it('DELETE 会删除分类', async () => { + mockDeleteCategory.mockResolvedValue(undefined) + + const response = await DELETE(createRequest(), { + params: Promise.resolve({ id: 'category-1' }), + }) + + expect(mockDeleteCategory).toHaveBeenCalledWith('category-1') + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 200, + data: { success: true }, + }), + ) + }) + + it('GET 分类不存在时会返回 404', async () => { + mockGetById.mockRejectedValue( + new AppError(ErrorCode.CATEGORY_NOT_FOUND, '分类不存在'), + ) + + const response = await GET(createRequest(), { + params: Promise.resolve({ id: 'category-missing' }), + }) + + expect(response).toEqual( + expect.objectContaining({ + success: false, + code: ErrorCode.CATEGORY_NOT_FOUND, + status: 404, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/categories/route.test.ts b/furniture-3d-system/src/app/api/categories/route.test.ts new file mode 100644 index 00000000..c092d852 --- /dev/null +++ b/furniture-3d-system/src/app/api/categories/route.test.ts @@ -0,0 +1,149 @@ +export {} + +import { beforeEach, describe, expect, it, jest } from '@jest/globals' + +const mockHandleRequest: any = jest.fn() +const mockJsonSuccess: any = jest.fn() +const mockJsonError: any = jest.fn() +const mockListActive: any = jest.fn() +const mockCreateCategory: any = jest.fn() + +jest.mock('@/server/core/response', () => ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), + jsonError: (...args: unknown[]) => mockJsonError(...args), +})) + +jest.mock('@/server/services/category.service', () => ({ + categoryService: { + listActive: (...args: unknown[]) => mockListActive(...args), + create: (...args: unknown[]) => mockCreateCategory(...args), + }, +})) + +const { GET, POST } = require('./route') + +function createGetRequest() { + return {} as any +} + +function createPostRequest(body: unknown) { + return { + json: jest.fn(async () => body), + } as any +} + +describe('GET/POST /api/categories', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-test', + user: { + id: 'admin-1', + role: 'ADMIN', + }, + }), + ) + + mockJsonSuccess.mockImplementation((data, status = 200, requestId = 'req-test') => ({ + success: true, + data, + status, + requestId, + headers: { + set: jest.fn(), + }, + })) + + mockJsonError.mockImplementation((code, message, status, requestId) => ({ + success: false, + code, + message, + status, + requestId, + })) + }) + + it('GET 会返回分类列表并设置短缓存', async () => { + mockListActive.mockResolvedValue([ + { id: 'category-1', name: '桌类' }, + ]) + + const response = await GET(createGetRequest()) + + expect(mockListActive).toHaveBeenCalled() + expect(response).toEqual( + expect.objectContaining({ + success: true, + data: { + items: [{ id: 'category-1', name: '桌类' }], + }, + }), + ) + expect(response.headers.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=30') + }) + + it('POST 会创建分类', async () => { + mockCreateCategory.mockResolvedValue({ + id: 'category-2', + name: '柜类', + }) + + const response = await POST( + createPostRequest({ + name: '柜类', + description: '柜子分类', + icon: 'box', + sortOrder: 2, + }), + ) + + expect(mockCreateCategory).toHaveBeenCalledWith({ + name: '柜类', + description: '柜子分类', + icon: 'box', + sortOrder: 2, + }) + expect(response).toEqual( + expect.objectContaining({ + success: true, + status: 201, + data: { + data: expect.objectContaining({ + id: 'category-2', + name: '柜类', + }), + }, + }), + ) + }) + + it('POST 非管理员会被拒绝', async () => { + mockHandleRequest.mockImplementationOnce(async (_request: unknown, handler: any) => + handler({ + requestId: 'req-test', + user: { + id: 'viewer-1', + role: 'VIEWER', + }, + }), + ) + + const response = await POST( + createPostRequest({ + name: '柜类', + }), + ) + + expect(mockCreateCategory).not.toHaveBeenCalled() + expect(response).toEqual( + expect.objectContaining({ + success: false, + message: '需要管理员权限', + status: 403, + }), + ) + }) +}) diff --git a/furniture-3d-system/src/app/api/internal/hr-sync/route.ts b/furniture-3d-system/src/app/api/internal/hr-sync/route.ts new file mode 100644 index 00000000..e8aa2c6d --- /dev/null +++ b/furniture-3d-system/src/app/api/internal/hr-sync/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { employeeDirectorySyncService } from '@/server/services/employee-directory-sync.service' + +function getSyncSecret() { + return process.env.HR_SYNC_SECRET?.trim() || process.env.INTERNAL_SYNC_SECRET?.trim() || '' +} + +export async function POST(request: NextRequest) { + const requestId = crypto.randomUUID() + const configuredSecret = getSyncSecret() + const requestSecret = request.headers.get('x-internal-sync-secret')?.trim() || '' + + if (!configuredSecret) { + return NextResponse.json( + { + success: false, + error: { + code: ErrorCode.SYSTEM_INTERNAL, + message: '内部同步密钥未配置', + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + }, + { status: 500 } + ) + } + + if (requestSecret !== configuredSecret) { + return NextResponse.json( + { + success: false, + error: { + code: ErrorCode.AUTH_PERMISSION_DENIED, + message: '内部同步鉴权失败', + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + }, + { status: 403 } + ) + } + + try { + const payload = await request.json() + const result = await employeeDirectorySyncService.sync(payload) + + return NextResponse.json( + { + success: true, + data: { + message: '人事同步成功', + result, + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + }, + { status: 200 } + ) + } catch (error) { + if (error instanceof AppError) { + return NextResponse.json( + { + success: false, + error: { + code: error.code, + message: error.message, + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + }, + { status: error.statusCode } + ) + } + + return NextResponse.json( + { + success: false, + error: { + code: ErrorCode.SYSTEM_INTERNAL, + message: '人事同步失败', + }, + meta: { + requestId, + timestamp: new Date().toISOString(), + }, + }, + { status: 500 } + ) + } +} diff --git a/furniture-3d-system/src/app/api/material-categories/route.ts b/furniture-3d-system/src/app/api/material-categories/route.ts index 1d6e874b..431a40a6 100644 --- a/furniture-3d-system/src/app/api/material-categories/route.ts +++ b/furniture-3d-system/src/app/api/material-categories/route.ts @@ -10,8 +10,6 @@ import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response' import { AppError, ErrorCode } from '@/server/core/errors' import { materialService } from '@/server/services' -const ADMIN_ROLES = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER'] as const - const createMaterialCategorySchema = z.object({ name: z.string().trim().min(1, '请填写分类名称').max(50, '分类名称不能超过 50 个字符'), icon: z.string().trim().max(50, '图标标识不能超过 50 个字符').optional().nullable(), @@ -62,6 +60,6 @@ export async function POST(request: NextRequest) { throw error } }, - { allowedRoles: [...ADMIN_ROLES] }, + { requireAuth: true }, ) } diff --git a/furniture-3d-system/src/app/api/materials/[id]/route.ts b/furniture-3d-system/src/app/api/materials/[id]/route.ts index e2e3ffaa..1a25af28 100644 --- a/furniture-3d-system/src/app/api/materials/[id]/route.ts +++ b/furniture-3d-system/src/app/api/materials/[id]/route.ts @@ -33,6 +33,15 @@ export async function PUT(request: NextRequest, context: RouteParams) { categoryId?: string color?: string thumbnailUrl?: string | null + textureUrl?: string | null + normalMapUrl?: string | null + metalness?: number + roughness?: number + opacity?: number + textureRepeat?: number + textureRotation?: number + textureSizeMm?: number + isPreset?: boolean isActive?: boolean } @@ -42,7 +51,18 @@ export async function PUT(request: NextRequest, context: RouteParams) { categoryId: payload.categoryId?.trim() || undefined, color: payload.color, thumbnailUrl: - payload.thumbnailUrl === undefined ? undefined : payload.thumbnailUrl?.trim() || '/materials/thumbnails/default.svg', + payload.thumbnailUrl === undefined ? undefined : payload.thumbnailUrl?.trim() || null, + textureUrl: + payload.textureUrl === undefined ? undefined : payload.textureUrl?.trim() || null, + normalMapUrl: + payload.normalMapUrl === undefined ? undefined : payload.normalMapUrl?.trim() || null, + metalness: payload.metalness, + roughness: payload.roughness, + opacity: payload.opacity, + textureRepeat: payload.textureRepeat, + textureRotation: payload.textureRotation, + textureSizeMm: payload.textureSizeMm, + isPreset: payload.isPreset, isActive: payload.isActive, }) return jsonSuccess(material, 200, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/materials/route.ts b/furniture-3d-system/src/app/api/materials/route.ts index c31d37f5..dcf094d7 100644 --- a/furniture-3d-system/src/app/api/materials/route.ts +++ b/furniture-3d-system/src/app/api/materials/route.ts @@ -1,7 +1,7 @@ /** * 材质列表 API * GET /api/materials - 获取所有材质(按分类分组) - * POST /api/materials - 新建材质(最小必填版) + * POST /api/materials - 新建材质 */ import { NextRequest } from 'next/server' @@ -25,11 +25,36 @@ export async function POST(request: NextRequest) { request, async (ctx) => { const body = await request.json().catch(() => null) - const { name, categoryId, color, thumbnailUrl } = (body ?? {}) as { + const { + name, + categoryId, + color, + thumbnailUrl, + textureUrl, + normalMapUrl, + metalness, + roughness, + opacity, + textureRepeat, + textureRotation, + textureSizeMm, + isPreset, + isActive, + } = (body ?? {}) as { name?: string categoryId?: string color?: string - thumbnailUrl?: string + thumbnailUrl?: string | null + textureUrl?: string | null + normalMapUrl?: string | null + metalness?: number + roughness?: number + opacity?: number + textureRepeat?: number + textureRotation?: number + textureSizeMm?: number + isPreset?: boolean + isActive?: boolean } if (!name?.trim()) { @@ -44,10 +69,17 @@ export async function POST(request: NextRequest) { name: name.trim(), categoryId, color: color || '#CCCCCC', - thumbnailUrl: thumbnailUrl?.trim() || undefined, - metalness: 0, - roughness: 0.5, - opacity: 1, + thumbnailUrl: thumbnailUrl?.trim() || null, + textureUrl: textureUrl?.trim() || null, + normalMapUrl: normalMapUrl?.trim() || null, + metalness: typeof metalness === 'number' ? metalness : 0, + roughness: typeof roughness === 'number' ? roughness : 0.5, + opacity: typeof opacity === 'number' ? opacity : 1, + textureRepeat: typeof textureRepeat === 'number' ? textureRepeat : undefined, + textureRotation: typeof textureRotation === 'number' ? textureRotation : undefined, + textureSizeMm: typeof textureSizeMm === 'number' ? textureSizeMm : undefined, + isPreset: typeof isPreset === 'boolean' ? isPreset : undefined, + isActive: typeof isActive === 'boolean' ? isActive : undefined, }) return jsonSuccess(created, 201, ctx.requestId) diff --git a/furniture-3d-system/src/app/api/materials/thumbnail/route.ts b/furniture-3d-system/src/app/api/materials/thumbnail/route.ts index 6814cb2d..443df1d4 100644 --- a/furniture-3d-system/src/app/api/materials/thumbnail/route.ts +++ b/furniture-3d-system/src/app/api/materials/thumbnail/route.ts @@ -32,7 +32,7 @@ function resolveSafeRelativePath(rawPath: string | null): string { if (!rawPath) return FALLBACK_RELATIVE_PATH const normalized = rawPath.startsWith('/') ? rawPath : `/${rawPath}` - const allowedRoots = ['/materials/thumbnails/', '/materials/pbr/', '/uploads/materials/'] + const allowedRoots = ['/materials/thumbnails/', '/materials/textures/', '/materials/pbr/', '/uploads/materials/'] if (!allowedRoots.some((root) => normalized.startsWith(root))) return FALLBACK_RELATIVE_PATH if (normalized.includes('..')) return FALLBACK_RELATIVE_PATH @@ -130,4 +130,3 @@ export async function GET(request: NextRequest) { { requireAuth: false } ) } - diff --git a/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts new file mode 100644 index 00000000..acb13af0 --- /dev/null +++ b/furniture-3d-system/src/app/internal/asset-thumbnail-image/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + buildAssetThumbnailImageStorageKey, + verifyAssetThumbnailRenderPayload, + type AssetThumbnailRenderAssetType, +} from '@/lib/asset-thumbnail-auth' +import { parseMaterialConfig } from '@/lib/material-config' +import { + buildPublicS3ObjectUrl, + doesS3ObjectExist, +} from '@/lib/storage' +import { assetVariantThumbnailService } from '@/server/services/asset-variant-thumbnail.service' + +export const dynamic = 'force-dynamic' + +function readStringParam(value: string | null): string | null { + return value && value.trim().length > 0 ? value.trim() : null +} + +function resolveRequestOrigin(request: NextRequest): string { + const configuredOrigin = + process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.NEXTAUTH_URL?.trim() + if (configuredOrigin) { + return configuredOrigin.replace(/\/$/, '') + } + + const forwardedHost = request.headers.get('x-forwarded-host') + const host = forwardedHost ?? request.headers.get('host') + const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol.replace(/:$/, '') + + if (!host) { + return request.nextUrl.origin.replace(/\/$/, '') + } + + return `${protocol}://${host}`.replace(/\/$/, '') +} + +export async function GET(request: NextRequest) { + const modelUrl = readStringParam(request.nextUrl.searchParams.get('modelUrl')) + const assetType = readStringParam( + request.nextUrl.searchParams.get('assetType'), + ) as AssetThumbnailRenderAssetType | null + const token = readStringParam(request.nextUrl.searchParams.get('token')) + const expiresAtValue = readStringParam(request.nextUrl.searchParams.get('expiresAt')) + const materialConfig = parseMaterialConfig( + readStringParam(request.nextUrl.searchParams.get('materialConfig')), + ) + const expiresAt = expiresAtValue ? Number(expiresAtValue) : NaN + + const isValid = + Boolean(modelUrl) && + Boolean(assetType) && + Boolean(token) && + Number.isFinite(expiresAt) && + verifyAssetThumbnailRenderPayload( + { + modelUrl: modelUrl!, + assetType: assetType!, + expiresAt, + materialConfig, + }, + token!, + ) + + if (!isValid || !modelUrl || !assetType) { + return NextResponse.json({ error: '缩略图令牌无效' }, { status: 401 }) + } + + try { + const storageKey = buildAssetThumbnailImageStorageKey({ + modelUrl, + assetType, + materialConfig, + }) + + if (await doesS3ObjectExist(storageKey)) { + return NextResponse.redirect(buildPublicS3ObjectUrl(storageKey), { status: 307 }) + } + + const imageUrl = await assetVariantThumbnailService.generateGenericThumbnail({ + identifier: storageKey, + assetType, + modelUrl, + renderBaseUrl: resolveRequestOrigin(request), + storageKey, + materialConfig, + }) + + return NextResponse.redirect(imageUrl, { status: 307 }) + } catch (error) { + console.error('[AssetThumbnailImageRoute] 生成带材质缩略图失败:', error) + return NextResponse.json({ error: '缩略图生成失败' }, { status: 500 }) + } +} diff --git a/furniture-3d-system/src/app/login/page.tsx b/furniture-3d-system/src/app/login/page.tsx index 32e0a32c..d8a9a0c6 100644 --- a/furniture-3d-system/src/app/login/page.tsx +++ b/furniture-3d-system/src/app/login/page.tsx @@ -69,7 +69,7 @@ export default function LoginPage() { } if (registered === '1') { - toast.success('注册成功,请直接登录'); + toast.success('注册成功,请等待管理员激活'); params.delete('registered'); params.delete('identifier'); } diff --git a/furniture-3d-system/src/app/materials/textures/[category]/[filename]/route.ts b/furniture-3d-system/src/app/materials/textures/[category]/[filename]/route.ts new file mode 100644 index 00000000..21099e3c --- /dev/null +++ b/furniture-3d-system/src/app/materials/textures/[category]/[filename]/route.ts @@ -0,0 +1,59 @@ +import path from 'node:path' +import { readFile } from 'node:fs/promises' +import { NextResponse } from 'next/server' + +const CONTENT_TYPE_BY_EXT: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', +} + +function getContentType(fileName: string): string { + const ext = path.extname(fileName).toLowerCase() + return CONTENT_TYPE_BY_EXT[ext] || 'application/octet-stream' +} + +function getSafePath(category: string, filename: string): string { + const safeCategory = path.basename(category) + const safeFilename = path.basename(filename) + return path.join(process.cwd(), 'public', 'materials', 'textures', safeCategory, safeFilename) +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ category: string; filename: string }> } +) { + const { category, filename } = await params + const targetPath = getSafePath(category, filename) + + try { + const content = await readFile(targetPath) + return new NextResponse(content, { + headers: { + 'Content-Type': getContentType(filename), + 'Cache-Control': 'public, max-age=604800', + }, + }) + } catch { + const fallbackPath = path.join(process.cwd(), 'public', 'materials', 'thumbnails', 'default.svg') + try { + const fallback = await readFile(fallbackPath) + return new NextResponse(fallback, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=604800', + }, + }) + } catch { + return NextResponse.json( + { + error: 'TEXTURE_NOT_FOUND', + message: '材质贴图不存在,且默认预览图缺失', + }, + { status: 404 } + ) + } + } +} diff --git a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx index 9ddcaacc..8b5a4f14 100644 --- a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx +++ b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx @@ -2,7 +2,8 @@ import { useEffect, useRef, useState } from 'react' import { useRouter } from 'next/navigation' -import { ChevronUp, LogOut, UserCircle2 } from 'lucide-react' +import { ChevronUp, LogOut, UserCircle2, Box, FileEdit, Users, LayoutDashboard } from 'lucide-react' +import Link from 'next/link' import { cn } from '@/lib/utils' interface SidebarUser { @@ -25,7 +26,7 @@ interface AuthMeResponse { const roleLabelMap: Record = { SUPER_ADMIN: '超级管理员', ADMIN: '管理员', - DEPARTMENT_MANAGER: '部门负责人', + DEPARTMENT_MANAGER: '负责人', TEAM_LEADER: '组长', DESIGNER: '设计师', VIEWER: '查看者', @@ -38,6 +39,8 @@ export function SidebarUserCenter() { const [isOpen, setIsOpen] = useState(false) const [isLoggingOut, setIsLoggingOut] = useState(false) + const isAdmin = currentUser?.role === 'SUPER_ADMIN' || currentUser?.role === 'ADMIN' + useEffect(() => { let active = true @@ -104,39 +107,91 @@ export function SidebarUserCenter() { } } - const secondaryLine = currentUser - ? [roleLabelMap[currentUser.role] ?? currentUser.role, currentUser.department?.name] - .filter(Boolean) - .join(' · ') - : '当前账号' - return ( -
+
{isOpen ? (
-

- {currentUser?.name ?? '当前账号'} -

-

- {currentUser?.email ?? '当前登录信息'} -

-

{secondaryLine}

- + {/* SECTION 1: Account Info */} +
+
+
+ + {currentUser?.name ?? '加载中...'} + + {currentUser && ( + + {roleLabelMap[currentUser.role] ?? '用户'} + + )} +
+ + {currentUser?.email ?? '正在获取账号信息...'} + +
+
+ + {/* SECTION 2: Workspace Shortcuts */} +
+

+ 工作区 +

+ setIsOpen(false)} + className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group" + > + + 正式产品 + + setIsOpen(false)} + className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group" + > + + 我的草稿 + +
+ + {/* SECTION 3: Admin & Actions */} +
+ {isAdmin && ( + <> + setIsOpen(false)} + className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group" + > + + 管理总控制台 + + setIsOpen(false)} + className="flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-label-secondary hover:bg-white hover:text-apple-blue transition-all group" + > + + 用户权限管理 + + + )} + +
) : null} @@ -145,18 +200,24 @@ export function SidebarUserCenter() { aria-label="用户中心" aria-expanded={isOpen} onClick={() => setIsOpen((current) => !current)} - className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium transition-colors" - style={{ - color: 'var(--color-label-primary)', - backgroundColor: 'var(--color-surface-container-lowest)', - }} + className={cn( + "flex w-full items-center gap-3 rounded-apple-lg px-3 py-2.5 transition-all duration-300", + isOpen + ? "bg-white shadow-apple-sm text-apple-blue" + : "text-apple-label-secondary hover:bg-white/50" + )} > - - 用户中心 +
+ + {currentUser &&
} +
+ + 用户中心 +
diff --git a/furniture-3d-system/src/components/layout/__tests__/AppSideNav.test.tsx b/furniture-3d-system/src/components/layout/__tests__/AppSideNav.test.tsx index 628a3c75..45c4176a 100644 --- a/furniture-3d-system/src/components/layout/__tests__/AppSideNav.test.tsx +++ b/furniture-3d-system/src/components/layout/__tests__/AppSideNav.test.tsx @@ -85,7 +85,9 @@ describe('AppSideNav', () => { await actor.click(screen.getByRole('button', { name: '用户中心' })) expect(await screen.findByText('测试账号')).toBeInTheDocument() - expect(screen.getByText('组长 · 研发一组')).toBeInTheDocument() + expect(screen.getByText('test@example.com')).toBeInTheDocument() + expect(screen.getByText('组长')).toBeInTheDocument() + expect(screen.getByText('工作区')).toBeInTheDocument() await actor.click(screen.getByRole('button', { name: '退出登录' })) diff --git a/furniture-3d-system/src/components/platform/AssemblyMaterialImage.tsx b/furniture-3d-system/src/components/platform/AssemblyMaterialImage.tsx index 784f0de2..d5214f4f 100644 --- a/furniture-3d-system/src/components/platform/AssemblyMaterialImage.tsx +++ b/furniture-3d-system/src/components/platform/AssemblyMaterialImage.tsx @@ -19,11 +19,12 @@ export function AssemblyMaterialImage({ () => Array.from(new Set(sources.filter((value) => value.trim().length > 0))), [sources], ) + const sourceSignature = useMemo(() => uniqueSources.join('\n'), [uniqueSources]) const [sourceIndex, setSourceIndex] = useState(0) useEffect(() => { setSourceIndex(0) - }, [uniqueSources]) + }, [sourceSignature]) const currentSource = uniqueSources[sourceIndex] ?? null diff --git a/furniture-3d-system/src/components/platform/__tests__/AssemblyMaterialImage.test.tsx b/furniture-3d-system/src/components/platform/__tests__/AssemblyMaterialImage.test.tsx new file mode 100644 index 00000000..ea2e58b5 --- /dev/null +++ b/furniture-3d-system/src/components/platform/__tests__/AssemblyMaterialImage.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { AssemblyMaterialImage } from '../AssemblyMaterialImage' + +describe('AssemblyMaterialImage', () => { + it('同一批图片源重新渲染时,不会把已经回退成功的图片重置回坏图', () => { + const { rerender } = render( + placeholder
} + />, + ) + + const firstImage = screen.getByAltText('规格图') as HTMLImageElement + expect(firstImage.src).toContain('runtime.png') + + fireEvent.error(firstImage) + + const fallbackImage = screen.getByAltText('规格图') as HTMLImageElement + expect(fallbackImage.src).toContain('fallback.png') + + rerender( + placeholder
} + />, + ) + + expect((screen.getByAltText('规格图') as HTMLImageElement).src).toContain('fallback.png') + }) +}) diff --git a/furniture-3d-system/src/hooks/useAuth.ts b/furniture-3d-system/src/hooks/useAuth.ts index a369fefd..150ec13c 100644 --- a/furniture-3d-system/src/hooks/useAuth.ts +++ b/furniture-3d-system/src/hooks/useAuth.ts @@ -23,6 +23,9 @@ export interface User { name: string; role: Role; status: UserStatus; + loginSource?: string; + activationRequired?: boolean; + employeeDirectoryId?: string | null; isFirstLogin: boolean; department?: { id: string; diff --git a/furniture-3d-system/src/lib/asset-thumbnail-auth.ts b/furniture-3d-system/src/lib/asset-thumbnail-auth.ts index 255841dd..000d6b12 100644 --- a/furniture-3d-system/src/lib/asset-thumbnail-auth.ts +++ b/furniture-3d-system/src/lib/asset-thumbnail-auth.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'node:crypto' +import { createHash, createHmac, timingSafeEqual } from 'node:crypto' import type { MaterialConfig } from '@/lib/3d/material-manager' import { serializeMaterialConfig } from '@/lib/material-config' @@ -83,3 +83,35 @@ export function buildAssetThumbnailRenderUrl( url.searchParams.set('token', signAssetThumbnailRenderPayload(payload)) return url.toString() } + +export function buildAssetThumbnailImageStorageKey( + payload: Pick, +): string { + const digest = createHash('sha256') + .update( + [ + payload.assetType, + payload.modelUrl, + serializeMaterialConfig(payload.materialConfig), + ].join('|'), + ) + .digest('hex') + + return `asset-center/runtime-thumbnails/${payload.assetType.toLowerCase()}/${digest}.png` +} + +export function buildAssetThumbnailImageUrl( + baseUrl: string, + payload: AssetThumbnailRenderPayload, +): string { + const url = new URL('/internal/asset-thumbnail-image', baseUrl) + url.searchParams.set('modelUrl', payload.modelUrl) + url.searchParams.set('assetType', payload.assetType) + url.searchParams.set('expiresAt', String(payload.expiresAt)) + const serializedMaterialConfig = serializeMaterialConfig(payload.materialConfig) + if (serializedMaterialConfig) { + url.searchParams.set('materialConfig', serializedMaterialConfig) + } + url.searchParams.set('token', signAssetThumbnailRenderPayload(payload)) + return url.toString() +} diff --git a/furniture-3d-system/src/lib/asset-variant-render-config.ts b/furniture-3d-system/src/lib/asset-variant-render-config.ts new file mode 100644 index 00000000..6a648c95 --- /dev/null +++ b/furniture-3d-system/src/lib/asset-variant-render-config.ts @@ -0,0 +1,344 @@ +import type { MaterialConfig } from '@/lib/3d/material-manager' + +export const ASSET_VARIANT_RENDER_CONFIG_KEY = 'renderConfig' +export const DEFAULT_VARIANT_UV_NODE_KEY = '__default__' + +export interface AssetVariantUvSurfaceConfig { + textureRepeat?: number | null + textureRotation?: number | null + textureSizeMm?: number | null +} + +export interface AssetVariantNodeUvConfig extends AssetVariantUvSurfaceConfig { + surfaces?: { + front?: AssetVariantUvSurfaceConfig | null + back?: AssetVariantUvSurfaceConfig | null + } | null +} + +export interface AssetVariantUvConfig { + version: 1 + nodes: Record +} + +export interface AssetVariantRenderConfig { + uv?: AssetVariantUvConfig | null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function toFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function normalizeUvSurfaceConfig( + value: unknown, +): AssetVariantUvSurfaceConfig | null { + if (!isRecord(value)) { + return null + } + + const textureRepeat = toFiniteNumber(value.textureRepeat) + const textureRotation = toFiniteNumber(value.textureRotation) + const textureSizeMm = toFiniteNumber(value.textureSizeMm) + + if ( + textureRepeat === undefined && + textureRotation === undefined && + textureSizeMm === undefined + ) { + return null + } + + return { + ...(textureRepeat !== undefined ? { textureRepeat } : {}), + ...(textureRotation !== undefined ? { textureRotation } : {}), + ...(textureSizeMm !== undefined ? { textureSizeMm } : {}), + } +} + +function normalizeNodeUvConfig(value: unknown): AssetVariantNodeUvConfig | null { + if (!isRecord(value)) { + return null + } + + const base = normalizeUvSurfaceConfig(value) + const surfacesRecord = isRecord(value.surfaces) ? value.surfaces : null + const front = normalizeUvSurfaceConfig(surfacesRecord?.front) + const back = normalizeUvSurfaceConfig(surfacesRecord?.back) + + if (!base && !front && !back) { + return null + } + + return { + ...(base ?? {}), + ...(front || back + ? { + surfaces: { + ...(front ? { front } : {}), + ...(back ? { back } : {}), + }, + } + : {}), + } +} + +export function normalizeAssetVariantUvConfig( + value: unknown, +): AssetVariantUvConfig | null { + if (!isRecord(value)) { + return null + } + + const nodesSource = isRecord(value.nodes) ? value.nodes : null + if (!nodesSource) { + return null + } + + const nodes = Object.fromEntries( + Object.entries(nodesSource) + .map(([nodeName, nodeConfig]) => [nodeName, normalizeNodeUvConfig(nodeConfig)] as const) + .filter((entry): entry is [string, AssetVariantNodeUvConfig] => Boolean(entry[0]) && Boolean(entry[1])), + ) + + if (Object.keys(nodes).length === 0) { + return null + } + + return { + version: 1, + nodes, + } +} + +export function normalizeAssetVariantRenderConfig( + value: unknown, +): AssetVariantRenderConfig | null { + if (!isRecord(value)) { + return null + } + + const uv = normalizeAssetVariantUvConfig(value.uv) + if (!uv) { + return null + } + + return { + uv, + } +} + +export function readAssetVariantRenderConfig( + businessData: Record | null | undefined, +): AssetVariantRenderConfig | null { + if (!businessData) { + return null + } + + return normalizeAssetVariantRenderConfig(businessData[ASSET_VARIANT_RENDER_CONFIG_KEY]) +} + +export function readAssetVariantUvConfig( + businessData: Record | null | undefined, +): AssetVariantUvConfig | null { + return readAssetVariantRenderConfig(businessData)?.uv ?? null +} + +export function writeAssetVariantUvConfig( + businessData: Record | null | undefined, + uvConfig: AssetVariantUvConfig | null | undefined, +): Record | null { + const nextBusinessData = businessData ? { ...businessData } : {} + const normalizedUvConfig = normalizeAssetVariantUvConfig(uvConfig) + const existingRenderConfig = readAssetVariantRenderConfig(nextBusinessData) + + if (normalizedUvConfig) { + nextBusinessData[ASSET_VARIANT_RENDER_CONFIG_KEY] = { + ...(existingRenderConfig ?? {}), + uv: normalizedUvConfig, + } + } else if (ASSET_VARIANT_RENDER_CONFIG_KEY in nextBusinessData) { + const nextRenderConfig = { ...(existingRenderConfig ?? {}) } + delete nextRenderConfig.uv + if (Object.keys(nextRenderConfig).length > 0) { + nextBusinessData[ASSET_VARIANT_RENDER_CONFIG_KEY] = nextRenderConfig + } else { + delete nextBusinessData[ASSET_VARIANT_RENDER_CONFIG_KEY] + } + } + + return Object.keys(nextBusinessData).length > 0 ? nextBusinessData : null +} + +export function getAssetVariantNodeUvConfig( + uvConfig: AssetVariantUvConfig | null | undefined, + nodeName?: string | null, +): AssetVariantNodeUvConfig | null { + if (!uvConfig) { + return null + } + + if (nodeName && uvConfig.nodes[nodeName]) { + return uvConfig.nodes[nodeName] ?? null + } + + if (uvConfig.nodes[DEFAULT_VARIANT_UV_NODE_KEY]) { + return uvConfig.nodes[DEFAULT_VARIANT_UV_NODE_KEY] ?? null + } + + const values = Object.values(uvConfig.nodes) + return values.length === 1 ? values[0] ?? null : null +} + +export function getAssetVariantUvSurfaceConfig( + uvConfig: AssetVariantUvConfig | null | undefined, + options?: { + nodeName?: string | null + face?: 'front' | 'back' | null + }, +): AssetVariantUvSurfaceConfig | null { + const nodeConfig = getAssetVariantNodeUvConfig(uvConfig, options?.nodeName) + if (!nodeConfig) { + return null + } + + if (options?.face === 'front' || options?.face === 'back') { + return nodeConfig.surfaces?.[options.face] ?? null + } + + return normalizeUvSurfaceConfig(nodeConfig) +} + +function hasBaseNodeUvConfig(nodeConfig: AssetVariantNodeUvConfig): boolean { + return ( + nodeConfig.textureRepeat !== undefined || + nodeConfig.textureRotation !== undefined || + nodeConfig.textureSizeMm !== undefined + ) +} + +function hasSurfaceNodeUvConfig( + nodeConfig: AssetVariantNodeUvConfig, +): boolean { + return Boolean(nodeConfig.surfaces?.front || nodeConfig.surfaces?.back) +} + +export function updateAssetVariantUvConfig( + uvConfig: AssetVariantUvConfig | null | undefined, + options: { + nodeName?: string | null + face?: 'front' | 'back' | null + }, + updater: (current: AssetVariantUvSurfaceConfig | null) => AssetVariantUvSurfaceConfig | null, +): AssetVariantUvConfig | null { + const normalizedConfig = normalizeAssetVariantUvConfig(uvConfig) ?? { + version: 1 as const, + nodes: {}, + } + const nodeKey = options.nodeName?.trim() || DEFAULT_VARIANT_UV_NODE_KEY + const currentNode = normalizedConfig.nodes[nodeKey] ?? {} + const nextNode: AssetVariantNodeUvConfig = { + ...currentNode, + ...(currentNode.surfaces + ? { + surfaces: { + ...(currentNode.surfaces.front ? { front: { ...currentNode.surfaces.front } } : {}), + ...(currentNode.surfaces.back ? { back: { ...currentNode.surfaces.back } } : {}), + }, + } + : {}), + } + + if (options.face === 'front' || options.face === 'back') { + const currentFaceConfig = nextNode.surfaces?.[options.face] ?? null + const nextFaceConfig = normalizeUvSurfaceConfig(updater(currentFaceConfig)) + + if (nextFaceConfig) { + nextNode.surfaces = { + ...(nextNode.surfaces ?? {}), + [options.face]: nextFaceConfig, + } + } else if (nextNode.surfaces) { + delete nextNode.surfaces[options.face] + if (Object.keys(nextNode.surfaces).length === 0) { + delete nextNode.surfaces + } + } + } else { + const nextBaseConfig = normalizeUvSurfaceConfig(updater(normalizeUvSurfaceConfig(nextNode))) + delete nextNode.textureRepeat + delete nextNode.textureRotation + delete nextNode.textureSizeMm + + if (nextBaseConfig) { + if (nextBaseConfig.textureRepeat !== undefined) { + nextNode.textureRepeat = nextBaseConfig.textureRepeat + } + if (nextBaseConfig.textureRotation !== undefined) { + nextNode.textureRotation = nextBaseConfig.textureRotation + } + if (nextBaseConfig.textureSizeMm !== undefined) { + nextNode.textureSizeMm = nextBaseConfig.textureSizeMm + } + } + } + + if (!hasBaseNodeUvConfig(nextNode) && !hasSurfaceNodeUvConfig(nextNode)) { + delete normalizedConfig.nodes[nodeKey] + } else { + normalizedConfig.nodes[nodeKey] = nextNode + } + + return Object.keys(normalizedConfig.nodes).length > 0 ? normalizedConfig : null +} + +function mergeUvSurfaceConfig( + materialConfig: MaterialConfig, + uvSurfaceConfig: AssetVariantUvSurfaceConfig | null, +): MaterialConfig { + if (!uvSurfaceConfig) { + return materialConfig + } + + return { + ...materialConfig, + ...(uvSurfaceConfig.textureRepeat !== undefined + ? { textureRepeat: uvSurfaceConfig.textureRepeat ?? undefined } + : {}), + ...(uvSurfaceConfig.textureRotation !== undefined + ? { textureRotation: uvSurfaceConfig.textureRotation ?? undefined } + : {}), + ...(uvSurfaceConfig.textureSizeMm !== undefined + ? { textureSizeMm: uvSurfaceConfig.textureSizeMm ?? undefined } + : {}), + } +} + +export function applyAssetVariantUvConfigToMaterial( + materialConfig: MaterialConfig | null | undefined, + uvConfig: AssetVariantUvConfig | null | undefined, + options?: { + nodeName?: string | null + face?: 'front' | 'back' | null + }, +): MaterialConfig | null { + if (!materialConfig) { + return null + } + + const nodeConfig = getAssetVariantNodeUvConfig(uvConfig, options?.nodeName) + if (!nodeConfig) { + return materialConfig + } + + const mergedBase = mergeUvSurfaceConfig(materialConfig, nodeConfig) + const faceConfig = + options?.face === 'front' || options?.face === 'back' + ? nodeConfig.surfaces?.[options.face] ?? null + : nodeConfig.surfaces?.front ?? null + + return mergeUvSurfaceConfig(mergedBase, faceConfig) +} diff --git a/furniture-3d-system/src/lib/auth-middleware.ts b/furniture-3d-system/src/lib/auth-middleware.ts index 2fcd933c..576da9d7 100644 --- a/furniture-3d-system/src/lib/auth-middleware.ts +++ b/furniture-3d-system/src/lib/auth-middleware.ts @@ -62,6 +62,13 @@ export async function authenticateRequest(request: NextRequest): Promise { + const objectKey = normalizeObjectKey(key) const command = new PutObjectCommand({ Bucket: BUCKET_NAME, - Key: key, + Key: objectKey, Body: buffer, ContentType: contentType, }) await s3Client.send(command) - return `${VALIDATED_PUBLIC_ENDPOINT}/${BUCKET_NAME}/${key}` + return buildPublicS3ObjectUrl(objectKey) +} + +export function buildPublicS3ObjectUrl(key: string): string { + const objectKey = normalizeObjectKey(key) + const base = publicBaseAlreadyContainsBucket(VALIDATED_PUBLIC_ENDPOINT) + ? VALIDATED_PUBLIC_ENDPOINT + : `${VALIDATED_PUBLIC_ENDPOINT}/${BUCKET_NAME}` + + return `${base.replace(/\/+$/, '')}/${objectKey}` +} + +export async function doesS3ObjectExist(key: string): Promise { + const objectKey = normalizeObjectKey(key) + try { + await s3Client.send( + new HeadObjectCommand({ + Bucket: BUCKET_NAME, + Key: objectKey, + }), + ) + return true + } catch (error) { + const statusCode = + typeof error === 'object' && + error !== null && + '$metadata' in error && + typeof (error as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode === + 'number' + ? (error as { $metadata?: { httpStatusCode?: number } }).$metadata!.httpStatusCode + : null + + if (statusCode === 404) { + return false + } + + const errorName = + typeof error === 'object' && error !== null && 'name' in error + ? String((error as { name?: unknown }).name) + : '' + + if (errorName === 'NotFound' || errorName === 'NoSuchKey') { + return false + } + + throw error + } } /** @@ -87,9 +201,10 @@ export async function uploadBufferToS3( */ export async function deleteFromS3(key: string): Promise { try { + const objectKey = normalizeObjectKey(key) const command = new DeleteObjectCommand({ Bucket: BUCKET_NAME, - Key: key, + Key: objectKey, }) await s3Client.send(command) } catch (error) { @@ -105,6 +220,10 @@ export function getStoragePublicEndpoint(): string { return VALIDATED_PUBLIC_ENDPOINT } +export function getStorageFilePathPrefix(): string { + return FILE_PATH_PREFIX +} + /** * 生成缩略图 * diff --git a/furniture-3d-system/src/server/repositories/auth.repository.ts b/furniture-3d-system/src/server/repositories/auth.repository.ts index f5df5899..4c34ab2e 100644 --- a/furniture-3d-system/src/server/repositories/auth.repository.ts +++ b/furniture-3d-system/src/server/repositories/auth.repository.ts @@ -21,6 +21,12 @@ export type AuthUserWithRelations = Prisma.UserGetPayload<{ name: true } } + employeeDirectory: { + select: { + id: true + sourceStatus: true + } + } } }> @@ -44,6 +50,7 @@ class AuthRepository { include: { department: { select: { id: true, name: true } }, team: { select: { id: true, name: true } }, + employeeDirectory: { select: { id: true, sourceStatus: true } }, }, }) } @@ -134,6 +141,7 @@ class AuthRepository { include: { department: { select: { id: true, name: true } }, team: { select: { id: true, name: true } }, + employeeDirectory: { select: { id: true, sourceStatus: true } }, }, }) } @@ -145,6 +153,8 @@ class AuthRepository { role: Role status: UserStatus isFirstLogin: boolean + loginSource: string + employeeDirectoryId: string | null departmentId: string | null teamId: string | null } | null> { @@ -157,6 +167,8 @@ class AuthRepository { role: true, status: true, isFirstLogin: true, + loginSource: true, + employeeDirectoryId: true, departmentId: true, teamId: true, }, diff --git a/furniture-3d-system/src/server/repositories/employee-directory.repository.ts b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts new file mode 100644 index 00000000..020f48a4 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/employee-directory.repository.ts @@ -0,0 +1,337 @@ +import { Prisma, EmployeeDirectory, EmployeeSourceStatus } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export interface EmployeeDirectoryMatchInput { + dingtalkUserId?: string | null + dingtalkUnionId?: string | null + mobile?: string | null +} + +export type EmployeeDirectoryWithUser = Prisma.EmployeeDirectoryGetPayload<{ + include: { + user: { + select: { + id: true + status: true + role: true + departmentId: true + teamId: true + } + } + } +}> + +class EmployeeDirectoryRepository { + private async resolveUniqueDepartmentName( + db: Pick, + input: { + externalId: string + name: string + parentFullPath?: string | null + } + ) { + const candidates = [ + input.name, + input.parentFullPath ? `${input.name}(${input.parentFullPath})` : `${input.name}(${input.externalId})`, + `${input.name}(${input.externalId})`, + ] + + for (const candidate of candidates) { + const existing = await db.department.findFirst({ + where: { + name: candidate, + NOT: { externalId: input.externalId }, + }, + select: { id: true }, + }) + + if (!existing) { + return candidate + } + } + + return `${input.name}(${input.externalId.slice(-6)})` + } + + async findById(id: string) { + return prisma.employeeDirectory.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + status: true, + role: true, + departmentId: true, + teamId: true, + }, + }, + }, + }) + } + + async findByDingtalkIdentity(input: EmployeeDirectoryMatchInput): Promise { + const orConditions: Prisma.EmployeeDirectoryWhereInput[] = [] + + if (input.dingtalkUserId) { + orConditions.push({ dingtalkUserId: input.dingtalkUserId }) + } + if (input.dingtalkUnionId) { + orConditions.push({ dingtalkUnionId: input.dingtalkUnionId }) + } + if (input.mobile) { + orConditions.push({ mobile: input.mobile }) + } + + if (orConditions.length === 0) { + return null + } + + return prisma.employeeDirectory.findFirst({ + where: { + sourceStatus: 'EMPLOYED', + OR: orConditions, + }, + include: { + user: { + select: { + id: true, + status: true, + role: true, + departmentId: true, + teamId: true, + }, + }, + }, + }) + } + + async upsertMany(input: { + departments: Array<{ + externalId: string + name: string + parentExternalId?: string | null + orderIndex?: number + rawSnapshot?: Prisma.InputJsonValue + }> + employees: Array<{ + dingtalkUserId?: string | null + dingtalkUnionId?: string | null + jobNumber?: string | null + name: string + mobile?: string | null + email?: string | null + title?: string | null + primaryDepartmentExternalId?: string | null + departmentExternalIds?: Prisma.InputJsonValue + rawSnapshot?: Prisma.InputJsonValue + }> + dimissionEntries: Array<{ + dingtalkUserId?: string | null + dingtalkUnionId?: string | null + mobile?: string | null + jobNumber?: string | null + rawSnapshot?: Prisma.InputJsonValue + }> + }) { + const now = new Date() + const departmentMap = new Map() + + const sortedDepartments = [...input.departments].sort((a, b) => { + const aDepth = a.parentExternalId ? 1 : 0 + const bDepth = b.parentExternalId ? 1 : 0 + if (aDepth !== bDepth) return aDepth - bDepth + return (a.orderIndex || 0) - (b.orderIndex || 0) + }) + + let unresolvedDepartments = sortedDepartments + while (unresolvedDepartments.length > 0) { + const remaining: typeof unresolvedDepartments = [] + let progressed = false + + for (const item of unresolvedDepartments) { + if (item.parentExternalId && !departmentMap.has(item.parentExternalId)) { + remaining.push(item) + continue + } + + const parent = item.parentExternalId ? departmentMap.get(item.parentExternalId) ?? null : null + const departmentName = await this.resolveUniqueDepartmentName(prisma, { + externalId: item.externalId, + name: item.name, + parentFullPath: parent?.fullPath ?? null, + }) + const fullPath = parent ? `${parent.fullPath} > ${departmentName}` : departmentName + + const department = await prisma.department.upsert({ + where: { externalId: item.externalId }, + update: { + name: departmentName, + fullPath, + parentId: parent?.id ?? null, + orderIndex: item.orderIndex ?? 0, + rawSnapshot: item.rawSnapshot, + syncedAt: now, + }, + create: { + name: departmentName, + fullPath, + externalId: item.externalId, + parentId: parent?.id ?? null, + orderIndex: item.orderIndex ?? 0, + rawSnapshot: item.rawSnapshot, + syncedAt: now, + }, + }) + + departmentMap.set(item.externalId, { id: department.id, fullPath }) + progressed = true + } + + if (!progressed) { + throw new Error('部门同步失败:存在无法解析的父部门关系') + } + + unresolvedDepartments = remaining + } + + for (const employee of input.employees) { + const matchConditions = [ + employee.dingtalkUserId ? { dingtalkUserId: employee.dingtalkUserId } : undefined, + employee.dingtalkUnionId ? { dingtalkUnionId: employee.dingtalkUnionId } : undefined, + employee.mobile ? { mobile: employee.mobile } : undefined, + employee.jobNumber ? { jobNumber: employee.jobNumber } : undefined, + ].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[] + + const existingEmployee = matchConditions.length + ? await prisma.employeeDirectory.findFirst({ + where: { + OR: matchConditions, + }, + select: { id: true }, + }) + : null + + if (!existingEmployee && !employee.name) { + continue + } + + if (existingEmployee) { + await prisma.employeeDirectory.update({ + where: { id: existingEmployee.id }, + data: { + dingtalkUserId: employee.dingtalkUserId ?? null, + dingtalkUnionId: employee.dingtalkUnionId ?? null, + jobNumber: employee.jobNumber ?? null, + name: employee.name, + mobile: employee.mobile ?? null, + email: employee.email ?? null, + title: employee.title ?? null, + sourceStatus: 'EMPLOYED', + primaryDepartmentExternalId: employee.primaryDepartmentExternalId ?? null, + departmentExternalIds: employee.departmentExternalIds, + rawSnapshot: employee.rawSnapshot, + syncedAt: now, + }, + }) + continue + } + + await prisma.employeeDirectory.create({ + data: { + dingtalkUserId: employee.dingtalkUserId ?? null, + dingtalkUnionId: employee.dingtalkUnionId ?? null, + jobNumber: employee.jobNumber ?? null, + name: employee.name, + mobile: employee.mobile ?? null, + email: employee.email ?? null, + title: employee.title ?? null, + sourceStatus: 'EMPLOYED', + primaryDepartmentExternalId: employee.primaryDepartmentExternalId ?? null, + departmentExternalIds: employee.departmentExternalIds, + rawSnapshot: employee.rawSnapshot, + syncedAt: now, + }, + }) + } + + for (const dimission of input.dimissionEntries) { + const dimissionConditions = [ + dimission.dingtalkUserId ? { dingtalkUserId: dimission.dingtalkUserId } : undefined, + dimission.dingtalkUnionId ? { dingtalkUnionId: dimission.dingtalkUnionId } : undefined, + dimission.mobile ? { mobile: dimission.mobile } : undefined, + dimission.jobNumber ? { jobNumber: dimission.jobNumber } : undefined, + ].filter(Boolean) as Prisma.EmployeeDirectoryWhereInput[] + + if (dimissionConditions.length === 0) { + continue + } + + const matchedEmployee = await prisma.employeeDirectory.findFirst({ + where: { + OR: dimissionConditions, + }, + select: { + id: true, + user: { + select: { id: true }, + }, + }, + }) + + if (!matchedEmployee) { + continue + } + + await prisma.employeeDirectory.update({ + where: { id: matchedEmployee.id }, + data: { + sourceStatus: 'DIMISSIONED', + rawSnapshot: dimission.rawSnapshot, + syncedAt: now, + }, + }) + + if (matchedEmployee.user?.id) { + await prisma.user.update({ + where: { id: matchedEmployee.user.id }, + data: { status: 'INACTIVE' }, + }) + } + } + + const activeEmployees = await prisma.employeeDirectory.findMany({ + where: { sourceStatus: 'EMPLOYED' }, + select: { + id: true, + user: { + select: { id: true }, + }, + primaryDepartmentExternalId: true, + }, + }) + + for (const employee of activeEmployees) { + if (!employee.user?.id) { + continue + } + const mappedDepartment = employee.primaryDepartmentExternalId + ? departmentMap.get(employee.primaryDepartmentExternalId) + : null + await prisma.user.update({ + where: { id: employee.user.id }, + data: { + departmentId: mappedDepartment?.id ?? null, + }, + }) + } + + return { + departments: departmentMap.size, + employees: input.employees.length, + dimissions: input.dimissionEntries.length, + } + } +} + +export const employeeDirectoryRepository = new EmployeeDirectoryRepository() diff --git a/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts new file mode 100644 index 00000000..491d1abd --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/dingtalk-auth.service.test.ts @@ -0,0 +1,61 @@ +import { afterAll, beforeEach, describe, expect, it, jest } from '@jest/globals' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + count: jest.fn(), + update: jest.fn(), + create: jest.fn(), + }, + }, +})) + +jest.mock('../auth.service', () => ({ + authService: { + createSessionForUserId: jest.fn(), + }, +})) + +jest.mock('../auth-redirect.service', () => ({ + resolvePostLoginRedirect: jest.fn(async (path?: string | null) => path || '/'), +})) + +const originalEnv = process.env +const { resolveDingtalkCallbackOrigin } = require('../dingtalk-auth.service') + +describe('dingtalk-auth.service', () => { + beforeEach(() => { + process.env = { ...originalEnv } + delete process.env.DINGTALK_CALLBACK_ORIGIN + delete process.env.NEXT_PUBLIC_APP_URL + delete process.env.NEXTAUTH_URL + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('应优先使用显式配置的钉钉回调源地址', () => { + process.env.DINGTALK_CALLBACK_ORIGIN = 'https://cp.jiansun.vip/' + process.env.NEXT_PUBLIC_APP_URL = 'http://180.97.215.57:5246' + + expect(resolveDingtalkCallbackOrigin('http://request.local')).toBe('https://cp.jiansun.vip') + }) + + it('未配置钉钉回调时应回退到应用公开地址', () => { + process.env.NEXT_PUBLIC_APP_URL = 'http://180.97.215.57:5246/' + + expect(resolveDingtalkCallbackOrigin('http://request.local')).toBe('http://180.97.215.57:5246') + }) + + it('应用公开地址缺失时应回退到 NEXTAUTH_URL', () => { + process.env.NEXTAUTH_URL = 'http://180.97.215.57:5246/' + + expect(resolveDingtalkCallbackOrigin('http://request.local')).toBe('http://180.97.215.57:5246') + }) + + it('所有配置都缺失时应回退到当前请求地址', () => { + expect(resolveDingtalkCallbackOrigin('http://request.local/')).toBe('http://request.local') + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts new file mode 100644 index 00000000..89bf5fd3 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/employee-directory-sync.service.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, jest, beforeEach } from '@jest/globals' +import { AppError, ErrorCode } from '@/server/core/errors' + +const mockUpsertMany: jest.Mock = jest.fn() + +jest.mock('../../repositories/employee-directory.repository', () => ({ + employeeDirectoryRepository: { + upsertMany: (...args: unknown[]) => mockUpsertMany(...args), + }, +})) + +const { employeeDirectorySyncService } = require('../employee-directory-sync.service') + +describe('employee-directory-sync.service', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUpsertMany.mockImplementation(async () => ({ + departments: 1, + employees: 1, + dimissions: 1, + })) + }) + + it('应将 dimissions 字段映射为仓储层需要的 dimissionEntries', async () => { + const payload = { + departments: [{ externalId: 'dept-1', name: '设计部' }], + employees: [{ name: '张三' }], + dimissions: [{ mobile: '13800000000' }], + } + + await employeeDirectorySyncService.sync(payload) + + expect(mockUpsertMany).toHaveBeenCalledWith({ + departments: payload.departments, + employees: payload.employees, + dimissionEntries: payload.dimissions, + }) + }) + + it('部门缺少必要字段时应报验证错误', async () => { + await expect( + employeeDirectorySyncService.sync({ + departments: [{ externalId: '', name: '' }], + employees: [], + dimissions: [], + }), + ).rejects.toMatchObject({ + code: ErrorCode.VALIDATION_FAILED, + message: '部门同步数据缺少必要字段', + } as Partial) + }) +}) diff --git a/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts b/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts index ad9c22de..650fa194 100644 --- a/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts +++ b/furniture-3d-system/src/server/services/asset-variant-thumbnail.service.ts @@ -80,7 +80,9 @@ function collectAllowedOrigins(renderBaseUrl: string): Set { } pushOrigin(renderBaseUrl) + pushOrigin(process.env.S3_PUBLIC_DOMAIN) pushOrigin(process.env.S3_PUBLIC_ENDPOINT) + pushOrigin(process.env.S3_PUBLIC_URL) pushOrigin(process.env.S3_ENDPOINT) return origins diff --git a/furniture-3d-system/src/server/services/auth-redirect.service.ts b/furniture-3d-system/src/server/services/auth-redirect.service.ts index e1f6767b..a374d8c2 100644 --- a/furniture-3d-system/src/server/services/auth-redirect.service.ts +++ b/furniture-3d-system/src/server/services/auth-redirect.service.ts @@ -17,6 +17,7 @@ const STATIC_REDIRECT_PATHS = new Set([ '/first-login', '/admin/categories', '/admin/materials', + '/asset-center/material-library', '/admin/themes', ]) diff --git a/furniture-3d-system/src/server/services/auth.service.ts b/furniture-3d-system/src/server/services/auth.service.ts index f70e9c6a..c04f4a3e 100644 --- a/furniture-3d-system/src/server/services/auth.service.ts +++ b/furniture-3d-system/src/server/services/auth.service.ts @@ -66,6 +66,9 @@ export interface AuthUserDTO { name: string role: Role status: UserStatus + loginSource: string + activationRequired: boolean + employeeDirectoryId: string | null isFirstLogin: boolean department: { id: string; name: string } | null team: { id: string; name: string } | null @@ -272,6 +275,15 @@ class AuthService { } } + if (user.status === 'PENDING') { + return { + authenticated: false, + code: ErrorCode.AUTH_PENDING_ACTIVATION, + message: '账号待管理员激活', + status: 403, + } + } + if (user.status === 'INACTIVE' || user.status === 'SUSPENDED') { return { authenticated: false, @@ -289,6 +301,9 @@ class AuthService { name: user.name, role: user.role, status: user.status, + loginSource: user.loginSource, + activationRequired: false, + employeeDirectoryId: user.employeeDirectoryId, isFirstLogin: user.isFirstLogin, department: user.department, team: user.team, @@ -324,6 +339,10 @@ class AuthService { throw new AppError(ErrorCode.AUTH_ACCOUNT_DISABLED, '账号已被禁用') } + if (user.status === 'PENDING') { + throw new AppError(ErrorCode.AUTH_PENDING_ACTIVATION, '账号待管理员激活') + } + return generateToken({ userId: user.id, email: user.email, @@ -354,6 +373,10 @@ class AuthService { } private assertAccountEnabled(user: Pick): void { + if (user.status === 'PENDING') { + throw new AppError(ErrorCode.AUTH_PENDING_ACTIVATION, '账号待管理员激活') + } + if (user.status === 'INACTIVE') { throw new AppError(ErrorCode.AUTH_ACCOUNT_DISABLED, '该账号已离职,无法登录') } @@ -383,6 +406,9 @@ class AuthService { name: user.name, role: user.role, status: user.status, + loginSource: user.loginSource, + activationRequired: user.status === 'PENDING', + employeeDirectoryId: user.employeeDirectoryId, isFirstLogin: user.isFirstLogin, department: user.department, team: user.team, diff --git a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts index d44f5242..d07e680e 100644 --- a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts +++ b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts @@ -6,6 +6,7 @@ import { generateRandomPassword, hashPassword } from '@/lib/password' import { AppError, ErrorCode } from '@/server/core/errors' import { authService, type LoginResult } from './auth.service' import { resolvePostLoginRedirect } from './auth-redirect.service' +import { employeeDirectoryRepository } from '../repositories/employee-directory.repository' const DINGTALK_AUTH_URL = 'https://login.dingtalk.com/oauth2/auth' const DINGTALK_USER_ACCESS_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken' @@ -30,13 +31,27 @@ interface DingtalkProfile { mobile?: string | null } +export function resolveDingtalkCallbackOrigin(requestOrigin: string): string { + const configuredOrigin = process.env.DINGTALK_CALLBACK_ORIGIN?.trim() + if (configuredOrigin) { + return configuredOrigin.replace(/\/$/, '') + } + + const appOrigin = process.env.NEXT_PUBLIC_APP_URL?.trim() || process.env.NEXTAUTH_URL?.trim() + if (appOrigin) { + return appOrigin.replace(/\/$/, '') + } + + return requestOrigin.replace(/\/$/, '') +} + class DingtalkAuthService { buildAuthorizationUrl(input: { requestOrigin: string redirectPath?: string | null }): string { const clientId = this.getClientId() - const callbackOrigin = this.getCallbackOrigin(input.requestOrigin) + const callbackOrigin = resolveDingtalkCallbackOrigin(input.requestOrigin) const callbackUrl = new URL('/api/auth/dingtalk/callback', callbackOrigin).toString() const state = this.encodeState({ redirectPath: input.redirectPath || '/', @@ -92,14 +107,6 @@ class DingtalkAuthService { return clientSecret } - private getCallbackOrigin(requestOrigin: string): string { - const configuredOrigin = process.env.DINGTALK_CALLBACK_ORIGIN?.trim() - if (configuredOrigin) { - return configuredOrigin.replace(/\/$/, '') - } - return requestOrigin.replace(/\/$/, '') - } - private getStateSecret(): string { const secret = process.env.JWT_SECRET?.trim() if (!secret) { @@ -252,18 +259,34 @@ class DingtalkAuthService { } private async findOrProvisionUser(profile: DingtalkProfile) { + const employeeDirectory = await employeeDirectoryRepository.findByDingtalkIdentity({ + dingtalkUserId: profile.userId, + dingtalkUnionId: profile.unionId, + mobile: profile.mobile, + }) + const existingByUserId = await prisma.user.findUnique({ where: { dingtalkUserId: profile.userId }, }) if (existingByUserId) { - return this.updateBoundUser(existingByUserId.id, profile) + return this.updateBoundUser( + existingByUserId.id, + profile, + employeeDirectory?.id ?? null, + employeeDirectory?.primaryDepartmentExternalId ?? null + ) } const existingByUnionId = await prisma.user.findUnique({ where: { dingtalkUnionId: profile.unionId }, }) if (existingByUnionId) { - return this.updateBoundUser(existingByUnionId.id, profile) + return this.updateBoundUser( + existingByUnionId.id, + profile, + employeeDirectory?.id ?? null, + employeeDirectory?.primaryDepartmentExternalId ?? null + ) } const normalizedEmail = this.normalizeEmail(profile.email) @@ -277,23 +300,21 @@ class DingtalkAuthService { throw new AppError(ErrorCode.VALIDATION_FAILED, '该邮箱已绑定其他钉钉账号,请联系管理员处理') } - return prisma.user.update({ - where: { id: existingByEmail.id }, - data: { - dingtalkUserId: profile.userId, - dingtalkUnionId: profile.unionId, - dingtalkBoundAt: new Date(), - loginSource: 'DINGTALK', - avatar: profile.avatar || existingByEmail.avatar, - phone: profile.mobile || existingByEmail.phone, - }, - }) + return this.updateBoundUser( + existingByEmail.id, + profile, + employeeDirectory?.id ?? null, + employeeDirectory?.primaryDepartmentExternalId ?? null + ) } } const loginName = await this.buildUniqueLoginName(profile.name) const email = await this.buildUniqueEmail(normalizedEmail, profile.userId) const password = await hashPassword(generateRandomPassword()) + const departmentId = employeeDirectory?.primaryDepartmentExternalId + ? await this.resolveDepartmentIdByExternalId(employeeDirectory.primaryDepartmentExternalId) + : null return prisma.user.create({ data: { @@ -301,7 +322,7 @@ class DingtalkAuthService { name: loginName, password, role: 'DESIGNER', - status: 'ACTIVE', + status: employeeDirectory ? 'ACTIVE' : 'PENDING', isFirstLogin: false, avatar: profile.avatar || null, phone: profile.mobile || null, @@ -309,11 +330,35 @@ class DingtalkAuthService { dingtalkUserId: profile.userId, dingtalkUnionId: profile.unionId, dingtalkBoundAt: new Date(), + employeeDirectoryId: employeeDirectory?.id ?? null, + departmentId, }, }) } - private async updateBoundUser(userId: string, profile: DingtalkProfile) { + private async updateBoundUser( + userId: string, + profile: DingtalkProfile, + employeeDirectoryId: string | null, + primaryDepartmentExternalId: string | null + ) { + const currentUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + status: true, + departmentId: true, + }, + }) + + const departmentId = primaryDepartmentExternalId + ? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId) + : null + + const nextStatus = + employeeDirectoryId && currentUser?.status === 'PENDING' + ? 'ACTIVE' + : currentUser?.status ?? 'ACTIVE' + return prisma.user.update({ where: { id: userId }, data: { @@ -323,10 +368,22 @@ class DingtalkAuthService { loginSource: 'DINGTALK', avatar: profile.avatar || undefined, phone: profile.mobile || undefined, + employeeDirectoryId: employeeDirectoryId ?? undefined, + departmentId: departmentId ?? currentUser?.departmentId ?? undefined, + status: nextStatus, }, }) } + private async resolveDepartmentIdByExternalId(externalId: string): Promise { + const department = await prisma.department.findUnique({ + where: { externalId }, + select: { id: true }, + }) + + return department?.id ?? null + } + private normalizeEmail(email: string | null | undefined): string | null { if (!email) { return null diff --git a/furniture-3d-system/src/server/services/employee-directory-sync.service.ts b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts new file mode 100644 index 00000000..811e4834 --- /dev/null +++ b/furniture-3d-system/src/server/services/employee-directory-sync.service.ts @@ -0,0 +1,68 @@ +import 'server-only' + +import { Prisma } from '@prisma/client' +import { AppError, ErrorCode } from '@/server/core/errors' +import { employeeDirectoryRepository } from '@/server/repositories/employee-directory.repository' + +export interface HrSyncDepartmentInput { + externalId: string + name: string + parentExternalId?: string | null + orderIndex?: number + rawSnapshot?: Prisma.InputJsonValue +} + +export interface HrSyncEmployeeInput { + dingtalkUserId?: string | null + dingtalkUnionId?: string | null + jobNumber?: string | null + name: string + mobile?: string | null + email?: string | null + title?: string | null + primaryDepartmentExternalId?: string | null + departmentExternalIds?: Prisma.InputJsonValue + rawSnapshot?: Prisma.InputJsonValue +} + +export interface HrSyncDimissionInput { + dingtalkUserId?: string | null + dingtalkUnionId?: string | null + mobile?: string | null + jobNumber?: string | null + rawSnapshot?: Prisma.InputJsonValue +} + +export interface HrSyncPayload { + departments: HrSyncDepartmentInput[] + employees: HrSyncEmployeeInput[] + dimissions: HrSyncDimissionInput[] +} + +class EmployeeDirectorySyncService { + async sync(payload: HrSyncPayload) { + if (!Array.isArray(payload.departments) || !Array.isArray(payload.employees) || !Array.isArray(payload.dimissions)) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '同步数据格式不正确') + } + + for (const department of payload.departments) { + if (!department.externalId || !department.name) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '部门同步数据缺少必要字段') + } + } + + for (const employee of payload.employees) { + if (!employee.name) { + throw new AppError(ErrorCode.VALIDATION_FAILED, '员工同步数据缺少姓名') + } + } + + return employeeDirectoryRepository.upsertMany({ + departments: payload.departments, + employees: payload.employees, + dimissionEntries: payload.dimissions, + }) + } +} + +export const employeeDirectorySyncService = new EmployeeDirectorySyncService() diff --git a/scripts/create-wyj-test-deploy.ps1 b/scripts/create-wyj-test-deploy.ps1 index 80530273..cb3246bc 100644 --- a/scripts/create-wyj-test-deploy.ps1 +++ b/scripts/create-wyj-test-deploy.ps1 @@ -224,6 +224,14 @@ CREATE UNIQUE INDEX IF NOT EXISTS "User_dingtalkUnionId_key" ON "User"("dingtalk CREATE INDEX IF NOT EXISTS "User_loginSource_idx" ON "User"("loginSource"); SQL npx prisma generate +PRISMA_SCHEMA_SYNC_SQL=/tmp/furniture-3d-system-wyj-test-prisma-schema-sync.sql +npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script -o "$PRISMA_SCHEMA_SYNC_SQL" +if [ -s "$PRISMA_SCHEMA_SYNC_SQL" ]; then + echo '=== 同步 Prisma 数据结构差异 ===' + sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB_NAME -f "$PRISMA_SCHEMA_SYNC_SQL" +else + echo '=== Prisma 数据结构已是最新 ===' +fi npm run build pm2 delete furniture-3d-system-wyj-test >/dev/null 2>&1 || true diff --git a/scripts/create-yydd-prod-deploy.ps1 b/scripts/create-yydd-prod-deploy.ps1 new file mode 100644 index 00000000..d93b418d --- /dev/null +++ b/scripts/create-yydd-prod-deploy.ps1 @@ -0,0 +1,352 @@ +param( + [string]$ProjectRoot = "D:\AI Code\3danzhuang\furniture-3d-system", + [string]$OutputRoot = "D:\AI Code\3danzhuang", + [string]$RemoteBase = "/opt/furniture-3d-system-prod", + [string]$ServerHost = "ltai5tdb2ogyj59fquyedh.yydd.ai", + [int]$AppPort = 3001, + [string]$PublicAppUrl = "", + [string]$Pm2Name = "furniture-3d-system-prod", + [string]$DbName = "furniture3d_prod", + [string]$DbUser = "furniture3d_prod", + [string]$DbPass = "", + [string]$HrSyncSecret = "", + [string]$S3Endpoint = "http://192.168.58.20:9000", + [string]$S3Bucket = "gongxiangpan", + [string]$S3Region = "us-east-1", + [string]$S3PublicDomain = "https://s3.jiansun.vip/gongxiangpan", + [string]$S3FilePath = "3danzhuang/furniture-3d-system/" +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $ProjectRoot)) { + throw "项目目录不存在: $ProjectRoot" +} + +function Get-EnvValue { + param( + [string]$Path, + [string]$Key + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + + $line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Key))=" } | Select-Object -First 1 + if (-not $line) { + return $null + } + + $value = $line.Substring($line.IndexOf('=') + 1).Trim() + if ($value.Length -ge 2) { + if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) { + $value = $value.Substring(1, $value.Length - 2) + } + } + + return $value +} + +function New-RandomSecret([int]$Length = 48) { + $bytes = New-Object byte[] $Length + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) + return [Convert]::ToBase64String($bytes).Replace('+', 'A').Replace('/', 'B').TrimEnd('=').Substring(0, $Length) +} + +$localEnvPath = Join-Path $ProjectRoot '.env' +$jwtSecret = Get-EnvValue -Path $localEnvPath -Key 'JWT_SECRET' +if ([string]::IsNullOrWhiteSpace($jwtSecret) -or $jwtSecret.Length -lt 32) { + $jwtSecret = New-RandomSecret 48 +} + +$nextAuthSecret = Get-EnvValue -Path $localEnvPath -Key 'NEXTAUTH_SECRET' +if ([string]::IsNullOrWhiteSpace($nextAuthSecret) -or $nextAuthSecret.Length -lt 32) { + $nextAuthSecret = New-RandomSecret 48 +} + +if ([string]::IsNullOrWhiteSpace($DbPass)) { + $DbPass = New-RandomSecret 32 +} + +if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) { + $HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'HR_SYNC_SECRET' +} +if ([string]::IsNullOrWhiteSpace($HrSyncSecret)) { + $HrSyncSecret = Get-EnvValue -Path $localEnvPath -Key 'INTERNAL_SYNC_SECRET' +} +if ([string]::IsNullOrWhiteSpace($HrSyncSecret) -or $HrSyncSecret.Length -lt 32) { + $HrSyncSecret = New-RandomSecret 48 +} + +if ([string]::IsNullOrWhiteSpace($PublicAppUrl)) { + $publicAppUrl = "http://$ServerHost`:$AppPort" +} else { + $publicAppUrl = $PublicAppUrl.TrimEnd('/') +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$archiveName = "deploy-furniture-prod-$timestamp.tar.gz" +$scriptName = "deploy-furniture-prod-$timestamp.sh" +$archivePath = Join-Path $OutputRoot $archiveName +$scriptPath = Join-Path $OutputRoot $scriptName + +$includePaths = @( + 'furniture-3d-system/package.json' + 'furniture-3d-system/package-lock.json' + 'furniture-3d-system/.npmrc' + 'furniture-3d-system/next.config.js' + 'furniture-3d-system/postcss.config.mjs' + 'furniture-3d-system/tsconfig.json' + 'furniture-3d-system/next-env.d.ts' + 'furniture-3d-system/prisma.config.ts' + 'furniture-3d-system/src' + 'furniture-3d-system/public' + 'furniture-3d-system/prisma' + 'furniture-3d-system/scripts' + 'furniture-3d-system/lib' +) + +Push-Location (Split-Path $ProjectRoot -Parent) +try { + if (Test-Path -LiteralPath $archivePath) { + Remove-Item -LiteralPath $archivePath -Force + } + + tar -czf $archivePath @includePaths +} finally { + Pop-Location +} + +$shellScript = @' +set -e +BASE=__REMOTE_BASE__ +APP=$BASE/furniture-3d-system +ARCHIVE=$BASE/__ARCHIVE_NAME__ +DB_NAME=__DB_NAME__ +DB_USER=__DB_USER__ +DB_PASS='__DB_PASS__' +APP_PORT=__APP_PORT__ +PM2_NAME=__PM2_NAME__ +PUBLIC_APP_URL='__PUBLIC_APP_URL__' +S3_ENDPOINT='__S3_ENDPOINT__' +S3_BUCKET='__S3_BUCKET__' +S3_REGION='__S3_REGION__' +S3_PUBLIC_DOMAIN='__S3_PUBLIC_DOMAIN__' +S3_FILE_PATH='__S3_FILE_PATH__' +NEXTAUTH_SECRET='__NEXTAUTH_SECRET__' +JWT_SECRET='__JWT_SECRET__' +HR_SYNC_SECRET='__HR_SYNC_SECRET__' + +EXISTING_ENV_PATH="$BASE/.env" +read_env_value() { + local key="$1" + if [ ! -f "$EXISTING_ENV_PATH" ]; then + return 0 + fi + local line + line=$(grep -E "^${key}=" "$EXISTING_ENV_PATH" | head -n 1 || true) + if [ -z "$line" ]; then + return 0 + fi + local value="${line#*=}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + printf '%s' "$value" +} + +S3_ACCESS_KEY_VALUE="${S3_ACCESS_KEY:-${S3_ACCESS_KEY_ID:-$(read_env_value S3_ACCESS_KEY)}}" +S3_SECRET_KEY_VALUE="${S3_SECRET_KEY:-${S3_SECRET_ACCESS_KEY:-$(read_env_value S3_SECRET_KEY)}}" +NEXTAUTH_SECRET_VALUE="${NEXTAUTH_SECRET:-$(read_env_value NEXTAUTH_SECRET)}" +JWT_SECRET_VALUE="${JWT_SECRET:-$(read_env_value JWT_SECRET)}" +DINGTALK_CALLBACK_ORIGIN_VALUE="${DINGTALK_CALLBACK_ORIGIN:-$(read_env_value DINGTALK_CALLBACK_ORIGIN)}" +DINGTALK_CLIENT_ID_VALUE="${DINGTALK_CLIENT_ID:-$(read_env_value DINGTALK_CLIENT_ID)}" +DINGTALK_CLIENT_SECRET_VALUE="${DINGTALK_CLIENT_SECRET:-$(read_env_value DINGTALK_CLIENT_SECRET)}" +HR_SYNC_SECRET_VALUE="${HR_SYNC_SECRET:-${INTERNAL_SYNC_SECRET:-$(read_env_value HR_SYNC_SECRET)}}" + +if [ -z "$NEXTAUTH_SECRET_VALUE" ]; then + NEXTAUTH_SECRET_VALUE="$NEXTAUTH_SECRET" +fi +if [ -z "$JWT_SECRET_VALUE" ]; then + JWT_SECRET_VALUE="$JWT_SECRET" +fi +if [ -z "$DINGTALK_CALLBACK_ORIGIN_VALUE" ]; then + DINGTALK_CALLBACK_ORIGIN_VALUE="$PUBLIC_APP_URL" +fi +if [ -z "$HR_SYNC_SECRET_VALUE" ]; then + HR_SYNC_SECRET_VALUE="$HR_SYNC_SECRET" +fi + +if [ -z "$S3_ACCESS_KEY_VALUE" ] || [ -z "$S3_SECRET_KEY_VALUE" ]; then + echo 'Missing shared S3 credentials. Export S3_ACCESS_KEY / S3_SECRET_KEY (or AWS-style names) before running this script.' + exit 1 +fi + +mkdir -p $BASE +mkdir -p $APP +OLD_LOCK_HASH="" +if [ -f "$APP/package-lock.json" ]; then + OLD_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}') +fi + +rm -rf \ + $APP/src \ + $APP/public \ + $APP/prisma \ + $APP/scripts \ + $APP/lib \ + $APP/.next + +rm -f \ + $APP/package.json \ + $APP/package-lock.json \ + $APP/.npmrc \ + $APP/next.config.js \ + $APP/postcss.config.mjs \ + $APP/tsconfig.json \ + $APP/next-env.d.ts \ + $APP/prisma.config.ts + +cd $BASE +tar -xzf $ARCHIVE + +NEW_LOCK_HASH="" +if [ -f "$APP/package-lock.json" ]; then + NEW_LOCK_HASH=$(sha256sum "$APP/package-lock.json" | awk '{print $1}') +fi + +sudo -u postgres psql -d postgres < $BASE/.env <> $BASE/.env +fi +if [ -n "$DINGTALK_CLIENT_SECRET_VALUE" ]; then + echo "DINGTALK_CLIENT_SECRET=$DINGTALK_CLIENT_SECRET_VALUE" >> $BASE/.env +fi +ln -sfn $BASE/.env $APP/.env + +cd $APP +export PUPPETEER_SKIP_DOWNLOAD=true +if [ ! -d "$APP/node_modules" ] || [ "$OLD_LOCK_HASH" != "$NEW_LOCK_HASH" ]; then + echo '=== Install or update dependencies ===' + npm install --no-audit --no-fund +else + echo '=== Reuse existing dependencies (package-lock unchanged) ===' +fi + +if find /root/.cache/puppeteer -type f -name chrome-headless-shell 2>/dev/null | grep -q .; then + echo '=== Reuse existing chrome-headless-shell ===' +else + echo '=== Install chrome-headless-shell ===' + PUPPETEER_CACHE_DIR=/root/.cache/puppeteer npx puppeteer browsers install chrome-headless-shell@stable +fi + +npx prisma generate +PRISMA_SCHEMA_SYNC_SQL=/tmp/furniture-3d-system-prod-prisma-schema-sync.sql +npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script -o "$PRISMA_SCHEMA_SYNC_SQL" +if [ -s "$PRISMA_SCHEMA_SYNC_SQL" ]; then + echo '=== Sync Prisma schema diff ===' + sudo -u postgres psql -v ON_ERROR_STOP=1 -d $DB_NAME -f "$PRISMA_SCHEMA_SYNC_SQL" +else + echo '=== Prisma schema already up to date ===' +fi + +npm run build + +mkdir -p "$APP/.next/standalone/.next" +rm -rf "$APP/.next/standalone/.next/static" "$APP/.next/standalone/public" +cp -R "$APP/.next/static" "$APP/.next/standalone/.next/static" +cp -R "$APP/public" "$APP/.next/standalone/public" + +pm2 delete $PM2_NAME >/dev/null 2>&1 || true +pm2 start /bin/bash --name $PM2_NAME -- -lc "cd '$APP' && set -a && source '$APP/.env' && set +a && mkdir -p '$APP/.next/standalone/.next' && rm -rf '$APP/.next/standalone/.next/static' '$APP/.next/standalone/public' && cp -R '$APP/.next/static' '$APP/.next/standalone/.next/static' && cp -R '$APP/public' '$APP/.next/standalone/public' && exec env PORT=$APP_PORT HOSTNAME=0.0.0.0 node '$APP/.next/standalone/server.js'" +pm2 save >/dev/null +sleep 5 +curl -I -s http://127.0.0.1:$APP_PORT | sed -n '1,12p' +echo '=== DONE ===' +pm2 describe $PM2_NAME | sed -n '1,35p' +'@ + +$shellScript = $shellScript.Replace('__REMOTE_BASE__', $RemoteBase) +$shellScript = $shellScript.Replace('__ARCHIVE_NAME__', $archiveName) +$shellScript = $shellScript.Replace('__DB_NAME__', $DbName) +$shellScript = $shellScript.Replace('__DB_USER__', $DbUser) +$shellScript = $shellScript.Replace('__DB_PASS__', $DbPass) +$shellScript = $shellScript.Replace('__APP_PORT__', [string]$AppPort) +$shellScript = $shellScript.Replace('__PM2_NAME__', $Pm2Name) +$shellScript = $shellScript.Replace('__PUBLIC_APP_URL__', $publicAppUrl) +$shellScript = $shellScript.Replace('__S3_ENDPOINT__', $S3Endpoint) +$shellScript = $shellScript.Replace('__S3_BUCKET__', $S3Bucket) +$shellScript = $shellScript.Replace('__S3_REGION__', $S3Region) +$shellScript = $shellScript.Replace('__S3_PUBLIC_DOMAIN__', $S3PublicDomain) +$shellScript = $shellScript.Replace('__S3_FILE_PATH__', $S3FilePath) +$shellScript = $shellScript.Replace('__NEXTAUTH_SECRET__', $nextAuthSecret) +$shellScript = $shellScript.Replace('__JWT_SECRET__', $jwtSecret) +$shellScript = $shellScript.Replace('__HR_SYNC_SECRET__', $HrSyncSecret) + +[System.IO.File]::WriteAllText($scriptPath, $shellScript, [System.Text.UTF8Encoding]::new($false)) + +$archive = Get-Item -LiteralPath $archivePath +[PSCustomObject]@{ + Archive = $archive.FullName + ArchiveMB = [math]::Round($archive.Length / 1MB, 2) + Script = $scriptPath + RemoteBase = $RemoteBase + AppPort = $AppPort + PublicAppUrl = $publicAppUrl + S3Bucket = $S3Bucket + S3FilePath = $S3FilePath +} diff --git a/scripts/hr_sync_worker.py b/scripts/hr_sync_worker.py new file mode 100644 index 00000000..716d714a --- /dev/null +++ b/scripts/hr_sync_worker.py @@ -0,0 +1,146 @@ +import json +import os +import sys +from datetime import datetime +from typing import Any + +import pytds +import requests + + +def env(name: str, default: str | None = None, required: bool = False) -> str: + value = os.getenv(name, default) + if isinstance(value, str): + value = value.strip() + if required and not value: + raise RuntimeError(f"missing env: {name}") + return value or "" + + +def get_connection(): + return pytds.connect( + server=env("HR_SQLSERVER_HOST", required=True), + port=int(env("HR_SQLSERVER_PORT", "1433")), + database=env("HR_SQLSERVER_DATABASE", required=True), + user=env("HR_SQLSERVER_USER", required=True), + password=env("HR_SQLSERVER_PASSWORD", required=True), + as_dict=True, + login_timeout=15, + timeout=60, + ) + + +def pick(row: dict[str, Any], *keys: str): + for key in keys: + if key in row and row[key] not in (None, ""): + return row[key] + return None + + +def stringify(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value).strip() + + +def normalize_department(row: dict[str, Any]) -> dict[str, Any]: + external_id = stringify(pick(row, "Id", "id", "group_id")) + name = stringify(pick(row, "name", "Name")) + if not external_id or not name: + raise RuntimeError("department row missing external id or name") + + return { + "externalId": external_id, + "name": name, + "parentExternalId": stringify(pick(row, "parent_id", "parentId", "ParentId")), + "orderIndex": int(pick(row, "sort", "sortOrder", "SortOrder") or 0), + "rawSnapshot": row, + } + + +def normalize_employee(row: dict[str, Any]) -> dict[str, Any]: + dept_list = pick(row, "dept_id_list", "deptIdList", "dept_ids") + department_external_ids = [] + if isinstance(dept_list, str) and dept_list.strip(): + department_external_ids = [item.strip() for item in dept_list.split(",") if item.strip()] + elif isinstance(dept_list, (list, tuple)): + department_external_ids = [str(item).strip() for item in dept_list if str(item).strip()] + + primary_department = department_external_ids[0] if department_external_ids else None + name = stringify(pick(row, "name", "Name")) + if not name: + raise RuntimeError("employee row missing name") + + return { + "dingtalkUserId": stringify(pick(row, "userid", "userId", "UserId")), + "dingtalkUnionId": stringify(pick(row, "unionid", "unionId", "UnionId")), + "jobNumber": stringify(pick(row, "job_number", "jobNumber", "JobNumber")), + "name": name, + "mobile": stringify(pick(row, "mobile", "Mobile")), + "email": stringify(pick(row, "email", "Email")), + "title": stringify(pick(row, "title", "Title")), + "primaryDepartmentExternalId": stringify(primary_department), + "departmentExternalIds": department_external_ids, + "rawSnapshot": row, + } + + +def normalize_dimission(row: dict[str, Any]) -> dict[str, Any]: + return { + "dingtalkUserId": stringify(pick(row, "userid", "userId", "UserId")), + "dingtalkUnionId": stringify(pick(row, "unionid", "unionId", "UnionId")), + "mobile": stringify(pick(row, "mobile", "Mobile")), + "jobNumber": stringify(pick(row, "job_number", "jobNumber", "JobNumber")), + "rawSnapshot": row, + } + + +def fetch_rows(cursor, query: str): + cursor.execute(query) + return list(cursor.fetchall()) + + +def build_payload() -> dict: + with get_connection() as conn: + with conn.cursor() as cursor: + departments = fetch_rows(cursor, "select * from dbo.hr_groups") + employees = fetch_rows(cursor, "select * from dbo.hr_employee") + dimissions = fetch_rows(cursor, "select * from dbo.hr_employee_dimission") + + return { + "departments": [normalize_department(row) for row in departments if pick(row, "Id", "id", "group_id")], + "employees": [normalize_employee(row) for row in employees if pick(row, "name", "Name")], + "dimissions": [normalize_dimission(row) for row in dimissions], + } + + +def push_payload(payload: dict): + endpoint = env("HR_SYNC_ENDPOINT", required=True) + secret = env("HR_SYNC_SECRET", required=True) + response = requests.post( + endpoint, + headers={ + "Content-Type": "application/json", + "x-internal-sync-secret": secret, + }, + data=json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8"), + timeout=120, + ) + response.raise_for_status() + return response.json() + + +def main(): + started_at = datetime.now().isoformat() + print(f"[{started_at}] start hr sync") + payload = build_payload() + result = push_payload(payload) + print(json.dumps(result, ensure_ascii=False, default=str)) + + +if __name__ == "__main__": + try: + main() + except Exception as exc: # noqa: BLE001 + print(f"hr sync failed: {exc}", file=sys.stderr) + raise diff --git a/会话恢复摘要-20260415.json b/会话恢复摘要-20260415.json new file mode 100644 index 00000000..be3e5dd3 --- /dev/null +++ b/会话恢复摘要-20260415.json @@ -0,0 +1,326 @@ +{ + "摘要类型": "会话恢复摘要(Conversation Resume Summary)", + "摘要版本": "2026-04-15", + "说明": "本摘要不包含系统级/开发级隐藏提示原文,只包含用户显式提供的信息、用户可见规则、项目状态、基础设施、关键决策、完成度和待办,目标是让新的模型尽量无缝继续本会话。", + "工作区": { + "根目录": "D:\\AI Code\\3danzhuang", + "主项目目录": "D:\\AI Code\\3danzhuang\\furniture-3d-system", + "默认执行口径": "用户未指定目录时,默认在 furniture-3d-system(主项目)下工作" + }, + "用户可见规则": { + "语言与表达": [ + "默认中文", + "按非程序员也能听懂的方式表达", + "先给结论,再给必要解释", + "如必须出现英文术语、状态、模块名、报错,统一写成 English(中文) 格式" + ], + "工作原则": [ + "优先根因修复,不做表面补丁", + "前端默认极简、克制、低干扰", + "发现用户方向不对要及时纠偏", + "不主动创建 commit(提交)/ push(推送)/ branch(分支),除非用户明确要求" + ], + "协作边界": { + "Gemini(前端专家)": "负责前端代码、UI(界面)视觉、排版动效、3D(立体)表现层、低干扰前端收口", + "Codex(后端 / 逻辑 / 联调)": "负责接口、数据、数据库、算法、部署、脚本、同步、非前端部分" + }, + "仓库规则补充": [ + "任务发生在 furniture-3d-system(主项目)时,先读取 3 份工作流文档", + "必须包含规范审查结论", + "如果不是独立复审,必须明确写“这是自审,不是独立复审”" + ] + }, + "已读协作文档": [ + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\docs\\workflow\\团队协作模式.md", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\docs\\workflow\\模块边界说明.md", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\docs\\workflow\\主线同步操作手册.md" + ], + "当前产品级关键决策": { + "首页定位": "首页不是总工作台,不承接大 3D(立体) 工作区;首页目标是“已正式入库产品 + 当前账号下的草稿组”", + "自动组合": "auto-combinations(自动组合)已废弃,主线改为 assisted assembly(辅助装配)", + "材质主链": "材质对业务统一表达为 finish(花色);花色负责默认视觉定义;资产录入阶段 3D(立体) 视口只做 UV(贴图方向与缩放)校准;业务阶段不再自由换材质", + "辅助装配": "目标是孔位级准确吸附,不再接受中心点假吸附;拖动过程中允许临时穿模,但最终吸附位姿不能穿模;最终判断对象不是最近单孔,而是 mountPattern(安装图样)", + "用户系统": "DingTalk(钉钉) 登录 + 本地 User(用户)/ Department(部门) + 外部 HR(人事) 只读同步 + 管理员激活", + "人员来源": "公司员工与项目账号分层存储:EmployeeDirectory(员工目录) 作为公司底册,User(项目账号) 作为项目登录与权限载体", + "账户激活规则": { + "公司员工命中本地员工目录": "DingTalk(钉钉) 登录后自动激活", + "DingTalk(钉钉) 登录但未命中员工目录": "创建项目账号,状态 PENDING(待激活)", + "普通 Register(注册) 用户": "创建项目账号,状态 PENDING(待激活)", + "激活权限": "只有 ADMIN(管理员) 和 SUPER_ADMIN(超级管理员) 可后台激活" + } + }, + "前端对接板状态": { + "文件": "D:\\AI Code\\3danzhuang\\docs\\前端UI改动清单.md", + "当前定位": "只作为当前轮次前端协作对接板,不是长期规范来源、不是历史总账", + "Gemini(前端专家)当前状态": "独立前端任务已清零,只等待 Codex(后端 / 逻辑 / 联调) 的数据链就绪后做最终验收", + "Codex(后端 / 逻辑 / 联调)当前剩余前后端联调职责": [ + "准确吸附数据主链", + "Users(用户管理) 最终联调", + "给 Gemini(前端专家) 提供稳定数据、权限和跳转承接" + ] + }, + "已完成事项": { + "资料库与材质链": { + "状态": "基本完成", + "已完成": [ + "基础资料库中花色 / 基材主链已收口", + "主贴图 / 缩略图显示链路已打通", + "PBR(物理材质) 参数在新资料库已补回主要项", + "法线图(normal map) 已从新资料库业务入口拿掉", + "统一了 previewUrl(统一预览地址) 逻辑,解决默认占位图误判问题", + "material-categories(花色分类) 已补出创建闭环并修掉 403(禁止访问)" + ], + "关键文件": [ + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\asset-center\\material-library\\MaterialLibraryClient.tsx", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\material.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\material-categories\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\lib\\material-thumbnail.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\lib\\material-asset-path.ts" + ] + }, + "首页与导航底座": { + "状态": "已完成底座", + "已完成": [ + "首页 tab(标签) 深链支持已完成", + "支持 /?tab=products 和 /?tab=drafts", + "首页产品区已按“正式产品”方向收口", + "HomeClient(首页客户端) 与 page(页面入口) 已承接深链" + ], + "关键文件": [ + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\page.tsx", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\HomeClient.workspace.tsx", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\home\\home-client.types.ts" + ] + }, + "用户系统": { + "状态": "代码主链基本完成,待真人联调封板", + "已完成": [ + "UserStatus(用户状态) 已引入 PENDING(待激活)", + "EmployeeDirectory(员工目录) 已落地", + "内部 HR Sync(人事同步) 接口已落地", + "DingTalk(钉钉) 登录自动命中员工目录并自动激活", + "未命中员工目录的 DingTalk(钉钉) 登录创建 PENDING(待激活) 账号", + "普通 Register(注册) 创建 PENDING(待激活) + DESIGNER(设计师) 账号", + "Users(用户管理) 后端已有 PENDING(待激活) 筛选、激活接口、loginSource(登录来源)、employeeDirectoryId(员工目录关联) 标记" + ], + "关键文件": [ + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\internal\\hr-sync\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\dingtalk-auth.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\auth\\register\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\users\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\users\\[id]\\activate\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\user-management.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\user.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\auth.service.ts" + ] + }, + "DingTalk(钉钉) 登录生产联通": { + "状态": "已完成", + "线上入口": "https://cp.jiansun.vip/login", + "回调地址": "https://cp.jiansun.vip/api/auth/dingtalk/callback", + "已解决问题": [ + "白屏问题", + "回调错误跳到 0.0.0.0 问题", + "HTTPS(加密访问) 已接通" + ] + }, + "HR(人事) 同步生产化": { + "状态": "已完成", + "同步服务器": "180.97.215.57", + "运行方式": "systemd(系统服务) + timer(定时器)", + "频率": "每天两次(03:00 和 15:00)", + "最近已验证同步统计": { + "departments(部门)": 1112, + "employees(员工)": 1394, + "dimissions(离职)": 4499 + } + }, + "缩略图回填": { + "状态": "已完成", + "执行环境": "生产服务器", + "关键结果": [ + "修复 chrome-headless-shell(无头浏览器壳) 执行权限", + "最终生产 backfill(回填) 已跑完", + "后续再次执行显示“没有需要处理的规格款”" + ], + "统计": { + "首次小样本回填": "3 成功 / 0 fallback(回退) / 0 failure(失败)", + "最终剩余任务回填": "3 成功 / 0 fallback(回退) / 0 failure(失败)" + } + }, + "生产部署": { + "状态": "已完成,可用", + "部署方式": "增量部署(只同步变更)", + "原因": "当前更适合 PM2(进程管理) + 独立目录 + 独立端口,不走整套 Docker(容器) 方案", + "项目进程": "furniture-3d-system-prod", + "生产目录": "/opt/furniture-3d-system-prod/furniture-3d-system", + "应用本地端口": 3001, + "公网入口": "https://cp.jiansun.vip", + "入口机": "180.97.215.57", + "生产机": "ltai5tdb2ogyj59fquyedh.yydd.ai:60022" + }, + "S3(对象存储) / MinIO(对象存储服务)": { + "状态": "已接通到共享生产桶", + "桶": "gongxiangpan", + "前缀": "3danzhuang/furniture-3d-system/", + "公开域名": "https://s3.jiansun.vip/gongxiangpan", + "已完成": [ + "存储配置兼容共享桶", + "支持独立前缀", + "修掉公开地址双桶名拼接问题", + "兼容 S3_PUBLIC_DOMAIN(公开访问域名)" + ], + "敏感配置说明": "生产密钥不在摘要中明文展开;非敏感说明在 D:\\AI Code\\gongxiangp2\\docs\\生产存储桶接入信息.md;临时本地忽略配置在 D:\\AI Code\\gongxiangp2\\.env.furniture-3d-system.local" + }, + "域名与入口": { + "当前正式入口": "https://cp.jiansun.vip", + "状态": "HTTP(普通访问) 和 HTTPS(加密访问) 已切到当前项目", + "历史说明": { + "ok.yydd.ai": "不是当前项目,也不是本次部署动坏的;当前解析到 45.192.99.48,打开是默认 Nginx(反向代理) 页", + "shujuku2": "仍然在线,实际在当前服务器的 8080 入口,对外旧域名已偏离当前机器" + } + }, + "其他项目影响检查": { + "shujuku2": "仍在线,未被本项目部署破坏", + "ai-chatbot": "按用户之前明确要求停用并让出 3001 端口,目录仍在,不是误删" + }, + "规则与文档清理": { + "状态": "已完成", + "已做": [ + "项目级 AGENTS(协作规则) 已精简", + "docs/前端UI改动清单.md 已从旧总账改为前端对接板", + "清掉明显台账 / agent-memory(代理记忆) / 会话交接文档", + "未碰账号、密码、公钥文件" + ] + } + }, + "未完成事项": { + "辅助装配_准确吸附": { + "状态": "DOING(进行中)", + "这是当前最主要未完成项": true, + "目标": [ + "插件输出更准的孔位语义", + "web(网页端) 严格按 mountFace(吸附面) + holeGroup(孔组) + mountPattern(安装图样) 工作", + "多孔位 / 多孔组匹配", + "最终位姿不能穿模", + "板材占位与孔位容量规则成立" + ], + "已具备": [ + "代码里已有 snapStatus(吸附状态) = hole-snap(孔位吸附已验证) / manual-unverified(手工摆放未验证)", + "工作台已有 candidate(候选)/ hole(孔位)/ highlight(高亮) 数据插槽", + "插件与 web(网页端) 已推进到 mountPattern(安装图样) 方向" + ], + "还差": [ + "真实样本资产用新插件重导后做真人拖拽验收", + "把最终数据链喂给 Gemini(前端专家) 做极简高亮收口" + ] + }, + "用户系统_最终验收": { + "状态": "待真人联调", + "待验收场景": [ + "公司员工 DingTalk(钉钉) 登录 -> 自动激活 -> 可登录", + "非员工 DingTalk(钉钉) 登录 -> PENDING(待激活)", + "普通 Register(注册) 用户 -> PENDING(待激活)", + "ADMIN(管理员) / SUPER_ADMIN(超级管理员) 后台激活 -> 用户可登录" + ] + }, + "Gemini(前端专家)待收口项": [ + "材质一致性最终肉眼验收", + "准确吸附极简高亮最终收口" + ] + }, + "当前前后端协作真实状态": { + "Gemini(前端专家)": "独立前端任务已清零;只等 Codex(后端 / 逻辑 / 联调) 数据链稳定后做验收", + "Codex(后端 / 逻辑 / 联调)": [ + "继续推进准确吸附数据主链", + "完成 Users(用户管理) 真实联调验收", + "必要时继续做生产联调与部署" + ] + }, + "生产环境与基础设施": { + "生产应用服务器": { + "主机": "ltai5tdb2ogyj59fquyedh.yydd.ai", + "SSH 端口": 60022, + "说明": "运行当前项目与 shujuku2(旧项目)" + }, + "公网入口服务器": { + "主机": "180.97.215.57", + "作用": "承接当前项目公网访问入口、frp(内网穿透)/ Nginx(反向代理)" + }, + "当前项目正式访问地址": "https://cp.jiansun.vip", + "对象存储": { + "内网端点": "http://192.168.58.20:9000", + "公开域名": "https://s3.jiansun.vip/gongxiangpan" + }, + "HR(人事) 外部库": { + "类型": "SQL Server(数据库)", + "只对白名单开放": "180.97.215.57", + "用途": "员工 / 组织 / 离职只读同步源,不做实时登录依赖" + } + }, + "关键统计数据": { + "HR(人事) 同步最近一次有效结果": { + "departments(部门)": 1112, + "employees(员工)": 1394, + "dimissions(离职)": 4499 + }, + "缩略图回填": { + "样本修复后成功": 3, + "剩余任务再次回填成功": 3, + "当前剩余待回填": 0 + }, + "首页深链相关测试": { + "Jest(测试)": "2 组 6 项通过" + } + }, + "外部系统与表": { + "HR(人事) 首选同步表": [ + "dbo.hr_employee(在职员工主表)", + "dbo.hr_groups(组织架构表)", + "dbo.hr_employee_register(员工详情表)", + "dbo.hr_employee_dimission(离职员工表)" + ], + "当前不纳入登录主链": [ + "dbo.ding_Workflow(流程主表)", + "dbo.ding_WorkflowScheme(OA 流程表单字段表)", + "dbo.ding_WorkflowTask(OA 流程审批日志表)" + ] + }, + "已确认存在的关键文件": [ + "D:\\AI Code\\3danzhuang\\docs\\前端UI改动清单.md", + "D:\\AI Code\\3danzhuang\\AGENTS.md", + "C:\\Users\\54972\\.codex\\AGENTS.md", + "D:\\AI Code\\3danzhuang\\scripts\\create-yydd-prod-deploy.ps1", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\dingtalk-auth.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\internal\\hr-sync\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\auth\\register\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\app\\api\\users\\[id]\\activate\\route.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\user-management.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\server\\services\\user.service.ts", + "D:\\AI Code\\3danzhuang\\furniture-3d-system\\src\\lib\\open-product\\assembly-workspace.ts", + "D:\\AI Code\\3danzhuang\\autocad-plugin\\FurnitureExporter\\MountPatternAnalyzer.cs" + ], + "敏感信息策略": { + "本摘要不内嵌明文": [ + "生产服务器密码", + "公网入口机密码", + "DingTalk(钉钉) AppSecret(应用密钥)", + "SQL Server(数据库) 密码", + "S3(对象存储) Access Key / Secret Key(访问密钥 / 密钥)" + ], + "原因": "为了避免复用摘要时泄漏明文敏感信息", + "已知存放位置": [ + "D:\\AI Code\\gongxiangp2\\docs\\生产存储桶接入信息.md(非敏感说明)", + "D:\\AI Code\\gongxiangp2\\.env.furniture-3d-system.local(临时本地忽略配置)", + "/opt/furniture-3d-system-prod/.env(生产环境变量,远端)" + ] + }, + "下一步建议顺序": [ + "1. 继续完成 辅助装配(assisted assembly) 的准确吸附数据主链", + "2. 用新插件重导一组标准样本资产,做真实拖拽吸附验收", + "3. 让 Gemini(前端专家) 完成吸附极简高亮与材质一致性的最后肉眼验收", + "4. 用真实公司员工 / 非员工 / 注册用户 / 管理员做用户系统闭环验收", + "5. 验收通过后,再决定是否继续推进更完整的后台或包装 / 仓库协作链" + ], + "恢复提示模板": "请基于以上 JSON(结构化摘要)继续工作。默认目录为 D:\\\\AI Code\\\\3danzhuang\\\\furniture-3d-system。继续遵守:中文优先、英文术语写成 English(中文)、Gemini(前端专家) 负责前端样式,Codex(后端 / 逻辑 / 联调) 负责后端、数据、数据库、部署和联调。当前最主要未完成项是辅助装配的准确吸附数据主链,其次是用户系统的最终真人联调验收。" +}