feat: sync validated admin auth material and exporter updates

This commit is contained in:
WuYongJie 2026-04-15 16:36:00 +08:00
parent 7ae45d4f0b
commit 15d5e6fb89
67 changed files with 5334 additions and 585 deletions

10
.gitignore vendored
View File

@ -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/

View File

@ -1,13 +1,15 @@
# AGENTS.md
适用范围:`D:\AI Code\3danzhuang` 全仓库。
适用范围:`D:\AI Code\3danzhuang` 全仓库。
未单独说明时,先遵循全局规则:`C:\Users\54972\.codex\AGENTS.md`。
这份文件只补充本仓库特有规则,不重复全局通用要求。
## 1. 默认方式
## 1. 默认执行口径
- 默认中文沟通,面向非程序员表达。
- 默认回复尽量简短、直白、好理解,非必要不展开。
- 默认先说“现在你要做什么 / 结论是什么”;技术细节、文件路径、命令和长解释只在用户要求时展开。
- 默认按当前会话直接开发。
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。
- 当任务发生在 `furniture-3d-system/` 时,默认按当前会话直接开发。
- 未经用户明确要求,不主动 `commit / push`。
- 禁止破坏性操作,除非用户明确授权。
- 没有“规范审查结论”,任务不算完成。
## 2. 三条硬规则
@ -16,48 +18,47 @@
- 必须符合统一视觉标准。
- 必须优先做根因修复,不能拿补丁冒充完成。
## 3. 固定流程
## 3. 固定协作入口
1. 开始前先用 1-2 句说明:目标、验收标准、根因判断。
2. 开发时优先复用现有实现,不顺手改无关问题。
3. 改完先做最小必要验证。
4. 交付前必须单独做一次规范审查。
5. 最终默认只交代:现在你要做什么、是否完成、有没有风险;技术细节只在用户要求时展开。
### 团队协作固定入口
当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面这些协作文档,再开始改代码:
当任务发生在 `furniture-3d-system/` 时,人和 AI 都必须先读取下面 3 份协作文档,再开始改代码:
- `furniture-3d-system/docs/workflow/团队协作模式.md`
- `furniture-3d-system/docs/workflow/模块边界说明.md`
- `furniture-3d-system/docs/workflow/主线同步操作手册.md`
这 3 份文档就是进入 `furniture-3d-system/` 的固定协作入口,不再额外约定第二套平级入口,也不再靠聊天记录补口头规则。
如果任务和包装协作有关,再额外读取:
- `furniture-3d-system/docs/workflow/同事交接模板.md`
- `furniture-3d-system/docs/workflow/同事本地AGENTS模板.md`
- `furniture-3d-system/docs/开发指南/包装方案增强/新平台包装底座-同事AI对接说明-20260331.md`
固定协作口径:
## 4. 协作与分支口径
- `main` 只作为 `WuYongJie` 的主线收口分支,其他协作者默认不能直接推主线
- `xuxiake` 的长期包装分支固定为 `codex/xuxiake-packaging`
- `xuxiake` 的临时任务分支固定为 `codex/xuxiake-packaging-<任务简称>`
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名
- 长期包装分支同步 `main` 时固定使用 `merge main`
- 临时个人任务分支同步 `main` 时才使用 `rebase main`
- `main` 只作为 `WuYongJie` 的主线收口分支,其他协作者默认不能直接推主线。
- `xuxiake` 的长期包装分支固定为 `codex/xuxiake-packaging`。
- `xuxiake` 的临时任务分支固定为 `codex/xuxiake-packaging-<任务简称>`。
- 新分支统一使用 `codex/<负责人>-<工作域>` 或 `codex/<负责人>-<工作域>-<任务简称>`。
- 不再使用 `feature/packaging`、`packaging/...`、`codex/xuxiake-dev` 这类泛分支名。
- 长期包装分支同步 `main` 时固定使用 `merge main`。
- 临时个人任务分支同步 `main` 时才使用 `rebase main`。
## 4. 规范审查
## 5. Gemini 前端协作口径
- 如项目已指定 `Gemini(前端专家)` 负责前端,则前端代码、UI 视觉、排版动效和 `3D(立体)` 表现层默认由 Gemini 主导。
- 我不主动改 Gemini 已定稿的前端样式;我默认负责底层逻辑、接口、数据、数据库、部署和非前端部分。
- 与 Gemini 联调时,必须优先遵守 Gemini 已确定的数据结构和类型口径,避免因为字段或格式不一致导致前端渲染异常。
- `docs/前端UI改动清单.md` 只作为当前轮次的轻量对接板使用,不作为长期规范来源、历史总账或唯一协作入口。
## 6. 规范审查
规范审查固定只查三件事:
- 架构有没有被破坏
- 视觉有没有跑偏
- 这次是不是根因修复
结论只能是:
- `passed`
- `passed with warnings`
- `rejected`
@ -67,14 +68,8 @@
统一清单:`docs/workflow/规范审查清单.md`
## 5. 标准来源
## 7. 标准来源
- `furniture-3d-system/.cursor/rules/*.mdc`
- `furniture-3d-system/docs/设计规范/苹果风格视觉标准.md`
- `docs/workflow/规范审查清单.md`
## 6. 边界
- 用户未指定目录时,默认在 `furniture-3d-system/` 执行。
- 未经用户明确要求,不主动 `commit / push`。
- 禁止破坏性操作,除非用户明确授权。

