From d6ddac46c2cc4e100be4f790505c899bd25e1bcb Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 14 May 2026 18:26:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E8=AE=BE=E8=AE=A1=E5=B8=88?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E8=BF=81=E7=A7=BB=E4=B8=BA=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/开发指南/快速测试指南.md | 11 +- .../docs/架构设计/auth-system-design.md | 4 +- .../docs/架构设计/permission-design-v2.md | 1869 +---------------- .../migration.sql | 71 + furniture-3d-system/prisma/schema.prisma | 2 +- furniture-3d-system/prisma/seed.ts | 7 +- .../scripts/performance-test.ts | 4 +- .../app/api/approval-workflows/route.test.ts | 104 + .../src/app/api/approval-workflows/route.ts | 42 +- .../families/[id]/review/route.ts | 2 +- .../api/asset-center/families/[id]/route.ts | 4 +- .../app/api/asset-center/families/route.ts | 2 +- .../app/api/asset-center/import-cad/route.ts | 2 +- .../substrate-dictionaries/[id]/route.ts | 4 +- .../substrate-dictionaries/route.ts | 2 +- .../api/asset-center/substrates/[id]/route.ts | 4 +- .../app/api/asset-center/substrates/route.ts | 2 +- .../api/asset-center/upload-image/route.ts | 2 +- .../variants/[id]/review/route.ts | 2 +- .../api/asset-center/variants/[id]/route.ts | 4 +- .../variants/[id]/thumbnail/route.ts | 2 +- .../app/api/asset-center/variants/route.ts | 2 +- .../src/app/api/auth/register/route.test.ts | 92 +- .../src/app/api/auth/register/route.ts | 34 +- .../src/app/api/draft-groups/route.ts | 2 +- .../app/api/intake/designer-options/route.ts | 2 +- .../presentation-preview/route.test.ts | 103 +- .../packaging/material-options/route.test.ts | 3 +- .../[id]/packaging/preview/route.test.ts | 3 +- .../api/pre-skus/[id]/packaging/route.test.ts | 3 +- .../app/api/pre-skus/[id]/packaging/route.ts | 2 +- .../generate-from-order/route.test.ts | 3 +- .../products/[id]/assembly/pdf/route.test.ts | 3 +- .../[versionId]/restore/route.test.ts | 3 +- .../[id]/assembly/versions/route.test.ts | 3 +- .../app/api/users/[id]/activate/route.test.ts | 13 +- .../src/app/api/users/[id]/activate/route.ts | 15 +- .../src/app/api/users/[id]/route.test.ts | 3 +- .../src/app/api/users/[id]/route.ts | 18 +- .../api/users/organization-tree/route.test.ts | 70 +- .../app/api/users/organization-tree/route.ts | 10 +- .../src/app/api/users/route.test.ts | 20 +- .../src/app/api/users/route.ts | 21 +- .../src/app/api/warehouse/_shared.ts | 10 +- .../app/api/warehouse/access-control.test.ts | 41 +- .../ApprovalWorkflowPageClient.tsx | 14 +- .../approval-workflows/approvalWorkflowApi.ts | 1 - .../src/app/intake/IntakeClient.test.tsx | 15 +- .../src/app/intake/IntakeClient.tsx | 10 +- .../NonIntegratedDetailClient.test.tsx | 21 +- .../NonIntegratedDetailClient.tsx | 40 +- .../app/intake/non-integrated/[id]/page.tsx | 2 +- furniture-3d-system/src/app/profile/page.tsx | 57 +- furniture-3d-system/src/app/users/page.tsx | 46 +- .../src/components/auth/PermissionGuard.tsx | 10 +- .../src/components/auth/RoleBadge.tsx | 6 - .../components/layout/SidebarUserCenter.tsx | 1 - .../src/components/ui/UserMenu.tsx | 1 - .../components/users/ActivateUserModal.tsx | 170 +- .../src/components/users/CreateUserModal.tsx | 104 +- .../components/users/DepartmentAccessTree.tsx | 134 +- .../components/users/DepartmentTreeSelect.tsx | 293 +++ .../src/components/users/EditUserModal.tsx | 83 +- .../src/components/users/HandoverModal.tsx | 38 +- .../users/OrganizationManageModal.tsx | 125 +- .../src/components/users/UserListItem.tsx | 15 +- .../__tests__/DepartmentAccessTree.test.tsx | 66 +- furniture-3d-system/src/hooks/useAuth.ts | 1 + .../src/lib/business-account-types.ts | 2 + .../src/lib/business-role-inference.ts | 195 ++ .../__tests__/open-platform-demo.test.ts | 3 +- furniture-3d-system/src/lib/permissions.ts | 16 +- .../src/lib/role-scope.test.ts | 24 + furniture-3d-system/src/lib/role-scope.ts | 34 +- furniture-3d-system/src/lib/server-session.ts | 1 + .../src/server/core/response.ts | 14 +- furniture-3d-system/src/server/core/types.ts | 3 +- .../src/server/dto/user.dto.ts | 10 +- .../server/repositories/auth.repository.ts | 10 +- .../repositories/handover.repository.ts | 5 +- .../server/repositories/user.repository.ts | 37 +- .../assembly.service.install-order.test.ts | 3 +- .../assembly.service.pdf-scope.test.ts | 3 +- .../__tests__/asset-review.service.test.ts | 3 +- .../draft-group.manual-assembly.test.ts | 6 +- .../__tests__/draft-group.service.test.ts | 3 +- ...ake-non-integrated-product.service.test.ts | 3 +- .../services/__tests__/intake.service.test.ts | 3 +- .../open-platform-demo-seed.service.test.ts | 3 +- .../__tests__/planning.service.test.ts | 3 +- .../__tests__/user-handover.service.test.ts | 27 +- .../user-management.organization-tree.test.ts | 78 +- .../__tests__/user-management.service.test.ts | 47 +- .../services/account-template.service.ts | 28 +- .../services/approval-workflow.service.ts | 52 +- .../src/server/services/auth.service.ts | 4 +- .../server/services/dingtalk-auth.service.ts | 19 +- .../intake-non-integrated-scope.service.ts | 3 +- .../open-platform-demo-seed.service.ts | 8 +- .../server/services/user-handover.service.ts | 1 + .../services/user-management.service.ts | 202 +- .../src/server/services/user.service.ts | 25 +- .../business-role.validator.test.ts | 31 + .../validators/business-role.validator.ts | 64 + .../src/server/validators/user.validator.ts | 4 +- furniture-3d-system/tests/api/auth-me.test.ts | 4 +- furniture-3d-system/tests/api/auth.test.ts | 20 +- .../tests/performance/rendering.test.tsx | 4 +- .../tests/security/authorization.test.ts | 62 +- .../tests/security/jwt-security.test.ts | 8 +- 110 files changed, 2062 insertions(+), 2891 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260514170000_migrate_designer_to_design_business_role/migration.sql create mode 100644 furniture-3d-system/src/app/api/approval-workflows/route.test.ts create mode 100644 furniture-3d-system/src/components/users/DepartmentTreeSelect.tsx create mode 100644 furniture-3d-system/src/lib/business-role-inference.ts create mode 100644 furniture-3d-system/src/lib/role-scope.test.ts create mode 100644 furniture-3d-system/src/server/validators/business-role.validator.test.ts create mode 100644 furniture-3d-system/src/server/validators/business-role.validator.ts 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 ( -
-
- - setIdentifier(e.target.value)} - placeholder="请输入邮箱或姓名" - /> -

