diff --git a/furniture-3d-system/docs/开发指南/快速测试指南.md b/furniture-3d-system/docs/开发指南/快速测试指南.md
index 96a314f0..725a3b32 100644
--- a/furniture-3d-system/docs/开发指南/快速测试指南.md
+++ b/furniture-3d-system/docs/开发指南/快速测试指南.md
@@ -144,10 +144,10 @@ npm run dev
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
- "email": "designer@company.com",
+ "email": "design@company.com",
"name": "设计师张三",
"password": "Designer@123456",
- "role": "DESIGNER"
+ "jobNumber": "JS0001"
}'
```
@@ -157,10 +157,11 @@ curl -X POST http://localhost:3000/api/auth/register \
"message": "注册成功",
"user": {
"id": "xxx",
- "email": "designer@company.com",
+ "email": "design@company.com",
"name": "设计师张三",
- "role": "DESIGNER",
- "status": "ACTIVE",
+ "role": "USER",
+ "businessRoles": ["DESIGN"],
+ "status": "PENDING",
"createdAt": "2025-12-12T..."
}
}
diff --git a/furniture-3d-system/docs/架构设计/auth-system-design.md b/furniture-3d-system/docs/架构设计/auth-system-design.md
index 3aa7a8fd..1be5f473 100644
--- a/furniture-3d-system/docs/架构设计/auth-system-design.md
+++ b/furniture-3d-system/docs/架构设计/auth-system-design.md
@@ -30,7 +30,7 @@ model User {
phone String?
// 角色与权限
- role Role @default(DESIGNER)
+ role Role @default(USER)
// 组织架构
departmentId String?
@@ -124,7 +124,7 @@ async function registerUser(data: {
name: data.name,
password: await hashPassword(data.password),
phone: data.phone,
- role: 'DESIGNER',
+ role: 'USER',\n businessRoles: ['DESIGN'],
status: 'ACTIVE',
isFirstLogin: false // 普通用户注册后不需要强制修改
}
diff --git a/furniture-3d-system/docs/架构设计/permission-design-v2.md b/furniture-3d-system/docs/架构设计/permission-design-v2.md
index a15420fa..187fe390 100644
--- a/furniture-3d-system/docs/架构设计/permission-design-v2.md
+++ b/furniture-3d-system/docs/架构设计/permission-design-v2.md
@@ -1,1823 +1,104 @@
-# 家具 3D 系统 - 权限设计方案 V2(私有部署版)
+# 家具 3D 系统 - 权限与业务类型设计
-## 📋 需求澄清
+更新日期:2026-05-14
-### 部署模式
-- **私有部署**:每个企业独立部署到自己的服务器
-- **企业隔离**:企业之间完全独立,无需多租户架构
-- **产品销售**:这是一个可售卖的软件产品
+本文是当前有效口径。旧的 6 角色方案已经废弃,权限角色不再承载具体业务身份。
-### 核心需求
-1. **协作模式**:
- - Product(型号):多人协作
- - SKU:单人负责制
-2. **离职交接**:支持 SKU 负责人交接
-3. **弱审批**:默认不需要,预留功能
-4. **客户访问**:通过分享链接,无需注册
-5. **数据安全**:防止离职员工带走数据
-6. **问题反馈**:查看者可以反馈产品问题
+## 核心结论
----
+- 权限角色只表达系统管理层级:`SUPER_ADMIN / ADMIN / SUPERVISOR / USER`。
+- 业务类型表达业务参与范围:`DESIGN / PLANNING / PURCHASING / WAREHOUSE / SUPPLIER`。
+- `设计` 已迁移为业务类型 `DESIGN`,不再是权限角色。
+- 管理员、主管如果要编辑具体业务字段,也需要拥有对应业务类型;权限角色本身不自动获得业务字段编辑权。
+- `USER + []` 是普通用户,只能查看和下载。
+- `USER + SUPPLIER` 只参与供应商协作范围,不获得内部业务写入权限。
-## 🏗️ 组织架构设计
+## 权限角色
-### 角色层级
-```
-超级管理员 (SUPER_ADMIN)
- ↓
-管理员 (ADMIN)
- ↓
-部门管理员 (DEPARTMENT_MANAGER)
- ↓
-组长 (TEAM_LEADER)
- ↓
-设计师 (DESIGNER)
- ↓
-查看者 (VIEWER)
+| 枚举 | 中文 | 作用 |
+| --- | --- | --- |
+| `SUPER_ADMIN` | 超级管理员 | 系统最高管理权限,用于初始化、全局配置、最高级用户管理。 |
+| `ADMIN` | 管理员 | 后台管理、跨部门管理、用户管理和业务监管。 |
+| `SUPERVISOR` | 主管 | 按绑定的部门或组织节点管理下属人员与范围内事务。 |
+| `USER` | 普通用户 | 默认账号角色,业务能力由 `businessRoles` 决定。 |
+
+`SUPERVISOR` 的范围由 `managedDepartmentId / managedTeamId` 或当前部门锚点决定,不再拆成多个数据库角色。
+
+## 业务类型
+
+| 枚举 | 中文 | 主要用途 |
+| --- | --- | --- |
+| `DESIGN` | 设计 | 产品研发、资产中心、CAD 导入、非集成开品设计字段、审批里的设计责任字段。 |
+| `PLANNING` | 企划 | 企划字段、立项准备、商品企划相关内容。 |
+| `PURCHASING` | 采购 | 采购字段、供应链承接、供应商与打样信息。 |
+| `WAREHOUSE` | 仓库 | 仓储、库位、入库出库和仓库相关操作。 |
+| `SUPPLIER` | 供应商 | 外部供应商协作字段,只在供应商链路内可写。 |
+
+内部业务写入集合固定为:
+
+```ts
+['DESIGN', 'PLANNING', 'PURCHASING', 'WAREHOUSE']
```
-### 角色定义
+供应商不在内部业务写入集合内。
-#### 1. 超级管理员 (SUPER_ADMIN)
-- **典型人员**:系统管理员、IT 负责人
-- **权限**:
- - ✅ 所有功能的最高权限
- - ✅ 管理所有用户
- - ✅ 系统设置
- - ✅ 数据备份和恢复
- - ✅ 查看所有操作日志
+## 写入规则
-#### 2. 管理员 (ADMIN)
-- **典型人员**:总经理、产品总监
-- **权限**:
- - ✅ 管理所有产品和 SKU
- - ✅ 管理用户(除超级管理员外)
- - ✅ 交接 SKU
- - ✅ 查看所有数据
- - ✅ 导出数据
- - ❌ 系统设置
+### 内部业务写入
-#### 3. 部门管理员 (DEPARTMENT_MANAGER)
-- **典型人员**:部门经理、设计部主管
-- **权限**:
- - ✅ 管理本部门及子部门的用户
- - ✅ 管理本部门的产品和 SKU
- - ✅ 交接本部门的 SKU
- - ✅ 查看本部门数据
- - ❌ 跨部门操作
+资产中心、CAD 导入、草稿组、包装方案、审批流程创建等主业务写入口必须满足:
-#### 4. 组长 (TEAM_LEADER)
-- **典型人员**:小组长、项目负责人
-- **权限**:
- - ✅ 管理小组成员的工作
- - ✅ 查看和编辑小组的产品
- - ✅ 协助交接小组的 SKU
- - ✅ 审核小组成员的工作
- - ❌ 删除产品
- - ❌ 管理用户
-
-#### 5. 设计师 (DESIGNER)
-- **典型人员**:3D 建模师、工程师
-- **权限**:
- - ✅ 上传产品
- - ✅ 编辑自己创建的产品
- - ✅ 编辑自己负责的 SKU
- - ✅ 交接自己负责的 SKU
- - ✅ 生成分享链接
- - ❌ 删除产品
- - ❌ 编辑他人的 SKU
-
-#### 6. 查看者 (VIEWER)
-- **典型人员**:销售、客服、质检
-- **权限**:
- - ✅ 查看所有产品
- - ✅ 生成分享链接
- - ✅ 提交问题反馈
- - ❌ 上传/编辑产品
- - ❌ 编辑 SKU
-
----
-
-## 📊 数据库模型设计
-
-
-### 用户模型
-
-```prisma
-enum Role {
- SUPER_ADMIN // 超级管理员
- ADMIN // 管理员
- DEPARTMENT_MANAGER // 部门管理员
- TEAM_LEADER // 组长
- DESIGNER // 设计师
- VIEWER // 查看者
-}
-
-enum UserStatus {
- ACTIVE // 在职
- INACTIVE // 离职
- SUSPENDED // 暂停
-}
-
-model User {
- id String @id @default(cuid())
-
- // 基本信息
- email String @unique // 邮箱(必填,用于登录)
- displayName String @unique // 姓名/花名(必填,用于登录和显示)
- password String
- avatar String?
- phone String?
-
- // 首次登录标识
- isFirstLogin Boolean @default(true) // 是否首次登录(超级管理员需强制修改信息)
-
- // 角色与权限
- role Role @default(DESIGNER)
-
- // 组织架构
- departmentId String?
- department Department? @relation("DepartmentMembers", fields: [departmentId], references: [id])
-
- teamId String? // 所属小组
- team Team? @relation("TeamMembers", fields: [teamId], references: [id])
-
- // 管理关系
- managedDepartmentId String?
- managedDepartment Department? @relation("DepartmentManager", fields: [managedDepartmentId], references: [id])
-
- managedTeamId String?
- managedTeam Team? @relation("TeamLeader", fields: [managedTeamId], references: [id])
-
- // 状态
- status UserStatus @default(ACTIVE)
-
- // 关联
- createdProducts Product[] @relation("ProductCreator")
- ownedSKUs ProductSKU[] @relation("SKUOwner")
- feedbacks Feedback[] @relation("FeedbackSubmitter")
-
- // 操作日志
- activityLogs ActivityLog[]
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- lastLoginAt DateTime?
-
- @@index([departmentId])
- @@index([teamId])
- @@index([status])
-}
+```ts
+businessRoles includes DESIGN / PLANNING / PURCHASING / WAREHOUSE
```
-### 部门模型
+只登录成功不等于可写;管理员或主管也不能仅凭权限角色绕过业务类型。
-```prisma
-model Department {
- id String @id @default(cuid())
-
- name String @unique
- fullPath String // 完整路径,如 "设计部-3D组-建模小组"
-
- // 层级关系
- parentId String?
- parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
- children Department[] @relation("DepartmentTree")
-
- // 部门负责人
- managerId String? @unique
- manager User? @relation("DepartmentManager", fields: [managerId], references: [id])
-
- // 成员
- members User[] @relation("DepartmentMembers")
-
- // 小组
- teams Team[]
-
- // 排序
- orderIndex Int @default(0)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@index([parentId])
-}
-```
+### 审批字段编辑
-### 小组模型(新增)
+审批流程字段按字段矩阵里的 `businessRole` 判断责任归属:
-```prisma
-model Team {
- id String @id @default(cuid())
-
- name String
- description String?
-
- // 所属部门
- departmentId String
- department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
-
- // 组长
- leaderId String? @unique
- leader User? @relation("TeamLeader", fields: [leaderId], references: [id])
-
- // 成员
- members User[] @relation("TeamMembers")
-
- // 排序
- orderIndex Int @default(0)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@unique([departmentId, name])
- @@index([departmentId])
-}
-```
+- 用户拥有对应业务类型,才可以编辑对应字段。
+- `participantUserIds` 只表示参与人或可见性,不授予跨业务字段编辑权。
+- 流程的 `businessRoles` 由字段矩阵推导,前端不能直接提交覆盖。
+- `productPositioning(产品定位与设计卖点)` 归属 `DESIGN`。
-### 产品模型
+### 非集成开品编辑
-```prisma
-model Product {
- id String @id @default(cuid())
-
- modelNumber String @unique
- name String
- description String?
- category String?
-
- // 3D模型
- modelUrl String?
- thumbnailUrl String?
-
- // 归属信息(型号级别 - 多人协作)
- createdBy String
- creator User @relation("ProductCreator", fields: [createdBy], references: [id])
-
- // 协作者列表
- collaborators Json? // [{ userId: "xxx", role: "editor", addedAt: "..." }]
-
- // 部门归属
- departmentId String?
- department Department? @relation(fields: [departmentId], references: [id])
-
- // 可见性
- visibility String @default("TEAM") // PRIVATE, TEAM, DEPARTMENT, PUBLIC
-
- // SKU列表
- skus ProductSKU[]
-
- // 装配步骤
- assemblySteps AssemblyStep[]
-
- // 分享链接
- shareLinks ShareLink[]
-
- // 问题反馈
- feedbacks Feedback[]
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- deletedAt DateTime?
-
- @@index([createdBy])
- @@index([departmentId])
-}
-```
+非集成开品保留业务归属判断:
-### SKU 模型
+- `USER + DESIGN` 只有在创建人或设计师字段匹配时,才能编辑归属自己的设计内容。
+- 企划、采购、仓库字段分别按对应业务类型判断。
+- 管理员/主管的管理权限不自动等于业务字段编辑权。
-```prisma
-model ProductSKU {
- id String @id @default(cuid())
- productId String
- product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
-
- skuCode String @unique
- attributes Json
-
- // 价格和库存
- price Decimal @db.Decimal(10, 2)
- costPrice Decimal? @db.Decimal(10, 2)
- stock Int @default(0)
-
- // 图片和模型
- images String[]
- modelUrl String?
-
- // 归属信息(SKU 级别 - 单人负责)
- ownerId String
- owner User @relation("SKUOwner", fields: [ownerId], references: [id])
-
- // 交接记录
- transferHistory Json? // [{ fromUserId, toUserId, transferredAt, reason, operatorId }]
-
- // 状态
- isActive Boolean @default(true)
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- deletedAt DateTime?
-
- @@index([productId])
- @@index([ownerId])
-}
-```
+## 注册、激活与钉钉
-### 问题反馈模型(新增)
+- 注册和钉钉新账号默认创建为 `USER`。
+- 系统根据花名册部门完整路径自动推导 `businessRoles`。
+- 已排除部门默认 `businessRoles=[]`,激活后是普通只读用户。
+- 待确认部门如果业务类型为空,激活弹窗提醒但允许继续。
+- 主管以上账号可后期人工调整业务类型。
-```prisma
-enum FeedbackStatus {
- PENDING // 待处理
- IN_PROGRESS // 处理中
- RESOLVED // 已解决
- CLOSED // 已关闭
-}
+## 旧数据迁移
-enum FeedbackPriority {
- LOW // 低
- MEDIUM // 中
- HIGH // 高
- URGENT // 紧急
-}
+历史数据迁移规则:
-model Feedback {
- id String @id @default(cuid())
-
- // 关联产品
- productId String
- product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
-
- skuId String? // 可选:具体的 SKU
-
- // 反馈内容
- title String
- description String @db.Text
- images String[] // 问题截图
-
- // 问题分类
- category String // 模型错误、尺寸错误、材质错误、装配步骤错误等
- priority FeedbackPriority @default(MEDIUM)
-
- // 提交者
- submitterId String
- submitter User @relation("FeedbackSubmitter", fields: [submitterId], references: [id])
-
- // 处理者
- assigneeId String?
- assignee User? @relation("FeedbackAssignee", fields: [assigneeId], references: [id])
-
- // 状态
- status FeedbackStatus @default(PENDING)
-
- // 处理记录
- comments Json? // [{ userId, content, createdAt }]
-
- // 解决方案
- resolution String? @db.Text
- resolvedAt DateTime?
-
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- @@index([productId])
- @@index([submitterId])
- @@index([assigneeId])
- @@index([status])
-}
+- 旧权限角色迁移为 `role=USER`。
+- 原业务身份追加 `businessRoles=['DESIGN']`。
+- `User / AccountTemplate / ApprovalWorkflow.businessRoles` 统一补齐为非空数组。
+- 新请求不再允许写入旧设计权限角色;旧值应返回 400,并提示“设计师已迁移为业务类型:设计”。
-// 在 User 模型中添加
-model User {
- // ... 现有字段
-
- assignedFeedbacks Feedback[] @relation("FeedbackAssignee")
-}
-```
+## 页面展示
----
+- 用户管理、个人中心、交接弹窗、账号模板和审批页面展示“业务类型”。
+- 权限角色筛选和表单只出现 4 个权限角色。
+- 页面文案应保持低噪音,不用大段说明解释权限体系。
-## 🔐 权限控制逻辑
+## 验收清单
-
-### 权限矩阵
-
-| 功能 | 超级管理员 | 管理员 | 部门管理员 | 组长 | 设计师 | 查看者 |
-|------|-----------|--------|-----------|------|--------|--------|
-| **用户管理** |
-| 创建用户 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| 编辑用户 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| 删除用户 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| 设置角色 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| **产品管理** |
-| 上传产品 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
-| 编辑产品 | ✅ | ✅ | ✅ (本部门) | ✅ (协作的) | ✅ (自己的) | ❌ |
-| 删除产品 | ✅ | ✅ | ✅ (本部门) | ✅ (本组) | ❌ | ❌ |
-| 添加协作者 | ✅ | ✅ | ✅ (本部门) | ✅ (自己的) | ✅ (自己的) | ❌ |
-| **SKU 管理** |
-| 创建 SKU | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
-| 编辑 SKU | ✅ | ✅ | ✅ (本部门) | ✅ (本组) | ✅ (自己的) | ❌ |
-| 删除 SKU | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| 交接 SKU | ✅ | ✅ | ✅ (本部门) | ❌ | ✅ (自己的) | ❌ |
-| **分享功能** |
-| 生成分享链接 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| 管理分享链接 | ✅ | ✅ | ✅ (本部门) | ✅ (本组) | ✅ (自己的) | ❌ |
-| **反馈功能** |
-| 提交反馈 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| 处理反馈 | ✅ | ✅ | ✅ (本部门) | ✅ (本组) | ✅ (自己的产品) | ❌ |
-| **系统设置** |
-| 系统配置 | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
-| 查看日志 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-| 数据导出 | ✅ | ✅ | ✅ (本部门) | ❌ | ❌ | ❌ |
-
-### SKU 交接权限详解
-
-```typescript
-function canTransferSKU(operator: User, sku: ProductSKU, toUser: User): boolean {
- // 1. 超级管理员可以交接任何 SKU
- if (operator.role === 'SUPER_ADMIN') return true
-
- // 2. 管理员可以交接任何 SKU
- if (operator.role === 'ADMIN') return true
-
- // 3. 部门管理员可以交接本部门的 SKU
- if (operator.role === 'DEPARTMENT_MANAGER' && operator.managedDepartmentId) {
- const owner = getUser(sku.ownerId)
- if (isDepartmentWithinScope(operator.managedDepartmentId, owner.departmentId)) {
- return true
- }
- }
-
- // 4. SKU 负责人可以交接自己负责的 SKU(新增)
- if (sku.ownerId === operator.id) {
- // 只能交接给同部门或同小组的人
- if (toUser.departmentId === operator.departmentId ||
- toUser.teamId === operator.teamId) {
- return true
- }
- }
-
- return false
-}
-```
-
-### 产品编辑权限
-
-```typescript
-function canEditProduct(user: User, product: Product): boolean {
- // 1. 超级管理员可编辑所有产品
- if (user.role === 'SUPER_ADMIN') return true
-
- // 2. 管理员可编辑所有产品
- if (user.role === 'ADMIN') return true
-
- // 3. 部门管理员可编辑本部门的产品
- if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
- if (isDepartmentWithinScope(user.managedDepartmentId, product.departmentId)) {
- return true
- }
- }
-
- // 4. 创建者可编辑
- if (product.createdBy === user.id) return true
-
- // 5. 协作者可编辑
- const collaborators = product.collaborators as Array<{userId: string, role: string}>
- if (collaborators?.some(c => c.userId === user.id && c.role === 'editor')) {
- return true
- }
-
- // 6. 组长可编辑本组成员创建的产品
- if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
- const creator = getUser(product.createdBy)
- if (creator.teamId === user.managedTeamId) {
- return true
- }
- }
-
- return false
-}
-
-function canDeleteProduct(user: User, product: Product): boolean {
- // 1. 超级管理员可删除所有产品
- if (user.role === 'SUPER_ADMIN') return true
-
- // 2. 管理员可删除所有产品
- if (user.role === 'ADMIN') return true
-
- // 3. 部门管理员可删除本部门的产品
- if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
- if (isDepartmentWithinScope(user.managedDepartmentId, product.departmentId)) {
- return true
- }
- }
-
- // 4. 组长可删除本组成员创建的产品(新增)
- if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
- const creator = getUser(product.createdBy)
- if (creator.teamId === user.managedTeamId) {
- return true
- }
- }
-
- return false
-}
-```
-
-### 反馈处理权限
-
-```typescript
-function canHandleFeedback(user: User, feedback: Feedback): boolean {
- // 1. 超级管理员和管理员可处理所有反馈
- if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true
-
- // 2. 部门管理员可处理本部门的反馈
- if (user.role === 'DEPARTMENT_MANAGER' && user.managedDepartmentId) {
- const product = getProduct(feedback.productId)
- if (isDepartmentWithinScope(user.managedDepartmentId, product.departmentId)) {
- return true
- }
- }
-
- // 3. 组长可处理本组的反馈
- if (user.role === 'TEAM_LEADER' && user.managedTeamId) {
- const product = getProduct(feedback.productId)
- const creator = getUser(product.createdBy)
- if (creator.teamId === user.managedTeamId) {
- return true
- }
- }
-
- // 4. 产品创建者可处理自己产品的反馈
- const product = getProduct(feedback.productId)
- if (product.createdBy === user.id) return true
-
- // 5. 产品协作者可处理反馈
- const collaborators = product.collaborators as Array<{userId: string}>
- if (collaborators?.some(c => c.userId === user.id)) {
- return true
- }
-
- // 6. 如果反馈指定了 SKU,SKU 负责人可处理
- if (feedback.skuId) {
- const sku = getSKU(feedback.skuId)
- if (sku.ownerId === user.id) return true
- }
-
- return false
-}
-```
-
----
-
-## 🔄 离职交接流程
-
-### 1. 交接触发
-
-```typescript
-// 当用户状态变更为 INACTIVE 时触发
-async function handleUserInactive(userId: string) {
- const user = await getUser(userId)
-
- // 1. 查找该用户负责的所有 SKU
- const ownedSKUs = await prisma.productSKU.findMany({
- where: { ownerId: userId },
- include: { product: true }
- })
-
- // 2. 查找该用户创建的产品
- const createdProducts = await prisma.product.findMany({
- where: { createdBy: userId }
- })
-
- // 3. 查找该用户作为协作者的产品
- const collaboratedProducts = await prisma.product.findMany({
- where: {
- collaborators: {
- path: '$[*].userId',
- array_contains: userId
- }
- }
- })
-
- // 4. 生成交接清单
- const transferTask = {
- userId,
- userName: user.realName || user.username,
- department: user.department?.name,
- team: user.team?.name,
- items: {
- ownedSKUs: ownedSKUs.length,
- createdProducts: createdProducts.length,
- collaboratedProducts: collaboratedProducts.length
- },
- createdAt: new Date()
- }
-
- // 5. 通知相关管理员
- await notifyManagers(user, transferTask)
-
- return transferTask
-}
-```
-
-### 2. SKU 交接(支持负责人自己交接)
-
-```typescript
-async function transferSKU(params: {
- skuId: string
- fromUserId: string
- toUserId: string
- reason: string
- operatorId: string
-}) {
- const { skuId, fromUserId, toUserId, reason, operatorId } = params
-
- const operator = await getUser(operatorId)
- const sku = await getSKU(skuId)
- const toUser = await getUser(toUserId)
-
- // 1. 验证权限
- if (!canTransferSKU(operator, sku, toUser)) {
- throw new Error('无权限执行交接')
- }
-
- // 2. 如果是负责人自己交接,验证接收人
- if (operator.id === fromUserId) {
- // 只能交接给同部门或同小组的人
- if (toUser.departmentId !== operator.departmentId &&
- toUser.teamId !== operator.teamId) {
- throw new Error('只能交接给同部门或同小组的成员')
- }
- }
-
- // 3. 更新 SKU 负责人
- await prisma.productSKU.update({
- where: { id: skuId },
- data: {
- ownerId: toUserId,
- transferHistory: {
- push: {
- fromUserId,
- toUserId,
- transferredAt: new Date().toISOString(),
- reason,
- operatorId,
- operatorRole: operator.role
- }
- }
- }
- })
-
- // 4. 记录操作日志
- await createActivityLog({
- userId: operatorId,
- type: 'SKU_TRANSFER',
- targetType: 'ProductSKU',
- targetId: skuId,
- action: `将 SKU ${sku.skuCode} 从 ${fromUserId} 交接给 ${toUserId}`,
- metadata: { fromUserId, toUserId, reason }
- })
-
- // 5. 通知相关人员
- await notifyUsers([fromUserId, toUserId], {
- type: 'SKU_TRANSFERRED',
- skuCode: sku.skuCode,
- reason
- })
-}
-```
-
----
-
-## 📢 问题反馈流程
-
-### 1. 提交反馈
-
-```typescript
-async function submitFeedback(params: {
- productId: string
- skuId?: string
- title: string
- description: string
- images: string[]
- category: string
- priority: FeedbackPriority
- submitterId: string
-}) {
- // 1. 创建反馈
- const feedback = await prisma.feedback.create({
- data: {
- ...params,
- status: 'PENDING'
- }
- })
-
- // 2. 自动分配处理者
- const product = await getProduct(params.productId)
-
- // 优先分配给 SKU 负责人
- if (params.skuId) {
- const sku = await getSKU(params.skuId)
- await prisma.feedback.update({
- where: { id: feedback.id },
- data: { assigneeId: sku.ownerId }
- })
- await notifyUser(sku.ownerId, {
- type: 'FEEDBACK_ASSIGNED',
- feedbackId: feedback.id
- })
- }
- // 否则分配给产品创建者
- else {
- await prisma.feedback.update({
- where: { id: feedback.id },
- data: { assigneeId: product.createdBy }
- })
- await notifyUser(product.createdBy, {
- type: 'FEEDBACK_ASSIGNED',
- feedbackId: feedback.id
- })
- }
-
- // 3. 同时通知组长和部门管理员
- const creator = await getUser(product.createdBy)
- if (creator.teamId) {
- const team = await getTeam(creator.teamId)
- if (team.leaderId) {
- await notifyUser(team.leaderId, {
- type: 'FEEDBACK_CREATED',
- feedbackId: feedback.id
- })
- }
- }
-
- return feedback
-}
-```
-
-### 2. 处理反馈
-
-```typescript
-async function handleFeedback(params: {
- feedbackId: string
- userId: string
- action: 'comment' | 'resolve' | 'close'
- content?: string
- resolution?: string
-}) {
- const { feedbackId, userId, action, content, resolution } = params
-
- const feedback = await getFeedback(feedbackId)
- const user = await getUser(userId)
-
- // 1. 验证权限
- if (!canHandleFeedback(user, feedback)) {
- throw new Error('无权限处理此反馈')
- }
-
- // 2. 根据操作类型处理
- switch (action) {
- case 'comment':
- // 添加评论
- await prisma.feedback.update({
- where: { id: feedbackId },
- data: {
- status: 'IN_PROGRESS',
- comments: {
- push: {
- userId,
- userName: user.realName || user.username,
- content,
- createdAt: new Date().toISOString()
- }
- }
- }
- })
- break
-
- case 'resolve':
- // 解决问题
- await prisma.feedback.update({
- where: { id: feedbackId },
- data: {
- status: 'RESOLVED',
- resolution,
- resolvedAt: new Date()
- }
- })
- break
-
- case 'close':
- // 关闭反馈
- await prisma.feedback.update({
- where: { id: feedbackId },
- data: {
- status: 'CLOSED'
- }
- })
- break
- }
-
- // 3. 通知提交者
- await notifyUser(feedback.submitterId, {
- type: 'FEEDBACK_UPDATED',
- feedbackId,
- action
- })
-}
-```
-
----
-
-## 📊 组织架构示例
-
-```
-公司
-├── 产品部
-│ ├── 设计组
-│ │ ├── 建模小组(组长:张三)
-│ │ │ ├── 设计师 A
-│ │ │ ├── 设计师 B
-│ │ │ └── 设计师 C
-│ │ └── 渲染小组(组长:李四)
-│ │ ├── 设计师 D
-│ │ └── 设计师 E
-│ └── 工程组
-│ └── 工程小组(组长:王五)
-│ ├── 工程师 F
-│ └── 工程师 G
-├── 销售部
-│ ├── 华东区
-│ └── 华南区
-└── 客服部
- └── 质检小组(组长:赵六)
- ├── 查看者 H
- └── 查看者 I
-```
-
----
-
-## 🚀 实施建议
-
-### Phase 1: 基础角色和权限(2 周)
-- [ ] 实现 6 种角色
-- [ ] 基础权限检查
-- [ ] 用户管理界面
-
-### Phase 2: 组织架构(1-2 周)
-- [ ] 部门管理
-- [ ] 小组管理
-- [ ] 组长角色功能
-
-### Phase 3: 产品归属(2 周)
-- [ ] Product 协作者
-- [ ] SKU 负责人
-- [ ] 权限过滤
-
-### Phase 4: 离职交接(2 周)
-- [ ] 交接检测
-- [ ] 交接界面
-- [ ] SKU 负责人自主交接
-
-### Phase 5: 问题反馈(1-2 周)
-- [ ] 反馈提交
-- [ ] 反馈处理
-- [ ] 通知系统
-
-### Phase 6: 数据安全(1 周)
-- [ ] 操作日志
-- [ ] 软删除
-- [ ] 审计报表
-
----
-
-## 💡 关键改进点
-
-### 1. 移除多租户架构
-- ✅ 简化数据库模型
-- ✅ 移除 Tenant 表
-- ✅ 每个企业独立部署
-
-### 2. 新增组长角色
-- ✅ 介于部门管理员和设计师之间
-- ✅ 管理小组日常工作
-- ✅ 协助交接和审核
-
-### 3. SKU 负责人可自主交接
-- ✅ 设计师可以交接自己负责的 SKU
-- ✅ 限制只能交接给同部门/同小组
-- ✅ 记录完整的交接历史
-
-### 4. 问题反馈系统
-- ✅ 查看者可以提交反馈
-- ✅ 自动分配给相关负责人
-- ✅ 支持评论和解决流程
-- ✅ 通知相关人员
-
----
-
-## 📝 总结
-
-这个方案的核心特点:
-
-1. **私有部署** ✅ - 每个企业独立部署,无需多租户
-2. **清晰的角色层级** ✅ - 6 种角色,职责明确
-3. **灵活的组织架构** ✅ - 部门 + 小组两级管理
-4. **完善的交接机制** ✅ - 支持管理员和负责人交接
-5. **问题反馈系统** ✅ - 查看者可反馈,自动分配处理
-6. **数据安全保障** ✅ - 操作日志、软删除、权限控制
-
-你觉得这个方案如何?有什么需要调整的地方吗? 😊
-
-
----
-
-## 🔧 系统初始化与数据识别
-
-### 首次安装初始化
-
-```typescript
-// 系统首次启动时自动执行
-async function initializeSystem() {
- // 1. 检查是否已初始化
- const userCount = await prisma.user.count()
-
- if (userCount === 0) {
- console.log('检测到首次安装,开始初始化系统...')
-
- // 2. 创建默认超级管理员
- const defaultAdmin = await prisma.user.create({
- data: {
- email: 'admin@company.com',
- username: 'admin',
- realName: '系统管理员',
- password: await hashPassword('Admin@123456'), // 默认密码
- role: 'SUPER_ADMIN',
- status: 'ACTIVE'
- }
- })
-
- console.log('✅ 默认超级管理员创建成功')
- console.log(' 邮箱: admin@company.com')
- console.log(' 密码: Admin@123456')
- console.log(' ⚠️ 请立即登录并修改默认密码!')
-
- // 3. 创建默认部门
- const defaultDepartment = await prisma.department.create({
- data: {
- name: '未分配部门',
- fullPath: '未分配部门',
- orderIndex: 0
- }
- })
-
- console.log('✅ 默认部门创建成功')
-
- // 4. 创建系统配置
- await prisma.systemSettings.create({
- data: {
- siteName: '3D家具管理系统',
- initialized: true,
- initializedAt: new Date()
- }
- })
-
- console.log('✅ 系统初始化完成!')
-
- return {
- success: true,
- adminEmail: 'admin@company.com',
- defaultPassword: 'Admin@123456'
- }
- }
-
- return {
- success: false,
- message: '系统已初始化'
- }
-}
-```
-
-### 系统重装后的数据识别
-
-#### 1. 数据库持久化
-```yaml
-# docker-compose.yml
-version: '3.8'
-services:
- postgres:
- image: postgres:14
- volumes:
- - ./data/postgres:/var/lib/postgresql/data # 数据持久化
- environment:
- POSTGRES_DB: furniture3d
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: ${DB_PASSWORD}
-
- minio:
- image: minio/minio
- volumes:
- - ./data/minio:/data # 文件持久化
- command: server /data --console-address ":9001"
-```
-
-**关键点**:
-- 数据库文件存储在 `./data/postgres`
-- MinIO 文件存储在 `./data/minio`
-- 重装系统时保留 `data` 目录即可保留所有数据
-
-#### 2. 数据完整性检查
-
-```typescript
-// 系统启动时检查数据完整性
-async function checkDataIntegrity() {
- console.log('开始检查数据完整性...')
-
- const issues: string[] = []
-
- // 1. 检查孤立的 SKU(产品已删除但 SKU 还在)
- const orphanedSKUs = await prisma.productSKU.findMany({
- where: {
- product: null
- }
- })
-
- if (orphanedSKUs.length > 0) {
- issues.push(`发现 ${orphanedSKUs.length} 个孤立的 SKU`)
- }
-
- // 2. 检查无效的用户引用
- const products = await prisma.product.findMany({
- include: { creator: true }
- })
-
- const invalidProducts = products.filter(p => !p.creator)
- if (invalidProducts.length > 0) {
- issues.push(`发现 ${invalidProducts.length} 个产品的创建者不存在`)
- }
-
- // 3. 检查无效的部门引用
- const usersWithInvalidDept = await prisma.user.findMany({
- where: {
- departmentId: { not: null },
- department: null
- }
- })
-
- if (usersWithInvalidDept.length > 0) {
- issues.push(`发现 ${usersWithInvalidDept.length} 个用户的部门不存在`)
- }
-
- // 4. 检查文件完整性
- const productsWithModels = await prisma.product.findMany({
- where: { modelUrl: { not: null } }
- })
-
- let missingFiles = 0
- for (const product of productsWithModels) {
- const exists = await checkFileExists(product.modelUrl!)
- if (!exists) {
- missingFiles++
- }
- }
-
- if (missingFiles > 0) {
- issues.push(`发现 ${missingFiles} 个产品的 3D 模型文件丢失`)
- }
-
- // 5. 输出检查结果
- if (issues.length === 0) {
- console.log('✅ 数据完整性检查通过')
- } else {
- console.log('⚠️ 数据完整性检查发现问题:')
- issues.forEach(issue => console.log(` - ${issue}`))
- console.log(' 建议运行数据修复工具')
- }
-
- return {
- success: issues.length === 0,
- issues
- }
-}
-```
-
-#### 3. 数据修复工具
-
-```typescript
-// 修复数据问题
-async function repairData() {
- console.log('开始修复数据...')
-
- // 1. 删除孤立的 SKU
- const deletedSKUs = await prisma.productSKU.deleteMany({
- where: {
- product: null
- }
- })
- console.log(`✅ 删除了 ${deletedSKUs.count} 个孤立的 SKU`)
-
- // 2. 修复无效的用户引用
- // 将创建者不存在的产品分配给超级管理员
- const superAdmin = await prisma.user.findFirst({
- where: { role: 'SUPER_ADMIN' }
- })
-
- if (superAdmin) {
- const updatedProducts = await prisma.product.updateMany({
- where: {
- creator: null
- },
- data: {
- createdBy: superAdmin.id
- }
- })
- console.log(`✅ 修复了 ${updatedProducts.count} 个产品的创建者`)
- }
-
- // 3. 修复无效的部门引用
- const defaultDept = await prisma.department.findFirst({
- where: { name: '未分配部门' }
- })
-
- if (defaultDept) {
- const updatedUsers = await prisma.user.updateMany({
- where: {
- departmentId: { not: null },
- department: null
- },
- data: {
- departmentId: defaultDept.id
- }
- })
- console.log(`✅ 修复了 ${updatedUsers.count} 个用户的部门`)
- }
-
- console.log('✅ 数据修复完成')
-}
-```
-
-#### 4. 备份与恢复
-
-```bash
-# 备份脚本
-#!/bin/bash
-
-BACKUP_DIR="./backups/$(date +%Y%m%d_%H%M%S)"
-mkdir -p $BACKUP_DIR
-
-# 1. 备份数据库
-docker exec furniture3d-postgres pg_dump -U postgres furniture3d > $BACKUP_DIR/database.sql
-
-# 2. 备份文件
-cp -r ./data/minio $BACKUP_DIR/files
-
-# 3. 备份配置
-cp .env $BACKUP_DIR/.env
-
-echo "✅ 备份完成: $BACKUP_DIR"
-```
-
-```bash
-# 恢复脚本
-#!/bin/bash
-
-BACKUP_DIR=$1
-
-if [ -z "$BACKUP_DIR" ]; then
- echo "用法: ./restore.sh <备份目录>"
- exit 1
-fi
-
-# 1. 恢复数据库
-docker exec -i furniture3d-postgres psql -U postgres furniture3d < $BACKUP_DIR/database.sql
-
-# 2. 恢复文件
-rm -rf ./data/minio
-cp -r $BACKUP_DIR/files ./data/minio
-
-# 3. 恢复配置
-cp $BACKUP_DIR/.env .env
-
-echo "✅ 恢复完成"
-```
-
-### 系统升级迁移
-
-```typescript
-// 数据库迁移版本管理
-const migrations = [
- {
- version: '1.0.0',
- description: '初始版本',
- up: async () => {
- // 初始化数据库
- }
- },
- {
- version: '1.1.0',
- description: '添加小组功能',
- up: async () => {
- // 添加 Team 表
- // 添加 User.teamId 字段
- }
- },
- {
- version: '1.2.0',
- description: '添加问题反馈功能',
- up: async () => {
- // 添加 Feedback 表
- }
- }
-]
-
-// 执行迁移
-async function runMigrations() {
- const currentVersion = await getCurrentVersion()
-
- for (const migration of migrations) {
- if (compareVersions(migration.version, currentVersion) > 0) {
- console.log(`执行迁移: ${migration.version} - ${migration.description}`)
- await migration.up()
- await updateVersion(migration.version)
- console.log(`✅ 迁移完成: ${migration.version}`)
- }
- }
-}
-```
-
----
-
-## 📦 部署检查清单
-
-### 首次部署
-- [ ] 配置环境变量(.env)
-- [ ] 启动数据库和 MinIO
-- [ ] 运行数据库迁移(prisma migrate)
-- [ ] 系统自动初始化
-- [ ] 登录并修改默认密码
-- [ ] 创建部门和用户
-- [ ] 上传测试产品
-
-### 系统重装
-- [ ] 备份 `data` 目录
-- [ ] 备份 `.env` 文件
-- [ ] 重新部署系统
-- [ ] 恢复 `data` 目录
-- [ ] 恢复 `.env` 文件
-- [ ] 运行数据完整性检查
-- [ ] 如有问题,运行数据修复工具
-
-### 系统升级
-- [ ] 备份当前数据
-- [ ] 拉取新版本代码
-- [ ] 运行数据库迁移
-- [ ] 重启服务
-- [ ] 验证功能正常
-
----
-
-## 📝 最终确认
-
-### 权限设计确认 ✅
-- [x] 6 种角色:超级管理员、管理员、部门管理员、组长、设计师、查看者
-- [x] 组长可以删除本组内的产品
-- [x] SKU 负责人可以交接给同部门/同小组成员
-- [x] 查看者可以提交问题反馈
-- [x] 私有部署,无需多租户架构
-
-### 数据安全确认 ✅
-- [x] 完整的操作日志
-- [x] 软删除机制
-- [x] 离职交接流程
-- [x] 数据完整性检查
-- [x] 备份与恢复方案
-
-### 系统初始化确认 ✅
-- [x] 首次安装自动创建超级管理员
-- [x] 数据持久化方案
-- [x] 数据完整性检查
-- [x] 数据修复工具
-- [x] 版本迁移机制
-
----
-
-## 🚀 下一步行动
-
-现在权限设计方案已经完成并确认,建议按以下顺序实施:
-
-### 1. 更新数据库模型(1 周)
-- 更新 Prisma Schema
-- 添加新的角色和字段
-- 运行数据库迁移
-
-### 2. 实现权限中间件(1 周)
-- 创建权限检查函数
-- 实现 API 路由保护
-- 添加前端权限控制
-
-### 3. 实现组织架构(1 周)
-- 部门管理界面
-- 小组管理界面
-- 用户分配
-
-### 4. 实现离职交接(1 周)
-- 交接检测
-- 交接界面
-- 通知系统
-
-### 5. 实现问题反馈(1 周)
-- 反馈提交界面
-- 反馈处理界面
-- 自动分配逻辑
-
-### 6. 系统初始化(3 天)
-- 首次安装初始化
-- 数据完整性检查
-- 备份恢复脚本
-
-**总计:约 5-6 周完成完整的权限系统**
-
-你觉得这个计划如何?我们可以开始实施了吗? 😊
-
-
----
-
-## 🔐 用户注册与登录设计
-
-### 注册流程
-
-```typescript
-// 用户注册
-async function registerUser(data: {
- email: string
- displayName: string // 姓名/花名
- password: string
- phone?: string
-}) {
- // 1. 验证邮箱格式
- if (!isValidEmail(data.email)) {
- throw new Error('邮箱格式不正确')
- }
-
- // 2. 验证邮箱唯一性
- const existingEmail = await prisma.user.findUnique({
- where: { email: data.email }
- })
-
- if (existingEmail) {
- throw new Error('该邮箱已被注册')
- }
-
- // 3. 验证姓名/花名唯一性
- const existingName = await prisma.user.findUnique({
- where: { displayName: data.displayName }
- })
-
- if (existingName) {
- throw new Error('该姓名/花名已被使用,请使用其他名称(可以添加部门后缀,如:张三-设计部)')
- }
-
- // 4. 验证密码强度
- if (!isStrongPassword(data.password)) {
- throw new Error('密码强度不足,至少8位,包含大小写字母和数字')
- }
-
- // 5. 创建用户
- const user = await prisma.user.create({
- data: {
- email: data.email,
- displayName: data.displayName,
- password: await hashPassword(data.password),
- phone: data.phone,
- role: 'DESIGNER', // 默认角色
- status: 'ACTIVE',
- isFirstLogin: false // 普通用户注册后不需要强制修改
- }
- })
-
- // 6. 记录日志
- await createActivityLog({
- userId: user.id,
- type: 'USER_REGISTER',
- action: `用户注册: ${user.displayName} (${user.email})`
- })
-
- return user
-}
-```
-
-### 登录流程(支持邮箱或姓名/花名)
-
-```typescript
-// 用户登录
-async function loginUser(credentials: {
- identifier: string // 邮箱或姓名
- password: string
-}) {
- // 1. 查找用户(支持邮箱或姓名)
- const user = await prisma.user.findFirst({
- where: {
- OR: [
- { email: credentials.identifier },
- { realName: credentials.identifier }
- ]
- }
- })
-
- if (!user) {
- throw new Error('用户不存在')
- }
-
- // 2. 检查用户状态
- if (user.status === 'INACTIVE') {
- throw new Error('账号已停用,请联系管理员')
- }
-
- if (user.status === 'SUSPENDED') {
- throw new Error('账号已暂停,请联系管理员')
- }
-
- // 3. 验证密码
- const isPasswordValid = await verifyPassword(
- credentials.password,
- user.password
- )
-
- if (!isPasswordValid) {
- // 记录登录失败
- await createActivityLog({
- userId: user.id,
- type: 'USER_LOGIN_FAILED',
- action: `登录失败: ${credentials.identifier}`,
- metadata: { identifier: credentials.identifier }
- })
-
- throw new Error('密码错误')
- }
-
- // 4. 更新最后登录时间
- await prisma.user.update({
- where: { id: user.id },
- data: { lastLoginAt: new Date() }
- })
-
- // 5. 记录登录成功
- await createActivityLog({
- userId: user.id,
- type: 'USER_LOGIN',
- action: `登录成功: ${user.realName}`,
- metadata: {
- identifier: credentials.identifier,
- loginMethod: credentials.identifier.includes('@') ? 'email' : 'realName'
- }
- })
-
- // 6. 生成 JWT Token
- const token = generateJWT({
- userId: user.id,
- email: user.email,
- realName: user.realName,
- role: user.role
- })
-
- return {
- user: {
- id: user.id,
- email: user.email,
- realName: user.realName,
- role: user.role,
- avatar: user.avatar,
- department: user.department,
- team: user.team
- },
- token
- }
-}
-```
-
-### 登录界面设计
-
-```tsx
-// 登录表单
-function LoginForm() {
- const [identifier, setIdentifier] = useState('')
- const [password, setPassword] = useState('')
-
- return (
-
- )
-}
-```
-
-### 注册界面设计
-
-```tsx
-// 注册表单
-function RegisterForm() {
- const [email, setEmail] = useState('')
- const [realName, setRealName] = useState('')
- const [password, setPassword] = useState('')
- const [confirmPassword, setConfirmPassword] = useState('')
-
- return (
-
- )
-}
-```
-
----
-
-## 💡 设计优势
-
-### 1. **灵活的登录方式**
-```
-用户可以选择:
-✅ 使用邮箱登录:zhang.san@company.com
-✅ 使用姓名登录:张三
-
-系统自动识别输入类型
-```
-
-### 2. **更好的用户体验**
-- 记住姓名比记住邮箱更容易
-- 适合中国用户习惯
-- 减少输入错误
-
-### 3. **唯一性保证**
-- 邮箱必须唯一(防止重复注册)
-- 姓名必须唯一(防止登录混淆)
-- 如果姓名重复,可以加后缀(如:张三-设计部)
-
-### 4. **安全性考虑**
-- 密码强度验证
-- 登录失败记录
-- 账号状态检查
-- JWT Token 认证
-
----
-
-## 🔄 姓名重复处理方案
-
-### 方案 A:自动添加后缀(推荐)
-
-```typescript
-async function generateUniqueName(baseName: string): Promise {
- let name = baseName
- let counter = 1
-
- while (true) {
- const existing = await prisma.user.findUnique({
- where: { realName: name }
- })
-
- if (!existing) {
- return name
- }
-
- // 添加数字后缀
- name = `${baseName}-${counter}`
- counter++
- }
-}
-
-// 使用示例
-const uniqueName = await generateUniqueName('张三')
-// 如果"张三"已存在,返回"张三-1"
-// 如果"张三-1"也存在,返回"张三-2"
-```
-
-### 方案 B:要求用户修改
-
-```typescript
-async function registerUser(data: RegisterData) {
- const existingName = await prisma.user.findUnique({
- where: { realName: data.realName }
- })
-
- if (existingName) {
- // 提示用户修改姓名
- throw new Error(
- `姓名"${data.realName}"已被使用,请尝试:\n` +
- `- ${data.realName}-设计部\n` +
- `- ${data.realName}-工程部\n` +
- `- 或使用其他姓名`
- )
- }
-
- // 继续注册...
-}
-```
-
-### 方案 C:添加部门前缀
-
-```typescript
-// 注册时自动添加部门前缀
-const uniqueName = `${department.name}-${realName}`
-// 例如:"设计部-张三"
-
-// 登录时支持简写
-// 用户输入"张三",系统自动匹配"设计部-张三"
-```
-
----
-
-## 🎯 我的建议
-
-### 1. **采用方案 A(自动添加后缀)**
-**理由**:
-- ✅ 用户体验最好
-- ✅ 自动处理,无需用户干预
-- ✅ 简单直观
-
-**实现**:
-```typescript
-// 注册时
-const uniqueName = await generateUniqueName(data.realName)
-
-await prisma.user.create({
- data: {
- email: data.email,
- realName: uniqueName, // 使用生成的唯一姓名
- password: hashedPassword
- }
-})
-```
-
-### 2. **登录时的智能匹配**
-```typescript
-// 如果用户输入"张三",但实际姓名是"张三-1"
-// 系统可以提示:"您是否要登录 张三-1?"
-
-async function findUserByName(inputName: string) {
- // 1. 精确匹配
- let user = await prisma.user.findUnique({
- where: { realName: inputName }
- })
-
- if (user) return user
-
- // 2. 模糊匹配(如果精确匹配失败)
- const users = await prisma.user.findMany({
- where: {
- realName: {
- startsWith: inputName
- }
- }
- })
-
- if (users.length === 1) {
- // 只有一个匹配,直接返回
- return users[0]
- }
-
- if (users.length > 1) {
- // 多个匹配,让用户选择
- throw new MultipleMatchError(users)
- }
-
- return null
-}
-```
-
-### 3. **显示友好的姓名**
-```typescript
-// 在界面上显示时,可以隐藏后缀
-function getDisplayName(user: User): string {
- // "张三-1" -> "张三"
- return user.realName.replace(/-\d+$/, '')
-}
-
-// 但在需要区分时显示完整姓名
-function getFullName(user: User): string {
- return user.realName // "张三-1"
-}
-```
-
----
-
-## 📝 更新后的数据库模型
-
-```prisma
-model User {
- id String @id @default(cuid())
-
- // 登录凭证
- email String @unique // 邮箱(必填,用于登录)
- realName String @unique // 真实姓名(必填,用于登录和显示)
- password String
-
- // 其他信息
- avatar String?
- phone String?
-
- // 角色与权限
- role Role @default(DESIGNER)
-
- // ... 其他字段保持不变
-
- @@index([email])
- @@index([realName])
-}
-```
-
----
-
-## ✅ 总结
-
-这个设计的优点:
-
-1. **灵活登录** ✅
- - 支持邮箱登录
- - 支持姓名登录
- - 自动识别输入类型
-
-2. **用户友好** ✅
- - 姓名比邮箱更容易记住
- - 自动处理重名问题
- - 智能匹配提示
-
-3. **安全可靠** ✅
- - 邮箱和姓名都唯一
- - 密码强度验证
- - 完整的日志记录
-
-4. **易于实现** ✅
- - 数据库模型简单
- - 逻辑清晰
- - 易于维护
-
-你觉得这个方案如何?有什么需要调整的地方吗? 😊
\ No newline at end of file
+- 数据库枚举里不存在旧设计权限角色。
+- 业务代码不再用旧设计权限角色判断写权限。
+- `/users` 不再出现旧设计权限角色选项。
+- `USER + []` 不能创建资产、草稿组、包装方案或审批流程。
+- `USER + DESIGN` 可以在归属匹配时编辑自己的非集成开品设计内容。
+- 仅在 `participantUserIds` 内但没有对应业务类型的用户,不能编辑审批字段。
diff --git a/furniture-3d-system/prisma/migrations/20260514170000_migrate_designer_to_design_business_role/migration.sql b/furniture-3d-system/prisma/migrations/20260514170000_migrate_designer_to_design_business_role/migration.sql
new file mode 100644
index 00000000..ba17cdeb
--- /dev/null
+++ b/furniture-3d-system/prisma/migrations/20260514170000_migrate_designer_to_design_business_role/migration.sql
@@ -0,0 +1,71 @@
+CREATE TYPE "BusinessAccountType_new" AS ENUM ('DESIGN', 'PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER');
+
+UPDATE "User" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
+UPDATE "AccountTemplate" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
+UPDATE "ApprovalWorkflow" SET "businessRoles" = ARRAY[]::"BusinessAccountType"[] WHERE "businessRoles" IS NULL;
+
+ALTER TABLE "User" ALTER COLUMN "businessRoles" DROP DEFAULT;
+ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" DROP DEFAULT;
+ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" DROP DEFAULT;
+
+ALTER TABLE "User"
+ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
+USING "businessRoles"::text[]::"BusinessAccountType_new"[];
+
+ALTER TABLE "AccountTemplate"
+ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
+USING "businessRoles"::text[]::"BusinessAccountType_new"[];
+
+ALTER TABLE "ApprovalWorkflow"
+ALTER COLUMN "businessRoles" TYPE "BusinessAccountType_new"[]
+USING "businessRoles"::text[]::"BusinessAccountType_new"[];
+
+DROP TYPE "BusinessAccountType";
+ALTER TYPE "BusinessAccountType_new" RENAME TO "BusinessAccountType";
+
+ALTER TABLE "User" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
+ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
+ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" SET DEFAULT ARRAY[]::"BusinessAccountType"[];
+
+ALTER TABLE "User" ALTER COLUMN "businessRoles" SET NOT NULL;
+ALTER TABLE "AccountTemplate" ALTER COLUMN "businessRoles" SET NOT NULL;
+ALTER TABLE "ApprovalWorkflow" ALTER COLUMN "businessRoles" SET NOT NULL;
+
+UPDATE "User"
+SET
+ "role" = 'USER',
+ "businessRoles" = CASE
+ WHEN 'DESIGN'::"BusinessAccountType" = ANY("businessRoles") THEN "businessRoles"
+ ELSE array_append(COALESCE("businessRoles", ARRAY[]::"BusinessAccountType"[]), 'DESIGN'::"BusinessAccountType")
+ END
+WHERE "role"::text = 'DESIGNER';
+
+UPDATE "AccountTemplate"
+SET
+ "role" = 'USER',
+ "businessRoles" = CASE
+ WHEN 'DESIGN'::"BusinessAccountType" = ANY("businessRoles") THEN "businessRoles"
+ ELSE array_append(COALESCE("businessRoles", ARRAY[]::"BusinessAccountType"[]), 'DESIGN'::"BusinessAccountType")
+ END
+WHERE "role"::text = 'DESIGNER';
+
+CREATE TYPE "Role_new" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER');
+
+ALTER TABLE "User" ALTER COLUMN "role" DROP DEFAULT;
+
+ALTER TABLE "User"
+ALTER COLUMN "role" TYPE "Role_new"
+USING (
+ CASE WHEN "role"::text = 'DESIGNER' THEN 'USER' ELSE "role"::text END
+)::"Role_new";
+
+ALTER TABLE "AccountTemplate"
+ALTER COLUMN "role" TYPE "Role_new"
+USING (
+ CASE WHEN "role"::text = 'DESIGNER' THEN 'USER' ELSE "role"::text END
+)::"Role_new";
+
+DROP TYPE "Role";
+ALTER TYPE "Role_new" RENAME TO "Role";
+
+ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'USER';
diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma
index abd5c956..880a77b3 100644
--- a/furniture-3d-system/prisma/schema.prisma
+++ b/furniture-3d-system/prisma/schema.prisma
@@ -932,7 +932,6 @@ enum Role {
SUPER_ADMIN // 超级管理员
ADMIN // 管理员
SUPERVISOR // 主管(按部门/小组管理锚点区分范围)
- DESIGNER // 设计师
USER // 普通用户
}
@@ -950,6 +949,7 @@ enum EmployeeSourceStatus {
}
enum BusinessAccountType {
+ DESIGN
PLANNING
PURCHASING
WAREHOUSE
diff --git a/furniture-3d-system/prisma/seed.ts b/furniture-3d-system/prisma/seed.ts
index 6c0797a2..0015554e 100644
--- a/furniture-3d-system/prisma/seed.ts
+++ b/furniture-3d-system/prisma/seed.ts
@@ -397,7 +397,7 @@ async function main() {
data: { leaderId: teamLeader.id },
});
- // 创建设计师
+ // 创建设计业务用户
const designers = [];
for (let i = 1; i <= 5; i++) {
const designer = await prisma.user.create({
@@ -405,7 +405,8 @@ async function main() {
email: `designer${i}@furniture3d.com`,
name: `设计师${i}号`,
password: hashedPassword,
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: design3DDept.id,
teamId: i <= 3 ? modelingTeam.id : animationTeam.id,
isFirstLogin: false,
@@ -771,7 +772,7 @@ async function main() {
console.log(' - 超级管理员:admin@furniture3d.com / Admin123456!');
console.log(' - 部门管理员:design.manager@furniture3d.com / Admin123456!');
console.log(' - 组长:modeling.leader@furniture3d.com / Admin123456!');
- console.log(' - 设计师:designer1@furniture3d.com / Admin123456!');
+ console.log(' - 设计业务用户:designer1@furniture3d.com / Admin123456!');
}
main()
diff --git a/furniture-3d-system/scripts/performance-test.ts b/furniture-3d-system/scripts/performance-test.ts
index 1d4affca..86e0db12 100644
--- a/furniture-3d-system/scripts/performance-test.ts
+++ b/furniture-3d-system/scripts/performance-test.ts
@@ -149,9 +149,9 @@ async function main() {
);
results.push(
- await measureQuery('用户列表(按角色)', async () => {
+ await measureQuery('用户列表(按设计业务类型)', async () => {
return await prisma.user.findMany({
- where: { role: 'DESIGNER' },
+ where: { businessRoles: { has: 'DESIGN' } },
});
})
);
diff --git a/furniture-3d-system/src/app/api/approval-workflows/route.test.ts b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts
new file mode 100644
index 00000000..babc7657
--- /dev/null
+++ b/furniture-3d-system/src/app/api/approval-workflows/route.test.ts
@@ -0,0 +1,104 @@
+export {}
+
+import { beforeEach, describe, expect, it, jest } from '@jest/globals'
+import { 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 mockCreateWorkflow: 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/approval-workflow.service', () => ({
+ approvalWorkflowService: {
+ create: (...args: unknown[]) => mockCreateWorkflow(...args),
+ },
+}))
+
+jest.mock('@/server/services', () => ({
+ intakeNonIntegratedProductService: {
+ getById: (...args: unknown[]) => mockGetById(...args),
+ },
+}))
+
+const { POST } = require('./route')
+
+function createRequest(body: unknown) {
+ return {
+ json: jest.fn(async () => body),
+ } as any
+}
+
+describe('POST /api/approval-workflows', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
+ handler({
+ requestId: 'req-test',
+ user: {
+ id: 'design-1',
+ email: 'design@example.com',
+ name: '设计用户',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
+ status: 'ACTIVE',
+ },
+ }),
+ )
+ mockJsonSuccess.mockImplementation((data, status, requestId) => ({ success: true, data, status, requestId }))
+ mockJsonError.mockImplementation((code, message, status, requestId) => ({ success: false, code, message, status, requestId }))
+ })
+
+ it('写入口要求内部业务写权限', async () => {
+ mockGetById.mockResolvedValue({ id: 'product-1' })
+ mockCreateWorkflow.mockResolvedValue({ id: 'workflow-1' })
+
+ await POST(createRequest({
+ productId: 'product-1',
+ approvalNumber: 'PD001',
+ title: '审批单',
+ }))
+
+ expect(mockHandleRequest.mock.calls[0][2]).toEqual(
+ expect.objectContaining({ requireInternalContentWrite: true }),
+ )
+ })
+
+ it('拒绝前端直接提交 businessRoles', async () => {
+ const response = await POST(createRequest({
+ productId: 'product-1',
+ approvalNumber: 'PD001',
+ title: '审批单',
+ businessRoles: ['DESIGN'],
+ }))
+
+ expect(mockCreateWorkflow).not.toHaveBeenCalled()
+ expect(response).toEqual(expect.objectContaining({
+ success: false,
+ code: ErrorCode.VALIDATION_FAILED,
+ status: 400,
+ }))
+ })
+
+ it('创建时不把 businessRoles 透传给服务层', async () => {
+ mockGetById.mockResolvedValue({ id: 'product-1' })
+ mockCreateWorkflow.mockResolvedValue({ id: 'workflow-1' })
+
+ await POST(createRequest({
+ productId: 'product-1',
+ approvalNumber: 'PD001',
+ title: '审批单',
+ }))
+
+ expect(mockCreateWorkflow).toHaveBeenCalledWith(
+ expect.not.objectContaining({ businessRoles: expect.anything() }),
+ expect.objectContaining({ id: 'design-1' }),
+ )
+ })
+})
diff --git a/furniture-3d-system/src/app/api/approval-workflows/route.ts b/furniture-3d-system/src/app/api/approval-workflows/route.ts
index 4726607e..b6c84ba3 100644
--- a/furniture-3d-system/src/app/api/approval-workflows/route.ts
+++ b/furniture-3d-system/src/app/api/approval-workflows/route.ts
@@ -1,7 +1,7 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { AppError, ErrorCode } from '@/server/core/errors'
-import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
+import { Prisma, type ApprovalWorkflowStatus } from '@prisma/client'
import { approvalWorkflowService } from '@/server/services/approval-workflow.service'
import { intakeNonIntegratedProductService } from '@/server/services'
@@ -32,24 +32,34 @@ export async function POST(request: NextRequest) {
return handleRequest(
request,
async (ctx) => {
- const body = await request.json()
+ const body = await request.json().catch(() => null)
try {
- await intakeNonIntegratedProductService.getById(body.productId, ctx.user)
+ if (!body || typeof body !== 'object') {
+ return jsonError(ErrorCode.VALIDATION_FAILED, '流程参数不正确', 400, ctx.requestId)
+ }
+
+ if ('businessRoles' in body) {
+ return jsonError(ErrorCode.VALIDATION_FAILED, '参与业务类型由审批字段矩阵自动推导,不能由前端直接提交', 400, ctx.requestId)
+ }
+
+ const payload = body as Record
+ const productId = typeof payload.productId === 'string' ? payload.productId : ''
+ const approvalNumber = typeof payload.approvalNumber === 'string' ? payload.approvalNumber : ''
+ const title = typeof payload.title === 'string' ? payload.title : ''
+
+ await intakeNonIntegratedProductService.getById(productId, ctx.user)
const workflow = await approvalWorkflowService.create(
{
- approvalNumber: body.approvalNumber,
- title: body.title,
- productId: body.productId,
- status: body.status as ApprovalWorkflowStatus | undefined,
- businessRoles: Array.isArray(body.businessRoles)
- ? (body.businessRoles as BusinessAccountType[])
+ approvalNumber,
+ title,
+ productId,
+ status: payload.status as ApprovalWorkflowStatus | undefined,
+ participantUserIds: Array.isArray(payload.participantUserIds)
+ ? (payload.participantUserIds as string[])
: undefined,
- participantUserIds: Array.isArray(body.participantUserIds)
- ? (body.participantUserIds as string[])
- : undefined,
- fieldMatrix: body.fieldMatrix,
- summary: body.summary,
- metadata: body.metadata,
+ fieldMatrix: payload.fieldMatrix as Prisma.InputJsonValue | null | undefined,
+ summary: payload.summary as Prisma.InputJsonValue | null | undefined,
+ metadata: payload.metadata as Prisma.InputJsonValue | null | undefined,
},
ctx.user
)
@@ -61,6 +71,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true }
+ { requireAuth: true, requireInternalContentWrite: true }
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/families/[id]/review/route.ts b/furniture-3d-system/src/app/api/asset-center/families/[id]/review/route.ts
index 9eb0e359..74257c07 100644
--- a/furniture-3d-system/src/app/api/asset-center/families/[id]/review/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/families/[id]/review/route.ts
@@ -40,6 +40,6 @@ export async function POST(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts
index 0ba350b6..4965a3d2 100644
--- a/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/families/[id]/route.ts
@@ -36,7 +36,7 @@ export async function PUT(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
@@ -58,6 +58,6 @@ export async function DELETE(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/families/route.ts b/furniture-3d-system/src/app/api/asset-center/families/route.ts
index 5a262e28..c902038b 100644
--- a/furniture-3d-system/src/app/api/asset-center/families/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/families/route.ts
@@ -63,6 +63,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts
index 8018e799..831f57a4 100644
--- a/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/import-cad/route.ts
@@ -119,6 +119,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts
index 96145632..7a27cad5 100644
--- a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/[id]/route.ts
@@ -38,7 +38,7 @@ export async function PUT(request: NextRequest, context: RouteParams) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
@@ -63,6 +63,6 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/route.ts b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/route.ts
index 8e386257..ae055cd2 100644
--- a/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/substrate-dictionaries/route.ts
@@ -49,6 +49,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts
index a38abeeb..e3bebed3 100644
--- a/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/substrates/[id]/route.ts
@@ -36,7 +36,7 @@ export async function PUT(request: NextRequest, context: RouteParams) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
@@ -55,6 +55,6 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/substrates/route.ts b/furniture-3d-system/src/app/api/asset-center/substrates/route.ts
index c429618c..1fe3544f 100644
--- a/furniture-3d-system/src/app/api/asset-center/substrates/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/substrates/route.ts
@@ -50,6 +50,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/upload-image/route.ts b/furniture-3d-system/src/app/api/asset-center/upload-image/route.ts
index 82fc66bf..3d971e66 100644
--- a/furniture-3d-system/src/app/api/asset-center/upload-image/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/upload-image/route.ts
@@ -52,5 +52,5 @@ export async function POST(request: NextRequest) {
fileName,
size: file.size,
}, 201, requestId)
- }, { requireAuth: true })
+ }, { requireAuth: true, requireInternalContentWrite: true })
}
diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/review/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/review/route.ts
index 3d0e7788..362322b7 100644
--- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/review/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/review/route.ts
@@ -40,6 +40,6 @@ export async function POST(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts
index 803e415e..7c145896 100644
--- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/route.ts
@@ -44,7 +44,7 @@ export async function PUT(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
@@ -66,6 +66,6 @@ export async function DELETE(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts
index d82d2f6b..8f10be8a 100644
--- a/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/variants/[id]/thumbnail/route.ts
@@ -26,6 +26,6 @@ export async function POST(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/asset-center/variants/route.ts b/furniture-3d-system/src/app/api/asset-center/variants/route.ts
index 5b3df475..64289422 100644
--- a/furniture-3d-system/src/app/api/asset-center/variants/route.ts
+++ b/furniture-3d-system/src/app/api/asset-center/variants/route.ts
@@ -95,6 +95,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/auth/register/route.test.ts b/furniture-3d-system/src/app/api/auth/register/route.test.ts
index 8b307d46..8f6d2d0c 100644
--- a/furniture-3d-system/src/app/api/auth/register/route.test.ts
+++ b/furniture-3d-system/src/app/api/auth/register/route.test.ts
@@ -6,9 +6,11 @@ import { AppError, ErrorCode } from '@/server/core/errors'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
const mockCreateUser: any = jest.fn()
+const mockFindByEmployeeIdentity: any = jest.fn()
const mockCreateLog: any = jest.fn()
const mockMatchByJobNumber: any = jest.fn()
const mockResolveDepartmentIdByExternalId: any = jest.fn()
+const mockFindDepartmentById: any = jest.fn()
jest.mock('@/server/core/response', () => ({
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
@@ -18,6 +20,7 @@ jest.mock('@/server/core/response', () => ({
jest.mock('@/server/services/user.service', () => ({
userService: {
create: (...args: unknown[]) => mockCreateUser(...args),
+ findByEmployeeIdentity: (...args: unknown[]) => mockFindByEmployeeIdentity(...args),
},
}))
@@ -34,6 +37,12 @@ jest.mock('@/server/services/external-employee-directory.service', () => ({
},
}))
+jest.mock('@/server/services/department.service', () => ({
+ departmentService: {
+ findById: (...args: unknown[]) => mockFindDepartmentById(...args),
+ },
+}))
+
const { POST } = require('./route')
function createRequest(body: unknown) {
@@ -54,6 +63,8 @@ describe('POST /api/auth/register', () => {
status,
requestId,
}))
+ mockFindByEmployeeIdentity.mockResolvedValue(null)
+ mockFindDepartmentById.mockResolvedValue(null)
})
it('工号匹配到在职员工后创建待激活账号', async () => {
@@ -68,27 +79,38 @@ describe('POST /api/auth/register', () => {
primaryDepartmentExternalId: 'dept-ext-1',
})
mockResolveDepartmentIdByExternalId.mockResolvedValue('dept-1')
+ mockFindDepartmentById.mockResolvedValue({
+ id: 'dept-1',
+ name: '产品研发部',
+ fullPath: '江苏简尚品牌管理有限公司 > 产品研发部',
+ })
mockCreateUser.mockResolvedValue({
id: 'user-1',
email: 'user@example.com',
- name: 'zhangsan',
+ name: '张三',
role: 'USER',
employeeDirectoryId: 'emp-1',
})
const response = await POST(createRequest({
email: 'user@example.com',
- name: 'zhangsan',
+ name: ' 张三 ',
jobNumber: '1001',
password: '123456',
}))
expect(mockMatchByJobNumber).toHaveBeenCalledWith('1001')
+ expect(mockFindByEmployeeIdentity).toHaveBeenCalledWith({
+ employeeDirectoryId: 'emp-1',
+ dingtalkUserId: 'dt-1',
+ dingtalkUnionId: 'union-1',
+ })
expect(mockCreateUser).toHaveBeenCalledWith(
expect.objectContaining({
email: 'user@example.com',
- name: 'zhangsan',
+ name: '张三',
role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'PENDING',
departmentId: 'dept-1',
employeeDirectoryId: 'emp-1',
@@ -99,6 +121,70 @@ describe('POST /api/auth/register', () => {
expect(response).toEqual(expect.objectContaining({ success: true, status: 201 }))
})
+ it('姓名与员工库不一致时拒绝注册,不绑定员工身份', async () => {
+ mockMatchByJobNumber.mockResolvedValue({
+ id: 'emp-1',
+ jobNumber: '1001',
+ name: '张三',
+ mobile: '13800000000',
+ sourceStatus: 'EMPLOYED',
+ dingtalkUserId: 'dt-1',
+ dingtalkUnionId: 'union-1',
+ primaryDepartmentExternalId: 'dept-ext-1',
+ })
+
+ await expect(POST(createRequest({
+ email: 'user@example.com',
+ name: '李四',
+ jobNumber: '1001',
+ password: '123456',
+ }))).rejects.toEqual(
+ expect.objectContaining({
+ code: ErrorCode.VALIDATION_FAILED,
+ message: '姓名与员工库不一致,请核对工号和姓名',
+ }) as AppError,
+ )
+
+ expect(mockFindByEmployeeIdentity).not.toHaveBeenCalled()
+ expect(mockResolveDepartmentIdByExternalId).not.toHaveBeenCalled()
+ expect(mockCreateUser).not.toHaveBeenCalled()
+ })
+
+ it('同一员工身份已注册时返回业务错误,不创建账号', async () => {
+ mockMatchByJobNumber.mockResolvedValue({
+ id: 'emp-1',
+ jobNumber: '1001',
+ name: '张三',
+ mobile: '13800000000',
+ sourceStatus: 'EMPLOYED',
+ dingtalkUserId: 'dt-1',
+ dingtalkUnionId: 'union-1',
+ primaryDepartmentExternalId: 'dept-ext-1',
+ })
+ mockFindByEmployeeIdentity.mockResolvedValue({
+ id: 'user-existing',
+ email: 'existing@example.com',
+ name: '张三',
+ role: 'USER',
+ employeeDirectoryId: 'emp-1',
+ })
+
+ await expect(POST(createRequest({
+ email: 'user@example.com',
+ name: '张三',
+ jobNumber: '1001',
+ password: '123456',
+ }))).rejects.toEqual(
+ expect.objectContaining({
+ code: ErrorCode.VALIDATION_FAILED,
+ message: '该员工已注册账号,请直接登录或联系管理员',
+ }) as AppError,
+ )
+
+ expect(mockResolveDepartmentIdByExternalId).not.toHaveBeenCalled()
+ expect(mockCreateUser).not.toHaveBeenCalled()
+ })
+
it('工号未匹配时拒绝注册,不创建账号', async () => {
mockMatchByJobNumber.mockResolvedValue(null)
diff --git a/furniture-3d-system/src/app/api/auth/register/route.ts b/furniture-3d-system/src/app/api/auth/register/route.ts
index de2b48dc..2c9401ef 100644
--- a/furniture-3d-system/src/app/api/auth/register/route.ts
+++ b/furniture-3d-system/src/app/api/auth/register/route.ts
@@ -11,6 +11,8 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import { userService } from '@/server/services/user.service'
import { logService } from '@/server/services/log.service'
import { externalEmployeeDirectoryService } from '@/server/services/external-employee-directory.service'
+import { departmentService } from '@/server/services/department.service'
+import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference'
/**
* 注册请求体
@@ -28,6 +30,10 @@ interface RegisterRequest {
// 注册允许的默认角色(防止权限提升)
const REGISTER_DEFAULT_ROLE = 'USER' as const
+function normalizePersonName(name: string | null | undefined): string {
+ return String(name ?? '').trim()
+}
+
export async function POST(request: NextRequest) {
return handleRequest(
request,
@@ -43,17 +49,43 @@ export async function POST(request: NextRequest) {
)
}
+ const submittedName = normalizePersonName(name)
+ const employeeName = normalizePersonName(employee.name)
+ if (submittedName !== employeeName) {
+ throw new AppError(
+ ErrorCode.VALIDATION_FAILED,
+ '姓名与员工库不一致,请核对工号和姓名'
+ )
+ }
+
+ const existingEmployeeUser = await userService.findByEmployeeIdentity({
+ employeeDirectoryId: employee.id,
+ dingtalkUserId: employee.dingtalkUserId,
+ dingtalkUnionId: employee.dingtalkUnionId,
+ })
+ if (existingEmployeeUser) {
+ throw new AppError(
+ ErrorCode.VALIDATION_FAILED,
+ '该员工已注册账号,请直接登录或联系管理员'
+ )
+ }
+
const departmentId = await externalEmployeeDirectoryService.resolveDepartmentIdByExternalId(
employee.primaryDepartmentExternalId
)
+ const department = departmentId
+ ? await departmentService.findById(departmentId).catch(() => null)
+ : null
+ const businessRoles = inferBusinessRolesFromDepartmentPath(department?.fullPath).businessRoles
// 调用服务层创建用户
// 强制使用 USER 角色,忽略用户传入的 role 参数,防止权限提升攻击
const user = await userService.create({
email,
- name,
+ name: employeeName,
password,
role: REGISTER_DEFAULT_ROLE,
+ businessRoles,
status: 'PENDING',
departmentId: departmentId ?? undefined,
isFirstLogin: false,
diff --git a/furniture-3d-system/src/app/api/draft-groups/route.ts b/furniture-3d-system/src/app/api/draft-groups/route.ts
index dd0f29a5..ec1ac90d 100644
--- a/furniture-3d-system/src/app/api/draft-groups/route.ts
+++ b/furniture-3d-system/src/app/api/draft-groups/route.ts
@@ -51,6 +51,6 @@ export async function POST(request: NextRequest) {
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/intake/designer-options/route.ts b/furniture-3d-system/src/app/api/intake/designer-options/route.ts
index 334418b5..88cd02e0 100644
--- a/furniture-3d-system/src/app/api/intake/designer-options/route.ts
+++ b/furniture-3d-system/src/app/api/intake/designer-options/route.ts
@@ -17,7 +17,7 @@ export async function GET(request: NextRequest) {
request,
async (ctx) => {
const designers = await userService.listAll({
- role: "DESIGNER",
+ businessRole: "DESIGN",
status: "ACTIVE",
});
diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/presentation-preview/route.test.ts b/furniture-3d-system/src/app/api/intake/non-integrated/presentation-preview/route.test.ts
index 11745926..bbfd62ae 100644
--- a/furniture-3d-system/src/app/api/intake/non-integrated/presentation-preview/route.test.ts
+++ b/furniture-3d-system/src/app/api/intake/non-integrated/presentation-preview/route.test.ts
@@ -3,9 +3,30 @@ export {}
const mockHandleRequest = jest.fn()
const mockJsonSuccess = jest.fn()
const mockJsonError = jest.fn()
-const mockResolveUploadFileForPreview = jest.fn()
-const mockSavePresentationPreview = jest.fn()
const mockExecFile = jest.fn()
+const mockExistsSync = jest.fn()
+const mockCopyFile = jest.fn()
+const mockMkdir = jest.fn()
+const mockMkdtemp = jest.fn()
+const mockRm = jest.fn()
+const mockStat = jest.fn()
+const mockResolveNonIntegratedAssetFilePath = jest.fn()
+const mockBuildNonIntegratedPresentationPreviewUrl = jest.fn(
+ () => '/non-integrated/presentation-previews/cache.pdf',
+)
+const mockGetNonIntegratedAssetDirectory = jest.fn(() => 'D:/mock/non-integrated')
+
+jest.mock('fs', () => ({
+ existsSync: (...args: unknown[]) => mockExistsSync(...args),
+}))
+
+jest.mock('fs/promises', () => ({
+ copyFile: (...args: unknown[]) => mockCopyFile(...args),
+ mkdir: (...args: unknown[]) => mockMkdir(...args),
+ mkdtemp: (...args: unknown[]) => mockMkdtemp(...args),
+ rm: (...args: unknown[]) => mockRm(...args),
+ stat: (...args: unknown[]) => mockStat(...args),
+}))
jest.mock('child_process', () => ({
execFile: (...args: unknown[]) => mockExecFile(...args),
@@ -17,18 +38,16 @@ jest.mock('@/server/core/response', () => ({
jsonError: (...args: unknown[]) => mockJsonError(...args),
}))
-jest.mock('@/server/services', () => ({
- intakeNonIntegratedProductService: {
- resolveUploadFileForPreview: (...args: unknown[]) => mockResolveUploadFileForPreview(...args),
- savePresentationPreview: (...args: unknown[]) => mockSavePresentationPreview(...args),
- },
+jest.mock('@/app/intake/non-integrated/nonIntegratedAssetServer', () => ({
+ buildNonIntegratedPresentationPreviewUrl: (...args: unknown[]) =>
+ mockBuildNonIntegratedPresentationPreviewUrl(...args),
+ getNonIntegratedAssetDirectory: (...args: unknown[]) => mockGetNonIntegratedAssetDirectory(...args),
+ resolveNonIntegratedAssetFilePath: (...args: unknown[]) =>
+ mockResolveNonIntegratedAssetFilePath(...args),
}))
-jest.mock('@/lib/storage', () => ({
- doesS3ObjectExist: jest.fn(),
- getStorageFilePathPrefix: jest.fn(() => ''),
- getStorageInternalEndpoint: jest.fn(() => 'http://127.0.0.1:9000'),
- uploadBufferToS3: jest.fn(),
+jest.mock('@/app/intake/non-integrated/nonIntegratedAssetUrl', () => ({
+ NON_INTEGRATED_PRESENTATION_PREVIEW_DIRECTORY: 'presentation-previews',
}))
const { POST } = require('./route')
@@ -55,7 +74,8 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
id: 'designer-1',
email: 'designer@example.com',
name: '设计师',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
}),
@@ -74,18 +94,21 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
status,
requestId,
}))
+
+ mockGetNonIntegratedAssetDirectory.mockReturnValue('D:/mock/non-integrated')
+ mockResolveNonIntegratedAssetFilePath.mockReturnValue('D:/mock/non-integrated/uploads/slides.pptx')
+ mockBuildNonIntegratedPresentationPreviewUrl.mockReturnValue('/non-integrated/presentation-previews/cache.pdf')
+ mockExistsSync.mockImplementation((filePath: string) =>
+ filePath === 'D:/mock/non-integrated/uploads/slides.pptx',
+ )
+ mockStat.mockResolvedValue({ size: 1024, mtimeMs: 1770000000000 })
+ mockMkdtemp.mockResolvedValue('D:/temp/non-integrated-presentation-preview-1')
+ mockCopyFile.mockResolvedValue(undefined)
+ mockMkdir.mockResolvedValue(undefined)
+ mockRm.mockResolvedValue(undefined)
})
- it('没有 soffice 时应优雅降级,不写回静态目录或共享预览记录', async () => {
- mockResolveUploadFileForPreview.mockResolvedValue({
- file: {
- id: 'file-1',
- name: 'slides.pptx',
- dataUrl: 'https://s3.example.com/file-1.pptx',
- mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
- storageKey: 'intake/non-integrated/product-1/uploads/file-1.pptx',
- },
- })
+ it('没有 soffice 时应返回受控错误,不写回缓存预览文件', async () => {
mockExecFile.mockImplementation(
(_command: unknown, _args: unknown, _options: unknown, callback: (error: Error) => void) =>
callback(new Error('soffice not found')),
@@ -93,26 +116,32 @@ describe('POST /api/intake/non-integrated/presentation-preview', () => {
const response = await POST(
createMockRequest({
- productId: 'product-1',
- fileId: 'file-1',
+ fileUrl: '/intake/non-integrated/uploads/slides.pptx',
}),
)
- expect(mockResolveUploadFileForPreview).toHaveBeenCalledWith(
- 'product-1',
- { fileId: 'file-1', fileUrl: '' },
- expect.objectContaining({ id: 'designer-1' }),
+ expect(mockResolveNonIntegratedAssetFilePath).toHaveBeenCalledWith(
+ '/intake/non-integrated/uploads/slides.pptx',
+ )
+ expect(mockCopyFile).toHaveBeenCalledWith(
+ 'D:/mock/non-integrated/uploads/slides.pptx',
+ expect.stringContaining('source.pptx'),
+ )
+ expect(mockCopyFile).not.toHaveBeenCalledWith(
+ expect.stringContaining('source.pdf'),
+ expect.stringContaining('presentation-previews'),
+ )
+ expect(mockRm).toHaveBeenCalledWith(
+ 'D:/temp/non-integrated-presentation-preview-1',
+ { recursive: true, force: true },
)
- expect(mockSavePresentationPreview).not.toHaveBeenCalled()
expect(response).toEqual(
expect.objectContaining({
- success: true,
- status: 200,
- data: {
- supported: false,
- pdfUrl: null,
- cached: false,
- reason: expect.stringContaining('soffice'),
+ success: false,
+ status: 500,
+ error: {
+ code: 'FILE_001',
+ message: expect.stringContaining('soffice not found'),
},
}),
)
diff --git a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/material-options/route.test.ts b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/material-options/route.test.ts
index 97de215b..5a0653e9 100644
--- a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/material-options/route.test.ts
+++ b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/material-options/route.test.ts
@@ -31,7 +31,8 @@ describe('pre-sku packaging material-options route', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
}),
diff --git a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/preview/route.test.ts b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/preview/route.test.ts
index 74ea37d3..f89cc00b 100644
--- a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/preview/route.test.ts
+++ b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/preview/route.test.ts
@@ -30,7 +30,8 @@ describe('pre-sku packaging preview route', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
}),
diff --git a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.test.ts b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.test.ts
index 7584af6c..a0f78e0a 100644
--- a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.test.ts
+++ b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.test.ts
@@ -32,7 +32,8 @@ describe('pre-sku packaging route', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
}),
diff --git a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.ts b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.ts
index dc55faec..68800347 100644
--- a/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.ts
+++ b/furniture-3d-system/src/app/api/pre-skus/[id]/packaging/route.ts
@@ -56,6 +56,6 @@ export async function PUT(
throw error
}
},
- { requireAuth: true },
+ { requireAuth: true, requireInternalContentWrite: true },
)
}
diff --git a/furniture-3d-system/src/app/api/products/[id]/assembly/generate-from-order/route.test.ts b/furniture-3d-system/src/app/api/products/[id]/assembly/generate-from-order/route.test.ts
index c54bdc8e..72253f38 100644
--- a/furniture-3d-system/src/app/api/products/[id]/assembly/generate-from-order/route.test.ts
+++ b/furniture-3d-system/src/app/api/products/[id]/assembly/generate-from-order/route.test.ts
@@ -34,7 +34,8 @@ describe('POST /api/products/[id]/assembly/generate-from-order', () => {
id: 'user-1',
email: 'user@example.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
}
diff --git a/furniture-3d-system/src/app/api/products/[id]/assembly/pdf/route.test.ts b/furniture-3d-system/src/app/api/products/[id]/assembly/pdf/route.test.ts
index 95b07b0d..bf07959c 100644
--- a/furniture-3d-system/src/app/api/products/[id]/assembly/pdf/route.test.ts
+++ b/furniture-3d-system/src/app/api/products/[id]/assembly/pdf/route.test.ts
@@ -23,7 +23,8 @@ describe('GET /api/products/[id]/assembly/pdf', () => {
id: 'user-1',
email: 'user@example.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
}
diff --git a/furniture-3d-system/src/app/api/products/[id]/assembly/versions/[versionId]/restore/route.test.ts b/furniture-3d-system/src/app/api/products/[id]/assembly/versions/[versionId]/restore/route.test.ts
index 432cd0ac..0742e940 100644
--- a/furniture-3d-system/src/app/api/products/[id]/assembly/versions/[versionId]/restore/route.test.ts
+++ b/furniture-3d-system/src/app/api/products/[id]/assembly/versions/[versionId]/restore/route.test.ts
@@ -39,7 +39,8 @@ describe('POST /api/products/[id]/assembly/versions/[versionId]/restore', () =>
id: 'user-1',
email: 'editor@test.com',
name: '编辑协作者',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
}),
diff --git a/furniture-3d-system/src/app/api/products/[id]/assembly/versions/route.test.ts b/furniture-3d-system/src/app/api/products/[id]/assembly/versions/route.test.ts
index 0610b2eb..7dc82b3c 100644
--- a/furniture-3d-system/src/app/api/products/[id]/assembly/versions/route.test.ts
+++ b/furniture-3d-system/src/app/api/products/[id]/assembly/versions/route.test.ts
@@ -47,7 +47,8 @@ describe('GET/POST /api/products/[id]/assembly/versions', () => {
id: 'user-1',
email: 'user@test.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/app/api/users/[id]/activate/route.test.ts b/furniture-3d-system/src/app/api/users/[id]/activate/route.test.ts
index 0015c31c..fdf37246 100644
--- a/furniture-3d-system/src/app/api/users/[id]/activate/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/activate/route.test.ts
@@ -65,13 +65,15 @@ describe('POST /api/users/[id]/activate', () => {
mockActivateUser.mockResolvedValue({
id: 'user-1',
status: 'ACTIVE',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: null,
- teamId: 'team-1',
+ teamId: null,
})
const response = await POST(createRequest({
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: null,
teamId: 'team-1',
}), {
@@ -81,9 +83,10 @@ describe('POST /api/users/[id]/activate', () => {
expect(mockActivateUser).toHaveBeenCalledWith(
'user-1',
{
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: null,
- teamId: 'team-1',
+ teamId: null,
},
expect.objectContaining({
id: 'admin-1',
diff --git a/furniture-3d-system/src/app/api/users/[id]/activate/route.ts b/furniture-3d-system/src/app/api/users/[id]/activate/route.ts
index d46aae70..f40214e0 100644
--- a/furniture-3d-system/src/app/api/users/[id]/activate/route.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/activate/route.ts
@@ -1,9 +1,11 @@
import { NextRequest } from 'next/server'
-import type { Role } from '@prisma/client'
import { AppError } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
-import { normalizeBusinessRoles } from '@/lib/business-account-types'
+import {
+ parseBusinessRolesInput,
+ parseUserRoleInput,
+} from '@/server/validators/business-role.validator'
export async function POST(
request: NextRequest,
@@ -12,17 +14,16 @@ export async function POST(
return handleRequest(request, async (ctx) => {
const { id } = await params
const body = await request.json().catch(() => ({} as Record))
- const role = typeof body.role === 'string' ? (body.role as Role) : undefined
- const businessRoles = Array.isArray(body.businessRoles)
- ? normalizeBusinessRoles(body.businessRoles)
+ const role = parseUserRoleInput(body.role)
+ const businessRoles = 'businessRoles' in body
+ ? parseBusinessRolesInput(body.businessRoles, { optional: true })
: undefined
const departmentId = typeof body.departmentId === 'string' ? body.departmentId : body.departmentId === null ? null : undefined
- const teamId = typeof body.teamId === 'string' ? body.teamId : body.teamId === null ? null : undefined
try {
const user = await userManagementService.activateUser(
id,
- { role, businessRoles, departmentId, teamId },
+ { role, businessRoles, departmentId, teamId: null },
ctx.user
)
return jsonSuccess({ message: '用户激活成功', user }, 200, ctx.requestId)
diff --git a/furniture-3d-system/src/app/api/users/[id]/route.test.ts b/furniture-3d-system/src/app/api/users/[id]/route.test.ts
index 8e4837f1..9907a1a9 100644
--- a/furniture-3d-system/src/app/api/users/[id]/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/route.test.ts
@@ -69,7 +69,8 @@ describe('GET/PUT/DELETE /api/users/[id]', () => {
mockGetUserDetail.mockResolvedValue({
id: 'user-1',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
})
const response = await GET(createRequest(), {
diff --git a/furniture-3d-system/src/app/api/users/[id]/route.ts b/furniture-3d-system/src/app/api/users/[id]/route.ts
index 06c88edb..303146e0 100644
--- a/furniture-3d-system/src/app/api/users/[id]/route.ts
+++ b/furniture-3d-system/src/app/api/users/[id]/route.ts
@@ -10,8 +10,11 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { AppError } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service'
-import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
-import { normalizeBusinessRoles } from '@/lib/business-account-types'
+import type { BusinessAccountType, UserStatus } from '@prisma/client'
+import {
+ parseBusinessRolesInput,
+ parseUserRoleInput,
+} from '@/server/validators/business-role.validator'
export async function GET(
request: NextRequest,
@@ -38,24 +41,25 @@ export async function PUT(
return handleRequest(request, async (ctx) => {
const { id } = await params
const body = await request.json()
- const { email, name, password, role, departmentId, teamId, phone, status, accountTemplateId } = body as {
+ const { email, name, password, departmentId, phone, status, accountTemplateId } = body as {
email?: string
name?: string
password?: string
- role?: Role
businessRoles?: BusinessAccountType[]
departmentId?: string | null
- teamId?: string | null
phone?: string | null
status?: UserStatus
accountTemplateId?: string | null
}
- const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
+ const role = parseUserRoleInput(body.role)
+ const businessRoles = 'businessRoles' in body
+ ? parseBusinessRolesInput(body.businessRoles, { optional: true })
+ : undefined
try {
const updatedUser = await userManagementService.updateUser(
id,
- { email, name, password, role, businessRoles, departmentId, teamId, phone, status, accountTemplateId },
+ { email, name, password, role, businessRoles, departmentId, teamId: null, phone, status, accountTemplateId },
ctx.user
)
diff --git a/furniture-3d-system/src/app/api/users/organization-tree/route.test.ts b/furniture-3d-system/src/app/api/users/organization-tree/route.test.ts
index 57e2411e..21356d70 100644
--- a/furniture-3d-system/src/app/api/users/organization-tree/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/organization-tree/route.test.ts
@@ -1,6 +1,7 @@
export {}
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
+import { AppError } from '@/server/core/errors'
const mockHandleRequest: any = jest.fn()
const mockJsonSuccess: any = jest.fn()
@@ -30,18 +31,29 @@ describe('GET /api/users/organization-tree', () => {
beforeEach(() => {
jest.clearAllMocks()
- mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) =>
- handler({
- requestId: 'req-tree',
- user: {
- id: 'admin-1',
- email: 'admin@example.com',
- name: '管理员',
- role: 'ADMIN',
- status: 'ACTIVE',
- },
- }),
- )
+ mockHandleRequest.mockImplementation(async (_request: unknown, handler: any) => {
+ try {
+ return await handler({
+ requestId: 'req-tree',
+ user: {
+ id: 'admin-1',
+ email: 'admin@example.com',
+ name: '管理员',
+ role: 'ADMIN',
+ status: 'ACTIVE',
+ },
+ })
+ } catch (error) {
+ if (error instanceof AppError) {
+ return {
+ success: false,
+ status: error.statusCode,
+ error: { message: error.message },
+ }
+ }
+ throw error
+ }
+ })
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
success: true,
@@ -74,13 +86,14 @@ describe('GET /api/users/organization-tree', () => {
})
const response = await GET(
- createGetRequest('http://localhost:3000/api/users/organization-tree?search=alice&role=DESIGNER&status=ACTIVE'),
+ createGetRequest('http://localhost:3000/api/users/organization-tree?search=alice&role=USER&businessRole=DESIGN&status=ACTIVE'),
)
expect(mockGetOrganizationTree).toHaveBeenCalledWith(
{
search: 'alice',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRole: 'DESIGN',
status: 'ACTIVE',
},
expect.objectContaining({
@@ -103,4 +116,33 @@ describe('GET /api/users/organization-tree', () => {
)
expect(response.data.departments[0]?.teams).toEqual(expect.any(Array))
})
+
+ it('拒绝旧 DESIGNER 筛选口径', async () => {
+ const roleResponse = await GET(
+ createGetRequest('http://localhost:3000/api/users/organization-tree?role=DESIGNER'),
+ )
+ const businessRoleResponse = await GET(
+ createGetRequest('http://localhost:3000/api/users/organization-tree?businessRole=DESIGNER'),
+ )
+
+ expect(roleResponse).toEqual(
+ expect.objectContaining({
+ success: false,
+ status: 400,
+ error: expect.objectContaining({
+ message: '设计师已迁移为业务类型:设计',
+ }),
+ }),
+ )
+ expect(businessRoleResponse).toEqual(
+ expect.objectContaining({
+ success: false,
+ status: 400,
+ error: expect.objectContaining({
+ message: '设计师已迁移为业务类型:设计',
+ }),
+ }),
+ )
+ expect(mockGetOrganizationTree).toHaveBeenCalledTimes(0)
+ })
})
diff --git a/furniture-3d-system/src/app/api/users/organization-tree/route.ts b/furniture-3d-system/src/app/api/users/organization-tree/route.ts
index 9bdcd83a..9ebadfd8 100644
--- a/furniture-3d-system/src/app/api/users/organization-tree/route.ts
+++ b/furniture-3d-system/src/app/api/users/organization-tree/route.ts
@@ -7,8 +7,12 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { userManagementService } from '@/server/services/user-management.service'
-import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
+import type { UserStatus } from '@prisma/client'
import type { AllowedRole } from '@/lib/role-scope'
+import {
+ parseBusinessRoleFilter,
+ parseUserRoleInput,
+} from '@/server/validators/business-role.validator'
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
@@ -18,8 +22,8 @@ export async function GET(request: NextRequest) {
async (ctx) => {
const searchParams = request.nextUrl.searchParams
const search = searchParams.get('search') || undefined
- const role = (searchParams.get('role') as Role | null) || undefined
- const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
+ const role = parseUserRoleInput(searchParams.get('role'))
+ const businessRole = parseBusinessRoleFilter(searchParams.get('businessRole'))
const status = (searchParams.get('status') as UserStatus | null) || undefined
const tree = await userManagementService.getOrganizationTree(
diff --git a/furniture-3d-system/src/app/api/users/route.test.ts b/furniture-3d-system/src/app/api/users/route.test.ts
index 07dd6fc6..2a3c0bbc 100644
--- a/furniture-3d-system/src/app/api/users/route.test.ts
+++ b/furniture-3d-system/src/app/api/users/route.test.ts
@@ -92,7 +92,7 @@ describe('GET/POST /api/users', () => {
const response = await GET(
createGetRequest(
- 'http://localhost:3000/api/users?page=2&limit=10&search=alice&role=DESIGNER&status=PENDING&departmentId=dept-9',
+ 'http://localhost:3000/api/users?page=2&limit=10&search=alice&role=USER&businessRole=DESIGN&status=PENDING&departmentId=dept-9',
),
)
@@ -101,7 +101,8 @@ describe('GET/POST /api/users', () => {
page: 2,
limit: 10,
search: 'alice',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRole: 'DESIGN',
status: 'PENDING',
departmentId: 'dept-9',
},
@@ -132,7 +133,8 @@ describe('GET/POST /api/users', () => {
id: 'user-2',
email: 'designer@example.com',
name: '设计师',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
})
@@ -141,7 +143,8 @@ describe('GET/POST /api/users', () => {
email: 'designer@example.com',
name: '设计师',
password: '123456',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: 'dept-2',
teamId: 'team-3',
phone: '13800000000',
@@ -149,19 +152,20 @@ describe('GET/POST /api/users', () => {
)
expect(mockCreateUser).toHaveBeenCalledWith(
- {
+ expect.objectContaining({
email: 'designer@example.com',
name: '设计师',
password: '123456',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: 'dept-2',
- teamId: 'team-3',
phone: '13800000000',
- },
+ }),
expect.objectContaining({
id: 'admin-1',
}),
)
+ expect(mockCreateUser.mock.calls[0][0]).not.toHaveProperty('teamId')
expect(response).toEqual(
expect.objectContaining({
success: true,
diff --git a/furniture-3d-system/src/app/api/users/route.ts b/furniture-3d-system/src/app/api/users/route.ts
index 3ab3e369..dbe92b02 100644
--- a/furniture-3d-system/src/app/api/users/route.ts
+++ b/furniture-3d-system/src/app/api/users/route.ts
@@ -9,9 +9,13 @@ import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess, jsonSuccessWithPagination, jsonError } from '@/server/core/response'
import { AppError, ErrorCode } from '@/server/core/errors'
import { userManagementService } from '@/server/services/user-management.service'
-import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
+import type { UserStatus } from '@prisma/client'
import type { AllowedRole } from '@/lib/role-scope'
-import { normalizeBusinessRoles } from '@/lib/business-account-types'
+import {
+ parseBusinessRoleFilter,
+ parseBusinessRolesInput,
+ parseUserRoleInput,
+} from '@/server/validators/business-role.validator'
const ALLOWED_ROLES: AllowedRole[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR_DEPARTMENT', 'SUPERVISOR_TEAM']
@@ -23,9 +27,9 @@ export async function GET(request: NextRequest) {
const page = parseInt(searchParams.get('page') || '1', 10)
const limit = parseInt(searchParams.get('limit') || '20', 10)
const search = searchParams.get('search') || undefined
- const role = searchParams.get('role') as Role | undefined
+ const role = parseUserRoleInput(searchParams.get('role'))
const status = searchParams.get('status') as UserStatus | undefined
- const businessRole = (searchParams.get('businessRole') as BusinessAccountType | null) || undefined
+ const businessRole = parseBusinessRoleFilter(searchParams.get('businessRole'))
const departmentId = searchParams.get('departmentId') || undefined
const result = await userManagementService.listUsers(
@@ -52,9 +56,12 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const body = await request.json()
- const { email, name, password, role, departmentId, teamId, phone, accountTemplateId } = body
+ const { email, name, password, departmentId, phone, accountTemplateId } = body
+ const role = parseUserRoleInput(body.role)
const jobNumber = typeof body.jobNumber === 'string' ? body.jobNumber : undefined
- const businessRoles = 'businessRoles' in body ? normalizeBusinessRoles(body.businessRoles) : undefined
+ const businessRoles = 'businessRoles' in body
+ ? parseBusinessRolesInput(body.businessRoles, { optional: true })
+ : undefined
if (!email || !name || !password) {
return jsonError(
@@ -66,7 +73,7 @@ export async function POST(request: NextRequest) {
}
try {
- const createInput = { email, name, password, role, departmentId, teamId, phone, accountTemplateId }
+ const createInput = { email, name, password, role, departmentId, phone, accountTemplateId }
const normalizedCreateInput = {
...createInput,
...(businessRoles ? { businessRoles } : {}),
diff --git a/furniture-3d-system/src/app/api/warehouse/_shared.ts b/furniture-3d-system/src/app/api/warehouse/_shared.ts
index e5280ba1..c1e3bcf0 100644
--- a/furniture-3d-system/src/app/api/warehouse/_shared.ts
+++ b/furniture-3d-system/src/app/api/warehouse/_shared.ts
@@ -1,4 +1,4 @@
-import { isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
+import { hasBusinessRole, isDepartmentSupervisor, isTeamSupervisor } from '@/lib/role-scope'
import { ErrorCode } from '@/server/core/errors'
import { jsonError } from '@/server/core/response'
import type { AuthUser } from '@/server/core/types'
@@ -7,11 +7,15 @@ export const WAREHOUSE_OPERATOR_ROLES = [
'SUPER_ADMIN',
'ADMIN',
'SUPERVISOR',
- 'DESIGNER',
+ 'USER',
] as const
export function hasWarehouseManagedAccess(user: AuthUser): boolean {
- if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN' || user.role === 'DESIGNER') {
+ if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') {
+ return true
+ }
+
+ if (hasBusinessRole(user, 'WAREHOUSE')) {
return true
}
diff --git a/furniture-3d-system/src/app/api/warehouse/access-control.test.ts b/furniture-3d-system/src/app/api/warehouse/access-control.test.ts
index 05fca71a..25ee30f9 100644
--- a/furniture-3d-system/src/app/api/warehouse/access-control.test.ts
+++ b/furniture-3d-system/src/app/api/warehouse/access-control.test.ts
@@ -45,6 +45,7 @@ describe('warehouse API access control', () => {
email: 'user@example.com',
name: '仓库用户',
role: 'ADMIN',
+ businessRoles: [],
status: 'ACTIVE',
departmentId: null,
teamId: null,
@@ -79,7 +80,7 @@ describe('warehouse API access control', () => {
}))
})
- it('库位列表路由使用当前 5 级角色口径', async () => {
+ it('库位列表路由使用权限角色加仓库业务类型口径', async () => {
mockListLocations.mockResolvedValue([])
await getLocations(createRequest('http://localhost/api/warehouse/locations'))
@@ -89,7 +90,7 @@ describe('warehouse API access control', () => {
expect.any(Function),
expect.objectContaining({
requireAuth: true,
- allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER'],
+ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'],
}),
)
})
@@ -138,7 +139,7 @@ describe('warehouse API access control', () => {
)
})
- it('配件库位绑定路由也使用当前 5 级角色口径', async () => {
+ it('配件库位绑定路由也使用权限角色加仓库业务类型口径', async () => {
mockBindVariantToLocations.mockResolvedValue([])
await postBindings(
@@ -153,7 +154,7 @@ describe('warehouse API access control', () => {
expect.any(Function),
expect.objectContaining({
requireAuth: true,
- allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER'],
+ allowedRoles: ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'],
}),
)
})
@@ -181,6 +182,38 @@ describe('warehouse API access control', () => {
)
})
+ it('仓库业务类型用户可以做配件库位绑定', async () => {
+ currentUser = {
+ ...currentUser,
+ role: 'USER',
+ businessRoles: ['WAREHOUSE'],
+ }
+ mockBindVariantToLocations.mockResolvedValue([
+ {
+ id: 'binding-warehouse',
+ assetVariantId: 'variant-1',
+ warehouseLocation: {
+ id: 'loc-1',
+ code: 'A-01',
+ name: '成品区 A01',
+ },
+ },
+ ])
+
+ const response = await postBindings(
+ createRequest('http://localhost/api/warehouse/bindings', {
+ variantId: 'variant-1',
+ locationIds: ['loc-1'],
+ }),
+ )
+
+ expect(mockBindVariantToLocations).toHaveBeenCalledWith({
+ variantId: 'variant-1',
+ locationIds: ['loc-1'],
+ })
+ expect(response).toEqual(expect.objectContaining({ success: true, status: 200 }))
+ })
+
it('带小组锚点的主管可以做配件库位绑定', async () => {
currentUser = {
...currentUser,
diff --git a/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx b/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx
index 29c2b646..3fea4d2c 100644
--- a/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx
+++ b/furniture-3d-system/src/app/approval-workflows/[accessKey]/ApprovalWorkflowPageClient.tsx
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import type { ApprovalWorkflowStatus, BusinessAccountType } from '@prisma/client'
import { AlertCircle, Clock3, FileStack, Lock, RefreshCw, ShieldCheck } from 'lucide-react'
import { AuthProvider, useAuth } from '@/hooks/useAuth'
-import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
+import { BUSINESS_ACCOUNT_TYPE_LABELS, BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types'
type FieldMatrixItem = {
key: string
@@ -59,6 +59,8 @@ const STATUS_LABELS: Record = {
ARCHIVED: '已归档',
}
+const APPROVAL_BUSINESS_ROLE_ORDER = BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value)
+
export function ApprovalWorkflowPageClient({ accessKey }: { accessKey: string }) {
return (
@@ -112,7 +114,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
const groupedFields = useMemo(() => {
const groups = new Map()
- for (const role of ['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]) {
+ for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
groups.set(role, [])
}
for (const item of workflow?.fieldMatrix || []) {
@@ -131,7 +133,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
const groupedFieldSections = useMemo(() => {
const sections = new Map>()
- for (const role of ['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]) {
+ for (const role of APPROVAL_BUSINESS_ROLE_ORDER) {
const roleFields = groupedFields.get(role) || []
const bySection = new Map()
for (const field of roleFields) {
@@ -254,7 +256,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
- {(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
+ {APPROVAL_BUSINESS_ROLE_ORDER.map((role) => {
const sections = groupedFieldSections.get(role) || []
if (sections.length === 0) return null
return (
@@ -278,7 +280,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
- {(['PLANNING', 'PURCHASING', 'WAREHOUSE', 'SUPPLIER'] as BusinessAccountType[]).map((role) => {
+ {APPROVAL_BUSINESS_ROLE_ORDER.map((role) => {
const sections = groupedFieldSections.get(role) || []
if (sections.length === 0) return null
const mine = myBusinessRoles.includes(role)
@@ -316,7 +318,7 @@ function ApprovalWorkflowPageBody({ accessKey }: { accessKey: string }) {
{section.fields.map((field) => {
- const readOnly = !mine && user?.role !== 'SUPER_ADMIN' && user?.role !== 'ADMIN'
+ const readOnly = !mine
return (
diff --git a/furniture-3d-system/src/app/approval-workflows/approvalWorkflowApi.ts b/furniture-3d-system/src/app/approval-workflows/approvalWorkflowApi.ts
index 3d3f18e4..4e5f99fa 100644
--- a/furniture-3d-system/src/app/approval-workflows/approvalWorkflowApi.ts
+++ b/furniture-3d-system/src/app/approval-workflows/approvalWorkflowApi.ts
@@ -82,7 +82,6 @@ export async function createApprovalWorkflow(input: {
approvalNumber: string
title: string
productId: string
- businessRoles: BusinessAccountType[]
}) {
const response = await fetch('/api/approval-workflows', {
method: 'POST',
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
index 9fa94e2b..c4004cd4 100644
--- a/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
+++ b/furniture-3d-system/src/app/intake/IntakeClient.test.tsx
@@ -1817,7 +1817,8 @@ describe('IntakeClient', () => {
renderSelector({
eligibleSkuList: [],
- userRole: 'DESIGNER',
+ userRole: 'USER',
+ userBusinessRoles: ['DESIGN'],
userName: 'Designer A',
})
@@ -1869,7 +1870,8 @@ describe('IntakeClient', () => {
renderSelector({
eligibleSkuList: [],
- userRole: 'DESIGNER',
+ userRole: 'USER',
+ userBusinessRoles: ['DESIGN'],
userName: 'Designer B',
})
@@ -1896,7 +1898,8 @@ describe('IntakeClient', () => {
renderSelector({
eligibleSkuList: [],
- userRole: 'DESIGNER',
+ userRole: 'USER',
+ userBusinessRoles: ['DESIGN'],
userName: 'Structure A',
})
@@ -1925,7 +1928,8 @@ describe('IntakeClient', () => {
renderSelector({
eligibleSkuList: [],
- userRole: 'DESIGNER',
+ userRole: 'USER',
+ userBusinessRoles: ['DESIGN'],
userId: 'creator-user-id',
userName: 'Creator',
})
@@ -1959,7 +1963,8 @@ describe('IntakeClient', () => {
renderSelector({
eligibleSkuList: [],
- userRole: 'DESIGNER',
+ userRole: 'USER',
+ userBusinessRoles: ['DESIGN'],
userName: '财神',
})
diff --git a/furniture-3d-system/src/app/intake/IntakeClient.tsx b/furniture-3d-system/src/app/intake/IntakeClient.tsx
index 8bb6cd98..4a564e82 100644
--- a/furniture-3d-system/src/app/intake/IntakeClient.tsx
+++ b/furniture-3d-system/src/app/intake/IntakeClient.tsx
@@ -200,10 +200,10 @@ function canEditNonIntegratedProduct(
product.openingReference?.structureDesigner,
]);
- return (
- user.role === "DESIGNER" &&
+ return Boolean(
+ user.businessRoles?.includes("DESIGN") &&
userName.length > 0 &&
- designerNames.includes(userName)
+ designerNames.includes(userName),
);
}
@@ -4408,7 +4408,7 @@ const PATENT_FEE_STATUS_OPTIONS: ProductPatentFeeStatus[] = [
"不缴",
];
const PATENT_SA_REQUIRED_OPTIONS: ProductPatentSaRequired[] = ["否", "是"];
-const PATENT_DESIGNER_MANAGER_ROLES = new Set([
+const PATENT_OWNER_MANAGER_ROLES = new Set([
"SUPER_ADMIN",
"ADMIN",
"SUPERVISOR",
@@ -6002,7 +6002,7 @@ export function ProductPatentDashboard({
const sortedOptions = Array.from(options).sort((a, b) =>
a.localeCompare(b, "zh-CN"),
);
- if (PATENT_DESIGNER_MANAGER_ROLES.has(user.role)) {
+ if (PATENT_OWNER_MANAGER_ROLES.has(user.role)) {
return sortedOptions;
}
diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
index e3f5363d..26a4c70e 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.test.tsx
@@ -447,7 +447,8 @@ describe("NonIntegratedDetailClient", () => {
const { container } = render(
,
@@ -496,7 +497,6 @@ describe("NonIntegratedDetailClient", () => {
productId: savedProducts[0]?.id,
approvalNumber: "PD20260505175418001",
title: "测试开品",
- businessRoles: [],
});
expect(push).toHaveBeenCalledWith(
`/intake/non-integrated/${encodeURIComponent(
@@ -682,6 +682,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="DNZ0001-bom-barcode-migration"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -823,6 +824,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="DNZ0001-bom-same-style-copy"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -878,6 +880,7 @@ describe("NonIntegratedDetailClient", () => {
productId={product.id}
initialProduct={product}
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -898,6 +901,7 @@ describe("NonIntegratedDetailClient", () => {
productId={product.id}
initialProduct={product}
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -1733,7 +1737,8 @@ describe("NonIntegratedDetailClient", () => {
,
);
@@ -1780,7 +1785,8 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="SZ0230-creator-edit"
initialDocumentWorkspaceCategory={openingReferenceCategory}
- currentUserRole="DESIGNER"
+ currentUserRole="USER"
+ currentUserBusinessRoles={["DESIGN"]}
currentUserId="creator-user-id"
currentUserName="Creator"
/>,
@@ -2233,6 +2239,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="SZ0230-basic-material-save"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -2258,6 +2265,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="SZ0230-basic-material-save"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -2301,6 +2309,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="SZ0230-basic-material-reconfirm"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -2503,7 +2512,7 @@ describe("NonIntegratedDetailClient", () => {
initialDocumentWorkspaceCategory={openingReferenceCategory}
currentUserRole="USER"
currentUserName="企划同事"
- currentUserDepartmentName="品牌中心"
+ currentUserBusinessRoles={["PLANNING"]}
/>,
);
@@ -2575,6 +2584,7 @@ describe("NonIntegratedDetailClient", () => {
initialDocumentWorkspaceCategory={openingReferenceCategory}
currentUserRole="SUPERVISOR"
currentUserName="主管同事"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
@@ -3447,6 +3457,7 @@ describe("NonIntegratedDetailClient", () => {
mode="edit"
productId="DNZ0001-sku-independent-confirmation"
currentUserRole="SUPER_ADMIN"
+ currentUserBusinessRoles={["DESIGN"]}
/>,
);
diff --git a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
index abcfbcb5..9b6bcbbe 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/NonIntegratedDetailClient.tsx
@@ -741,6 +741,12 @@ const NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES = new Set([
"SUPER_ADMIN",
"SUPERVISOR",
]);
+const INTERNAL_CONTENT_BUSINESS_ROLES = new Set([
+ "DESIGN",
+ "PLANNING",
+ "PURCHASING",
+ "WAREHOUSE",
+]);
const PURCHASING_EDITABLE_WORKSPACE_CATEGORIES =
new Set([
PRODUCT_FACTORY_WORKSPACE_CATEGORY,
@@ -9165,8 +9171,14 @@ export default function NonIntegratedDetailClient({
const hasCurrentUserContext = Boolean(currentUserRole);
const isCurrentUserAdmin =
currentUserRole === "ADMIN" || currentUserRole === "SUPER_ADMIN";
+ const hasCurrentUserInternalBusinessRole = Boolean(
+ currentUserBusinessRoles?.some((role) =>
+ INTERNAL_CONTENT_BUSINESS_ROLES.has(role),
+ ),
+ );
const isCurrentUserFullDetailEditor = currentUserRole
- ? NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES.has(currentUserRole)
+ ? NON_INTEGRATED_FULL_DETAIL_EDIT_ROLES.has(currentUserRole) &&
+ hasCurrentUserInternalBusinessRole
: false;
const currentProductDesignerNames = useMemo(
() =>
@@ -9184,8 +9196,11 @@ export default function NonIntegratedDetailClient({
const currentUserNameNormalized = currentUserName?.trim() ?? "";
const currentUserIdNormalized = currentUserId?.trim() ?? "";
const currentProductCreator = existingProduct?.createdBy?.trim() ?? "";
+ const isCurrentUserDesign = Boolean(
+ currentUserBusinessRoles?.includes("DESIGN"),
+ );
const isCurrentProductDesigner = Boolean(
- currentUserRole === "DESIGNER" &&
+ isCurrentUserDesign &&
currentUserNameNormalized &&
currentProductDesignerNames.includes(currentUserNameNormalized),
);
@@ -9195,18 +9210,12 @@ export default function NonIntegratedDetailClient({
(currentUserNameNormalized &&
currentProductCreator === currentUserNameNormalized)),
);
- const isCurrentUserPlanning =
- currentUserBusinessRoles?.includes("PLANNING") ||
- Boolean(
- currentUserDepartmentName?.includes("企划") ||
- currentUserDepartmentName?.includes("品牌中心"),
- );
- const isCurrentUserPurchasing =
- currentUserBusinessRoles?.includes("PURCHASING") ||
- Boolean(
- currentUserDepartmentName?.includes("供应链") ||
- currentUserDepartmentName?.includes("采购"),
- );
+ const isCurrentUserPlanning = Boolean(
+ currentUserBusinessRoles?.includes("PLANNING"),
+ );
+ const isCurrentUserPurchasing = Boolean(
+ currentUserBusinessRoles?.includes("PURCHASING"),
+ );
const linkEntryProductPatentDashboardUser =
useMemo(
() => ({
@@ -9251,7 +9260,7 @@ export default function NonIntegratedDetailClient({
const isReadOnly =
mode === "view" || isFlowAbandoned || !canEditActiveWorkspace;
const canAbandonProduct =
- currentUserRole === "DESIGNER" && canEditCurrentProduct;
+ isCurrentUserDesign && canEditCurrentProduct;
const createApprovalNumberRef = useRef(
mode === "create" ? buildApprovalNumber() : "",
);
@@ -16428,7 +16437,6 @@ export default function NonIntegratedDetailClient({
productId: savedProduct.id,
approvalNumber: draftTemporaryCode,
title: draftProductName,
- businessRoles: [],
});
await navigator.clipboard.writeText(
new URL(nextProductDetailUrl, window.location.origin).toString(),
diff --git a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx
index ac20c34a..4349b8e7 100644
--- a/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx
+++ b/furniture-3d-system/src/app/intake/non-integrated/[id]/page.tsx
@@ -31,7 +31,7 @@ export default async function NonIntegratedDetailPage({
const linkEntryView = resolvedSearchParams.workspace === 'opening-reference'
const isFullDetailEditor = FULL_DETAIL_EDIT_ROLES.has(authUser.role)
const isCurrentProductDesigner =
- authUser.role === 'DESIGNER' &&
+ authUser.businessRoles?.includes('DESIGN') &&
Boolean(authUser.name.trim()) &&
initialProduct?.designer.trim() === authUser.name.trim()
const isCurrentProductCreator =
diff --git a/furniture-3d-system/src/app/profile/page.tsx b/furniture-3d-system/src/app/profile/page.tsx
index bfa007de..e0c832db 100644
--- a/furniture-3d-system/src/app/profile/page.tsx
+++ b/furniture-3d-system/src/app/profile/page.tsx
@@ -1,7 +1,7 @@
'use client'
import Link from 'next/link'
-import { Activity, Building2, CalendarClock, Fingerprint, KeyRound, LogOut, ShieldCheck, UserRound, Users } from 'lucide-react'
+import { BriefcaseBusiness, Building2, CalendarClock, Fingerprint, KeyRound, LogOut, ShieldCheck, UserRound, Users, type LucideIcon } from 'lucide-react'
import { AuthProvider, useAuth } from '@/hooks/useAuth'
import { RequireAuth } from '@/components/auth/RequireAuth'
import Button from '@/components/ui/Button'
@@ -13,6 +13,7 @@ import {
ObsidianRailButton,
ObsidianRailGroup,
} from '@/components/platform/ObsidianControlShell'
+import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types'
const roleLabels: Record = {
SUPER_ADMIN: '超级管理员',
@@ -20,7 +21,6 @@ const roleLabels: Record = {
SUPERVISOR: '主管',
SUPERVISOR_DEPARTMENT: '部门主管',
SUPERVISOR_TEAM: '小组主管',
- DESIGNER: '设计师',
USER: '普通用户',
}
@@ -47,6 +47,10 @@ function ProfileContent() {
const canAccessAdmin = Boolean(user && ['SUPER_ADMIN', 'ADMIN'].includes(user.role))
const roleLabel = user ? roleLabels[user.role] ?? user.role : '-'
const statusLabel = user ? statusLabels[user.status] ?? user.status : '-'
+ const businessRoleText = user?.businessRoles?.length
+ ? user.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role] ?? role).join(' / ')
+ : '普通用户'
+ const departmentText = user?.department?.fullPath || user?.department?.name || '未归属'
const lastLoginText = user?.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '暂无记录'
return (
@@ -82,15 +86,15 @@ function ProfileContent() {
}
inspector={
-
+
+
-
-
+
@@ -99,16 +103,16 @@ function ProfileContent() {
}
>
-
-
+
+
-
{user?.name || '当前用户'}
-
{user?.email}
+
{user?.name || '当前用户'}
+
{user?.email}
-
-
角色
-
{roleLabel}
+
+
业务类型
+
{businessRoleText}
@@ -119,14 +123,13 @@ function ProfileContent() {
@@ -147,23 +150,23 @@ function ProfileContent() {
)
}
-function ProfileMetric({ icon: Icon, label, value }: { icon: typeof Fingerprint; label: string; value: string }) {
+function ProfileMetric({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
-
-
-
{label}
-
{value}
+
)
}
-function ProfileLine({ icon: Icon, label, value }: { icon: typeof Building2; label: string; value: string }) {
+function ProfileLine({ icon: Icon, label, value }: { icon: LucideIcon; label: string; value: string }) {
return (
-
-
+
+
-
{label}
-
{value}
+
{label}
+
{value}
)
@@ -171,8 +174,8 @@ function ProfileLine({ icon: Icon, label, value }: { icon: typeof Building2; lab
function ProfileAction({ href, title }: { href: string; title: string }) {
return (
-
-
{title}
+
+
{title}
)
}
diff --git a/furniture-3d-system/src/app/users/page.tsx b/furniture-3d-system/src/app/users/page.tsx
index 22f5e914..c414295b 100644
--- a/furniture-3d-system/src/app/users/page.tsx
+++ b/furniture-3d-system/src/app/users/page.tsx
@@ -21,6 +21,7 @@ import { RequireAuth } from '@/components/auth/RequireAuth'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import Button from '@/components/ui/Button'
import { Alert } from '@/components/ui/Alert'
+import { DeleteConfirmDialog } from '@/components/ui/DeleteConfirmDialog'
import { ActivateUserModal } from '@/components/users/ActivateUserModal'
import { CreateUserModal } from '@/components/users/CreateUserModal'
import { EditUserModal } from '@/components/users/EditUserModal'
@@ -43,7 +44,6 @@ const ROLE_OPTIONS = [
{ value: 'SUPER_ADMIN', label: '超级管理员', icon: KeyRound },
{ value: 'ADMIN', label: '管理员', icon: ShieldCheck },
{ value: 'SUPERVISOR', label: '主管', icon: Briefcase },
- { value: 'DESIGNER', label: '设计师', icon: Fingerprint },
{ value: 'USER', label: '普通用户', icon: Users },
] as const
@@ -61,7 +61,6 @@ const roleLabels: Record
= {
SUPERVISOR: '主管',
SUPERVISOR_DEPARTMENT: '部门主管',
SUPERVISOR_TEAM: '小组主管',
- DESIGNER: '设计师',
USER: '普通用户',
}
@@ -97,6 +96,7 @@ function UsersPageContent() {
const [editingUser, setEditingUser] = useState(null)
const [activationUser, setActivationUser] = useState(null)
const [handoverUser, setHandoverUser] = useState(null)
+ const [deleteUserId, setDeleteUserId] = useState(null)
const [actionError, setActionError] = useState(null)
const debouncedSearchQuery = useDebounce(searchQuery, 300)
@@ -156,6 +156,7 @@ function UsersPageContent() {
const closeEditModal = useCallback(() => setEditingUser(null), [])
const closeActivateModal = useCallback(() => setActivationUser(null), [])
const closeHandoverModal = useCallback(() => setHandoverUser(null), [])
+ const closeDeleteDialog = useCallback(() => setDeleteUserId(null), [])
const handleCreateSuccess = useCallback(() => {
closeCreateModal()
@@ -204,20 +205,31 @@ function UsersPageContent() {
}, [])
const handleDeleteUser = useCallback(
- async (userId: string) => {
+ (userId: string) => {
+ setActionError(null)
+ setDeleteUserId(userId)
+ },
+ [],
+ )
+
+ const confirmDeleteUser = useCallback(
+ async () => {
+ if (!deleteUserId) return
+
try {
setActionError(null)
- if (!window.confirm('确定要移除此访问权限吗?此操作不可撤销。')) return
- const response = await fetch(`/api/users/${userId}`, { method: 'DELETE', credentials: 'include' })
- if (!response.ok) throw new Error('删除用户失败')
- if (selectedUser?.id === userId) setSelectedUser(null)
- if (editingUser?.id === userId) setEditingUser(null)
+ const response = await fetch(`/api/users/${deleteUserId}`, { method: 'DELETE', credentials: 'include' })
+ const payload = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(payload.error?.message || '删除用户失败')
+ if (selectedUser?.id === deleteUserId) setSelectedUser(null)
+ if (editingUser?.id === deleteUserId) setEditingUser(null)
triggerRefresh()
} catch (error) {
setActionError(error instanceof Error ? error.message : '删除失败')
+ throw error
}
},
- [editingUser?.id, selectedUser?.id, triggerRefresh],
+ [deleteUserId, editingUser?.id, selectedUser?.id, triggerRefresh],
)
const handleActivateUser = useCallback(
@@ -356,8 +368,11 @@ function UsersPageContent() {
-
-
+
) : null}
+
)
}
diff --git a/furniture-3d-system/src/components/auth/PermissionGuard.tsx b/furniture-3d-system/src/components/auth/PermissionGuard.tsx
index 7e4ee8d5..d2bee9fe 100644
--- a/furniture-3d-system/src/components/auth/PermissionGuard.tsx
+++ b/furniture-3d-system/src/components/auth/PermissionGuard.tsx
@@ -118,12 +118,6 @@ export function CreatorGuard({
children: ReactNode;
fallback?: ReactNode;
}) {
- return (
-
- {children}
-
- );
+ const { canCreateProduct } = usePermissions();
+ return canCreateProduct() ? <>{children}> : <>{fallback ?? null}>;
}
diff --git a/furniture-3d-system/src/components/auth/RoleBadge.tsx b/furniture-3d-system/src/components/auth/RoleBadge.tsx
index 2f1590a9..084af6af 100644
--- a/furniture-3d-system/src/components/auth/RoleBadge.tsx
+++ b/furniture-3d-system/src/components/auth/RoleBadge.tsx
@@ -45,12 +45,6 @@ const ROLE_CONFIG: Record<
bgColor: 'bg-apple-purple/10',
borderColor: 'border-apple-purple/20',
},
- DESIGNER: {
- label: '设计师',
- color: 'text-apple-green dark:text-apple-green',
- bgColor: 'bg-apple-green/10',
- borderColor: 'border-apple-green/20',
- },
USER: {
label: '普通用户',
color: 'text-apple-label-secondary dark:text-apple-label-tertiary',
diff --git a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
index 9da91d5e..75e6dc58 100644
--- a/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
+++ b/furniture-3d-system/src/components/layout/SidebarUserCenter.tsx
@@ -27,7 +27,6 @@ const roleLabelMap: Record = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
- DESIGNER: '设计师',
USER: '普通用户',
}
diff --git a/furniture-3d-system/src/components/ui/UserMenu.tsx b/furniture-3d-system/src/components/ui/UserMenu.tsx
index 04eebeb1..e78da175 100644
--- a/furniture-3d-system/src/components/ui/UserMenu.tsx
+++ b/furniture-3d-system/src/components/ui/UserMenu.tsx
@@ -124,7 +124,6 @@ export default function UserMenu({ user }: UserMenuProps) {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
- DESIGNER: '设计师',
USER: '普通用户',
}
const isAdmin = resolvedUser.role === 'SUPER_ADMIN' || resolvedUser.role === 'ADMIN'
diff --git a/furniture-3d-system/src/components/users/ActivateUserModal.tsx b/furniture-3d-system/src/components/users/ActivateUserModal.tsx
index 11eb5672..106e9448 100644
--- a/furniture-3d-system/src/components/users/ActivateUserModal.tsx
+++ b/furniture-3d-system/src/components/users/ActivateUserModal.tsx
@@ -6,20 +6,10 @@ import { AlertCircle, ShieldCheck } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import Button from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
+import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
import { getApiErrorMessage } from '@/lib/utils';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
-
-interface DepartmentItem {
- id: string;
- name: string;
- level?: number;
- children?: DepartmentItem[];
-}
-
-interface TeamItem {
- id: string;
- name: string;
-}
+import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
interface ActivationUser {
id: string;
@@ -27,7 +17,11 @@ interface ActivationUser {
role: string;
businessRoles?: BusinessAccountType[];
departmentId?: string | null;
- teamId?: string | null;
+ department?: {
+ id: string;
+ name: string;
+ fullPath?: string;
+ } | null;
}
interface ActivateUserModalProps {
@@ -42,10 +36,21 @@ const ROLE_LABELS: Record = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
- DESIGNER: '设计师',
USER: '普通用户(只看 / 下载)',
};
+function findDepartmentById(
+ departments: DepartmentTreeOption[],
+ departmentId: string,
+): DepartmentTreeOption | null {
+ for (const department of departments) {
+ if (department.id === departmentId) return department;
+ const child = findDepartmentById(department.children ?? [], departmentId);
+ if (child) return child;
+ }
+ return null;
+}
+
export function ActivateUserModal({
isOpen,
user,
@@ -57,12 +62,13 @@ export function ActivateUserModal({
role: (user.role as Role) || allowedRoles[0] || 'USER',
businessRoles: user.businessRoles || [],
departmentId: user.departmentId || '',
- teamId: user.teamId || '',
});
- const [departments, setDepartments] = useState([]);
- const [teams, setTeams] = useState([]);
+ const [departments, setDepartments] = useState([]);
const [errors, setErrors] = useState>({});
const [loading, setLoading] = useState(false);
+ const [businessRolesTouched, setBusinessRolesTouched] = useState(false);
+ const [emptyBusinessRoleWarningAccepted, setEmptyBusinessRoleWarningAccepted] = useState(false);
+ const [emptyBusinessRoleWarning, setEmptyBusinessRoleWarning] = useState(null);
const roleOptions = useMemo(
() =>
@@ -81,12 +87,48 @@ export function ActivateUserModal({
role: nextRole,
businessRoles: user.businessRoles || [],
departmentId: user.departmentId || '',
- teamId: user.teamId || '',
});
setErrors({});
+ setBusinessRolesTouched(false);
+ setEmptyBusinessRoleWarningAccepted(false);
+ setEmptyBusinessRoleWarning(null);
}, [allowedRoles, user]);
+ const selectedDepartment = useMemo(() => {
+ if (!formData.departmentId) return null;
+ return (
+ findDepartmentById(departments, formData.departmentId) ||
+ (user.department?.id === formData.departmentId
+ ? {
+ id: user.department.id,
+ name: user.department.name,
+ fullPath: user.department.fullPath || user.department.name,
+ }
+ : null)
+ );
+ }, [departments, formData.departmentId, user.department]);
+
+ const departmentBusinessRoleInference = useMemo(
+ () => inferBusinessRolesFromDepartmentPath(selectedDepartment?.fullPath),
+ [selectedDepartment?.fullPath],
+ );
+
+ useEffect(() => {
+ if (businessRolesTouched || !formData.departmentId) return;
+ setFormData((current) => ({
+ ...current,
+ businessRoles: departmentBusinessRoleInference.businessRoles,
+ }));
+ }, [
+ businessRolesTouched,
+ departmentBusinessRoleInference.businessRoles,
+ formData.departmentId,
+ ]);
+
const toggleBusinessRole = (role: BusinessAccountType) => {
+ setBusinessRolesTouched(true);
+ setEmptyBusinessRoleWarningAccepted(false);
+ setEmptyBusinessRoleWarning(null);
setFormData((current) => ({
...current,
businessRoles: current.businessRoles.includes(role)
@@ -103,16 +145,7 @@ export function ActivateUserModal({
const data = await response.json();
const departmentTree = data.data?.departments || data.departments || [];
- const flattenDepts = (items: DepartmentItem[], level = 0): DepartmentItem[] =>
- items.reduce((acc, item) => {
- acc.push({ ...item, level });
- if (item.children?.length) {
- acc.push(...flattenDepts(item.children, level + 1));
- }
- return acc;
- }, []);
-
- setDepartments(flattenDepts(departmentTree));
+ setDepartments(departmentTree);
} catch (error) {
console.error('加载部门列表失败:', error);
}
@@ -123,35 +156,6 @@ export function ActivateUserModal({
}
}, [isOpen]);
- useEffect(() => {
- const loadTeams = async () => {
- if (!formData.departmentId) {
- setTeams([]);
- return;
- }
-
- try {
- const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
- credentials: 'include',
- });
- if (!response.ok) {
- setTeams([]);
- return;
- }
-
- const data = await response.json();
- setTeams(data.data?.teams || data.teams || []);
- } catch (error) {
- console.error('加载小组列表失败:', error);
- setTeams([]);
- }
- };
-
- if (isOpen) {
- void loadTeams();
- }
- }, [formData.departmentId, isOpen]);
-
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setErrors({});
@@ -161,6 +165,19 @@ export function ActivateUserModal({
return;
}
+ if (
+ formData.businessRoles.length === 0 &&
+ departmentBusinessRoleInference.shouldWarnWhenEmpty &&
+ !departmentBusinessRoleInference.excluded &&
+ !emptyBusinessRoleWarningAccepted
+ ) {
+ setEmptyBusinessRoleWarning(
+ `${departmentBusinessRoleInference.reason}。继续激活会按普通用户处理,仅保留查看和下载权限。`,
+ );
+ setEmptyBusinessRoleWarningAccepted(true);
+ return;
+ }
+
try {
setLoading(true);
const response = await fetch(`/api/users/${user.id}/activate`, {
@@ -171,7 +188,7 @@ export function ActivateUserModal({
role: formData.role,
businessRoles: formData.businessRoles,
departmentId: formData.departmentId || null,
- teamId: formData.teamId || null,
+ teamId: null,
}),
});
@@ -222,33 +239,19 @@ export function ActivateUserModal({
errorText={errors.role}
className="bg-white"
/>
-
diff --git a/furniture-3d-system/src/components/users/CreateUserModal.tsx b/furniture-3d-system/src/components/users/CreateUserModal.tsx
index 4ecda61b..112a2187 100644
--- a/furniture-3d-system/src/components/users/CreateUserModal.tsx
+++ b/furniture-3d-system/src/components/users/CreateUserModal.tsx
@@ -11,7 +11,9 @@ import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import Button from '@/components/ui/Button';
import { AlertCircle } from 'lucide-react';
+import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
+import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference';
import { getApiErrorMessage } from '@/lib/utils';
interface CreateUserModalProps {
@@ -21,18 +23,6 @@ interface CreateUserModalProps {
onSuccess: () => void;
}
-interface DepartmentItem {
- id: string;
- name: string;
- level?: number;
- children?: DepartmentItem[];
-}
-
-interface TeamItem {
- id: string;
- name: string;
-}
-
interface AccountTemplateItem {
id: string;
name: string;
@@ -45,10 +35,21 @@ const ROLE_LABELS: Record
= {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
- DESIGNER: '设计师',
USER: '普通用户(只看 / 下载)',
};
+function findDepartmentById(
+ departments: DepartmentTreeOption[],
+ departmentId: string,
+): DepartmentTreeOption | null {
+ for (const department of departments) {
+ if (department.id === departmentId) return department;
+ const child = findDepartmentById(department.children ?? [], departmentId);
+ if (child) return child;
+ }
+ return null;
+}
+
export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: CreateUserModalProps) {
const [formData, setFormData] = useState({
email: '',
@@ -60,15 +61,14 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
businessRoles: [] as BusinessAccountType[],
phone: '',
departmentId: '',
- teamId: '',
accountTemplateId: '',
});
const [errors, setErrors] = useState>({});
const [loading, setLoading] = useState(false);
- const [departments, setDepartments] = useState([]);
- const [teams, setTeams] = useState([]);
+ const [departments, setDepartments] = useState([]);
const [templates, setTemplates] = useState([]);
+ const [businessRolesTouched, setBusinessRolesTouched] = useState(false);
useEffect(() => {
setFormData((current) => ({
@@ -85,16 +85,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
if (response.ok) {
const data = await response.json();
const departmentTree = data.data?.departments || data.departments || [];
- const flattenDepts = (depts: DepartmentItem[], level = 0): DepartmentItem[] => {
- return depts.reduce((acc, dept) => {
- acc.push({ ...dept, level });
- if (dept.children && dept.children.length > 0) {
- acc.push(...flattenDepts(dept.children, level + 1));
- }
- return acc;
- }, [] as DepartmentItem[]);
- };
- setDepartments(flattenDepts(departmentTree));
+ setDepartments(departmentTree);
}
} catch (error) {
console.error('加载部门列表失败:', error);
@@ -106,34 +97,6 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
}
}, [isOpen]);
- useEffect(() => {
- const loadTeams = async () => {
- if (!formData.departmentId) {
- setTeams([]);
- return;
- }
-
- try {
- const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
- credentials: 'include',
- });
- if (!response.ok) {
- setTeams([]);
- return;
- }
- const data = await response.json();
- setTeams(data.data?.teams || data.teams || []);
- } catch (error) {
- console.error('加载小组列表失败:', error);
- setTeams([]);
- }
- };
-
- if (isOpen) {
- void loadTeams();
- }
- }, [formData.departmentId, isOpen]);
-
useEffect(() => {
const loadTemplates = async () => {
try {
@@ -155,7 +118,19 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
}
}, [isOpen]);
+ useEffect(() => {
+ if (businessRolesTouched || !formData.departmentId) return;
+ const department = findDepartmentById(departments, formData.departmentId);
+ if (!department) return;
+ const inferred = inferBusinessRolesFromDepartmentPath(department.fullPath);
+ setFormData((current) => ({
+ ...current,
+ businessRoles: inferred.businessRoles,
+ }));
+ }, [businessRolesTouched, departments, formData.departmentId]);
+
const toggleBusinessRole = (role: BusinessAccountType) => {
+ setBusinessRolesTouched(true);
setFormData((current) => ({
...current,
businessRoles: current.businessRoles.includes(role)
@@ -172,6 +147,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
role: template?.role || current.role,
businessRoles: template?.businessRoles || current.businessRoles,
}));
+ if (template) setBusinessRolesTouched(true);
};
const handleSubmit = async (e: React.FormEvent) => {
@@ -207,7 +183,7 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
businessRoles: formData.businessRoles,
phone: formData.phone || null,
departmentId: formData.departmentId || null,
- teamId: formData.teamId || null,
+ teamId: null,
accountTemplateId: formData.accountTemplateId || null,
}),
});
@@ -270,14 +246,16 @@ export function CreateUserModal({ isOpen, allowedRoles, onClose, onSuccess }: Cr
]}
className="bg-white"
/>
- setFormData({ ...formData, departmentId: e.target.value, teamId: '' })} options={[
- { value: '', label: '未分配部门' },
- ...departments.map((dept) => ({ value: dept.id, label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}` })),
- ]} className="bg-white" />
- setFormData({ ...formData, teamId: e.target.value })} options={[
- { value: '', label: '未分配小组' },
- ...teams.map((team) => ({ value: team.id, label: team.name })),
- ]} className="bg-white" />
+ {
+ setBusinessRolesTouched(false);
+ setFormData({ ...formData, departmentId });
+ }}
+ className="bg-white"
+ />
业务类型
diff --git a/furniture-3d-system/src/components/users/DepartmentAccessTree.tsx b/furniture-3d-system/src/components/users/DepartmentAccessTree.tsx
index 51e11619..29ca4932 100644
--- a/furniture-3d-system/src/components/users/DepartmentAccessTree.tsx
+++ b/furniture-3d-system/src/components/users/DepartmentAccessTree.tsx
@@ -10,7 +10,6 @@ import {
ShieldCheck,
UserCircle2,
Users,
- Users2,
} from 'lucide-react';
import { Alert } from '@/components/ui/Alert';
import { UserListItem } from '@/components/users/UserListItem';
@@ -55,6 +54,7 @@ export interface UserTreeItem {
department?: {
id: string;
name: string;
+ fullPath?: string;
};
team?: {
id: string;
@@ -174,101 +174,6 @@ function UserLedgerHeader() {
);
}
-function TeamNodeView({
- team,
- currentUserId,
- selectedUserId,
- onSelect,
- onEdit,
- onDelete,
- onActivate,
- onHandover,
- onEmployeeMatch,
- canEditUser,
- canActivateUser,
-}: {
- team: TeamNode;
- currentUserId?: string;
- selectedUserId?: string;
- onSelect?: (user: UserTreeItem) => void;
- onEdit: (user: UserTreeItem) => void;
- onDelete: (userId: string) => Promise
| void;
- onActivate: (user: UserTreeItem) => Promise | void;
- onHandover: (user: UserTreeItem) => void;
- onEmployeeMatch: (user: UserTreeItem) => Promise | void;
- canEditUser?: (user: UserTreeItem) => boolean;
- canActivateUser?: (user: UserTreeItem) => boolean;
-}) {
- const [isOpen, setIsOpen] = useState(false);
-
- return (
-
-
-
- {isOpen && (
-
- {team.users.length > 0 ? (
-
-
-
- {team.users.map((user) => (
-
- ))}
-
-
- ) : (
-
-
- 该小组当前没有匹配的成员
-
- )}
-
- )}
-
- );
-}
-
function DepartmentNodeView({
department,
depth,
@@ -298,9 +203,7 @@ function DepartmentNodeView({
}) {
const [isOpen, setIsOpen] = useState(depth === 0);
const [directUsersOpen, setDirectUsersOpen] = useState(true);
- const [teamsOpen, setTeamsOpen] = useState(true);
const hasDirectUsers = department.users.length > 0;
- const hasTeams = department.teams.length > 0;
const hasChildren = department.children.length > 0;
return (
@@ -342,7 +245,6 @@ function DepartmentNodeView({
-
@@ -392,38 +294,6 @@ function DepartmentNodeView({
)}
- {hasTeams && (
-
-
}
- title="小组"
- count={department.teams.length}
- isOpen={teamsOpen}
- onToggle={() => setTeamsOpen((current) => !current)}
- />
- {teamsOpen && (
-
- {department.teams.map((team) => (
-
- ))}
-
- )}
-
- )}
-
{hasChildren && (
@@ -451,7 +321,7 @@ function DepartmentNodeView({
)}
- {!hasDirectUsers && !hasTeams && !hasChildren && (
+ {!hasDirectUsers && !hasChildren && (
该部门当前没有匹配的组织成员
diff --git a/furniture-3d-system/src/components/users/DepartmentTreeSelect.tsx b/furniture-3d-system/src/components/users/DepartmentTreeSelect.tsx
new file mode 100644
index 00000000..1d903ed6
--- /dev/null
+++ b/furniture-3d-system/src/components/users/DepartmentTreeSelect.tsx
@@ -0,0 +1,293 @@
+'use client';
+
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Building2, Check, ChevronDown, ChevronRight, Search } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+export interface DepartmentTreeOption {
+ id: string;
+ name: string;
+ fullPath: string;
+ parentId?: string | null;
+ children?: DepartmentTreeOption[];
+}
+
+interface DepartmentTreeSelectProps {
+ label: string;
+ value: string;
+ departments: DepartmentTreeOption[];
+ onChange: (departmentId: string) => void;
+ placeholder?: string;
+ emptyText?: string;
+ required?: boolean;
+ disabled?: boolean;
+ errorText?: string;
+ className?: string;
+}
+
+function walkDepartments(
+ items: DepartmentTreeOption[],
+ visit: (department: DepartmentTreeOption, ancestors: string[]) => void,
+ ancestors: string[] = [],
+) {
+ for (const item of items) {
+ visit(item, ancestors);
+ if (item.children?.length) {
+ walkDepartments(item.children, visit, [...ancestors, item.id]);
+ }
+ }
+}
+
+function filterDepartments(items: DepartmentTreeOption[], searchText: string): DepartmentTreeOption[] {
+ const keyword = searchText.trim().toLowerCase();
+ if (!keyword) return items;
+
+ return items.reduce
((result, item) => {
+ const children = filterDepartments(item.children ?? [], keyword);
+ const matched =
+ item.name.toLowerCase().includes(keyword) ||
+ item.fullPath.toLowerCase().includes(keyword);
+
+ if (!matched && children.length === 0) {
+ return result;
+ }
+
+ result.push({
+ ...item,
+ children,
+ });
+ return result;
+ }, []);
+}
+
+export function DepartmentTreeSelect({
+ label,
+ value,
+ departments,
+ onChange,
+ placeholder = '未分配部门',
+ emptyText = '暂无部门数据',
+ required = false,
+ disabled = false,
+ errorText,
+ className,
+}: DepartmentTreeSelectProps) {
+ const [open, setOpen] = useState(false);
+ const [searchText, setSearchText] = useState('');
+ const [expandedIds, setExpandedIds] = useState>(() => new Set());
+ const containerRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ const departmentMeta = useMemo(() => {
+ const byId = new Map();
+ const ancestorsById = new Map();
+
+ walkDepartments(departments, (department, ancestors) => {
+ byId.set(department.id, department);
+ ancestorsById.set(department.id, ancestors);
+ });
+
+ return { byId, ancestorsById };
+ }, [departments]);
+
+ const selectedDepartment = value ? departmentMeta.byId.get(value) ?? null : null;
+ const filteredDepartments = useMemo(
+ () => filterDepartments(departments, searchText),
+ [departments, searchText],
+ );
+ const hasError = Boolean(errorText);
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (!containerRef.current?.contains(event.target as Node)) {
+ setOpen(false);
+ }
+ }
+
+ if (open) {
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) {
+ setSearchText('');
+ return;
+ }
+
+ setExpandedIds((current) => {
+ const nextExpanded = new Set(current);
+ if (value) {
+ for (const ancestorId of departmentMeta.ancestorsById.get(value) ?? []) {
+ nextExpanded.add(ancestorId);
+ }
+ }
+ if (departments.length === 1) {
+ nextExpanded.add(departments[0].id);
+ }
+ return nextExpanded;
+ });
+ window.setTimeout(() => searchInputRef.current?.focus(), 0);
+ }, [open, value, departmentMeta.ancestorsById, departments]);
+
+ function toggleExpanded(departmentId: string) {
+ setExpandedIds((current) => {
+ const next = new Set(current);
+ if (next.has(departmentId)) {
+ next.delete(departmentId);
+ } else {
+ next.add(departmentId);
+ }
+ return next;
+ });
+ }
+
+ function handleSelect(departmentId: string) {
+ onChange(departmentId);
+ setOpen(false);
+ setSearchText('');
+ }
+
+ function renderDepartment(department: DepartmentTreeOption, depth: number) {
+ const children = department.children ?? [];
+ const hasChildren = children.length > 0;
+ const expanded = Boolean(searchText.trim()) || expandedIds.has(department.id);
+ const selected = value === department.id;
+
+ return (
+
+
+
+
+
+
+
+ {hasChildren && expanded ? (
+
+ {children.map((child) => renderDepartment(child, depth + 1))}
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {errorText ?
{errorText}
: null}
+
+ {open ? (
+
+
+
+
+ setSearchText(event.target.value)}
+ placeholder="搜索组织路径"
+ className="h-full min-w-0 flex-1 bg-transparent text-xs font-semibold text-[var(--obs-shell-title)] outline-none placeholder:text-[var(--obs-shell-subtle)]"
+ />
+
+
+
+
+
+
+ {filteredDepartments.length > 0 ? (
+
+ {filteredDepartments.map((department) => renderDepartment(department, 0))}
+
+ ) : (
+
+ {emptyText}
+
+ )}
+
+
+ ) : null}
+
+ );
+}
diff --git a/furniture-3d-system/src/components/users/EditUserModal.tsx b/furniture-3d-system/src/components/users/EditUserModal.tsx
index 24c02ee4..cd6b7b60 100644
--- a/furniture-3d-system/src/components/users/EditUserModal.tsx
+++ b/furniture-3d-system/src/components/users/EditUserModal.tsx
@@ -12,6 +12,7 @@ import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import Button from '@/components/ui/Button';
import { AlertCircle } from 'lucide-react';
+import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect';
import { BUSINESS_ACCOUNT_TYPE_OPTIONS } from '@/lib/business-account-types';
import { getApiErrorMessage } from '@/lib/utils';
@@ -24,25 +25,12 @@ interface UserData {
status: string;
phone?: string | null;
departmentId?: string | null;
- teamId?: string | null;
accountTemplateId?: string | null;
accountTemplateOverrides?: {
overrideFields?: string[];
} | null;
}
-interface DepartmentItem {
- id: string;
- name: string;
- level?: number;
- children?: DepartmentItem[];
-}
-
-interface TeamItem {
- id: string;
- name: string;
-}
-
interface AccountTemplateItem {
id: string;
name: string;
@@ -64,7 +52,6 @@ const ROLE_LABELS: Record = {
SUPER_ADMIN: '超级管理员',
ADMIN: '管理员',
SUPERVISOR: '主管',
- DESIGNER: '设计师',
USER: '普通用户(只看 / 下载)',
};
@@ -86,14 +73,12 @@ export function EditUserModal({
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
- teamId: user.teamId || '',
accountTemplateId: user.accountTemplateId || '',
});
const [errors, setErrors] = useState>({});
const [loading, setLoading] = useState(false);
- const [departments, setDepartments] = useState([]);
- const [teams, setTeams] = useState([]);
+ const [departments, setDepartments] = useState([]);
const [templates, setTemplates] = useState([]);
// 加载部门列表
@@ -107,16 +92,7 @@ export function EditUserModal({
if (response.ok) {
const data = await response.json();
const departmentTree = data.data?.departments || data.departments || [];
- const flattenDepts = (depts: DepartmentItem[], level = 0): DepartmentItem[] => {
- return depts.reduce((acc, dept) => {
- acc.push({ ...dept, level });
- if (dept.children && dept.children.length > 0) {
- acc.push(...flattenDepts(dept.children, level + 1));
- }
- return acc;
- }, []);
- };
- setDepartments(flattenDepts(departmentTree));
+ setDepartments(departmentTree);
}
} catch (error) {
console.error('加载部门列表失败:', error);
@@ -128,34 +104,6 @@ export function EditUserModal({
}
}, [isOpen]);
- useEffect(() => {
- const loadTeams = async () => {
- if (!formData.departmentId) {
- setTeams([]);
- return;
- }
-
- try {
- const response = await fetch(`/api/teams?departmentId=${formData.departmentId}`, {
- credentials: 'include',
- });
- if (!response.ok) {
- setTeams([]);
- return;
- }
- const data = await response.json();
- setTeams(data.data?.teams || data.teams || []);
- } catch (error) {
- console.error('加载小组列表失败:', error);
- setTeams([]);
- }
- };
-
- if (isOpen) {
- void loadTeams();
- }
- }, [formData.departmentId, isOpen]);
-
useEffect(() => {
const loadTemplates = async () => {
try {
@@ -191,7 +139,6 @@ export function EditUserModal({
status: user.status,
phone: user.phone || '',
departmentId: user.departmentId || '',
- teamId: user.teamId || '',
accountTemplateId: user.accountTemplateId || '',
});
}, [user]);
@@ -281,7 +228,7 @@ export function EditUserModal({
status: formData.status,
phone: formData.phone || null,
departmentId: formData.departmentId || null,
- teamId: formData.teamId || null,
+ teamId: null,
accountTemplateId: formData.accountTemplateId || null,
};
@@ -407,27 +354,11 @@ export function EditUserModal({
disabled={disablePrivilegeFields}
className="bg-white"
/>
- setFormData({ ...formData, departmentId: e.target.value, teamId: '' })}
- options={[
- { value: '', label: '未分配部门' },
- ...departments.map((dept) => ({
- value: dept.id,
- label: `${' '.repeat((dept.level || 0) * 2)}${dept.name}`,
- })),
- ]}
- className="bg-white"
- />
- setFormData({ ...formData, teamId: e.target.value })}
- options={[
- { value: '', label: '未分配小组' },
- ...teams.map((team) => ({ value: team.id, label: team.name })),
- ]}
+ departments={departments}
+ onChange={(departmentId) => setFormData({ ...formData, departmentId })}
className="bg-white"
/>
diff --git a/furniture-3d-system/src/components/users/HandoverModal.tsx b/furniture-3d-system/src/components/users/HandoverModal.tsx
index 14dc699f..e25eb547 100644
--- a/furniture-3d-system/src/components/users/HandoverModal.tsx
+++ b/furniture-3d-system/src/components/users/HandoverModal.tsx
@@ -8,15 +8,19 @@ import { Input, Textarea } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { useDebounce } from '@/hooks/usePerformance';
import { cn, getApiErrorMessage } from '@/lib/utils';
+import { BUSINESS_ACCOUNT_TYPE_LABELS } from '@/lib/business-account-types';
+import type { BusinessAccountType } from '@prisma/client';
interface HandoverModalUser {
id: string;
name: string;
email: string;
role: string;
+ businessRoles?: BusinessAccountType[];
status: string;
department?: {
name: string;
+ fullPath?: string;
} | null;
team?: {
name: string;
@@ -31,6 +35,7 @@ interface CandidateUser {
department?: {
id?: string;
name: string;
+ fullPath?: string;
} | null;
}
@@ -234,10 +239,6 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
warnings.push('该用户当前仍挂着部门负责人职责,系统不会自动改派负责人。');
}
- if (checklist?.summary.hasManagedTeam) {
- warnings.push('该用户当前仍挂着小组负责人职责,系统不会自动改派组长。');
- }
-
if ((checklist?.summary.totalProducts ?? 0) > 0) {
warnings.push('当前交接流程不会改写“创建人”归属,只处理 SKU 所有权和协作关系。');
}
@@ -250,7 +251,7 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
{ value: '', label: loadingCandidates ? '正在加载接收人...' : '请选择接收人' },
...candidateUsers.map((candidate) => ({
value: candidate.id,
- label: `${candidate.name}${candidate.department?.name ? ` · ${candidate.department.name}` : ''}`,
+ label: `${candidate.name}${candidate.department ? ` · ${candidate.department.fullPath || candidate.department.name}` : ''}`,
})),
],
[candidateUsers, loadingCandidates],
@@ -313,6 +314,16 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
};
const ownedSkus = checklist?.handoverItems.ownedSKUs.items ?? [];
+ const userBusinessRoleText = checklist?.user.businessRoles?.length
+ ? checklist.user.businessRoles.map((role) => BUSINESS_ACCOUNT_TYPE_LABELS[role] ?? role).join(' / ')
+ : '普通用户';
+ const roleText = checklist?.user.role === 'SUPER_ADMIN'
+ ? '超级管理员'
+ : checklist?.user.role === 'ADMIN'
+ ? '管理员'
+ : checklist?.user.role === 'SUPERVISOR'
+ ? '主管'
+ : '普通用户';
return (
-
+
姓名
@@ -356,16 +367,23 @@ export function HandoverModal({ isOpen, user, onClose, onSuccess }: HandoverModa
所属
- {checklist.user.department?.name || '未分配部门'}
- {checklist.user.team?.name ? ` · ${checklist.user.team.name}` : ''}
+ {checklist.user.department?.fullPath || checklist.user.department?.name || '未分配部门'}
- 当前角色
+ 权限角色
- {checklist.user.role}
+ {roleText}
+
+
+
+
+ 业务类型
+
+
+ {userBusinessRoleText}
diff --git a/furniture-3d-system/src/components/users/OrganizationManageModal.tsx b/furniture-3d-system/src/components/users/OrganizationManageModal.tsx
index e9357e0a..375e9de4 100644
--- a/furniture-3d-system/src/components/users/OrganizationManageModal.tsx
+++ b/furniture-3d-system/src/components/users/OrganizationManageModal.tsx
@@ -1,47 +1,27 @@
'use client'
-import { useEffect, useMemo, useState } from 'react'
-import { AlertCircle, Building2, Users2 } from 'lucide-react'
+import { useEffect, useState } from 'react'
+import { AlertCircle, Building2 } from 'lucide-react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
-import { Select } from '@/components/ui/Select'
+import { DepartmentTreeSelect, type DepartmentTreeOption } from '@/components/users/DepartmentTreeSelect'
import { getApiErrorMessage } from '@/lib/utils'
-interface DepartmentItem {
- id: string
- name: string
- level?: number
- children?: DepartmentItem[]
-}
-
interface OrganizationManageModalProps {
isOpen: boolean
onClose: () => void
onSuccess: () => void
}
-function flattenDepartments(items: DepartmentItem[], level = 0): DepartmentItem[] {
- return items.reduce
((acc, item) => {
- acc.push({ ...item, level })
- if (item.children?.length) {
- acc.push(...flattenDepartments(item.children, level + 1))
- }
- return acc
- }, [])
-}
-
export function OrganizationManageModal({
isOpen,
onClose,
onSuccess,
}: OrganizationManageModalProps) {
- const [departments, setDepartments] = useState([])
+ const [departments, setDepartments] = useState([])
const [departmentName, setDepartmentName] = useState('')
const [parentId, setParentId] = useState('')
- const [teamName, setTeamName] = useState('')
- const [teamDepartmentId, setTeamDepartmentId] = useState('')
- const [teamDescription, setTeamDescription] = useState('')
const [error, setError] = useState(null)
const [message, setMessage] = useState(null)
const [loading, setLoading] = useState(false)
@@ -55,34 +35,12 @@ export function OrganizationManageModal({
if (!response.ok) return
const payload = await response.json()
- setDepartments(flattenDepartments(payload.data?.departments || payload.departments || []))
+ setDepartments(payload.data?.departments || payload.departments || [])
}
void loadDepartments()
}, [isOpen, reloadToken])
- const departmentOptions = useMemo(
- () => [
- { value: '', label: '作为一级部门' },
- ...departments.map((department) => ({
- value: department.id,
- label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
- })),
- ],
- [departments]
- )
-
- const teamDepartmentOptions = useMemo(
- () => [
- { value: '', label: '选择所属部门' },
- ...departments.map((department) => ({
- value: department.id,
- label: `${' '.repeat((department.level || 0) * 2)}${department.name}`,
- })),
- ],
- [departments]
- )
-
const createDepartment = async () => {
if (!departmentName.trim()) {
setError('请输入部门名称')
@@ -109,7 +67,7 @@ export function OrganizationManageModal({
}
setDepartmentName('')
setParentId('')
- setMessage('部门已创建')
+ setMessage('组织节点已创建')
setReloadToken((current) => current + 1)
onSuccess()
} finally {
@@ -117,44 +75,6 @@ export function OrganizationManageModal({
}
}
- const createTeam = async () => {
- if (!teamName.trim()) {
- setError('请输入小组名称')
- return
- }
- if (!teamDepartmentId) {
- setError('请选择小组所属部门')
- return
- }
-
- setLoading(true)
- setError(null)
- setMessage(null)
- try {
- const response = await fetch('/api/teams', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify({
- name: teamName.trim(),
- departmentId: teamDepartmentId,
- description: teamDescription.trim() || null,
- }),
- })
- const payload = await response.json()
- if (!response.ok) {
- setError(getApiErrorMessage(payload.error, '创建小组失败'))
- return
- }
- setTeamName('')
- setTeamDescription('')
- setMessage('小组已创建')
- onSuccess()
- } finally {
- setLoading(false)
- }
- }
-
return (
@@ -173,34 +93,21 @@ export function OrganizationManageModal({
-
-
-
-
-
小组
-
-
-
-
diff --git a/furniture-3d-system/src/components/users/UserListItem.tsx b/furniture-3d-system/src/components/users/UserListItem.tsx
index 6fc94dc1..a06cd8e9 100644
--- a/furniture-3d-system/src/components/users/UserListItem.tsx
+++ b/furniture-3d-system/src/components/users/UserListItem.tsx
@@ -27,7 +27,6 @@ import {
Phone,
RefreshCw,
Trash2,
- Users,
type LucideIcon,
} from 'lucide-react';
import type { BusinessAccountType } from '@prisma/client';
@@ -61,6 +60,7 @@ interface UserListItemProps {
department?: {
id: string;
name: string;
+ fullPath?: string;
};
team?: {
id: string;
@@ -126,11 +126,6 @@ const roleConfig: Record
- {user.department?.name || '未归属部门'}
-
-
-
- {user.team?.name || '未归属小组'}
+
+ {user.department?.fullPath || user.department?.name || '未归属部门'}
+
diff --git a/furniture-3d-system/src/components/users/__tests__/DepartmentAccessTree.test.tsx b/furniture-3d-system/src/components/users/__tests__/DepartmentAccessTree.test.tsx
index 94397f55..cd67399a 100644
--- a/furniture-3d-system/src/components/users/__tests__/DepartmentAccessTree.test.tsx
+++ b/furniture-3d-system/src/components/users/__tests__/DepartmentAccessTree.test.tsx
@@ -39,41 +39,23 @@ describe('DepartmentAccessTree', () => {
orderIndex: 0,
manager: { id: 'manager-1', name: '林部长' },
summary: {
- directUserCount: 1,
- totalUserCount: 3,
- childDepartmentCount: 1,
- teamCount: 1,
- },
+ directUserCount: 1,
+ totalUserCount: 3,
+ childDepartmentCount: 1,
+ teamCount: 0,
+ },
users: [
{
id: 'user-root',
name: '部门直属成员',
email: 'root@example.com',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
},
],
- teams: [
- {
- id: 'team-1',
- name: '执行组',
- description: '执行小组',
- departmentId: 'dept-root',
- leader: { id: 'leader-1', name: '组长甲' },
- summary: { memberCount: 1 },
- users: [
- {
- id: 'team-user-1',
- name: '小组成员A',
- email: 'team@example.com',
- role: 'DESIGNER',
- status: 'ACTIVE',
- createdAt: '2026-01-01T00:00:00Z',
- },
- ],
- },
- ],
+ teams: [],
children: [
{
id: 'dept-child',
@@ -83,12 +65,22 @@ describe('DepartmentAccessTree', () => {
orderIndex: 1,
manager: { id: 'manager-2', name: '顾主管' },
summary: {
- directUserCount: 0,
+ directUserCount: 1,
totalUserCount: 1,
childDepartmentCount: 0,
teamCount: 0,
},
- users: [],
+ users: [
+ {
+ id: 'dept-child-user-1',
+ name: '策划组成员A',
+ email: 'team@example.com',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
+ status: 'ACTIVE',
+ createdAt: '2026-01-01T00:00:00Z',
+ },
+ ],
teams: [],
children: [],
},
@@ -111,19 +103,21 @@ describe('DepartmentAccessTree', () => {
) as jest.Mock
})
- it('应渲染部门、小组和人员三层结构,并保持默认展开策略', async () => {
+ it('应渲染部门树和人员归属,并保持默认展开策略', async () => {
const actor = userEvent.setup()
render(
,
)
@@ -136,17 +130,15 @@ describe('DepartmentAccessTree', () => {
expect(await screen.findAllByText('品牌中心')).not.toHaveLength(0)
expect(screen.getByText('林部长')).toBeInTheDocument()
expect(screen.getByText('直属成员')).toBeInTheDocument()
- expect(screen.getAllByText('小组').length).toBeGreaterThan(0)
+ expect(screen.queryByText('小组')).not.toBeInTheDocument()
expect(screen.getByText('策划部')).toBeInTheDocument()
expect(screen.getByText('部门直属成员')).toBeInTheDocument()
- expect(screen.getByText('执行组')).toBeInTheDocument()
- expect(screen.getByText('组长甲')).toBeInTheDocument()
- expect(screen.queryByText('小组成员A')).not.toBeInTheDocument()
+ expect(screen.queryByText('策划组成员A')).not.toBeInTheDocument()
expect(screen.getByText('未分配成员')).toBeInTheDocument()
- await actor.click(screen.getByRole('button', { name: /执行组/i }))
+ await actor.click(screen.getByRole('button', { name: /策划部/i }))
- expect(screen.getByText('小组成员A')).toBeInTheDocument()
+ expect(screen.getByText('策划组成员A')).toBeInTheDocument()
})
it('未分配人员区应默认展开,并允许折叠', async () => {
@@ -156,12 +148,14 @@ describe('DepartmentAccessTree', () => {
,
)
@@ -180,6 +174,7 @@ describe('DepartmentAccessTree', () => {
{
onDelete={jest.fn()}
onActivate={jest.fn()}
onHandover={jest.fn()}
+ onEmployeeMatch={jest.fn()}
/>,
)
diff --git a/furniture-3d-system/src/hooks/useAuth.ts b/furniture-3d-system/src/hooks/useAuth.ts
index 8c5b2e61..f4409303 100644
--- a/furniture-3d-system/src/hooks/useAuth.ts
+++ b/furniture-3d-system/src/hooks/useAuth.ts
@@ -39,6 +39,7 @@ export interface User {
department?: {
id: string;
name: string;
+ fullPath?: string;
} | null;
team?: {
id: string;
diff --git a/furniture-3d-system/src/lib/business-account-types.ts b/furniture-3d-system/src/lib/business-account-types.ts
index 5434e43c..6337fa08 100644
--- a/furniture-3d-system/src/lib/business-account-types.ts
+++ b/furniture-3d-system/src/lib/business-account-types.ts
@@ -4,6 +4,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
value: BusinessAccountType
label: string
}> = [
+ { value: 'DESIGN', label: '设计' },
{ value: 'PLANNING', label: '企划' },
{ value: 'PURCHASING', label: '采购' },
{ value: 'WAREHOUSE', label: '仓库' },
@@ -11,6 +12,7 @@ export const BUSINESS_ACCOUNT_TYPE_OPTIONS: Array<{
]
export const BUSINESS_ACCOUNT_TYPE_LABELS: Record = {
+ DESIGN: '设计',
PLANNING: '企划',
PURCHASING: '采购',
WAREHOUSE: '仓库',
diff --git a/furniture-3d-system/src/lib/business-role-inference.ts b/furniture-3d-system/src/lib/business-role-inference.ts
new file mode 100644
index 00000000..1a99ebbb
--- /dev/null
+++ b/furniture-3d-system/src/lib/business-role-inference.ts
@@ -0,0 +1,195 @@
+import type { BusinessAccountType } from '@prisma/client'
+
+export interface BusinessRoleInferenceResult {
+ businessRoles: BusinessAccountType[]
+ excluded: boolean
+ shouldWarnWhenEmpty: boolean
+ reason: string
+}
+
+const EXCLUDED_ROOTS = new Set([
+ '客服中心',
+ '外贸部',
+ '财务中心',
+ '总经理室',
+ '直播中心',
+ '商务中心',
+ '分销部',
+ '业务支持',
+ '公共视觉部',
+ '经营管理中心',
+ '摄影中心',
+ '广州项目组',
+ '摄影需求',
+ '快递公司',
+ '人力行政中心',
+ '外部交流',
+])
+
+const LONGCHEN_EXCLUDED_CHILDREN = [
+ /^客服中心/,
+ /^财务中心/,
+ /^运营中心/,
+ /^总经理室/,
+ /^直播中心/,
+ /^商务中心/,
+]
+
+function splitDepartmentPath(fullPath: string | null | undefined): string[] {
+ return String(fullPath ?? '')
+ .split('>')
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
+
+function getBusinessParts(fullPath: string | null | undefined): string[] {
+ const parts = splitDepartmentPath(fullPath)
+ if (parts[0] && /公司|有限公司/.test(parts[0])) {
+ return parts.slice(1)
+ }
+ return parts
+}
+
+function includesSegment(parts: string[], pattern: RegExp): boolean {
+ return parts.some((part) => pattern.test(part))
+}
+
+function isBrandException(parts: string[]): boolean {
+ return includesSegment(parts, /空间视觉部|商品企划部/)
+}
+
+function uniqRoles(roles: BusinessAccountType[]): BusinessAccountType[] {
+ return [...new Set(roles)]
+}
+
+export function inferBusinessRolesFromDepartmentPath(
+ fullPath: string | null | undefined,
+): BusinessRoleInferenceResult {
+ const parts = getBusinessParts(fullPath)
+ const root = parts[0] ?? ''
+ const child = parts[1] ?? ''
+
+ if (!root) {
+ return {
+ businessRoles: [],
+ excluded: false,
+ shouldWarnWhenEmpty: false,
+ reason: '未分配部门',
+ }
+ }
+
+ if (root === '品牌中心' && !isBrandException(parts)) {
+ return {
+ businessRoles: [],
+ excluded: true,
+ shouldWarnWhenEmpty: false,
+ reason: '品牌中心默认不参与业务类型,空间视觉部/商品企划部除外',
+ }
+ }
+
+ if (EXCLUDED_ROOTS.has(root) || /^运营中心/.test(root) || /^效率部/.test(root)) {
+ return {
+ businessRoles: [],
+ excluded: true,
+ shouldWarnWhenEmpty: false,
+ reason: `${root} 不参与业务类型`,
+ }
+ }
+
+ if (root === '龙晨品牌中心') {
+ if (LONGCHEN_EXCLUDED_CHILDREN.some((pattern) => pattern.test(child))) {
+ return {
+ businessRoles: [],
+ excluded: true,
+ shouldWarnWhenEmpty: false,
+ reason: `龙晨品牌中心 > ${child} 不参与业务类型`,
+ }
+ }
+
+ if (/^供应链中心/.test(child)) {
+ return {
+ businessRoles: ['PURCHASING'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '龙晨品牌中心供应链节点默认归采购',
+ }
+ }
+
+ if (/^营销策划中心/.test(child)) {
+ return {
+ businessRoles: ['PLANNING'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '龙晨品牌中心营销策划节点默认归企划',
+ }
+ }
+
+ return {
+ businessRoles: [],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '龙晨品牌中心需要按内部节点确认业务类型',
+ }
+ }
+
+ if (root === '供应商') {
+ return {
+ businessRoles: ['SUPPLIER'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '供应商节点默认归供应商',
+ }
+ }
+
+ if (root === '仓储中心') {
+ return {
+ businessRoles: ['WAREHOUSE'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '仓储中心默认归仓库',
+ }
+ }
+
+ if (root === '供应链中心') {
+ return {
+ businessRoles: ['PURCHASING'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '供应链中心默认归采购',
+ }
+ }
+
+ if (root === '产品研发部') {
+ return {
+ businessRoles: ['DESIGN'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '产品研发部默认归设计',
+ }
+ }
+
+ if (root === '品牌中心' && includesSegment(parts, /空间视觉部/)) {
+ return {
+ businessRoles: ['DESIGN'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '品牌中心空间视觉部默认归设计',
+ }
+ }
+
+ if (root === '品牌中心' && includesSegment(parts, /商品企划部/)) {
+ return {
+ businessRoles: ['PLANNING'],
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '品牌中心商品企划部默认归企划',
+ }
+ }
+
+ return {
+ businessRoles: uniqRoles([]),
+ excluded: false,
+ shouldWarnWhenEmpty: true,
+ reason: '该部门不在自动映射表内,需要激活人确认业务类型',
+ }
+}
diff --git a/furniture-3d-system/src/lib/open-product/__tests__/open-platform-demo.test.ts b/furniture-3d-system/src/lib/open-product/__tests__/open-platform-demo.test.ts
index 16879647..cfb463c8 100644
--- a/furniture-3d-system/src/lib/open-product/__tests__/open-platform-demo.test.ts
+++ b/furniture-3d-system/src/lib/open-product/__tests__/open-platform-demo.test.ts
@@ -12,7 +12,8 @@ describe('open-platform-demo helpers', () => {
id: 'user-designer',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
},
{
diff --git a/furniture-3d-system/src/lib/permissions.ts b/furniture-3d-system/src/lib/permissions.ts
index aa148311..1036ada4 100644
--- a/furniture-3d-system/src/lib/permissions.ts
+++ b/furniture-3d-system/src/lib/permissions.ts
@@ -3,7 +3,7 @@
* 实现细粒度的权限控制逻辑
*/
-import { Role, UserStatus } from '@prisma/client'
+import { Role, UserStatus, type BusinessAccountType } from '@prisma/client'
import { departmentRepository } from '@/server/repositories/department.repository'
import { userRepository } from '@/server/repositories/user.repository'
import {
@@ -14,7 +14,6 @@ import {
getManagedTeamId,
isAdmin,
isDepartmentSupervisor,
- isReadOnlyUser,
isSupervisor,
isTeamSupervisor,
} from '@/lib/role-scope'
@@ -27,6 +26,7 @@ export interface UserInfo {
email: string
name: string
role: Role
+ businessRoles?: BusinessAccountType[]
status: UserStatus
departmentId?: string
teamId?: string
@@ -199,7 +199,7 @@ export function canCreateProduct(user: UserInfo): boolean {
export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
const collaboratorUsers = getCollaboratorUsers(product.collaboratorUsers)
- if (isReadOnlyUser(user)) return false
+ if (!canCreateContent(user)) return false
if (isAdmin(user)) return true
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
@@ -219,7 +219,7 @@ export function canEditProduct(user: UserInfo, product: ProductInfo): boolean {
* 检查是否可以删除产品
*/
export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean {
- if (isReadOnlyUser(user)) return false
+ if (!canCreateContent(user)) return false
if (isAdmin(user)) return true
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
@@ -233,6 +233,7 @@ export function canDeleteProduct(user: UserInfo, product: ProductInfo): boolean
* 检查是否可以添加协作者
*/
export function canAddCollaborator(user: UserInfo, product: ProductInfo): boolean {
+ if (!canCreateContent(user)) return false
if (isAdmin(user)) return true
if (isDepartmentSupervisor(user) && isWithinManagedDepartment(user, product.departmentId)) {
@@ -266,7 +267,7 @@ export function canCreateSKU(user: UserInfo): boolean {
export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo): boolean {
const collaboratorUsers = product ? getCollaboratorUsers(product.collaboratorUsers) : []
- if (isReadOnlyUser(user)) return false
+ if (!canCreateContent(user)) return false
if (isAdmin(user)) return true
if (sku.ownerId === user.id) return true
@@ -289,8 +290,8 @@ export function canEditSKU(user: UserInfo, sku: SKUInfo, product?: ProductInfo):
* 检查是否可以删除 SKU
*/
export function canDeleteSKU(user: UserInfo, sku: SKUInfo): boolean {
+ if (!canCreateContent(user)) return false
if (isAdmin(user)) return true
- if (isReadOnlyUser(user)) return false
return sku.ownerId === user.id
}
@@ -426,7 +427,7 @@ export function canViewActivityLogs(user: UserInfo): boolean {
* 检查是否可以导出数据
*/
export function canExportData(user: UserInfo): boolean {
- return isAdmin(user) || isDepartmentSupervisor(user) || isSupervisor(user) || user.role === 'DESIGNER' || user.role === 'USER'
+ return isAdmin(user) || isDepartmentSupervisor(user) || isSupervisor(user) || user.role === 'USER'
}
// ============================================
@@ -463,6 +464,7 @@ export async function getUserInfo(userId: string): Promise {
email: user.email,
name: user.name,
role: user.role,
+ businessRoles: user.businessRoles,
status: user.status,
departmentId: user.departmentId || undefined,
teamId: user.teamId || undefined,
diff --git a/furniture-3d-system/src/lib/role-scope.test.ts b/furniture-3d-system/src/lib/role-scope.test.ts
new file mode 100644
index 00000000..3db3d75c
--- /dev/null
+++ b/furniture-3d-system/src/lib/role-scope.test.ts
@@ -0,0 +1,24 @@
+import { canCreateContent, hasBusinessRole, isReadOnlyUser } from './role-scope'
+
+describe('role-scope business roles', () => {
+ it('内部业务类型用户可以写入主业务', () => {
+ expect(canCreateContent({ role: 'USER', businessRoles: ['DESIGN'] })).toBe(true)
+ expect(canCreateContent({ role: 'USER', businessRoles: ['PLANNING'] })).toBe(true)
+ expect(canCreateContent({ role: 'USER', businessRoles: ['PURCHASING'] })).toBe(true)
+ expect(canCreateContent({ role: 'USER', businessRoles: ['WAREHOUSE'] })).toBe(true)
+ })
+
+ it('管理角色本身不自动获得业务写权限', () => {
+ expect(canCreateContent({ role: 'ADMIN', businessRoles: [] })).toBe(false)
+ expect(canCreateContent({ role: 'SUPERVISOR', businessRoles: [] })).toBe(false)
+ })
+
+ it('供应商和无业务类型普通用户保持只读主链', () => {
+ expect(canCreateContent({ role: 'USER', businessRoles: ['SUPPLIER'] })).toBe(false)
+ expect(isReadOnlyUser({ role: 'USER', businessRoles: [] })).toBe(true)
+ })
+
+ it('设计业务类型取代旧设计师权限角色', () => {
+ expect(hasBusinessRole({ role: 'USER', businessRoles: ['DESIGN'] }, 'DESIGN')).toBe(true)
+ })
+})
diff --git a/furniture-3d-system/src/lib/role-scope.ts b/furniture-3d-system/src/lib/role-scope.ts
index 46e9fece..34c2a429 100644
--- a/furniture-3d-system/src/lib/role-scope.ts
+++ b/furniture-3d-system/src/lib/role-scope.ts
@@ -1,16 +1,25 @@
import type { UserRole } from '@/server/core/types'
+import type { BusinessAccountType } from '@prisma/client'
export type AllowedRole = UserRole | 'SUPERVISOR_DEPARTMENT' | 'SUPERVISOR_TEAM'
export interface ScopedRoleUser {
role: UserRole
+ businessRoles?: BusinessAccountType[] | string[] | null
departmentId?: string | null
teamId?: string | null
managedDepartmentId?: string | null
managedTeamId?: string | null
}
-const ROLE_HIERARCHY: UserRole[] = ['USER', 'DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN']
+const ROLE_HIERARCHY: UserRole[] = ['USER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN']
+const USER_ROLE_SET = new Set(ROLE_HIERARCHY)
+const INTERNAL_CONTENT_BUSINESS_ROLES = new Set([
+ 'DESIGN',
+ 'PLANNING',
+ 'PURCHASING',
+ 'WAREHOUSE',
+])
export function getRoleRank(role: UserRole): number {
return ROLE_HIERARCHY.indexOf(role)
@@ -28,6 +37,11 @@ export function getAssignableRolesForRole(operatorRole: UserRole): UserRole[] {
return ROLE_HIERARCHY.filter((role) => canAssignRoleByHierarchy(operatorRole, role))
}
+export function normalizeUserRole(value: unknown): UserRole | undefined {
+ if (typeof value !== 'string') return undefined
+ return USER_ROLE_SET.has(value) ? (value as UserRole) : undefined
+}
+
export function getManagedDepartmentId(user: ScopedRoleUser): string | undefined {
return user.managedDepartmentId ?? user.departmentId ?? undefined
}
@@ -57,15 +71,27 @@ export function isTeamSupervisor(user: ScopedRoleUser): boolean {
}
export function isDesigner(user: ScopedRoleUser): boolean {
- return user.role === 'DESIGNER'
+ return hasBusinessRole(user, 'DESIGN')
}
export function isReadOnlyUser(user: ScopedRoleUser): boolean {
- return user.role === 'USER'
+ return user.role === 'USER' && !hasInternalContentBusinessRole(user)
}
export function canCreateContent(user: ScopedRoleUser): boolean {
- return !isReadOnlyUser(user)
+ return hasInternalContentBusinessRole(user)
+}
+
+export function hasBusinessRole(user: ScopedRoleUser, role: BusinessAccountType): boolean {
+ return Boolean(user.businessRoles?.includes(role))
+}
+
+export function hasInternalContentBusinessRole(user: ScopedRoleUser): boolean {
+ return Boolean(
+ user.businessRoles?.some((role) =>
+ INTERNAL_CONTENT_BUSINESS_ROLES.has(role as BusinessAccountType),
+ ),
+ )
}
export function hasAllowedRole(user: ScopedRoleUser, allowedRoles: readonly AllowedRole[]): boolean {
diff --git a/furniture-3d-system/src/lib/server-session.ts b/furniture-3d-system/src/lib/server-session.ts
index a6b6f7fa..7871c4da 100644
--- a/furniture-3d-system/src/lib/server-session.ts
+++ b/furniture-3d-system/src/lib/server-session.ts
@@ -29,6 +29,7 @@ export async function requireSession(redirectPath = '/login'): Promise<{
email: user.email,
name: user.name,
role: user.role,
+ businessRoles: user.businessRoles,
status: user.status,
departmentId: user.department?.id || undefined,
teamId: user.team?.id || undefined,
diff --git a/furniture-3d-system/src/server/core/response.ts b/furniture-3d-system/src/server/core/response.ts
index 0c088c37..397cb117 100644
--- a/furniture-3d-system/src/server/core/response.ts
+++ b/furniture-3d-system/src/server/core/response.ts
@@ -10,7 +10,7 @@ import type { ApiResponse, PaginationMeta, ResponseMeta, AuthUser, RequestContex
import { AppError, ErrorCode, ERROR_MESSAGES, isAppError } from './errors'
import { HTTP_STATUS, ENV } from './constants'
import type { AllowedRole } from '@/lib/role-scope'
-import { hasAllowedRole } from '@/lib/role-scope'
+import { canCreateContent, hasAllowedRole } from '@/lib/role-scope'
// ============ 响应构建器 ============
@@ -145,6 +145,8 @@ interface HandleRequestOptions {
requireAuth?: boolean
/** 允许的角色 */
allowedRoles?: AllowedRole[]
+ /** 是否需要内部业务写入权限 */
+ requireInternalContentWrite?: boolean
}
// 函数重载:requireAuth: false 时使用 OptionalAuthRequestContext
@@ -195,6 +197,15 @@ export async function handleRequest(
)
}
+ if (options.requireInternalContentWrite && !canCreateContent(user)) {
+ return jsonError(
+ ErrorCode.AUTH_PERMISSION_DENIED,
+ '当前账号没有内部业务写入权限',
+ HTTP_STATUS.FORBIDDEN,
+ requestId
+ )
+ }
+
// 构建认证上下文(user 必填)
const ctx: RequestContext = {
request,
@@ -259,6 +270,7 @@ async function getUserFromRequest(request: NextRequest): Promise | null
+ department?: Pick | null
team?: Pick | null
employeeDirectory?: Parameters[0]
accountTemplateId?: string | null
@@ -203,7 +205,7 @@ export function toUserListItemDTO(
departmentId: user.departmentId,
teamId: user.teamId,
department: user.department
- ? { id: user.department.id, name: user.department.name }
+ ? { id: user.department.id, name: user.department.name, fullPath: user.department.fullPath }
: null,
team: user.team
? { id: user.team.id, name: user.team.name }
@@ -266,7 +268,7 @@ export function toUserDetailDTO(
*/
export function toCurrentUserDTO(
user: User & {
- department?: Pick | null
+ department?: Pick | null
team?: Pick | null
},
permissions: string[]
@@ -280,7 +282,7 @@ export function toCurrentUserDTO(
businessRoles: user.businessRoles,
permissions,
department: user.department
- ? { id: user.department.id, name: user.department.name }
+ ? { id: user.department.id, name: user.department.name, fullPath: user.department.fullPath }
: null,
team: user.team
? { id: user.team.id, name: user.team.name }
diff --git a/furniture-3d-system/src/server/repositories/auth.repository.ts b/furniture-3d-system/src/server/repositories/auth.repository.ts
index d02a1245..5d48c6c6 100644
--- a/furniture-3d-system/src/server/repositories/auth.repository.ts
+++ b/furniture-3d-system/src/server/repositories/auth.repository.ts
@@ -13,6 +13,7 @@ export type AuthUserWithRelations = Prisma.UserGetPayload<{
select: {
id: true
name: true
+ fullPath: true
}
}
team: {
@@ -25,6 +26,7 @@ export type AuthUserWithRelations = Prisma.UserGetPayload<{
select: {
id: true
name: true
+ fullPath: true
}
}
managedTeam: {
@@ -61,9 +63,9 @@ class AuthRepository {
return prisma.user.findFirst({
where: isEmail ? { email: identifier } : { name: identifier },
include: {
- department: { select: { id: true, name: true } },
+ department: { select: { id: true, name: true, fullPath: true } },
team: { select: { id: true, name: true } },
- managedDepartment: { select: { id: true, name: true } },
+ managedDepartment: { select: { id: true, name: true, fullPath: true } },
managedTeam: { select: { id: true, name: true } },
employeeDirectory: { select: { id: true, sourceStatus: true } },
},
@@ -155,9 +157,9 @@ class AuthRepository {
return prisma.user.findUnique({
where: { id: userId },
include: {
- department: { select: { id: true, name: true } },
+ department: { select: { id: true, name: true, fullPath: true } },
team: { select: { id: true, name: true } },
- managedDepartment: { select: { id: true, name: true } },
+ managedDepartment: { select: { id: true, name: true, fullPath: true } },
managedTeam: { select: { id: true, name: true } },
employeeDirectory: { select: { id: true, sourceStatus: true } },
},
diff --git a/furniture-3d-system/src/server/repositories/handover.repository.ts b/furniture-3d-system/src/server/repositories/handover.repository.ts
index 82af2d52..01b2a44a 100644
--- a/furniture-3d-system/src/server/repositories/handover.repository.ts
+++ b/furniture-3d-system/src/server/repositories/handover.repository.ts
@@ -23,9 +23,9 @@ class HandoverRepository {
return prisma.user.findUnique({
where: { id: userId },
include: {
- department: { select: { name: true } },
+ department: { select: { name: true, fullPath: true } },
team: { select: { name: true } },
- managedDepartment: { select: { id: true, name: true } },
+ managedDepartment: { select: { id: true, name: true, fullPath: true } },
managedTeam: { select: { id: true, name: true } },
},
})
@@ -71,7 +71,6 @@ class HandoverRepository {
where: { id: departmentId },
include: {
members: { select: { id: true, name: true } },
- teams: { select: { id: true, name: true } },
},
})
}
diff --git a/furniture-3d-system/src/server/repositories/user.repository.ts b/furniture-3d-system/src/server/repositories/user.repository.ts
index d98ad768..1632bf98 100644
--- a/furniture-3d-system/src/server/repositories/user.repository.ts
+++ b/furniture-3d-system/src/server/repositories/user.repository.ts
@@ -59,7 +59,7 @@ class UserRepositoryClass extends BaseRepository<
where: { email },
include: {
department: {
- select: { id: true, name: true },
+ select: { id: true, name: true, fullPath: true },
},
team: {
select: { id: true, name: true },
@@ -73,7 +73,7 @@ class UserRepositoryClass extends BaseRepository<
where: { name },
include: {
department: {
- select: { id: true, name: true },
+ select: { id: true, name: true, fullPath: true },
},
team: {
select: { id: true, name: true },
@@ -96,6 +96,32 @@ class UserRepositoryClass extends BaseRepository<
})
}
+ async findByEmployeeIdentity(input: {
+ employeeDirectoryId?: string | null
+ dingtalkUserId?: string | null
+ dingtalkUnionId?: string | null
+ }): Promise {
+ const orConditions: Prisma.UserWhereInput[] = []
+
+ if (input.employeeDirectoryId) {
+ orConditions.push({ employeeDirectoryId: input.employeeDirectoryId })
+ }
+ if (input.dingtalkUserId) {
+ orConditions.push({ dingtalkUserId: input.dingtalkUserId })
+ }
+ if (input.dingtalkUnionId) {
+ orConditions.push({ dingtalkUnionId: input.dingtalkUnionId })
+ }
+
+ if (orConditions.length === 0) {
+ return null
+ }
+
+ return this.prisma.user.findFirst({
+ where: { OR: orConditions },
+ })
+ }
+
/**
* 检查邮箱是否已存在
*/
@@ -144,7 +170,7 @@ class UserRepositoryClass extends BaseRepository<
take,
include: {
department: {
- select: { id: true, name: true },
+ select: { id: true, name: true, fullPath: true },
},
team: {
select: { id: true, name: true },
@@ -231,7 +257,7 @@ class UserRepositoryClass extends BaseRepository<
email: true,
avatar: true,
role: true,
- department: { select: { name: true } },
+ department: { select: { name: true, fullPath: true } },
team: { select: { name: true } },
},
})
@@ -250,7 +276,7 @@ class UserRepositoryClass extends BaseRepository<
email: true,
avatar: true,
role: true,
- department: { select: { name: true } },
+ department: { select: { name: true, fullPath: true } },
team: { select: { name: true } },
},
})
@@ -264,6 +290,7 @@ class UserRepositoryClass extends BaseRepository<
email: true,
name: true,
role: true,
+ businessRoles: true,
status: true,
departmentId: true,
teamId: true,
diff --git a/furniture-3d-system/src/server/services/__tests__/assembly.service.install-order.test.ts b/furniture-3d-system/src/server/services/__tests__/assembly.service.install-order.test.ts
index cf8a3c04..76128a9f 100644
--- a/furniture-3d-system/src/server/services/__tests__/assembly.service.install-order.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/assembly.service.install-order.test.ts
@@ -63,7 +63,8 @@ describe('AssemblyServiceClass.saveInstallOrderAndGenerateStepsForUser', () => {
id: 'user-1',
email: 'user-1@example.com',
name: '测试用户',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
}
diff --git a/furniture-3d-system/src/server/services/__tests__/assembly.service.pdf-scope.test.ts b/furniture-3d-system/src/server/services/__tests__/assembly.service.pdf-scope.test.ts
index aa5c209b..7e77f5e8 100644
--- a/furniture-3d-system/src/server/services/__tests__/assembly.service.pdf-scope.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/assembly.service.pdf-scope.test.ts
@@ -61,7 +61,8 @@ describe('AssemblyServiceClass.getAssemblyPdfData', () => {
id: 'user-1',
email: 'user@example.com',
name: '测试用户',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
}
diff --git a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts
index d60ea53f..3bb73c67 100644
--- a/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/asset-review.service.test.ts
@@ -40,7 +40,8 @@ describe('assetCenterService review workflow', () => {
id: 'user-designer',
email: 'designer@example.com',
name: '设计师',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts
index 86513ab3..55ba16b1 100644
--- a/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/draft-group.manual-assembly.test.ts
@@ -168,7 +168,8 @@ describe('draftGroupService manual assembly', () => {
id: 'user-1',
email: 'test@example.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-1',
teamId: 'team-1',
@@ -212,7 +213,8 @@ describe('draftGroupService manual assembly', () => {
id: 'user-1',
email: 'test@example.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts
index d1a46b3e..e029074b 100644
--- a/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/draft-group.service.test.ts
@@ -215,7 +215,8 @@ describe('draftGroupService', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts
index 873b30c7..8f18300d 100644
--- a/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/intake-non-integrated-product.service.test.ts
@@ -23,7 +23,8 @@ const baseUser = {
id: 'creator-1',
email: 'creator@example.com',
name: 'Creator',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts
index 58c66ebf..f4acb976 100644
--- a/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/intake.service.test.ts
@@ -64,7 +64,8 @@ describe('intakeService', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts
index ffb56044..f86808b8 100644
--- a/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/open-platform-demo-seed.service.test.ts
@@ -268,7 +268,8 @@ describe('openPlatformDemoSeedService', () => {
id: 'user-planner',
email: 'planner@example.com',
name: '开放平台录入员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
} as never)
.mockResolvedValueOnce({
diff --git a/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts b/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts
index 34408c51..717aef7e 100644
--- a/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/planning.service.test.ts
@@ -62,7 +62,8 @@ describe('planningService', () => {
id: 'user-1',
email: 'planner@example.com',
name: '规划员',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
departmentId: 'dept-1',
teamId: 'team-1',
diff --git a/furniture-3d-system/src/server/services/__tests__/user-handover.service.test.ts b/furniture-3d-system/src/server/services/__tests__/user-handover.service.test.ts
index b4475576..36f4fc9a 100644
--- a/furniture-3d-system/src/server/services/__tests__/user-handover.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/user-handover.service.test.ts
@@ -55,7 +55,8 @@ describe('userHandoverService', () => {
id: 'user-1',
email: 'user@example.com',
name: '待离职员工',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'INACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -77,7 +78,8 @@ describe('userHandoverService', () => {
id: 'user-1',
name: '待离职员工',
email: 'user@example.com',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'INACTIVE',
department: { name: '设计部' },
team: { name: 'A 组' },
@@ -126,7 +128,8 @@ describe('userHandoverService', () => {
id: 'user-1',
email: 'user@example.com',
name: '待离职员工',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'INACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -144,7 +147,8 @@ describe('userHandoverService', () => {
id: 'user-2',
email: 'target@example.com',
name: '接收人',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'PENDING',
loginSource: 'LOCAL',
activationRequired: true,
@@ -185,7 +189,8 @@ describe('userHandoverService', () => {
id: 'user-1',
email: 'user@example.com',
name: '待离职员工',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'INACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -203,7 +208,8 @@ describe('userHandoverService', () => {
id: 'user-2',
email: 'receiver@example.com',
name: '接收人',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -282,7 +288,8 @@ describe('userHandoverService', () => {
id: 'user-outside',
email: 'outside@example.com',
name: '外部用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -315,7 +322,8 @@ describe('userHandoverService', () => {
id: 'user-1',
name: '待离职员工',
email: 'user@example.com',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'INACTIVE',
department: { name: '设计部' },
team: { name: 'A 组' },
@@ -344,7 +352,8 @@ describe('userHandoverService', () => {
id: 'user-outside-team',
email: 'outside-team@example.com',
name: '外组成员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
diff --git a/furniture-3d-system/src/server/services/__tests__/user-management.organization-tree.test.ts b/furniture-3d-system/src/server/services/__tests__/user-management.organization-tree.test.ts
index bbecc984..7391480c 100644
--- a/furniture-3d-system/src/server/services/__tests__/user-management.organization-tree.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/user-management.organization-tree.test.ts
@@ -1,7 +1,6 @@
import { ErrorCode } from '@/server/core/errors'
import { userManagementService } from '../user-management.service'
import { departmentService } from '../department.service'
-import { teamService } from '../team.service'
import { userService } from '../user.service'
jest.mock('../department.service', () => ({
@@ -10,12 +9,6 @@ jest.mock('../department.service', () => ({
},
}))
-jest.mock('../team.service', () => ({
- teamService: {
- findAll: jest.fn(),
- },
-}))
-
jest.mock('../user.service', () => ({
userService: {
listAll: jest.fn(),
@@ -25,7 +18,6 @@ jest.mock('../user.service', () => ({
describe('userManagementService.getOrganizationTree', () => {
const mockedDepartmentService = jest.mocked(departmentService)
- const mockedTeamService = jest.mocked(teamService)
const mockedUserService = jest.mocked(userService)
beforeEach(() => {
@@ -56,23 +48,7 @@ describe('userManagementService.getOrganizationTree', () => {
orderIndex: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
- _count: { members: 1, teams: 1, children: 0 },
- },
- ] as never)
-
- mockedTeamService.findAll.mockResolvedValue([
- {
- id: 'team-child',
- name: '策划执行组',
- description: '品牌中心执行小组',
- departmentId: 'dept-child',
- department: { id: 'dept-child', name: '策划一组' },
- leaderId: 'leader-1',
- leader: { id: 'leader-1', name: '组长甲', email: 'leader@example.com' },
- orderIndex: 0,
- createdAt: new Date('2026-01-01T00:00:00Z'),
- updatedAt: new Date('2026-01-01T00:00:00Z'),
- _count: { members: 1 },
+ _count: { members: 1, teams: 0, children: 0 },
},
] as never)
@@ -81,7 +57,8 @@ describe('userManagementService.getOrganizationTree', () => {
id: 'user-root',
email: 'root@example.com',
name: '部门成员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -89,7 +66,7 @@ describe('userManagementService.getOrganizationTree', () => {
avatar: null,
phone: '13800000000',
departmentId: 'dept-root',
- department: { id: 'dept-root', name: '品牌中心' },
+ department: { id: 'dept-root', name: '品牌中心', fullPath: '品牌中心' },
teamId: null,
team: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
@@ -107,9 +84,9 @@ describe('userManagementService.getOrganizationTree', () => {
avatar: null,
phone: null,
departmentId: 'dept-child',
- department: { id: 'dept-child', name: '策划一组' },
- teamId: 'team-child',
- team: { id: 'team-child', name: '策划执行组' },
+ department: { id: 'dept-child', name: '策划一组', fullPath: '品牌中心 > 策划一组' },
+ teamId: null,
+ team: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
lastLoginAt: null,
},
@@ -147,6 +124,7 @@ describe('userManagementService.getOrganizationTree', () => {
expect(mockedUserService.listAll).toHaveBeenCalledWith({
search: '成员',
role: undefined,
+ businessRole: undefined,
status: undefined,
departmentIds: undefined,
})
@@ -159,10 +137,8 @@ describe('userManagementService.getOrganizationTree', () => {
expect(result.departments[0]?.users).toHaveLength(1)
expect(result.departments[0]?.summary.directUserCount).toBe(1)
expect(result.departments[0]?.summary.totalUserCount).toBe(2)
- expect(result.departments[0]?.children[0]?.users).toHaveLength(0)
- expect(result.departments[0]?.children[0]?.teams).toHaveLength(1)
- expect(result.departments[0]?.children[0]?.teams[0]?.summary.memberCount).toBe(1)
- expect(result.departments[0]?.children[0]?.teams[0]?.users).toHaveLength(1)
+ expect(result.departments[0]?.children[0]?.users).toHaveLength(1)
+ expect(result.departments[0]?.children[0]?.teams).toHaveLength(0)
expect(result.unassignedUsers).toHaveLength(1)
})
@@ -190,7 +166,7 @@ describe('userManagementService.getOrganizationTree', () => {
orderIndex: 1,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
- _count: { members: 1, teams: 1, children: 0 },
+ _count: { members: 1, teams: 0, children: 0 },
},
{
id: 'dept-c',
@@ -206,29 +182,14 @@ describe('userManagementService.getOrganizationTree', () => {
},
] as never)
- mockedTeamService.findAll.mockResolvedValue([
- {
- id: 'team-b',
- name: '运营推进组',
- description: null,
- departmentId: 'dept-b',
- department: { id: 'dept-b', name: '事业二部' },
- leaderId: 'leader-b',
- leader: { id: 'leader-b', name: '小组长', email: 'leader-b@example.com' },
- orderIndex: 0,
- createdAt: new Date('2026-01-01T00:00:00Z'),
- updatedAt: new Date('2026-01-01T00:00:00Z'),
- _count: { members: 1 },
- },
- ] as never)
-
mockedUserService.getDepartmentIdsForManager.mockResolvedValue(['dept-a', 'dept-b'] as never)
mockedUserService.listAll.mockResolvedValue([
{
id: 'user-b',
email: 'member@example.com',
name: '部门负责人下属',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
loginSource: 'LOCAL',
activationRequired: false,
@@ -236,9 +197,9 @@ describe('userManagementService.getOrganizationTree', () => {
avatar: null,
phone: '13812345678',
departmentId: 'dept-b',
- department: { id: 'dept-b', name: '事业二部' },
- teamId: 'team-b',
- team: { id: 'team-b', name: '运营推进组' },
+ department: { id: 'dept-b', name: '事业二部', fullPath: '运营中心 > 事业二部' },
+ teamId: null,
+ team: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
lastLoginAt: new Date('2026-01-03T00:00:00Z'),
},
@@ -260,19 +221,20 @@ describe('userManagementService.getOrganizationTree', () => {
expect(mockedUserService.listAll).toHaveBeenCalledWith({
search: undefined,
role: undefined,
+ businessRole: undefined,
status: undefined,
departmentIds: ['dept-a', 'dept-b'],
})
expect(result.departments).toHaveLength(1)
expect(result.departments[0]?.children).toHaveLength(1)
- expect(result.departments[0]?.children[0]?.teams).toHaveLength(1)
- expect(result.departments[0]?.children[0]?.teams[0]?.users[0]).toEqual(
+ expect(result.departments[0]?.children[0]?.teams).toHaveLength(0)
+ expect(result.departments[0]?.children[0]?.users[0]).toEqual(
expect.objectContaining({
email: 'm***r@example.com',
phone: '138****5678',
})
)
- expect(result.departments[0]?.children[0]?.teams[0]?.users[0]).not.toHaveProperty('lastLoginAt')
+ expect(result.departments[0]?.children[0]?.users[0]).not.toHaveProperty('lastLoginAt')
})
it('无权限角色不能读取组织树', async () => {
diff --git a/furniture-3d-system/src/server/services/__tests__/user-management.service.test.ts b/furniture-3d-system/src/server/services/__tests__/user-management.service.test.ts
index e6637073..e70a324a 100644
--- a/furniture-3d-system/src/server/services/__tests__/user-management.service.test.ts
+++ b/furniture-3d-system/src/server/services/__tests__/user-management.service.test.ts
@@ -16,6 +16,12 @@ jest.mock('../user.service', () => ({
},
}))
+jest.mock('../department.service', () => ({
+ departmentService: {
+ findById: jest.fn(),
+ },
+}))
+
describe('userManagementService.activateUser', () => {
const mockedLogRepository = jest.mocked(logRepository)
const mockedUserService = jest.mocked(userService)
@@ -28,11 +34,12 @@ describe('userManagementService.activateUser', () => {
status: 'ACTIVE' as const,
}
- const designerUser = {
+ const designBusinessUser = {
id: 'designer-1',
email: 'designer@example.com',
name: '设计师',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
+ businessRoles: ['DESIGN'] as const,
status: 'ACTIVE' as const,
}
@@ -64,7 +71,8 @@ describe('userManagementService.activateUser', () => {
id: 'user-pending',
email: 'pending@example.com',
name: '待激活用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: [],
status: 'PENDING',
departmentId: 'dept-a',
teamId: 'team-a',
@@ -73,23 +81,25 @@ describe('userManagementService.activateUser', () => {
id: 'user-pending',
email: 'pending@example.com',
name: '待激活用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-a',
- teamId: 'team-a',
+ teamId: null,
} as never)
const result = await userManagementService.activateUser(
'user-pending',
- {},
+ { businessRoles: ['DESIGN'] },
adminUser,
)
expect(mockedUserService.getById).toHaveBeenCalledWith('user-pending')
expect(mockedUserService.update).toHaveBeenCalledWith('user-pending', {
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: 'dept-a',
- teamId: 'team-a',
+ teamId: null,
status: 'ACTIVE',
})
expect(mockedLogRepository.createLog).toHaveBeenCalledWith(
@@ -103,7 +113,7 @@ describe('userManagementService.activateUser', () => {
expect(result.status).toBe('ACTIVE')
})
- it('主管可以激活本部门待激活用户,但只能分配更低角色', async () => {
+ it('主管可以激活本部门待激活用户,并可设置设计业务类型', async () => {
mockedUserService.getById.mockResolvedValue({
id: 'user-pending',
email: 'pending@example.com',
@@ -117,7 +127,8 @@ describe('userManagementService.activateUser', () => {
id: 'user-pending',
email: 'pending@example.com',
name: '待激活设计师',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-a',
teamId: null,
@@ -125,17 +136,19 @@ describe('userManagementService.activateUser', () => {
const result = await userManagementService.activateUser(
'user-pending',
- { role: 'DESIGNER' },
+ { role: 'USER', businessRoles: ['DESIGN'] },
departmentSupervisor,
)
expect(mockedUserService.update).toHaveBeenCalledWith('user-pending', {
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
departmentId: 'dept-a',
teamId: null,
status: 'ACTIVE',
})
- expect(result.role).toBe('DESIGNER')
+ expect(result.role).toBe('USER')
+ expect(result.businessRoles).toEqual(['DESIGN'])
})
it('主管不能把待激活账号激活成同级角色', async () => {
@@ -186,7 +199,7 @@ describe('userManagementService.activateUser', () => {
it('非管理员角色不能激活用户', async () => {
await expect(
- userManagementService.activateUser('user-pending', {}, designerUser),
+ userManagementService.activateUser('user-pending', {}, designBusinessUser),
).rejects.toMatchObject({
code: ErrorCode.AUTH_PERMISSION_DENIED,
})
@@ -200,7 +213,8 @@ describe('userManagementService.activateUser', () => {
id: 'user-active',
email: 'active@example.com',
name: '已激活用户',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-a',
teamId: 'team-a',
@@ -222,7 +236,8 @@ describe('userManagementService.activateUser', () => {
id: 'user-2',
email: 'user2@example.com',
name: '设计师乙',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
status: 'ACTIVE',
departmentId: 'dept-a',
teamId: null,
diff --git a/furniture-3d-system/src/server/services/account-template.service.ts b/furniture-3d-system/src/server/services/account-template.service.ts
index 9530789a..08275536 100644
--- a/furniture-3d-system/src/server/services/account-template.service.ts
+++ b/furniture-3d-system/src/server/services/account-template.service.ts
@@ -1,6 +1,10 @@
import { prisma } from '@/lib/prisma'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
+import {
+ parseBusinessRolesInput,
+ parseUserRoleInput,
+} from '@/server/validators/business-role.validator'
import { Prisma, type BusinessAccountType, type Role } from '@prisma/client'
export interface AccountTemplateInput {
@@ -29,6 +33,14 @@ export interface AccountTemplateDTO {
}
const DEFAULT_TEMPLATES: Array = [
+ {
+ name: '设计岗位模板',
+ code: 'design-default',
+ description: '默认设计账号模板,负责产品研发、结构设计和视觉建模相关工作。',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
+ defaultScope: { visibility: 'workflow-and-users' },
+ },
{
name: '企划岗位模板',
code: 'planning-default',
@@ -139,13 +151,16 @@ class AccountTemplateService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '模板名称和编码不能为空')
}
+ const role = parseUserRoleInput(input.role, 'role') ?? 'USER'
+ const businessRoles = parseBusinessRolesInput(input.businessRoles, { optional: true }) ?? []
+
const template = await prisma.accountTemplate.create({
data: {
name: input.name.trim(),
code: input.code.trim(),
description: input.description?.trim() || null,
- role: input.role,
- businessRoles: input.businessRoles ?? [],
+ role,
+ businessRoles,
defaultScope: input.defaultScope ?? Prisma.JsonNull,
defaultFieldPermissions: input.defaultFieldPermissions ?? Prisma.JsonNull,
isActive: input.isActive ?? true,
@@ -162,14 +177,19 @@ class AccountTemplateService {
throw new AppError(ErrorCode.VALIDATION_FAILED, '岗位模板不存在')
}
+ const role = 'role' in input ? parseUserRoleInput(input.role, 'role') : undefined
+ const businessRoles = 'businessRoles' in input
+ ? parseBusinessRolesInput(input.businessRoles, { optional: true })
+ : undefined
+
const template = await prisma.accountTemplate.update({
where: { id },
data: {
name: input.name?.trim(),
code: input.code?.trim(),
description: input.description === undefined ? undefined : input.description?.trim() || null,
- role: input.role,
- businessRoles: input.businessRoles,
+ role,
+ businessRoles,
defaultScope: input.defaultScope === undefined ? undefined : input.defaultScope ?? Prisma.JsonNull,
defaultFieldPermissions:
input.defaultFieldPermissions === undefined
diff --git a/furniture-3d-system/src/server/services/approval-workflow.service.ts b/furniture-3d-system/src/server/services/approval-workflow.service.ts
index 7897ab0e..1f933b08 100644
--- a/furniture-3d-system/src/server/services/approval-workflow.service.ts
+++ b/furniture-3d-system/src/server/services/approval-workflow.service.ts
@@ -1,6 +1,7 @@
import { prisma } from '@/lib/prisma'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
+import { canCreateContent } from '@/lib/role-scope'
import { Prisma, type ApprovalWorkflowStatus, type BusinessAccountType } from '@prisma/client'
import {
getNonIntegratedFormalSpu,
@@ -117,7 +118,7 @@ const DEFAULT_FIELD_MATRIX: FieldMatrixItem[] = [
label: '产品定位与设计卖点',
section: '立项准备',
helperText: '这里承接开品理由、目标人群、风格方向和设计卖点。',
- businessRole: 'PLANNING',
+ businessRole: 'DESIGN',
required: true,
allowSecondEdit: true,
placeholder: '说明为什么开这款、目标客群、风格、差异化卖点和预期定位。',
@@ -297,6 +298,14 @@ const LEGACY_FIELD_KEYS = new Set([
'supplierNote',
])
+const BUSINESS_ROLE_SET = new Set([
+ 'DESIGN',
+ 'PLANNING',
+ 'PURCHASING',
+ 'WAREHOUSE',
+ 'SUPPLIER',
+])
+
class ApprovalWorkflowService {
private toProductDTO(product: WorkflowForDTO['product']): ApprovalWorkflowProductDTO {
const normalizedProduct = normalizeNonIntegratedProductRecord(
@@ -393,6 +402,11 @@ class ApprovalWorkflowService {
}
private toDTO(workflow: WorkflowForDTO): ApprovalWorkflowDTO {
+ const matrix = this.getFieldMatrix(workflow.fieldMatrix)
+ const businessRoles = workflow.businessRoles.length > 0
+ ? workflow.businessRoles
+ : this.getWorkflowBusinessRoles(matrix)
+
return {
id: workflow.id,
approvalNumber: workflow.approvalNumber,
@@ -400,11 +414,11 @@ class ApprovalWorkflowService {
status: workflow.status,
productId: workflow.productId,
accessKey: workflow.accessKey,
- businessRoles: workflow.businessRoles,
+ businessRoles,
participantUserIds: workflow.participantUserIds,
initiatorId: workflow.initiatorId,
lastSyncedAt: workflow.lastSyncedAt?.toISOString() ?? null,
- fieldMatrix: this.getFieldMatrix(workflow.fieldMatrix) as unknown as Prisma.JsonValue,
+ fieldMatrix: matrix as unknown as Prisma.JsonValue,
summary: workflow.summary,
metadata: workflow.metadata,
product: this.toProductDTO(workflow.product),
@@ -420,7 +434,12 @@ class ApprovalWorkflowService {
for (const item of value) {
if (!item || typeof item !== 'object') continue
const record = item as Record
- if (typeof record.key !== 'string' || typeof record.label !== 'string' || typeof record.businessRole !== 'string') {
+ if (
+ typeof record.key !== 'string' ||
+ typeof record.label !== 'string' ||
+ typeof record.businessRole !== 'string' ||
+ !BUSINESS_ROLE_SET.has(record.businessRole as BusinessAccountType)
+ ) {
continue
}
items.push({
@@ -449,18 +468,26 @@ class ApprovalWorkflowService {
return items
}
+ private getWorkflowBusinessRoles(matrix: FieldMatrixItem[]): BusinessAccountType[] {
+ return [...new Set(matrix.map((item) => item.businessRole))]
+ }
+
private assertWorkflowAccess(
workflow: {
participantUserIds: string[]
businessRoles: BusinessAccountType[]
+ fieldMatrix?: Prisma.JsonValue | null
},
currentUser: AuthUser & { businessRoles?: BusinessAccountType[] }
) {
if (currentUser.role === 'SUPER_ADMIN' || currentUser.role === 'ADMIN') return
if (workflow.participantUserIds.includes(currentUser.id)) return
+ const workflowBusinessRoles = workflow.businessRoles.length > 0
+ ? workflow.businessRoles
+ : this.getWorkflowBusinessRoles(this.getFieldMatrix(workflow.fieldMatrix ?? null))
const userBusinessRoles = currentUser.businessRoles ?? []
- if (workflow.businessRoles.some((role) => userBusinessRoles.includes(role))) return
+ if (workflowBusinessRoles.some((role) => userBusinessRoles.includes(role))) return
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不在该流程的参与范围内')
}
@@ -470,12 +497,18 @@ class ApprovalWorkflowService {
title: string
productId: string
status?: ApprovalWorkflowStatus
- businessRoles?: BusinessAccountType[]
participantUserIds?: string[]
fieldMatrix?: Prisma.InputJsonValue | null
summary?: Prisma.InputJsonValue | null
metadata?: Prisma.InputJsonValue | null
}, currentUser: AuthUser): Promise {
+ if (!canCreateContent(currentUser)) {
+ throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有内部业务写入权限')
+ }
+
+ const matrix = this.getFieldMatrix((input.fieldMatrix ?? null) as Prisma.JsonValue | null)
+ const businessRoles = this.getWorkflowBusinessRoles(matrix)
+
const created = await prisma.approvalWorkflow.create({
data: {
approvalNumber: input.approvalNumber.trim(),
@@ -483,10 +516,10 @@ class ApprovalWorkflowService {
productId: input.productId,
status: input.status ?? 'DRAFT',
accessKey: crypto.randomUUID(),
- businessRoles: input.businessRoles ?? [],
+ businessRoles,
participantUserIds: input.participantUserIds ?? [],
initiatorId: currentUser.id,
- fieldMatrix: (input.fieldMatrix ?? DEFAULT_FIELD_MATRIX) as Prisma.InputJsonValue,
+ fieldMatrix: matrix as unknown as Prisma.InputJsonValue,
summary: input.summary ?? Prisma.JsonNull,
metadata: input.metadata ?? Prisma.JsonNull,
},
@@ -646,9 +679,6 @@ class ApprovalWorkflowService {
const userBusinessRoles = currentUser.businessRoles ?? []
const responsible =
- currentUser.role === 'SUPER_ADMIN' ||
- currentUser.role === 'ADMIN' ||
- workflow.participantUserIds.includes(currentUser.id) ||
userBusinessRoles.includes(targetField.businessRole)
if (!responsible) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号不能编辑这个字段')
diff --git a/furniture-3d-system/src/server/services/auth.service.ts b/furniture-3d-system/src/server/services/auth.service.ts
index b69a3e30..b9a102ea 100644
--- a/furniture-3d-system/src/server/services/auth.service.ts
+++ b/furniture-3d-system/src/server/services/auth.service.ts
@@ -72,9 +72,9 @@ export interface AuthUserDTO {
activationRequired: boolean
employeeDirectoryId: string | null
isFirstLogin: boolean
- department: { id: string; name: string } | null
+ department: { id: string; name: string; fullPath: string } | null
team: { id: string; name: string } | null
- managedDepartment: { id: string; name: string } | null
+ managedDepartment: { id: string; name: string; fullPath: string } | null
managedTeam: { id: string; name: string } | null
avatar: string | null
lastLoginAt: Date | null
diff --git a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts
index 5fb6ac4e..e789dc4b 100644
--- a/furniture-3d-system/src/server/services/dingtalk-auth.service.ts
+++ b/furniture-3d-system/src/server/services/dingtalk-auth.service.ts
@@ -7,6 +7,7 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import { authService, type LoginResult } from './auth.service'
import { resolvePostLoginRedirect } from './auth-redirect.service'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
+import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference'
const DINGTALK_AUTH_URL = 'https://login.dingtalk.com/oauth2/auth'
const DINGTALK_USER_ACCESS_TOKEN_URL = 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'
@@ -315,6 +316,7 @@ class DingtalkAuthService {
const departmentId = employeeDirectory?.primaryDepartmentExternalId
? await this.resolveDepartmentIdByExternalId(employeeDirectory.primaryDepartmentExternalId)
: null
+ const businessRoles = departmentId ? await this.inferBusinessRolesByDepartmentId(departmentId) : []
return prisma.user.create({
data: {
@@ -322,6 +324,7 @@ class DingtalkAuthService {
name: loginName,
password,
role: 'USER',
+ businessRoles,
status: 'PENDING',
isFirstLogin: false,
avatar: profile.avatar || null,
@@ -353,6 +356,10 @@ class DingtalkAuthService {
const departmentId = primaryDepartmentExternalId
? await this.resolveDepartmentIdByExternalId(primaryDepartmentExternalId)
: null
+ const nextDepartmentId = departmentId ?? currentUser?.departmentId ?? null
+ const businessRoles = nextDepartmentId
+ ? await this.inferBusinessRolesByDepartmentId(nextDepartmentId)
+ : undefined
return prisma.user.update({
where: { id: userId },
@@ -364,7 +371,8 @@ class DingtalkAuthService {
avatar: profile.avatar || undefined,
phone: profile.mobile || undefined,
employeeDirectoryId: employeeDirectoryId ?? undefined,
- departmentId: departmentId ?? currentUser?.departmentId ?? undefined,
+ departmentId: nextDepartmentId ?? undefined,
+ businessRoles,
status: currentUser?.status ?? 'PENDING',
},
})
@@ -379,6 +387,15 @@ class DingtalkAuthService {
return department?.id ?? null
}
+ private async inferBusinessRolesByDepartmentId(departmentId: string) {
+ const department = await prisma.department.findUnique({
+ where: { id: departmentId },
+ select: { fullPath: true },
+ })
+
+ return inferBusinessRolesFromDepartmentPath(department?.fullPath).businessRoles
+ }
+
private normalizeEmail(email: string | null | undefined): string | null {
if (!email) {
return null
diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts
index 63060676..0d5b1cab 100644
--- a/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts
+++ b/furniture-3d-system/src/server/services/intake-non-integrated-scope.service.ts
@@ -1,4 +1,5 @@
import { Prisma, type IntakeNonIntegratedProduct } from '@prisma/client'
+import { hasBusinessRole } from '@/lib/role-scope'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
@@ -91,7 +92,7 @@ class IntakeNonIntegratedScopeService {
if (record.createdBy === user.id) return true
return (
- user.role === 'DESIGNER' &&
+ hasBusinessRole(user, 'DESIGN') &&
record.designer.trim().length > 0 &&
record.designer.trim() === user.name.trim()
)
diff --git a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts
index 7acd2074..2581c679 100644
--- a/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts
+++ b/furniture-3d-system/src/server/services/open-platform-demo-seed.service.ts
@@ -131,6 +131,7 @@ function toAuthUser(user: {
email: string
name: string
role: AuthUser['role']
+ businessRoles?: AuthUser['businessRoles']
status: AuthUser['status']
departmentId?: string | null
teamId?: string | null
@@ -140,6 +141,7 @@ function toAuthUser(user: {
email: user.email,
name: user.name,
role: user.role,
+ businessRoles: user.businessRoles ?? [],
status: user.status,
departmentId: user.departmentId ?? undefined,
teamId: user.teamId ?? undefined,
@@ -227,6 +229,7 @@ async function ensureSeedUser(input: {
email: string
name: string
role: AuthUser['role']
+ businessRoles?: AuthUser['businessRoles']
}) {
const hashedPassword = await hash(OPEN_PLATFORM_DEMO_PASSWORD, 10)
return prisma.user.upsert({
@@ -235,6 +238,7 @@ async function ensureSeedUser(input: {
name: input.name,
password: hashedPassword,
role: input.role,
+ businessRoles: input.businessRoles ?? [],
status: 'ACTIVE',
isFirstLogin: false,
},
@@ -243,6 +247,7 @@ async function ensureSeedUser(input: {
name: input.name,
password: hashedPassword,
role: input.role,
+ businessRoles: input.businessRoles ?? [],
status: 'ACTIVE',
isFirstLogin: false,
},
@@ -992,7 +997,8 @@ class OpenPlatformDemoSeedService {
const plannerRecord = await ensureSeedUser({
email: 'open-platform-planner@example.com',
name: '开放平台录入员',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
})
const reviewerRecord = await ensureSeedUser({
email: 'open-platform-reviewer@example.com',
diff --git a/furniture-3d-system/src/server/services/user-handover.service.ts b/furniture-3d-system/src/server/services/user-handover.service.ts
index 69c0adea..e0694301 100644
--- a/furniture-3d-system/src/server/services/user-handover.service.ts
+++ b/furniture-3d-system/src/server/services/user-handover.service.ts
@@ -106,6 +106,7 @@ class UserHandoverService {
name: targetUser.name,
email: targetUser.email,
role: targetUser.role,
+ businessRoles: targetUser.businessRoles,
status: targetUser.status,
department: targetUser.department,
team: targetUser.team,
diff --git a/furniture-3d-system/src/server/services/user-management.service.ts b/furniture-3d-system/src/server/services/user-management.service.ts
index d5ea8abc..a262777f 100644
--- a/furniture-3d-system/src/server/services/user-management.service.ts
+++ b/furniture-3d-system/src/server/services/user-management.service.ts
@@ -8,17 +8,15 @@ import {
} from '@/lib/permissions'
import {
getManagedDepartmentId,
- getManagedTeamId,
isDepartmentSupervisor,
- isTeamSupervisor,
} from '@/lib/role-scope'
+import { inferBusinessRolesFromDepartmentPath } from '@/lib/business-role-inference'
import { AppError, ErrorCode } from '@/server/core/errors'
import type { AuthUser } from '@/server/core/types'
import type { BusinessAccountType, Role, UserStatus } from '@prisma/client'
import { logRepository } from '../repositories/log.repository'
import { departmentService, type DepartmentDTO } from './department.service'
import { externalEmployeeDirectoryService } from './external-employee-directory.service'
-import { teamService, type TeamDTO } from './team.service'
import { userService } from './user.service'
const ALLOWED_LIST_ROLES: Role[] = ['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR']
@@ -73,7 +71,11 @@ interface OrganizationTreeTeamNode {
name: string
description: string | null
departmentId: string
- leader: TeamDTO['leader']
+ leader: {
+ id: string
+ name: string
+ email?: string
+ } | null
orderIndex: number
summary: {
memberCount: number
@@ -92,11 +94,23 @@ interface OrganizationTreeOutput {
}
class UserManagementService {
+ private async inferBusinessRolesForDepartment(departmentId?: string | null): Promise {
+ if (!departmentId) return []
+
+ try {
+ const department = await departmentService.findById(departmentId)
+ return inferBusinessRolesFromDepartmentPath(department.fullPath).businessRoles
+ } catch {
+ return []
+ }
+ }
+
private toUserInfo(user: {
id: string
email: string
name: string
role: Role
+ businessRoles?: BusinessAccountType[]
status: UserStatus
departmentId?: string | null
teamId?: string | null
@@ -106,6 +120,7 @@ class UserManagementService {
email: user.email,
name: user.name,
role: user.role,
+ businessRoles: user.businessRoles,
status: user.status,
departmentId: user.departmentId ?? undefined,
teamId: user.teamId ?? undefined,
@@ -169,10 +184,6 @@ class UserManagementService {
return getManagedDepartmentId(currentUser)
}
- private getManagedTeamId(currentUser: AuthUser): string | undefined {
- return getManagedTeamId(currentUser)
- }
-
private canAssignRole(operator: AuthUser, targetRole: Role): boolean {
return canAssignManagedRole(operator, targetRole)
}
@@ -183,6 +194,7 @@ class UserManagementService {
email: string
name: string
role: Role
+ businessRoles?: BusinessAccountType[]
status: UserStatus
departmentId?: string | null
teamId?: string | null
@@ -197,6 +209,7 @@ class UserManagementService {
return {
...this.toUserInfo(targetUser),
role: overrides?.role ?? targetUser.role,
+ businessRoles: targetUser.businessRoles,
status: overrides?.status ?? targetUser.status,
departmentId: overrides?.departmentId !== undefined ? overrides.departmentId ?? undefined : targetUser.departmentId ?? undefined,
teamId: overrides?.teamId !== undefined ? overrides.teamId ?? undefined : targetUser.teamId ?? undefined,
@@ -258,27 +271,11 @@ class UserManagementService {
}
}
- private createTeamNode(team: TeamDTO): OrganizationTreeTeamNode {
- return {
- id: team.id,
- name: team.name,
- description: team.description,
- departmentId: team.departmentId,
- leader: team.leader,
- orderIndex: team.orderIndex,
- summary: {
- memberCount: 0,
- },
- users: [],
- }
- }
-
private computeOrganizationSummary(node: OrganizationTreeDepartmentNode): number {
const childUserTotal = node.children.reduce((sum, child) => sum + this.computeOrganizationSummary(child), 0)
- const teamUserTotal = node.teams.reduce((sum, team) => sum + team.summary.memberCount, 0)
node.summary.childDepartmentCount = node.children.length
- node.summary.teamCount = node.teams.length
- node.summary.totalUserCount = node.summary.directUserCount + teamUserTotal + childUserTotal
+ node.summary.teamCount = 0
+ node.summary.totalUserCount = node.summary.directUserCount + childUserTotal
return node.summary.totalUserCount
}
@@ -286,13 +283,10 @@ class UserManagementService {
node.children = node.children
.map((child) => this.pruneOrganizationNode(child))
.filter((child): child is OrganizationTreeDepartmentNode => Boolean(child))
- node.teams = node.teams.filter((team) => team.summary.memberCount > 0)
-
const hasDirectUsers = node.users.length > 0
- const hasTeams = node.teams.length > 0
const hasChildren = node.children.length > 0
- if (!hasDirectUsers && !hasTeams && !hasChildren) {
+ if (!hasDirectUsers && !hasChildren) {
return null
}
@@ -308,39 +302,6 @@ class UserManagementService {
const limit = Math.min(100, Math.max(1, input.limit ?? 20))
const canViewFull = this.canViewFullUserInfo(currentUser)
- if (isTeamSupervisor(currentUser)) {
- const managedTeamId = this.getManagedTeamId(currentUser)
- if (!managedTeamId) {
- return {
- users: [],
- page: 1,
- limit,
- total: 0,
- totalPages: 0,
- hasMore: false,
- }
- }
-
- const result = await userService.list({
- page,
- limit,
- search: input.search,
- role: input.role,
- businessRole: input.businessRole,
- status: input.status,
- teamId: managedTeamId,
- })
-
- return {
- users: this.sanitizeUserData(result.users as unknown as Array>, canViewFull),
- page: result.page,
- limit: result.limit,
- total: result.total,
- totalPages: result.totalPages,
- hasMore: result.page < result.totalPages,
- }
- }
-
if (isDepartmentSupervisor(currentUser)) {
const managedDepartmentId = this.getManagedDepartmentId(currentUser)
if (!managedDepartmentId) {
@@ -386,6 +347,17 @@ class UserManagementService {
}
}
+ if (currentUser.role === 'SUPERVISOR') {
+ return {
+ users: [],
+ page: 1,
+ limit,
+ total: 0,
+ totalPages: 0,
+ hasMore: false,
+ }
+ }
+
const result = await userService.list({
page,
limit,
@@ -417,10 +389,8 @@ class UserManagementService {
const canViewFull = this.canViewFullUserInfo(currentUser)
const hasFilters = Boolean(input.search || input.role || input.businessRole || input.status)
const allDepartments = await departmentService.findAllFlat()
- const allTeams = await teamService.findAll()
let scopedDepartments = allDepartments
- let scopedTeams = allTeams
let scopedDepartmentIds: string[] | undefined
if (isDepartmentSupervisor(currentUser)) {
@@ -436,63 +406,26 @@ class UserManagementService {
const allowedIds = new Set(scopedDepartmentIds)
scopedDepartments = allDepartments.filter((department) => allowedIds.has(department.id))
- scopedTeams = allTeams.filter((team) => allowedIds.has(team.departmentId))
}
let allUsers: Awaited> = []
- if (isTeamSupervisor(currentUser)) {
- const managedTeamId = this.getManagedTeamId(currentUser)
- if (!managedTeamId) {
- return this.buildEmptyOrganizationTree()
- }
-
- scopedTeams = allTeams.filter((team) => team.id === managedTeamId)
- if (scopedTeams.length === 0) {
- return this.buildEmptyOrganizationTree()
- }
-
- allUsers = await userService.listAll({
- search: input.search,
- role: input.role,
- businessRole: input.businessRole,
- status: input.status,
- teamId: managedTeamId,
- })
-
- const teamDepartmentIds = new Set(
- scopedTeams.map((team) => team.departmentId)
- )
-
- scopedDepartments = allDepartments.filter((department) => teamDepartmentIds.has(department.id))
- } else {
- allUsers = await userService.listAll({
- search: input.search,
- role: input.role,
- businessRole: input.businessRole,
- status: input.status,
- departmentIds: scopedDepartmentIds,
- })
-
- if (!scopedDepartmentIds) {
- scopedTeams = allTeams
- }
+ if (currentUser.role === 'SUPERVISOR' && !isDepartmentSupervisor(currentUser)) {
+ return this.buildEmptyOrganizationTree()
}
+ allUsers = await userService.listAll({
+ search: input.search,
+ role: input.role,
+ businessRole: input.businessRole,
+ status: input.status,
+ departmentIds: scopedDepartmentIds,
+ })
+
const departmentNodes = new Map()
for (const department of scopedDepartments) {
departmentNodes.set(department.id, this.createDepartmentNode(department))
}
- const teamNodes = new Map()
- for (const team of scopedTeams) {
- const departmentNode = departmentNodes.get(team.departmentId)
- if (!departmentNode) continue
-
- const teamNode = this.createTeamNode(team)
- departmentNode.teams.push(teamNode)
- teamNodes.set(team.id, teamNode)
- }
-
const roots: OrganizationTreeDepartmentNode[] = []
for (const department of scopedDepartments) {
const node = departmentNodes.get(department.id)
@@ -526,16 +459,6 @@ class UserManagementService {
continue
}
- const teamId = typeof user.teamId === 'string' ? user.teamId : null
- if (teamId) {
- const targetTeam = teamNodes.get(teamId)
- if (targetTeam && targetTeam.departmentId === departmentId) {
- targetTeam.users.push(user)
- targetTeam.summary.memberCount += 1
- continue
- }
- }
-
targetNode.users.push(user)
targetNode.summary.directUserCount += 1
}
@@ -610,11 +533,8 @@ class UserManagementService {
}
}
- if (isTeamSupervisor(currentUser)) {
- const managedTeamId = this.getManagedTeamId(currentUser)
- if (!managedTeamId || input.teamId !== managedTeamId) {
- throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '小组主管只能在本组内创建账号')
- }
+ if (currentUser.role === 'SUPERVISOR' && !isDepartmentSupervisor(currentUser)) {
+ throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '主管账号需要先绑定可管理的部门')
}
const employee = input.jobNumber
@@ -628,15 +548,18 @@ class UserManagementService {
const mappedDepartmentId = employee?.primaryDepartmentExternalId
? await externalEmployeeDirectoryService.resolveDepartmentIdByExternalId(employee.primaryDepartmentExternalId)
: null
+ const effectiveDepartmentId = input.departmentId ?? mappedDepartmentId ?? undefined
+ const effectiveBusinessRoles =
+ input.businessRoles ?? (await this.inferBusinessRolesForDepartment(effectiveDepartmentId))
const createdUser = await userService.create({
email: input.email,
name: input.name,
password: input.password,
role: input.role || 'USER',
- businessRoles: input.businessRoles ?? [],
- departmentId: input.departmentId ?? mappedDepartmentId ?? undefined,
- teamId: input.teamId,
+ businessRoles: effectiveBusinessRoles,
+ departmentId: effectiveDepartmentId,
+ teamId: undefined,
phone: input.phone ?? employee?.mobile ?? undefined,
accountTemplateId: input.accountTemplateId ?? null,
isFirstLogin: false,
@@ -693,7 +616,7 @@ class UserManagementService {
role: nextRole,
status: input.status,
departmentId: input.departmentId,
- teamId: input.teamId,
+ teamId: null,
})
if (!canManageUser(currentUser, effectiveTargetUserInfo)) {
@@ -704,7 +627,10 @@ class UserManagementService {
this.assertRoleAssignmentAllowed(currentUser, nextRole)
}
- const updatedUser = await userService.update(userId, input)
+ const updatedUser = await userService.update(userId, {
+ ...input,
+ teamId: null,
+ })
await logRepository.createLog({
userId: currentUser.id,
@@ -778,7 +704,7 @@ class UserManagementService {
const effectiveTargetUserInfo = this.buildEffectiveUserInfo(targetUser, {
role: targetRole,
departmentId: input.departmentId,
- teamId: input.teamId,
+ teamId: null,
status: 'ACTIVE',
})
@@ -787,12 +713,15 @@ class UserManagementService {
}
this.assertRoleAssignmentAllowed(currentUser, targetRole)
+ const nextDepartmentId = input.departmentId ?? targetUser.departmentId
+ const nextBusinessRoles =
+ input.businessRoles ?? (await this.inferBusinessRolesForDepartment(nextDepartmentId))
const updatedUser = await userService.update(userId, {
role: targetRole,
- businessRoles: input.businessRoles ?? targetUser.businessRoles,
- departmentId: input.departmentId ?? targetUser.departmentId,
- teamId: input.teamId ?? targetUser.teamId,
+ businessRoles: nextBusinessRoles,
+ departmentId: nextDepartmentId,
+ teamId: null,
status: 'ACTIVE',
})
@@ -807,7 +736,6 @@ class UserManagementService {
role: updatedUser.role,
businessRoles: updatedUser.businessRoles,
departmentId: updatedUser.departmentId,
- teamId: updatedUser.teamId,
},
})
diff --git a/furniture-3d-system/src/server/services/user.service.ts b/furniture-3d-system/src/server/services/user.service.ts
index e2b6dceb..b3278924 100644
--- a/furniture-3d-system/src/server/services/user.service.ts
+++ b/furniture-3d-system/src/server/services/user.service.ts
@@ -3,7 +3,6 @@ import { AppError, ErrorCode } from '@/server/core/errors'
import { accountTemplateService } from './account-template.service'
import type { BusinessAccountType, EmployeeSourceStatus, Prisma, Role, UserStatus } from '@prisma/client'
import { departmentRepository } from '../repositories/department.repository'
-import { teamRepository } from '../repositories/team.repository'
import { userRepository } from '../repositories/user.repository'
export interface CreateUserInput {
@@ -92,7 +91,7 @@ export interface UserDTO {
avatar: string | null
phone: string | null
departmentId: string | null
- department: { id: string; name: string } | null
+ department: { id: string; name: string; fullPath: string } | null
teamId: string | null
team: { id: string; name: string } | null
createdAt: Date
@@ -156,7 +155,7 @@ class UserService {
avatar: string | null
phone: string | null
departmentId: string | null
- department?: { id: string; name: string } | null
+ department?: { id: string; name: string; fullPath: string } | null
teamId: string | null
team?: { id: string; name: string } | null
createdAt: Date
@@ -263,6 +262,15 @@ class UserService {
return user ? this.toDTO(user) : null
}
+ async findByEmployeeIdentity(input: {
+ employeeDirectoryId?: string | null
+ dingtalkUserId?: string | null
+ dingtalkUnionId?: string | null
+ }): Promise {
+ const user = await userRepository.findByEmployeeIdentity(input)
+ return user ? this.toDTO(user) : null
+ }
+
async create(input: CreateUserInput): Promise {
validateEmail(input.email)
validateName(input.name)
@@ -285,13 +293,6 @@ class UserService {
}
}
- if (input.teamId) {
- const teamExists = await teamRepository.existsById(input.teamId)
- if (!teamExists) {
- throw new AppError(ErrorCode.TEAM_NOT_FOUND, '小组不存在')
- }
- }
-
const hashedPassword = await hashPassword(input.password)
const templateAssignment = await accountTemplateService.buildUserTemplateAssignment({
accountTemplateId: input.accountTemplateId,
@@ -305,7 +306,7 @@ class UserService {
role: templateAssignment.role ?? 'USER',
businessRoles: templateAssignment.businessRoles ?? [],
departmentId: input.departmentId,
- teamId: input.teamId,
+ teamId: null,
phone: input.phone,
isFirstLogin: input.isFirstLogin ?? false,
status: input.status ?? 'PENDING',
@@ -375,7 +376,7 @@ class UserService {
role: templateAssignment?.role ?? input.role,
businessRoles: templateAssignment?.businessRoles ?? input.businessRoles,
departmentId: input.departmentId,
- teamId: input.teamId,
+ teamId: input.teamId === undefined ? undefined : null,
phone: input.phone,
status: input.status,
avatar: input.avatar,
diff --git a/furniture-3d-system/src/server/validators/business-role.validator.test.ts b/furniture-3d-system/src/server/validators/business-role.validator.test.ts
new file mode 100644
index 00000000..40453bd9
--- /dev/null
+++ b/furniture-3d-system/src/server/validators/business-role.validator.test.ts
@@ -0,0 +1,31 @@
+import { ErrorCode } from '@/server/core/errors'
+import {
+ parseBusinessRoleFilter,
+ parseBusinessRolesInput,
+ parseUserRoleInput,
+} from './business-role.validator'
+
+describe('business-role.validator', () => {
+ it('明确拒绝旧 DESIGNER 权限角色', () => {
+ expect(() => parseUserRoleInput('DESIGNER')).toThrow('设计师已迁移为业务类型:设计')
+ })
+
+ it('明确拒绝旧 DESIGNER 业务类型输入', () => {
+ expect(() => parseBusinessRolesInput(['DESIGNER'])).toThrow('设计师已迁移为业务类型:设计')
+ expect(() => parseBusinessRoleFilter('DESIGNER')).toThrow('设计师已迁移为业务类型:设计')
+ })
+
+ it('保留设计业务类型输入', () => {
+ expect(parseUserRoleInput('USER')).toBe('USER')
+ expect(parseBusinessRolesInput(['DESIGN'])).toEqual(['DESIGN'])
+ expect(parseBusinessRoleFilter('DESIGN')).toBe('DESIGN')
+ })
+
+ it('非法业务类型返回验证错误', () => {
+ try {
+ parseBusinessRolesInput(['UNKNOWN'])
+ } catch (error) {
+ expect(error).toMatchObject({ code: ErrorCode.VALIDATION_FAILED })
+ }
+ })
+})
diff --git a/furniture-3d-system/src/server/validators/business-role.validator.ts b/furniture-3d-system/src/server/validators/business-role.validator.ts
new file mode 100644
index 00000000..1b8b4c5c
--- /dev/null
+++ b/furniture-3d-system/src/server/validators/business-role.validator.ts
@@ -0,0 +1,64 @@
+import { BUSINESS_ACCOUNT_TYPE_OPTIONS, normalizeBusinessRoles } from '@/lib/business-account-types'
+import { normalizeUserRole } from '@/lib/role-scope'
+import { AppError, ErrorCode } from '@/server/core/errors'
+import type { BusinessAccountType, Role } from '@prisma/client'
+
+const LEGACY_DESIGNER_MESSAGE = '设计师已迁移为业务类型:设计'
+const BUSINESS_ROLE_SET = new Set(BUSINESS_ACCOUNT_TYPE_OPTIONS.map((option) => option.value))
+
+function rejectLegacyDesignerInput(): never {
+ throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, LEGACY_DESIGNER_MESSAGE)
+}
+
+export function parseUserRoleInput(value: unknown, fieldName = 'role'): Role | undefined {
+ if (value === undefined || value === null || value === '') return undefined
+
+ if (value === 'DESIGNER') {
+ rejectLegacyDesignerInput()
+ }
+
+ const role = normalizeUserRole(value)
+ if (!role) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `${fieldName} 不正确`)
+ }
+
+ return role as Role
+}
+
+export function parseBusinessRoleFilter(value: unknown): BusinessAccountType | undefined {
+ if (value === undefined || value === null || value === '') return undefined
+
+ if (value === 'DESIGNER') {
+ rejectLegacyDesignerInput()
+ }
+
+ if (typeof value !== 'string' || !BUSINESS_ROLE_SET.has(value as BusinessAccountType)) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, '业务类型不正确')
+ }
+
+ return value as BusinessAccountType
+}
+
+export function parseBusinessRolesInput(
+ value: unknown,
+ options: { optional?: boolean } = {},
+): BusinessAccountType[] | undefined {
+ if (value === undefined || value === null) {
+ return options.optional ? undefined : []
+ }
+
+ if (!Array.isArray(value)) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, '业务类型必须是数组')
+ }
+
+ if (value.includes('DESIGNER')) {
+ rejectLegacyDesignerInput()
+ }
+
+ const invalid = value.filter((item) => typeof item !== 'string' || !BUSINESS_ROLE_SET.has(item as BusinessAccountType))
+ if (invalid.length > 0) {
+ throw new AppError(ErrorCode.VALIDATION_FAILED, '业务类型不正确')
+ }
+
+ return normalizeBusinessRoles(value)
+}
diff --git a/furniture-3d-system/src/server/validators/user.validator.ts b/furniture-3d-system/src/server/validators/user.validator.ts
index e2e19cb1..1ed8f318 100644
--- a/furniture-3d-system/src/server/validators/user.validator.ts
+++ b/furniture-3d-system/src/server/validators/user.validator.ts
@@ -29,7 +29,7 @@ export const createUserSchema = z.object({
password: passwordSchema,
role: z
- .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
+ .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'])
.default('USER'),
departmentId: z
@@ -77,7 +77,7 @@ export const updateUserSchema = z.object({
.or(z.literal('')),
role: z
- .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'DESIGNER', 'USER'])
+ .enum(['SUPER_ADMIN', 'ADMIN', 'SUPERVISOR', 'USER'])
.optional(),
departmentId: z
diff --git a/furniture-3d-system/tests/api/auth-me.test.ts b/furniture-3d-system/tests/api/auth-me.test.ts
index 07796254..28c588fa 100644
--- a/furniture-3d-system/tests/api/auth-me.test.ts
+++ b/furniture-3d-system/tests/api/auth-me.test.ts
@@ -84,7 +84,7 @@ describe('/api/auth/me 端点测试', () => {
id: testUser.id,
email: testUser.email,
name: testUser.name,
- role: 'DESIGNER',
+ role: 'USER',
status: 'ACTIVE',
isFirstLogin: false,
department: null,
@@ -103,7 +103,7 @@ describe('/api/auth/me 端点测试', () => {
id: testUser.id,
email: testUser.email,
name: testUser.name,
- role: 'DESIGNER',
+ role: 'USER',
status: 'ACTIVE',
};
diff --git a/furniture-3d-system/tests/api/auth.test.ts b/furniture-3d-system/tests/api/auth.test.ts
index 927c061e..4dd2f945 100644
--- a/furniture-3d-system/tests/api/auth.test.ts
+++ b/furniture-3d-system/tests/api/auth.test.ts
@@ -33,7 +33,7 @@ describe('认证 API 测试', () => {
email: 'newuser@test.com',
name: '测试用户',
password: 'Test123456!',
- role: 'DESIGNER'
+ role: 'USER'
};
const hashedPassword = await hashPassword(userData.password);
@@ -61,7 +61,7 @@ describe('认证 API 测试', () => {
email: 'duplicate@test.com',
name: '用户1',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
}
});
@@ -71,7 +71,7 @@ describe('认证 API 测试', () => {
email: 'duplicate@test.com',
name: '用户2',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
}
})
).rejects.toThrow();
@@ -85,7 +85,7 @@ describe('认证 API 测试', () => {
email: 'user1@test.com',
name: '重复姓名',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
}
});
@@ -95,7 +95,7 @@ describe('认证 API 测试', () => {
email: 'user2@test.com',
name: '重复姓名',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
}
})
).rejects.toThrow();
@@ -110,7 +110,7 @@ describe('认证 API 测试', () => {
email: 'login@test.com',
name: '登录测试用户',
password: hashedPassword,
- role: 'DESIGNER',
+ role: 'USER',
isFirstLogin: false
}
});
@@ -163,7 +163,7 @@ describe('认证 API 测试', () => {
email: 'firstlogin@test.com',
name: '首次登录用户',
password: hashedPassword,
- role: 'DESIGNER',
+ role: 'USER',
isFirstLogin: true
}
});
@@ -189,7 +189,7 @@ describe('认证 API 测试', () => {
email: 'flag@test.com',
name: '标志测试',
password: hashedPassword,
- role: 'DESIGNER',
+ role: 'USER',
isFirstLogin: true
}
});
@@ -214,13 +214,13 @@ describe('认证 API 测试', () => {
email: 'zhang.san@test.com',
name: '张三',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
},
{
email: 'li.si@test.com',
name: '李四',
password: hashedPassword,
- role: 'DESIGNER'
+ role: 'USER'
}
]
});
diff --git a/furniture-3d-system/tests/performance/rendering.test.tsx b/furniture-3d-system/tests/performance/rendering.test.tsx
index 40adb2f7..5aef69a6 100644
--- a/furniture-3d-system/tests/performance/rendering.test.tsx
+++ b/furniture-3d-system/tests/performance/rendering.test.tsx
@@ -18,7 +18,7 @@ describe('渲染性能测试', () => {
id: '1',
email: 'test@example.com',
name: '测试用户',
- role: 'DESIGNER',
+ role: 'USER',
status: 'ACTIVE',
phone: '13800138000',
department: {
@@ -268,7 +268,7 @@ describe('渲染性能测试', () => {
id: `user-${i}`,
email: `user${i}@example.com`,
name: `用户${i}`,
- role: 'DESIGNER',
+ role: 'USER',
status: 'ACTIVE',
createdAt: '2025-01-01T00:00:00Z',
lastLoginAt: '2025-12-13T10:00:00Z',
diff --git a/furniture-3d-system/tests/security/authorization.test.ts b/furniture-3d-system/tests/security/authorization.test.ts
index 8d334832..c7fda2c0 100644
--- a/furniture-3d-system/tests/security/authorization.test.ts
+++ b/furniture-3d-system/tests/security/authorization.test.ts
@@ -14,6 +14,7 @@ import { verifyToken } from '@/lib/jwt';
import {
canEditProduct,
canDeleteProduct,
+ canCreateProduct,
canManageUser,
canViewProduct,
} from '@/lib/permissions';
@@ -34,7 +35,8 @@ const mockUsers = {
},
designer: {
id: 'designer-1',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
email: 'designer@example.com',
name: 'Designer',
departmentId: 'dept-1',
@@ -42,7 +44,8 @@ const mockUsers = {
},
designerCollaborator: {
id: 'designer-2',
- role: 'DESIGNER',
+ role: 'USER',
+ businessRoles: ['DESIGN'],
email: 'designer2@example.com',
name: 'Designer Collaborator',
departmentId: 'dept-1',
@@ -189,12 +192,26 @@ describe('权限绕过安全测试', () => {
expect(canEdit).toBe(true);
});
- it('管理员可以编辑任何产品', () => {
+ it('没有业务类型的管理员不能直接编辑产品内容', () => {
const canEdit = canEditProduct(
mockUsers.admin as any,
mockProducts.publicProduct as any
);
+ expect(canEdit).toBe(false);
+ });
+
+ it('带内部业务类型的管理员可以编辑产品内容', () => {
+ const adminWithBusinessRole = {
+ ...mockUsers.admin,
+ businessRoles: ['DESIGN'],
+ };
+
+ const canEdit = canEditProduct(
+ adminWithBusinessRole as any,
+ mockProducts.publicProduct as any
+ );
+
expect(canEdit).toBe(true);
});
});
@@ -209,21 +226,35 @@ describe('权限绕过安全测试', () => {
expect(canDelete).toBe(false);
});
- it('管理员可以删除产品', () => {
+ it('没有业务类型的管理员不能直接删除产品内容', () => {
const canDelete = canDeleteProduct(
mockUsers.admin as any,
mockProducts.publicProduct as any
);
- expect(canDelete).toBe(true);
+ expect(canDelete).toBe(false);
});
- it('超级管理员可以删除任何产品', () => {
+ it('没有业务类型的超级管理员不能直接删除产品内容', () => {
const canDelete = canDeleteProduct(
mockUsers.superAdmin as any,
mockProducts.publicProduct as any
);
+ expect(canDelete).toBe(false);
+ });
+
+ it('带内部业务类型的超级管理员可以删除产品内容', () => {
+ const superAdminWithBusinessRole = {
+ ...mockUsers.superAdmin,
+ businessRoles: ['DESIGN'],
+ };
+
+ const canDelete = canDeleteProduct(
+ superAdminWithBusinessRole as any,
+ mockProducts.publicProduct as any
+ );
+
expect(canDelete).toBe(true);
});
});
@@ -340,16 +371,10 @@ describe('权限绕过安全测试', () => {
it('查看者不能创建产品', () => {
const viewer = mockUsers.viewer;
- // 在实际 API 中,应该检查用户角色
- const canCreate = (user: any): boolean => {
- const allowedRoles = ['DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'];
- return allowedRoles.includes(user.role);
- };
-
- expect(canCreate(viewer)).toBe(false);
+ expect(canCreateProduct(viewer as any)).toBe(false);
});
- it('设计师不能删除用户', () => {
+ it('设计业务用户不能删除用户', () => {
const designer = mockUsers.designer;
const canDelete = canManageUser(
@@ -360,7 +385,7 @@ describe('权限绕过安全测试', () => {
expect(canDelete).toBe(false);
});
- it('设计师不能查看系统日志', () => {
+ it('设计业务用户不能查看系统日志', () => {
const designer = mockUsers.designer;
const canViewLogs = (user: any): boolean => {
@@ -412,13 +437,12 @@ describe('权限绕过安全测试', () => {
expect(hasPermission).toBe(false);
});
- it('POST /api/products 应该需要设计师或更高权限', () => {
- const allowedRoles = ['DESIGNER', 'SUPERVISOR', 'ADMIN', 'SUPER_ADMIN'];
+ it('POST /api/products 应该需要内部业务类型', () => {
const viewer = mockUsers.viewer;
const designer = mockUsers.designer;
- expect(allowedRoles.includes(viewer.role)).toBe(false);
- expect(allowedRoles.includes(designer.role)).toBe(true);
+ expect(canCreateProduct(viewer as any)).toBe(false);
+ expect(canCreateProduct(designer as any)).toBe(true);
});
});
diff --git a/furniture-3d-system/tests/security/jwt-security.test.ts b/furniture-3d-system/tests/security/jwt-security.test.ts
index 46bca967..fae4bfa6 100644
--- a/furniture-3d-system/tests/security/jwt-security.test.ts
+++ b/furniture-3d-system/tests/security/jwt-security.test.ts
@@ -71,7 +71,7 @@ describe('JWT 安全增强测试', () => {
userId: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
status: 'ACTIVE' as const,
isFirstLogin: false,
};
@@ -90,7 +90,7 @@ describe('JWT 安全增强测试', () => {
userId: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
status: 'ACTIVE' as const,
isFirstLogin: false,
};
@@ -120,7 +120,7 @@ describe('JWT 安全增强测试', () => {
userId: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
status: 'ACTIVE' as const,
isFirstLogin: false,
};
@@ -144,7 +144,7 @@ describe('JWT 安全增强测试', () => {
userId: 'test-user-id',
email: 'test@example.com',
name: 'Test User',
- role: 'DESIGNER' as const,
+ role: 'USER' as const,
status: 'ACTIVE' as const,
isFirstLogin: false,
};