View File

@ -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;

View File

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

View File

@ -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,
};
}
}
}

View File

@ -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]);
}
}
}

View File

@ -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)

View File

@ -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);

View File

@ -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),
});
}

View File

@ -46,6 +46,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>
@ -418,12 +447,24 @@ public class HoleInfo
/// 例如:main(主孔)/ side(侧孔)
/// </summary>
public string? HoleGroupRole { get; set; }
/// <summary>
/// 当前孔实际打在板件外表面的面标签。
/// 盲孔通常只有 1 个面;通孔通常会有 2 个相对面。
/// 例如:top / bottom / left / right / front / back
/// </summary>
public string[] SurfaceFaces { get; set; } = System.Array.Empty<string>();
/// <summary>
/// 推断的用途(根据直径)
/// </summary>
public string? InferredUsage { get; set; }
/// <summary>
/// 低置信度或歧义提示
/// </summary>
public List<string> Warnings { get; set; } = new();
/// <summary>
/// 针对该孔位的紧固件建议(v2.3 新增)
/// 说明:
@ -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>

View 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";
}
}

View File

@ -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

View 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;
}
}

View File

@ -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`

View File

@ -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/

View File

@ -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`、`自动组合`、`自动拼装`,统一按旧称呼理解,不再代表当前主线入口。

View File

@ -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` 负责收口。

View File

@ -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 只处理冲突文件。
```
补充说明:
- 历史文档里如果还出现固定包装分支名,那是旧协作阶段留下的例子,不再代表当前唯一允许的长期分支。
- 当前哪条分支算“长期共享分支”,以最新交接、当前任务边界和实际协作方式为准。

View File

@ -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` 承接链,收口时额外确认:有没有把局部协作逻辑误带进正式入库主链,或者把旧兼容链重新当成主链真相。
---

View File

@ -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` 仍然属于主线真相,不在仓库侧重复造数。
- 工艺图组合本期只预留接口,不在主线里单独展开。
- 草稿组详情页里的包装区只负责主线最小联通版占位,不是最终包装工作区。
- 轻仓库可以增强包装成品、库存、库位和展示交互,但不能绕过正式入库单独制造主链结果。

View File

