feat: sync validated admin auth material and exporter updates
This commit is contained in:
parent
7ae45d4f0b
commit
15d5e6fb89
10
.gitignore
vendored
10
.gitignore
vendored
@ -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/
|
||||
|
||||
61
AGENTS.md
61
AGENTS.md
@ -1,13 +1,15 @@
|
||||
# AGENTS.md
|
||||
|
||||
适用范围:`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`。
|
||||
- 禁止破坏性操作,除非用户明确授权。
|
||||
|
||||
@ -71,6 +71,7 @@ public class Commands
|
||||
public List<PanelInfo> Panels { get; } = new();
|
||||
public List<HoleInfo> Holes { get; } = new();
|
||||
public List<HoleGroupInfo> HoleGroups { get; } = new();
|
||||
public List<MountPatternInfo> MountPatterns { get; } = new();
|
||||
public int LogicalHoleCount { get; set; }
|
||||
public List<EdgeFeatureInfo> EdgeFeatures { get; } = new();
|
||||
public List<ExcludedHoleInfo> 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;
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Xunit;
|
||||
|
||||
namespace FurnitureExporter.Tests;
|
||||
|
||||
namespace FurnitureExporter.Tests
|
||||
{
|
||||
public class AssemblySemanticAnalyzerTests
|
||||
{
|
||||
[Fact]
|
||||
@ -19,21 +19,21 @@ public class AssemblySemanticAnalyzerTests
|
||||
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.Assembly = new AssemblySemanticAnalyzer().Analyze(
|
||||
exportData.Panels,
|
||||
exportData.Holes,
|
||||
exportData.HoleGroups,
|
||||
@ -51,33 +51,33 @@ public class AssemblySemanticAnalyzerTests
|
||||
Assert.Equal("1.0", root["assembly"]?["version"]?.Value<string>());
|
||||
Assert.NotNull(root["assembly"]?["panelAssemblies"]);
|
||||
Assert.NotNull(root["assembly"]?["connections"]);
|
||||
Assert.DoesNotContain("installOrder", json, System.StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("installOrder", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_PrefersHoleGroupAndHardwareEvidence_OverPanelTypeFallback()
|
||||
public void Analyze_PrefersHardwareRelation_ForConnectedPanel()
|
||||
{
|
||||
var panels = new List<PanelInfo>
|
||||
{
|
||||
CreatePanel(
|
||||
"panel_001",
|
||||
"side_panel",
|
||||
position: new[] { 9.0, 280.0, 360.0 },
|
||||
normal: new[] { 1.0, 0.0, 0.0 },
|
||||
boundingBoxMin: new[] { 0.0, 0.0, 0.0 },
|
||||
boundingBoxMax: new[] { 18.0, 560.0, 720.0 }),
|
||||
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 }),
|
||||
};
|
||||
|
||||
var holes = new List<HoleInfo>
|
||||
{
|
||||
new()
|
||||
new HoleInfo
|
||||
{
|
||||
Id = "panel_001_hole_001",
|
||||
PanelId = "panel_001",
|
||||
@ -88,7 +88,7 @@ public class AssemblySemanticAnalyzerTests
|
||||
Depth = 12.0,
|
||||
Type = "blind",
|
||||
},
|
||||
new()
|
||||
new HoleInfo
|
||||
{
|
||||
Id = "panel_001_hole_002",
|
||||
PanelId = "panel_001",
|
||||
@ -103,7 +103,7 @@ public class AssemblySemanticAnalyzerTests
|
||||
|
||||
var holeGroups = new List<HoleGroupInfo>
|
||||
{
|
||||
new()
|
||||
new HoleGroupInfo
|
||||
{
|
||||
Id = "panel_001_group_000",
|
||||
PanelId = "panel_001",
|
||||
@ -121,13 +121,13 @@ public class AssemblySemanticAnalyzerTests
|
||||
|
||||
var hardwareTypes = new List<HardwareType>
|
||||
{
|
||||
new()
|
||||
new HardwareType
|
||||
{
|
||||
TypeId = "type_000",
|
||||
InferredHardwareType = "connector",
|
||||
Instances = new List<HardwareInstance>
|
||||
{
|
||||
new()
|
||||
new HardwareInstance
|
||||
{
|
||||
Position = new Position3D { X = 12.0, Y = 260.0, Z = 360.0 },
|
||||
PrimaryPanelId = "panel_001",
|
||||
@ -143,47 +143,52 @@ public class AssemblySemanticAnalyzerTests
|
||||
},
|
||||
};
|
||||
|
||||
var assembly = AssemblySemanticAnalyzer.Analyze(panels, holes, holeGroups, hardwareTypes);
|
||||
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("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(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.Contains("panel_002", panelAssembly.Mates.Right ?? new List<string>());
|
||||
Assert.True(panelAssembly.Confidence >= 0.75);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Analyze_FallsBackToFaceMate_WhenNoHardwareBindingExists()
|
||||
public void Analyze_FallsBackToShelfDirection_WhenNoHardwareBindingExists()
|
||||
{
|
||||
var panels = new List<PanelInfo>
|
||||
{
|
||||
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 }),
|
||||
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",
|
||||
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 }),
|
||||
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",
|
||||
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 }),
|
||||
};
|
||||
|
||||
var assembly = AssemblySemanticAnalyzer.Analyze(
|
||||
var assembly = new AssemblySemanticAnalyzer().Analyze(
|
||||
panels,
|
||||
new List<HoleInfo>(),
|
||||
new List<HoleGroupInfo>(),
|
||||
@ -191,12 +196,13 @@ public class AssemblySemanticAnalyzerTests
|
||||
|
||||
var shelf = Assert.Single(assembly.PanelAssemblies.Where(item => item.PanelId == "panel_shelf"));
|
||||
|
||||
Assert.Equal("slot_insert", shelf.MountMode);
|
||||
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(shelf.Mates, mate => mate.MateType == "face_contact");
|
||||
Assert.Contains("panel_left", shelf.Mates.Left ?? new List<string>());
|
||||
Assert.Contains("panel_right", shelf.Mates.Right ?? new List<string>());
|
||||
Assert.True(shelf.TravelDistance > 0);
|
||||
Assert.True(System.Math.Abs(shelf.AssemblyAxis[0]) > 0.9);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(
|
||||
@ -220,10 +226,11 @@ public class AssemblySemanticAnalyzerTests
|
||||
},
|
||||
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),
|
||||
Math.Round(boundingBoxMax[0] - boundingBoxMin[0], 2),
|
||||
Math.Round(boundingBoxMax[1] - boundingBoxMin[1], 2),
|
||||
Math.Round(boundingBoxMax[2] - boundingBoxMin[2], 2),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<HoleInfo>
|
||||
{
|
||||
CreateHole("hole_a", "panel_line", new[] { -40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_b", "panel_line", new[] { 40.0, 0.0, 0.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
};
|
||||
|
||||
var patterns = MountPatternAnalyzer.BuildMountPatterns(
|
||||
new[] { panel },
|
||||
holes,
|
||||
new List<HoleGroupInfo>());
|
||||
|
||||
var pattern = Assert.Single(patterns);
|
||||
Assert.Equal("line_2", pattern.PatternType);
|
||||
Assert.Equal(2, pattern.MinRequiredHoles);
|
||||
Assert.Equal(2, pattern.PreferredHoleCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMountPatterns_ForSymmetricRectangle_DefaultsToTwoRequiredHoles()
|
||||
{
|
||||
var panel = CreatePanel("panel_rect");
|
||||
var holes = new List<HoleInfo>
|
||||
{
|
||||
CreateHole("hole_a", "panel_rect", new[] { -40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_b", "panel_rect", new[] { 40.0, 0.0, -20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_c", "panel_rect", new[] { -40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
CreateHole("hole_d", "panel_rect", new[] { 40.0, 0.0, 20.0 }, new[] { 0.0, -1.0, 0.0 }, new[] { "top" }),
|
||||
};
|
||||
|
||||
var patterns = MountPatternAnalyzer.BuildMountPatterns(
|
||||
new[] { panel },
|
||||
holes,
|
||||
new List<HoleGroupInfo>());
|
||||
|
||||
var pattern = Assert.Single(patterns);
|
||||
Assert.Equal("rectangle_4", pattern.PatternType);
|
||||
Assert.Equal(2, pattern.MinRequiredHoles);
|
||||
Assert.Equal(4, pattern.PreferredHoleCount);
|
||||
Assert.True(pattern.Symmetry);
|
||||
Assert.Empty(pattern.RequiredHoleIds);
|
||||
Assert.Equal(4, pattern.OptionalHoleIds.Length);
|
||||
}
|
||||
|
||||
private static PanelInfo CreatePanel(string id)
|
||||
{
|
||||
return new PanelInfo
|
||||
{
|
||||
Id = id,
|
||||
Position = new[] { 0.0, 0.0, 0.0 },
|
||||
Normal = new[] { 0.0, 1.0, 0.0 },
|
||||
BoundingBox = new BoundingBox3D
|
||||
{
|
||||
Min = new[] { -300.0, -9.0, -300.0 },
|
||||
Max = new[] { 300.0, 9.0, 300.0 },
|
||||
},
|
||||
Size = new[] { 600.0, 18.0, 600.0 },
|
||||
};
|
||||
}
|
||||
|
||||
private static HoleInfo CreateHole(string id, string panelId, double[] position, double[] normal, string[] surfaceFaces)
|
||||
{
|
||||
return new HoleInfo
|
||||
{
|
||||
Id = id,
|
||||
PanelId = panelId,
|
||||
Position = position,
|
||||
Normal = normal,
|
||||
Diameter = 8.0,
|
||||
Radius = 4.0,
|
||||
Depth = 12.0,
|
||||
Type = "blind",
|
||||
SurfaceFaces = surfaceFaces,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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<string>())
|
||||
.Where(face => !string.IsNullOrWhiteSpace(face))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray(),
|
||||
};
|
||||
groups.Add(group);
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,13 @@ public class ExportData
|
||||
/// </summary>
|
||||
public string Units { get; set; } = "毫米";
|
||||
|
||||
/// <summary>
|
||||
/// CAD 世界坐标系标识。
|
||||
/// 当前导出统一按 AutoCAD 世界坐标:X 向右,Y 向前,Z 向上。
|
||||
/// Web 端后续做孔位吸附时,不再需要靠启发式猜测坐标系。
|
||||
/// </summary>
|
||||
public string CoordinateSystem { get; set; } = "AUTOCAD_WORLD_Z_UP";
|
||||
|
||||
/// <summary>
|
||||
/// 原点偏移 [X, Y, Z] (mm)
|
||||
/// 所有坐标已应用此偏移,使模型居中于原点
|
||||
@ -103,6 +110,15 @@ public class ExportData
|
||||
/// </summary>
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样(v2.5 新增)
|
||||
/// 说明:
|
||||
/// - 吸附面先缩小范围;
|
||||
/// - HoleGroup 负责把几何孔组织成业务孔位;
|
||||
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位数量(v2.2.4 新增)
|
||||
/// 计算方式:以 HoleGroupId 合并计数(HoleGroupId 为空则按 Hole.Id 计数)。
|
||||
@ -313,6 +329,11 @@ public class PanelInfo
|
||||
/// 该板件上的逻辑孔位分组(v2.2.4 新增)
|
||||
/// </summary>
|
||||
public List<HoleGroupInfo> HoleGroups { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 该板件上的安装图样(v2.5 新增)
|
||||
/// </summary>
|
||||
public List<MountPatternInfo> MountPatterns { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -391,6 +412,14 @@ public class HoleInfo
|
||||
/// </summary>
|
||||
public double BindingConfidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// 孔位整体可用置信度(0-1)
|
||||
/// 说明:
|
||||
/// - BindingConfidence 只表示“孔位和板件绑定得稳不稳”;
|
||||
/// - Confidence 表示“这个孔位几何与语义整体是否可信”。
|
||||
/// </summary>
|
||||
public double Confidence { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// 绑定原因说明(调试/回放)
|
||||
/// </summary>
|
||||
@ -419,11 +448,23 @@ public class HoleInfo
|
||||
/// </summary>
|
||||
public string? HoleGroupRole { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前孔实际打在板件外表面的面标签。
|
||||
/// 盲孔通常只有 1 个面;通孔通常会有 2 个相对面。
|
||||
/// 例如:top / bottom / left / right / front / back
|
||||
/// </summary>
|
||||
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 推断的用途(根据直径)
|
||||
/// </summary>
|
||||
public string? InferredUsage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 针对该孔位的紧固件建议(v2.3 新增)
|
||||
/// 说明:
|
||||
@ -511,6 +552,99 @@ public class HoleGroupInfo
|
||||
/// 可读原因(调试/回放)
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 当前孔组默认作用的安装面。
|
||||
/// 例如:top / bottom / left / right / front / back / unknown
|
||||
/// </summary>
|
||||
public string MountFace { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 逻辑孔位实际落在哪些板件外表面上。
|
||||
/// 默认按主孔 + 组内成员孔合并去重。
|
||||
/// </summary>
|
||||
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样(v2.5 新增)
|
||||
/// </summary>
|
||||
public class MountPatternInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 图样 ID(如 panel_001_mount_top_001)
|
||||
/// </summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 所属板件 ID
|
||||
/// </summary>
|
||||
public string PanelId { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 默认安装面:top / bottom / left / right / front / back / unknown
|
||||
/// </summary>
|
||||
public string MountFace { get; set; } = "unknown";
|
||||
|
||||
/// <summary>
|
||||
/// 图样类型:single / line_2 / triangle_3 / rectangle_4 / generic
|
||||
/// </summary>
|
||||
public string PatternType { get; set; } = "generic";
|
||||
|
||||
/// <summary>
|
||||
/// 属于该图样的几何孔位 ID
|
||||
/// </summary>
|
||||
public string[] HoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 必需命中的孔位 ID
|
||||
/// </summary>
|
||||
public string[] RequiredHoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 可选命中的孔位 ID
|
||||
/// </summary>
|
||||
public string[] OptionalHoleIds { get; set; } = System.Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 吸附成立所需的最少命中孔数
|
||||
/// </summary>
|
||||
public int MinRequiredHoles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 推荐命中孔数(越接近越稳)
|
||||
/// </summary>
|
||||
public int PreferredHoleCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否允许对称复用
|
||||
/// </summary>
|
||||
public bool Symmetry { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 图样参考点(默认取图样中心)
|
||||
/// </summary>
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 图样参考法向(默认取安装面法向)
|
||||
/// </summary>
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
|
||||
/// <summary>
|
||||
/// 图样置信度(0-1)
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 低置信度或歧义提示
|
||||
/// </summary>
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
441
autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs
Normal file
441
autocad-plugin/FurnitureExporter/MountPatternAnalyzer.cs
Normal file
@ -0,0 +1,441 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FurnitureExporter;
|
||||
|
||||
/// <summary>
|
||||
/// 安装图样识别器。
|
||||
///
|
||||
/// 设计口径:
|
||||
/// - 吸附面(mount face)先缩小候选范围;
|
||||
/// - HoleGroup 负责把几何孔组织成业务孔位;
|
||||
/// - MountPattern 负责定义“至少命中几个孔才允许吸附成立”。
|
||||
/// </summary>
|
||||
public static class MountPatternAnalyzer
|
||||
{
|
||||
private const double DefaultClusterDistanceMm = 220.0;
|
||||
|
||||
public static List<MountPatternInfo> BuildMountPatterns(
|
||||
IReadOnlyList<PanelInfo> panels,
|
||||
IReadOnlyList<HoleInfo> holes,
|
||||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||||
{
|
||||
var patterns = new List<MountPatternInfo>();
|
||||
if (panels == null || panels.Count == 0)
|
||||
{
|
||||
return patterns;
|
||||
}
|
||||
|
||||
var holesByPanel = holes
|
||||
.Where(h => !string.IsNullOrWhiteSpace(h.PanelId))
|
||||
.GroupBy(h => h.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var groupsByPanel = holeGroups
|
||||
.Where(group => !string.IsNullOrWhiteSpace(group.PanelId))
|
||||
.GroupBy(group => group.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
var panelHoles = holesByPanel.TryGetValue(panel.Id, out var panelHoleList)
|
||||
? panelHoleList
|
||||
: new List<HoleInfo>();
|
||||
var panelGroups = groupsByPanel.TryGetValue(panel.Id, out var panelGroupList)
|
||||
? panelGroupList
|
||||
: new List<HoleGroupInfo>();
|
||||
|
||||
var units = BuildUnits(panel.Id, panelHoles, panelGroups);
|
||||
if (units.Count == 0)
|
||||
{
|
||||
panel.MountPatterns = new List<MountPatternInfo>();
|
||||
continue;
|
||||
}
|
||||
|
||||
var panelPatterns = new List<MountPatternInfo>();
|
||||
var unitsByFace = units
|
||||
.SelectMany(unit =>
|
||||
unit.MountFaces.Select(face => new { Face = face, Unit = unit }))
|
||||
.GroupBy(item => item.Face, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var faceGroup in unitsByFace)
|
||||
{
|
||||
var clusters = BuildClusters(faceGroup.Select(item => item.Unit).Distinct().ToList());
|
||||
var patternIndex = 0;
|
||||
foreach (var cluster in clusters)
|
||||
{
|
||||
var pattern = BuildPattern(panel.Id, faceGroup.Key, patternIndex++, cluster);
|
||||
panelPatterns.Add(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
panel.MountPatterns = panelPatterns;
|
||||
patterns.AddRange(panelPatterns);
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
public static void AttachPatternsToPanels(IReadOnlyList<PanelInfo> panels, IReadOnlyList<MountPatternInfo> patterns)
|
||||
{
|
||||
var map = patterns
|
||||
.GroupBy(pattern => pattern.PanelId, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var panel in panels)
|
||||
{
|
||||
panel.MountPatterns = map.TryGetValue(panel.Id, out var items)
|
||||
? items
|
||||
: new List<MountPatternInfo>();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MountPatternUnit> BuildUnits(
|
||||
string panelId,
|
||||
IReadOnlyList<HoleInfo> holes,
|
||||
IReadOnlyList<HoleGroupInfo> holeGroups)
|
||||
{
|
||||
var units = new List<MountPatternUnit>();
|
||||
var groupedHoleIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in holeGroups)
|
||||
{
|
||||
foreach (var holeId in group.HoleIds ?? Array.Empty<string>())
|
||||
{
|
||||
groupedHoleIds.Add(holeId);
|
||||
}
|
||||
|
||||
var mountFaces = NormalizeMountFaces(group.SurfaceFaces, group.MountFace);
|
||||
group.MountFace = mountFaces.FirstOrDefault() ?? "unknown";
|
||||
units.Add(new MountPatternUnit
|
||||
{
|
||||
Id = group.Id,
|
||||
PanelId = panelId,
|
||||
HoleIds = group.HoleIds ?? Array.Empty<string>(),
|
||||
Position = group.Position,
|
||||
Normal = group.Normal,
|
||||
GroupType = group.GroupType,
|
||||
MountFaces = mountFaces,
|
||||
Confidence = Clamp01(group.Confidence <= 0 ? 0.72 : group.Confidence),
|
||||
Warnings = group.Warnings?.ToList() ?? new List<string>(),
|
||||
Source = "hole-group",
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var hole in holes)
|
||||
{
|
||||
if (groupedHoleIds.Contains(hole.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
units.Add(new MountPatternUnit
|
||||
{
|
||||
Id = hole.Id,
|
||||
PanelId = panelId,
|
||||
HoleIds = new[] { hole.Id },
|
||||
Position = hole.Position,
|
||||
Normal = hole.Normal,
|
||||
GroupType = hole.HoleGroupType,
|
||||
MountFaces = NormalizeMountFaces(hole.SurfaceFaces),
|
||||
Confidence = Clamp01(hole.Confidence <= 0 ? 0.76 : hole.Confidence),
|
||||
Warnings = hole.Warnings?.ToList() ?? new List<string>(),
|
||||
Source = "single-hole",
|
||||
});
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
private static List<List<MountPatternUnit>> BuildClusters(IReadOnlyList<MountPatternUnit> units)
|
||||
{
|
||||
var clusters = new List<List<MountPatternUnit>>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var unit in units)
|
||||
{
|
||||
if (!visited.Add(unit.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var cluster = new List<MountPatternUnit>();
|
||||
var queue = new Queue<MountPatternUnit>();
|
||||
queue.Enqueue(unit);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
cluster.Add(current);
|
||||
|
||||
foreach (var candidate in units)
|
||||
{
|
||||
if (visited.Contains(candidate.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Distance(current.Position, candidate.Position) > DefaultClusterDistanceMm)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.Add(candidate.Id);
|
||||
queue.Enqueue(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
clusters.Add(cluster.OrderBy(item => item.Position[0]).ThenBy(item => item.Position[1]).ThenBy(item => item.Position[2]).ToList());
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
private static MountPatternInfo BuildPattern(
|
||||
string panelId,
|
||||
string mountFace,
|
||||
int patternIndex,
|
||||
IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
var holeIds = cluster
|
||||
.SelectMany(unit => unit.HoleIds)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var warnings = cluster
|
||||
.SelectMany(unit => unit.Warnings)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var pointCount = cluster.Count;
|
||||
var patternType = ResolvePatternType(cluster);
|
||||
var isSymmetricRectangle = string.Equals(patternType, "rectangle_4", StringComparison.OrdinalIgnoreCase);
|
||||
var minRequiredHoles = ResolveMinRequiredHoleCount(patternType, pointCount, isSymmetricRectangle);
|
||||
var preferredHoleCount = pointCount;
|
||||
|
||||
var requiredHoleIds = Array.Empty<string>();
|
||||
var optionalHoleIds = Array.Empty<string>();
|
||||
|
||||
if (pointCount <= 3 || !isSymmetricRectangle)
|
||||
{
|
||||
requiredHoleIds = holeIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
optionalHoleIds = holeIds;
|
||||
warnings.Add("四孔对称图样默认按 2 孔完成初始定位,剩余孔位作为增强固定。");
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
warnings.Add("当前图样由几何聚类自动生成,建议后续在资产录入阶段人工复核。");
|
||||
}
|
||||
|
||||
return new MountPatternInfo
|
||||
{
|
||||
Id = $"{panelId}_mount_{mountFace}_{patternIndex:D3}",
|
||||
PanelId = panelId,
|
||||
MountFace = mountFace,
|
||||
PatternType = patternType,
|
||||
HoleIds = holeIds,
|
||||
RequiredHoleIds = requiredHoleIds,
|
||||
OptionalHoleIds = optionalHoleIds,
|
||||
MinRequiredHoles = minRequiredHoles,
|
||||
PreferredHoleCount = preferredHoleCount,
|
||||
Symmetry = isSymmetricRectangle,
|
||||
Position = AveragePosition(cluster),
|
||||
Normal = ResolveMountFaceNormal(mountFace),
|
||||
Confidence = ComputePatternConfidence(cluster, patternType, warnings.Count),
|
||||
Warnings = warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolvePatternType(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
return cluster.Count switch
|
||||
{
|
||||
1 => "single",
|
||||
2 => "line_2",
|
||||
3 => "triangle_3",
|
||||
4 when IsRectangle(cluster) => "rectangle_4",
|
||||
_ => "generic",
|
||||
};
|
||||
}
|
||||
|
||||
private static int ResolveMinRequiredHoleCount(string patternType, int pointCount, bool isSymmetricRectangle)
|
||||
{
|
||||
if (string.Equals(patternType, "single", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "line_2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (string.Equals(patternType, "triangle_3", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (isSymmetricRectangle)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (pointCount <= 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return pointCount <= 4 ? pointCount : Math.Min(pointCount, 4);
|
||||
}
|
||||
|
||||
private static bool IsRectangle(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
if (cluster.Count != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var distances = new List<double>();
|
||||
for (var i = 0; i < cluster.Count; i++)
|
||||
{
|
||||
for (var j = i + 1; j < cluster.Count; j++)
|
||||
{
|
||||
distances.Add(Distance(cluster[i].Position, cluster[j].Position));
|
||||
}
|
||||
}
|
||||
|
||||
distances.Sort();
|
||||
if (distances.Count != 6)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sideA = distances[0];
|
||||
var sideB = distances[2];
|
||||
var diagonalA = distances[4];
|
||||
var diagonalB = distances[5];
|
||||
|
||||
return AreClose(sideA, distances[1], 0.12) &&
|
||||
AreClose(sideB, distances[3], 0.12) &&
|
||||
AreClose(diagonalA, diagonalB, 0.08);
|
||||
}
|
||||
|
||||
private static double[] AveragePosition(IReadOnlyList<MountPatternUnit> cluster)
|
||||
{
|
||||
if (cluster.Count == 0)
|
||||
{
|
||||
return new[] { 0.0, 0.0, 0.0 };
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
Math.Round(cluster.Average(item => item.Position[0]), 2),
|
||||
Math.Round(cluster.Average(item => item.Position[1]), 2),
|
||||
Math.Round(cluster.Average(item => item.Position[2]), 2),
|
||||
};
|
||||
}
|
||||
|
||||
private static double[] ResolveMountFaceNormal(string mountFace)
|
||||
{
|
||||
return mountFace.ToLowerInvariant() switch
|
||||
{
|
||||
"left" => new[] { -1.0, 0.0, 0.0 },
|
||||
"right" => new[] { 1.0, 0.0, 0.0 },
|
||||
"front" => new[] { 0.0, -1.0, 0.0 },
|
||||
"back" => new[] { 0.0, 1.0, 0.0 },
|
||||
"bottom" => new[] { 0.0, 0.0, -1.0 },
|
||||
"top" => new[] { 0.0, 0.0, 1.0 },
|
||||
_ => new[] { 0.0, 0.0, 1.0 },
|
||||
};
|
||||
}
|
||||
|
||||
private static double ComputePatternConfidence(
|
||||
IReadOnlyList<MountPatternUnit> cluster,
|
||||
string patternType,
|
||||
int warningCount)
|
||||
{
|
||||
if (cluster.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var baseConfidence = cluster.Average(item => item.Confidence);
|
||||
if (string.Equals(patternType, "generic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
baseConfidence = Math.Min(baseConfidence, 0.68);
|
||||
}
|
||||
|
||||
if (warningCount > 0)
|
||||
{
|
||||
baseConfidence -= Math.Min(0.14, warningCount * 0.04);
|
||||
}
|
||||
|
||||
return Math.Round(Clamp01(baseConfidence), 4);
|
||||
}
|
||||
|
||||
private static string[] NormalizeMountFaces(IEnumerable<string>? faces, string? fallbackFace = null)
|
||||
{
|
||||
var normalized = (faces ?? Array.Empty<string>())
|
||||
.Where(face => !string.IsNullOrWhiteSpace(face))
|
||||
.Select(face => face.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized.ToArray();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallbackFace))
|
||||
{
|
||||
return new[] { fallbackFace!.Trim().ToLowerInvariant() };
|
||||
}
|
||||
|
||||
return new[] { "unknown" };
|
||||
}
|
||||
|
||||
private static double Distance(IReadOnlyList<double> left, IReadOnlyList<double> right)
|
||||
{
|
||||
var dx = left[0] - right[0];
|
||||
var dy = left[1] - right[1];
|
||||
var dz = left[2] - right[2];
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static bool AreClose(double left, double right, double toleranceRatio)
|
||||
{
|
||||
var baseValue = Math.Max(Math.Abs(left), Math.Abs(right));
|
||||
if (baseValue <= 1e-6)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Math.Abs(left - right) / baseValue <= toleranceRatio;
|
||||
}
|
||||
|
||||
private static double Clamp01(double value)
|
||||
{
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
private sealed class MountPatternUnit
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string PanelId { get; set; } = "";
|
||||
public string[] HoleIds { get; set; } = Array.Empty<string>();
|
||||
public double[] Position { get; set; } = new double[3];
|
||||
public double[] Normal { get; set; } = new double[3];
|
||||
public string? GroupType { get; set; }
|
||||
public string[] MountFaces { get; set; } = Array.Empty<string>();
|
||||
public double Confidence { get; set; }
|
||||
public List<string> Warnings { get; set; } = new();
|
||||
public string Source { get; set; } = "single-hole";
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
/// <summary>
|
||||
/// 从板材实体中提取孔位信息
|
||||
/// </summary>
|
||||
private List<HoleInfo> ExtractHoles(Solid3d solid, string panelId, double panelThickness)
|
||||
private List<HoleInfo> ExtractHoles(
|
||||
Solid3d solid,
|
||||
string panelId,
|
||||
double panelThickness,
|
||||
BoundingBox3D panelBoundingBox)
|
||||
{
|
||||
var holes = new List<HoleInfo>();
|
||||
|
||||
@ -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
|
||||
|
||||
226
autocad-plugin/FurnitureExporter/PanelFaceSemanticResolver.cs
Normal file
226
autocad-plugin/FurnitureExporter/PanelFaceSemanticResolver.cs
Normal file
@ -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<double>? position,
|
||||
IReadOnlyList<double>? normal,
|
||||
double depth)
|
||||
{
|
||||
if (boundingBox?.Min?.Length != 3 || boundingBox.Max?.Length != 3)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var center = ToVector3(position);
|
||||
var axis = Normalize(ToVector3(normal));
|
||||
if (center == null || axis == null)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var faces = new List<string>();
|
||||
var halfDepth = Math.Max(0, depth) / 2.0;
|
||||
var dominantFaces = ResolveDominantAxisFaces(axis);
|
||||
|
||||
if (halfDepth > 0.01)
|
||||
{
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
Offset(center, axis, halfDepth),
|
||||
dominantFaces.Positive,
|
||||
dominantFaces.Negative,
|
||||
SurfaceFaceToleranceMm));
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
Offset(center, axis, -halfDepth),
|
||||
dominantFaces.Negative,
|
||||
dominantFaces.Positive,
|
||||
SurfaceFaceToleranceMm));
|
||||
}
|
||||
|
||||
if (faces.Count == 0)
|
||||
{
|
||||
AddFace(
|
||||
faces,
|
||||
ResolveNearestSurfaceFace(
|
||||
boundingBox,
|
||||
center,
|
||||
dominantFaces.Positive,
|
||||
dominantFaces.Negative,
|
||||
SurfaceFaceToleranceMm + InteriorFaceToleranceMm));
|
||||
}
|
||||
|
||||
return faces
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static string OppositeFace(string? face)
|
||||
{
|
||||
return face?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"left" => "right",
|
||||
"right" => "left",
|
||||
"front" => "back",
|
||||
"back" => "front",
|
||||
"top" => "bottom",
|
||||
"bottom" => "top",
|
||||
_ => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddFace(ICollection<string> faces, string? face)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(face))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
faces.Add(face!.Trim().ToLowerInvariant());
|
||||
}
|
||||
|
||||
private static double[]? ToVector3(IReadOnlyList<double>? value)
|
||||
{
|
||||
if (value == null || value.Count < 3)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[] { value[0], value[1], value[2] };
|
||||
}
|
||||
|
||||
private static double[]? Normalize(double[]? value)
|
||||
{
|
||||
if (value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var length = Math.Sqrt(value[0] * value[0] + value[1] * value[1] + value[2] * value[2]);
|
||||
if (length < 1e-9)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new[]
|
||||
{
|
||||
value[0] / length,
|
||||
value[1] / length,
|
||||
value[2] / length,
|
||||
};
|
||||
}
|
||||
|
||||
private static double[] Offset(double[] origin, double[] direction, double distance)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
origin[0] + direction[0] * distance,
|
||||
origin[1] + direction[1] * distance,
|
||||
origin[2] + direction[2] * distance,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ResolveNearestSurfaceFace(BoundingBox3D box, IReadOnlyList<double> point)
|
||||
{
|
||||
return ResolveNearestSurfaceFace(box, point, null, null, SurfaceFaceToleranceMm);
|
||||
}
|
||||
|
||||
private static string? ResolveNearestSurfaceFace(
|
||||
BoundingBox3D box,
|
||||
IReadOnlyList<double> point,
|
||||
string? preferredFace,
|
||||
string? secondaryFace,
|
||||
double tolerance)
|
||||
{
|
||||
var candidates = new List<(string Face, double Distance)>();
|
||||
|
||||
if (IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance) &&
|
||||
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
|
||||
{
|
||||
candidates.Add(("left", Math.Abs(point[0] - box.Min[0])));
|
||||
candidates.Add(("right", Math.Abs(point[0] - box.Max[0])));
|
||||
}
|
||||
|
||||
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
|
||||
IsWithinRange(point[2], box.Min[2], box.Max[2], tolerance))
|
||||
{
|
||||
candidates.Add(("front", Math.Abs(point[1] - box.Min[1])));
|
||||
candidates.Add(("back", Math.Abs(point[1] - box.Max[1])));
|
||||
}
|
||||
|
||||
if (IsWithinRange(point[0], box.Min[0], box.Max[0], tolerance) &&
|
||||
IsWithinRange(point[1], box.Min[1], box.Max[1], tolerance))
|
||||
{
|
||||
candidates.Add(("bottom", Math.Abs(point[2] - box.Min[2])));
|
||||
candidates.Add(("top", Math.Abs(point[2] - box.Max[2])));
|
||||
}
|
||||
|
||||
var best = candidates
|
||||
.Where(item => item.Distance <= tolerance)
|
||||
.OrderBy(item => GetFacePriority(item.Face, preferredFace, secondaryFace))
|
||||
.ThenBy(item => item.Distance)
|
||||
.FirstOrDefault();
|
||||
|
||||
return string.IsNullOrWhiteSpace(best.Face) ? null : best.Face;
|
||||
}
|
||||
|
||||
private static (string Positive, string Negative) ResolveDominantAxisFaces(IReadOnlyList<double> axis)
|
||||
{
|
||||
var absX = Math.Abs(axis[0]);
|
||||
var absY = Math.Abs(axis[1]);
|
||||
var absZ = Math.Abs(axis[2]);
|
||||
|
||||
if (absX >= absY && absX >= absZ)
|
||||
{
|
||||
return axis[0] >= 0
|
||||
? ("right", "left")
|
||||
: ("left", "right");
|
||||
}
|
||||
|
||||
if (absY >= absX && absY >= absZ)
|
||||
{
|
||||
return axis[1] >= 0
|
||||
? ("back", "front")
|
||||
: ("front", "back");
|
||||
}
|
||||
|
||||
return axis[2] >= 0
|
||||
? ("top", "bottom")
|
||||
: ("bottom", "top");
|
||||
}
|
||||
|
||||
private static int GetFacePriority(string face, string? preferredFace, string? secondaryFace)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferredFace) &&
|
||||
string.Equals(face, preferredFace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(secondaryFace) &&
|
||||
string.Equals(face, secondaryFace, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static bool IsWithinRange(double value, double min, double max, double tolerance)
|
||||
{
|
||||
return value >= min - tolerance && value <= max + tolerance;
|
||||
}
|
||||
}
|
||||
@ -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`
|
||||
|
||||
2
furniture-3d-system/.gitignore
vendored
2
furniture-3d-system/.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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`、`自动组合`、`自动拼装`,统一按旧称呼理解,不再代表当前主线入口。
|
||||
|
||||
@ -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` 负责收口。
|
||||
|
||||
@ -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 只处理冲突文件。
|
||||
```
|
||||
|
||||
补充说明:
|
||||
|
||||
- 历史文档里如果还出现固定包装分支名,那是旧协作阶段留下的例子,不再代表当前唯一允许的长期分支。
|
||||
- 当前哪条分支算“长期共享分支”,以最新交接、当前任务边界和实际协作方式为准。
|
||||
|
||||
@ -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` 承接链,收口时额外确认:有没有把局部协作逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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` 仍然属于主线真相,不在仓库侧重复造数。
|
||||
- 工艺图组合本期只预留接口,不在主线里单独展开。
|
||||
- 草稿组详情页里的包装区只负责主线最小联通版占位,不是最终包装工作区。
|
||||
- 轻仓库可以增强包装成品、库存、库位和展示交互,但不能绕过正式入库单独制造主链结果。
|
||||
|
||||
@ -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`、`自动组合`、`自动拼装` 等旧称呼,统一按历史口径理解,不再代表当前主线页面入口。
|
||||
|
||||
@ -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']
|
||||
},
|
||||
|
||||
@ -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<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const resolvedSearchParams = searchParams ? await searchParams : undefined
|
||||
const query = new URLSearchParams()
|
||||
|
||||
Object.entries(resolvedSearchParams ?? {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => {
|
||||
if (item) {
|
||||
query.append(key, item)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
export default async function MaterialManagementPage() {
|
||||
// 验证用户权限
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get('auth-token')?.value
|
||||
|
||||
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 (
|
||||
<QueryProvider>
|
||||
<MaterialManagementClient userRole={payload.role} />
|
||||
</QueryProvider>
|
||||
redirect(
|
||||
query.size > 0
|
||||
? `/asset-center/material-library?${query.toString()}`
|
||||
: '/asset-center/material-library',
|
||||
)
|
||||
}
|
||||
|
||||
134
furniture-3d-system/src/app/admin/page.tsx
Normal file
134
furniture-3d-system/src/app/admin/page.tsx
Normal file
@ -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 (
|
||||
<div className="relative min-h-screen overflow-hidden bg-[#f9f9fb] font-apple-system">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.03] mix-blend-multiply"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(#546067 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative mx-auto max-w-5xl px-8 py-20">
|
||||
<header className="mb-16 animate-in fade-in slide-in-from-top-4 duration-1000">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-apple-lg border border-apple-blue/20 bg-apple-blue/10 shadow-apple-sm">
|
||||
<ShieldCheck className="h-5 w-5 text-apple-blue" />
|
||||
</div>
|
||||
<span className="text-[10px] font-apple-black uppercase tracking-[0.3em] text-apple-label-tertiary">
|
||||
管理终端
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mb-4 text-apple-3xl font-apple-black leading-none tracking-tighter text-apple-label-primary uppercase">
|
||||
系统<span className="ml-2 text-apple-blue">总控制台</span>
|
||||
</h1>
|
||||
<p className="max-w-lg text-apple-sm font-apple-medium leading-relaxed text-apple-label-tertiary">
|
||||
欢迎进入后台管理中心。在这里可以快速进入权限、主题、材质和分类等核心管理模块。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 animate-in fade-in slide-in-from-bottom-6 duration-1000 delay-200 fill-mode-both md:grid-cols-2">
|
||||
{adminModules.map((module) => (
|
||||
<Link
|
||||
key={module.href}
|
||||
href={module.href}
|
||||
className="group relative rounded-apple-xl border border-apple-separator/5 bg-white p-8 shadow-apple-sm transition-all duration-500 hover:-translate-y-1 hover:shadow-apple-xl active:scale-[0.98]"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className={cn(
|
||||
'mb-6 rounded-apple-lg p-3 shadow-apple-xs transition-transform duration-500 group-hover:scale-110',
|
||||
module.bgColor,
|
||||
module.color,
|
||||
)}
|
||||
>
|
||||
<module.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<ArrowRight className="h-5 w-5 text-apple-label-tertiary opacity-0 transition-all duration-500 group-hover:translate-x-1 group-hover:opacity-100" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-apple-md font-apple-bold text-apple-label-primary transition-colors group-hover:text-apple-blue">
|
||||
{module.title}
|
||||
</h2>
|
||||
<p className="text-apple-xs font-apple-medium leading-relaxed text-apple-label-tertiary">
|
||||
{module.description}
|
||||
</p>
|
||||
<span className="pointer-events-none absolute bottom-4 right-6 select-none text-[40px] font-apple-black uppercase tracking-tighter opacity-[0.02]">
|
||||
{module.href.split('/').pop()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-20 border-t border-apple-separator/10 pt-8">
|
||||
<p className="text-center text-[10px] font-apple-bold uppercase tracking-widest text-apple-label-tertiary opacity-40">
|
||||
Precision Atelier · 管理基础设施
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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: '材质已禁用',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
110
furniture-3d-system/src/app/api/admin/materials/route.test.ts
Normal file
110
furniture-3d-system/src/app/api/admin/materials/route.test.ts
Normal file
@ -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: '材质创建成功',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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<string, unknown>([
|
||||
['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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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: '主题已停用',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
153
furniture-3d-system/src/app/api/admin/themes/[id]/route.test.ts
Normal file
153
furniture-3d-system/src/app/api/admin/themes/[id]/route.test.ts
Normal file
@ -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: '主题删除成功',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
130
furniture-3d-system/src/app/api/admin/themes/route.test.ts
Normal file
130
furniture-3d-system/src/app/api/admin/themes/route.test.ts
Normal file
@ -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: '主题创建成功',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { dingtalkAuthService } from '@/server/services/dingtalk-auth.service'
|
||||
|
||||
function buildLoginRedirect(request: NextRequest, message?: string) {
|
||||
const url = new URL('/login', request.nextUrl.origin)
|
||||
const redirectPath = request.nextUrl.searchParams.get('redirect')
|
||||
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,
|
||||
@ -9,4 +22,8 @@ export async function GET(request: NextRequest) {
|
||||
})
|
||||
|
||||
return NextResponse.redirect(authUrl)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '钉钉登录暂不可用,请联系管理员检查配置'
|
||||
return NextResponse.redirect(buildLoginRedirect(request, message))
|
||||
}
|
||||
}
|
||||
|
||||
162
furniture-3d-system/src/app/api/categories/[id]/route.test.ts
Normal file
162
furniture-3d-system/src/app/api/categories/[id]/route.test.ts
Normal file
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
149
furniture-3d-system/src/app/api/categories/route.test.ts
Normal file
149
furniture-3d-system/src/app/api/categories/route.test.ts
Normal file
@ -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,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
99
furniture-3d-system/src/app/api/internal/hr-sync/route.ts
Normal file
99
furniture-3d-system/src/app/api/internal/hr-sync/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -69,7 +69,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (registered === '1') {
|
||||
toast.success('注册成功,请直接登录');
|
||||
toast.success('注册成功,请等待管理员激活');
|
||||
params.delete('registered');
|
||||
params.delete('identifier');
|
||||
}
|
||||
|
||||
@ -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<string, string> = {
|
||||
'.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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<string, string> = {
|
||||
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,40 +107,92 @@ export function SidebarUserCenter() {
|
||||
}
|
||||
}
|
||||
|
||||
const secondaryLine = currentUser
|
||||
? [roleLabelMap[currentUser.role] ?? currentUser.role, currentUser.department?.name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '当前账号'
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div ref={containerRef} className="relative px-2 py-2 border-t border-apple-separator/10">
|
||||
{isOpen ? (
|
||||
<div
|
||||
className="absolute bottom-full left-0 right-0 mb-2 rounded-apple-lg px-3 py-3 shadow-apple-md"
|
||||
className="absolute bottom-full left-2 right-2 mb-3 rounded-apple-xl shadow-apple-2xl animate-in fade-in slide-in-from-bottom-2 duration-300 overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.92)',
|
||||
backdropFilter: 'blur(18px) saturate(180%)',
|
||||
border: '1px solid var(--color-separator)',
|
||||
backgroundColor: 'rgba(255,255,255,0.85)',
|
||||
backdropFilter: 'blur(24px) saturate(160%)',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
}}
|
||||
>
|
||||
<p className="truncate text-sm font-apple-medium text-apple-label-primary">
|
||||
{currentUser?.name ?? '当前账号'}
|
||||
{/* SECTION 1: Account Info */}
|
||||
<div className="px-5 py-4 border-b border-apple-separator/5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-apple-sm font-apple-bold text-apple-label-primary tracking-tight">
|
||||
{currentUser?.name ?? '加载中...'}
|
||||
</span>
|
||||
{currentUser && (
|
||||
<span className="px-1.5 py-0.5 rounded-apple-sm bg-apple-blue/10 text-apple-blue text-[9px] font-apple-black uppercase tracking-widest">
|
||||
{roleLabelMap[currentUser.role] ?? '用户'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-apple-label-tertiary font-apple-medium truncate">
|
||||
{currentUser?.email ?? '正在获取账号信息...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Workspace Shortcuts */}
|
||||
<div className="p-1.5 space-y-0.5">
|
||||
<p className="px-3.5 py-2 text-[9px] font-apple-black text-apple-label-tertiary uppercase tracking-[0.2em]">
|
||||
工作区
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-apple-label-tertiary">
|
||||
{currentUser?.email ?? '当前登录信息'}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-[11px] text-apple-label-secondary">{secondaryLine}</p>
|
||||
<Link
|
||||
href="/?tab=products"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Box size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
正式产品
|
||||
</Link>
|
||||
<Link
|
||||
href="/?tab=drafts"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<FileEdit size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
我的草稿
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: Admin & Actions */}
|
||||
<div className="p-1.5 border-t border-apple-separator/5 bg-black/[0.02]">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Link
|
||||
href="/admin"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<LayoutDashboard size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
管理总控制台
|
||||
</Link>
|
||||
<Link
|
||||
href="/users"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Users size={14} className="opacity-40 group-hover:opacity-100" />
|
||||
用户权限管理
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-full border border-apple-separator/70 bg-apple-bg-secondary/80 px-3 py-2 text-xs text-apple-label-primary transition hover:bg-apple-bg-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2 rounded-apple-lg text-apple-xs font-apple-bold text-apple-red hover:bg-apple-red/5 transition-all group"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<LogOut size={14} className="opacity-60 group-hover:opacity-100" />
|
||||
<span>{isLoggingOut ? '退出中...' : '退出登录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
@ -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"
|
||||
)}
|
||||
>
|
||||
<UserCircle2 size={16} strokeWidth={1.5} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">用户中心</span>
|
||||
<div className="relative">
|
||||
<UserCircle2 size={18} strokeWidth={1.5} />
|
||||
{currentUser && <div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 border-2 border-[#f2f4f6]" />}
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-left text-apple-xs font-apple-bold tracking-tight">
|
||||
用户中心
|
||||
</span>
|
||||
<ChevronUp
|
||||
size={14}
|
||||
strokeWidth={1.5}
|
||||
className={cn('shrink-0 transition-transform duration-200', isOpen ? 'rotate-0' : 'rotate-180')}
|
||||
strokeWidth={2}
|
||||
className={cn('shrink-0 transition-transform duration-300', isOpen ? 'rotate-0' : 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -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: '退出登录' }))
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AssemblyMaterialImage } from '../AssemblyMaterialImage'
|
||||
|
||||
describe('AssemblyMaterialImage', () => {
|
||||
it('同一批图片源重新渲染时,不会把已经回退成功的图片重置回坏图', () => {
|
||||
const { rerender } = render(
|
||||
<AssemblyMaterialImage
|
||||
sources={['http://example.com/runtime.png', 'http://example.com/fallback.png']}
|
||||
alt="规格图"
|
||||
className="h-10 w-10"
|
||||
placeholder={<div>placeholder</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
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(
|
||||
<AssemblyMaterialImage
|
||||
sources={['http://example.com/runtime.png', 'http://example.com/fallback.png']}
|
||||
alt="规格图"
|
||||
className="h-10 w-10"
|
||||
placeholder={<div>placeholder</div>}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect((screen.getByAltText('规格图') as HTMLImageElement).src).toContain('fallback.png')
|
||||
})
|
||||
})
|
||||
@ -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;
|
||||
|
||||
@ -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<AssetThumbnailRenderPayload, 'modelUrl' | 'assetType' | 'materialConfig'>,
|
||||
): 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()
|
||||
}
|
||||
|
||||
344
furniture-3d-system/src/lib/asset-variant-render-config.ts
Normal file
344
furniture-3d-system/src/lib/asset-variant-render-config.ts
Normal file
@ -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<string, AssetVariantNodeUvConfig>
|
||||
}
|
||||
|
||||
export interface AssetVariantRenderConfig {
|
||||
uv?: AssetVariantUvConfig | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown> | null | undefined,
|
||||
): AssetVariantRenderConfig | null {
|
||||
if (!businessData) {
|
||||
return null
|
||||
}
|
||||
|
||||
return normalizeAssetVariantRenderConfig(businessData[ASSET_VARIANT_RENDER_CONFIG_KEY])
|
||||
}
|
||||
|
||||
export function readAssetVariantUvConfig(
|
||||
businessData: Record<string, unknown> | null | undefined,
|
||||
): AssetVariantUvConfig | null {
|
||||
return readAssetVariantRenderConfig(businessData)?.uv ?? null
|
||||
}
|
||||
|
||||
export function writeAssetVariantUvConfig(
|
||||
businessData: Record<string, unknown> | null | undefined,
|
||||
uvConfig: AssetVariantUvConfig | null | undefined,
|
||||
): Record<string, unknown> | 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)
|
||||
}
|
||||
@ -62,6 +62,13 @@ export async function authenticateRequest(request: NextRequest): Promise<AuthRes
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (userInfo.status === 'PENDING') {
|
||||
return {
|
||||
success: false,
|
||||
error: '账号待管理员激活',
|
||||
};
|
||||
}
|
||||
|
||||
if (userInfo.status !== 'ACTIVE') {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@ -66,7 +66,7 @@ export function parseMaterialConfig(
|
||||
const thumbnailUrl = toOptionalString(parsed.thumbnailUrl)
|
||||
const color = toOptionalString(parsed.color)
|
||||
|
||||
if (!id || !name || !categoryId || !thumbnailUrl || !color) {
|
||||
if (!id || !name || !categoryId || !color) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ export function toMaterialConfig(material: MaterialDTO | null | undefined): Mate
|
||||
name: material.name,
|
||||
categoryId: material.categoryId,
|
||||
categoryName: material.categoryName,
|
||||
thumbnailUrl: material.thumbnailUrl,
|
||||
thumbnailUrl: material.thumbnailUrl ?? material.previewUrl ?? material.textureUrl ?? null,
|
||||
textureUrl: material.textureUrl,
|
||||
normalMapUrl: material.normalMapUrl,
|
||||
roughnessMapUrl: material.roughnessMapUrl,
|
||||
|
||||
@ -9,15 +9,82 @@
|
||||
* @created 2025-12-12
|
||||
*/
|
||||
|
||||
import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
HeadObjectCommand,
|
||||
} from '@aws-sdk/client-s3'
|
||||
|
||||
// S3/MinIO 配置 - 安全增强
|
||||
const INTERNAL_ENDPOINT = process.env.S3_ENDPOINT;
|
||||
const PUBLIC_ENDPOINT = process.env.S3_PUBLIC_ENDPOINT || INTERNAL_ENDPOINT;
|
||||
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
|
||||
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
|
||||
const S3_REGION = process.env.S3_REGION || 'us-east-1';
|
||||
const BUCKET_NAME = process.env.S3_BUCKET || 'furniture-3d-models';
|
||||
function trimToNull(value: string | undefined): string | null {
|
||||
const normalized = value?.trim()
|
||||
return normalized ? normalized : null
|
||||
}
|
||||
|
||||
function readBooleanEnv(value: string | undefined, fallback: boolean): boolean {
|
||||
const normalized = value?.trim().toLowerCase()
|
||||
if (!normalized) return fallback
|
||||
return ['1', 'true', 'yes', 'on'].includes(normalized)
|
||||
}
|
||||
|
||||
function normalizePrefix(value: string | undefined): string {
|
||||
const normalized = trimToNull(value)
|
||||
if (!normalized) return ''
|
||||
return normalized.replace(/^\/+|\/+$/g, '') + '/'
|
||||
}
|
||||
|
||||
function normalizeObjectKey(key: string): string {
|
||||
const normalizedKey = key.trim().replace(/^\/+/, '')
|
||||
if (!normalizedKey) {
|
||||
throw new Error('[Storage] 对象 key 不能为空。')
|
||||
}
|
||||
|
||||
if (!FILE_PATH_PREFIX) {
|
||||
return normalizedKey
|
||||
}
|
||||
|
||||
return normalizedKey.startsWith(FILE_PATH_PREFIX)
|
||||
? normalizedKey
|
||||
: `${FILE_PATH_PREFIX}${normalizedKey}`
|
||||
}
|
||||
|
||||
function buildPublicBaseUrl(): string {
|
||||
const configured =
|
||||
trimToNull(process.env.S3_PUBLIC_DOMAIN) ||
|
||||
trimToNull(process.env.S3_PUBLIC_ENDPOINT) ||
|
||||
trimToNull(process.env.S3_PUBLIC_URL) ||
|
||||
trimToNull(process.env.S3_ENDPOINT)
|
||||
|
||||
if (!configured) {
|
||||
throw new Error('[Storage] 未找到可用的公开存储地址,请配置 S3_PUBLIC_DOMAIN 或 S3_ENDPOINT。')
|
||||
}
|
||||
|
||||
return configured.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function publicBaseAlreadyContainsBucket(publicBaseUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(publicBaseUrl)
|
||||
const pathname = parsed.pathname.replace(/\/+$/, '')
|
||||
return pathname === `/${BUCKET_NAME}`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// S3/MinIO 配置 - 统一兼容旧新变量名
|
||||
const INTERNAL_ENDPOINT = trimToNull(process.env.S3_ENDPOINT)
|
||||
const PUBLIC_ENDPOINT = buildPublicBaseUrl()
|
||||
const S3_ACCESS_KEY =
|
||||
trimToNull(process.env.S3_ACCESS_KEY) ||
|
||||
trimToNull(process.env.S3_ACCESS_KEY_ID)
|
||||
const S3_SECRET_KEY =
|
||||
trimToNull(process.env.S3_SECRET_KEY) ||
|
||||
trimToNull(process.env.S3_SECRET_ACCESS_KEY)
|
||||
const S3_REGION = process.env.S3_REGION || 'us-east-1'
|
||||
const BUCKET_NAME = process.env.S3_BUCKET || 'furniture-3d-models'
|
||||
const FORCE_PATH_STYLE = readBooleanEnv(process.env.S3_ENABLE_PATH_STYLE, true)
|
||||
const FILE_PATH_PREFIX = normalizePrefix(process.env.S3_FILE_PATH)
|
||||
|
||||
// 启动时验证 S3 配置
|
||||
if (!INTERNAL_ENDPOINT) {
|
||||
@ -32,10 +99,10 @@ if (!S3_ACCESS_KEY || !S3_SECRET_KEY) {
|
||||
);
|
||||
}
|
||||
|
||||
const VALIDATED_INTERNAL_ENDPOINT = INTERNAL_ENDPOINT as string;
|
||||
const VALIDATED_PUBLIC_ENDPOINT = PUBLIC_ENDPOINT as string;
|
||||
const VALIDATED_S3_ACCESS_KEY = S3_ACCESS_KEY as string;
|
||||
const VALIDATED_S3_SECRET_KEY = S3_SECRET_KEY as string;
|
||||
const VALIDATED_INTERNAL_ENDPOINT = INTERNAL_ENDPOINT as string
|
||||
const VALIDATED_PUBLIC_ENDPOINT = PUBLIC_ENDPOINT as string
|
||||
const VALIDATED_S3_ACCESS_KEY = S3_ACCESS_KEY as string
|
||||
const VALIDATED_S3_SECRET_KEY = S3_SECRET_KEY as string
|
||||
|
||||
// 初始化 S3 客户端
|
||||
const s3Client = new S3Client({
|
||||
@ -45,7 +112,7 @@ const s3Client = new S3Client({
|
||||
accessKeyId: VALIDATED_S3_ACCESS_KEY,
|
||||
secretAccessKey: VALIDATED_S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true, // MinIO 需要
|
||||
forcePathStyle: FORCE_PATH_STYLE,
|
||||
})
|
||||
|
||||
/**
|
||||
@ -70,16 +137,63 @@ export async function uploadBufferToS3(
|
||||
key: string,
|
||||
contentType: string,
|
||||
): Promise<string> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缩略图
|
||||
*
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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<Prisma.TransactionClient, 'department'>,
|
||||
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<EmployeeDirectoryWithUser | null> {
|
||||
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<string, { id: string; fullPath: string }>()
|
||||
|
||||
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()
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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<AppError>)
|
||||
})
|
||||
})
|
||||
@ -80,7 +80,9 @@ function collectAllowedOrigins(renderBaseUrl: string): Set<string> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -17,6 +17,7 @@ const STATIC_REDIRECT_PATHS = new Set([
|
||||
'/first-login',
|
||||
'/admin/categories',
|
||||
'/admin/materials',
|
||||
'/asset-center/material-library',
|
||||
'/admin/themes',
|
||||
])
|
||||
|
||||
|
||||
@ -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<AuthUserWithRelations, 'status'>): 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,
|
||||
|
||||
@ -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<string | null> {
|
||||
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
|
||||
|
||||
@ -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()
|
||||
@ -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
|
||||
|
||||
352
scripts/create-yydd-prod-deploy.ps1
Normal file
352
scripts/create-yydd-prod-deploy.ps1
Normal file
@ -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 <<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '$DB_USER') THEN
|
||||
CREATE ROLE $DB_USER LOGIN PASSWORD '$DB_PASS';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
ALTER ROLE $DB_USER WITH PASSWORD '$DB_PASS';
|
||||
SQL
|
||||
|
||||
if ! sudo -u postgres psql -d postgres -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
|
||||
sudo -u postgres createdb -O $DB_USER $DB_NAME
|
||||
fi
|
||||
|
||||
sudo -u postgres psql -d postgres <<SQL
|
||||
GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;
|
||||
ALTER DATABASE $DB_NAME OWNER TO $DB_USER;
|
||||
SQL
|
||||
|
||||
sudo -u postgres psql -d $DB_NAME <<SQL
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $DB_USER;
|
||||
GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO $DB_USER;
|
||||
SQL
|
||||
|
||||
cat > $BASE/.env <<EOF
|
||||
NODE_ENV=production
|
||||
PORT=$APP_PORT
|
||||
HOSTNAME=0.0.0.0
|
||||
DATABASE_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
|
||||
DIRECT_URL=postgresql://$DB_USER:$DB_PASS@127.0.0.1:5432/$DB_NAME
|
||||
NEXTAUTH_URL=$PUBLIC_APP_URL
|
||||
NEXT_PUBLIC_APP_URL=$PUBLIC_APP_URL
|
||||
NEXTAUTH_SECRET=$NEXTAUTH_SECRET_VALUE
|
||||
JWT_SECRET=$JWT_SECRET_VALUE
|
||||
S3_ENDPOINT=$S3_ENDPOINT
|
||||
S3_BUCKET=$S3_BUCKET
|
||||
S3_REGION=$S3_REGION
|
||||
S3_ENABLE_PATH_STYLE=1
|
||||
S3_PUBLIC_DOMAIN=$S3_PUBLIC_DOMAIN
|
||||
S3_FILE_PATH=$S3_FILE_PATH
|
||||
S3_ACCESS_KEY=$S3_ACCESS_KEY_VALUE
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY_VALUE
|
||||
PUPPETEER_SKIP_DOWNLOAD=true
|
||||
DINGTALK_CALLBACK_ORIGIN=$DINGTALK_CALLBACK_ORIGIN_VALUE
|
||||
HR_SYNC_SECRET=$HR_SYNC_SECRET_VALUE
|
||||
EOF
|
||||
|
||||
if [ -n "$DINGTALK_CLIENT_ID_VALUE" ]; then
|
||||
echo "DINGTALK_CLIENT_ID=$DINGTALK_CLIENT_ID_VALUE" >> $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
|
||||
}
|
||||
146
scripts/hr_sync_worker.py
Normal file
146
scripts/hr_sync_worker.py
Normal file
@ -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
|
||||
326
会话恢复摘要-20260415.json
Normal file
326
会话恢复摘要-20260415.json
Normal file
@ -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(后端 / 逻辑 / 联调) 负责后端、数据、数据库、部署和联调。当前最主要未完成项是辅助装配的准确吸附数据主链,其次是用户系统的最终真人联调验收。"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user