- 支持使用邮箱或姓名登录 -

-
- -
- - setPassword(e.target.value)} - placeholder="请输入密码" - /> -
- - -
- ) -} -``` - -### 注册界面设计 - -```tsx -// 注册表单 -function RegisterForm() { - const [email, setEmail] = useState('') - const [realName, setRealName] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - - return ( -
-
- - setEmail(e.target.value)} - placeholder="请输入邮箱" - required - /> -
- -
- - setRealName(e.target.value)} - placeholder="请输入真实姓名" - required - /> -

- 姓名将用于登录和显示,请确保唯一性 -

-
- -
- - setPassword(e.target.value)} - placeholder="至少8位,包含大小写字母和数字" - required - /> -
- -
- - setConfirmPassword(e.target.value)} - placeholder="请再次输入密码" - required - /> -
- - -
- ) -} -``` - ---- - -## 💡 设计优势 - -### 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}

+
+ +

{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" /> - setFormData((current) => ({ ...current, teamId: event.target.value }))} - options={[ - { value: '', label: '未分配小组' }, - ...teams.map((team) => ({ value: team.id, label: team.name })), - ]} + departmentId, + })); + }} className="bg-white" />
@@ -267,6 +270,11 @@ export function ActivateUserModal({ ))}
+ {emptyBusinessRoleWarning ? ( +
+ {emptyBusinessRoleWarning} +
+ ) : null}
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, 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, 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({
-

部门

+

组织节点

- setDepartmentName(event.target.value)} /> - setDepartmentName(event.target.value)} /> +
-
-
- -
-
- -

小组

-
-
- setTeamName(event.target.value)} /> - setTeamDescription(event.target.value)} placeholder="可选" /> -
-
-
-
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, };