@ -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`、`自动组合`、`自动拼装` 等旧称呼,统一按历史口径理解,不再代表当前主线页面入口。

View File

@ -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']
},

View File

@ -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()
export default async function MaterialManagementPage() {
// 验证用户权限
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
Object.entries(resolvedSearchParams ?? {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item) {
query.append(key, item)
}
})
return
}
if (!token) {
redirect('/login?redirect=/admin/materials')
}
if (value) {
query.set(key, value)
}
})
const payload = verifyToken(token)
if (!payload) {
redirect('/login?redirect=/admin/materials')
}
// 检查是否有管理权限
const allowedRoles = ['SUPER_ADMIN', 'ADMIN', 'DEPARTMENT_MANAGER']
if (!allowedRoles.includes(payload.role)) {
redirect('/?error=unauthorized')
}
return (
<QueryProvider>
<MaterialManagementClient userRole={payload.role} />
</QueryProvider>
redirect(
query.size > 0
? `/asset-center/material-library?${query.toString()}`
: '/asset-center/material-library',
)
}

View 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>
)
}

View File

@ -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: '材质已禁用',
},
}),
)
})
})

View 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: '材质创建成功',
},
}),
)
})
})

View File

@ -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,
}),
)
})
})

View File

@ -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: '主题已停用',
}),
}),
)
})
})

View 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: '主题删除成功',
},
}),
)
})
})

View 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: '主题创建成功',
},
}),
)
})
})

View File

@ -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',
)
})
})

View File

@ -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())

View File

@ -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')
})
})

View File

@ -1,12 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { dingtalkAuthService } from '@/server/services/dingtalk-auth.service'
export async function GET(request: NextRequest) {
function buildLoginRedirect(request: NextRequest, message?: string) {
const url = new URL('/login', request.nextUrl.origin)
const redirectPath = request.nextUrl.searchParams.get('redirect')
const authUrl = dingtalkAuthService.buildAuthorizationUrl({
requestOrigin: request.nextUrl.origin,
redirectPath,
})
return NextResponse.redirect(authUrl)
if (redirectPath?.startsWith('/')) {
url.searchParams.set('redirect', redirectPath)
}
if (message) {
url.searchParams.set('dingtalkError', message)
}
return url
}
export async function GET(request: NextRequest) {
try {
const redirectPath = request.nextUrl.searchParams.get('redirect')
const authUrl = dingtalkAuthService.buildAuthorizationUrl({
requestOrigin: request.nextUrl.origin,
redirectPath,
})
return NextResponse.redirect(authUrl)
} catch (error) {
const message = error instanceof Error ? error.message : '钉钉登录暂不可用,请联系管理员检查配置'
return NextResponse.redirect(buildLoginRedirect(request, message))
}
}

View 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,
}),
)
})
})

View 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,
}),
)
})
})

View 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 }
)
}
}

View File

@ -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 },
)
}

View File

@ -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)

View File

@ -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)

View File

@ -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 }
)
}

View File

@ -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 })
}
}

View File

@ -69,7 +69,7 @@ export default function LoginPage() {
}
if (registered === '1') {
toast.success('注册成功,请直接登录');
toast.success('注册成功,请等待管理员激活');
params.delete('registered');
params.delete('identifier');
}

View File

@ -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 }
)
}
}
}

View File

@ -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,39 +107,91 @@ 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 ?? '当前账号'}
</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>
<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"
>
<LogOut className="h-3.5 w-3.5" />
<span>{isLoggingOut ? '退出中...' : '退出登录'}</span>
</button>
{/* 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>
<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="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 size={14} className="opacity-60 group-hover:opacity-100" />
<span>{isLoggingOut ? '退出中...' : '退出登录'}</span>
</button>
</div>
</div>
) : null}
@ -145,18 +200,24 @@ export function SidebarUserCenter() {
aria-label="用户中心"
aria-expanded={isOpen}
onClick={() => setIsOpen((current) => !current)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-xs font-medium transition-colors"
style={{
color: 'var(--color-label-primary)',
backgroundColor: 'var(--color-surface-container-lowest)',
}}
className={cn(
"flex w-full items-center gap-3 rounded-apple-lg px-3 py-2.5 transition-all duration-300",
isOpen
? "bg-white shadow-apple-sm text-apple-blue"
: "text-apple-label-secondary hover:bg-white/50"
)}
>
<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>

View File

@ -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: '退出登录' }))

View File

@ -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

View File

@ -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')
})
})

View File

@ -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;

View File

@ -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()
}

View 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)
}

View File

@ -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,

View File

@ -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,

View File

@ -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
}
/**
* 生成缩略图
*

View File

@ -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,
},

View File

@ -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()

View File

@ -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')
})
})

View File

@ -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>)
})
})

View File

@ -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

View File

@ -17,6 +17,7 @@ const STATIC_REDIRECT_PATHS = new Set([
'/first-login',
'/admin/categories',
'/admin/materials',
'/asset-center/material-library',
'/admin/themes',
])

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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

View 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
View 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

View 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(后端 / 逻辑 / 联调) 负责后端、数据、数据库、部署和联调。当前最主要未完成项是辅助装配的准确吸附数据主链,其次是用户系统的最终真人联调验收。"
}