Compare commits

...
38 Commits
Author SHA1 Message Date
Stev_Wang 50afaee49c docs(plan): 回写 A3 标准完成状态(WBS 状态列 + 最终验收)
按 A1 的完成状态格式补齐 A3 §6.4:新增执行状态说明、WBS 表状态列并逐项标 ✅;完成日期更新为 2026-11-04 最终验收。仅文档变更。
2026-11-04 09:37:24 +08:00
Stev_Wang c90a036f82 docs(ci): 明确 CI 仅本地执行,远端 Gitea 不启用 CI
背景:远端自建 Gitea 未部署 Actions runner,推送后线上 CI 无法执行;原规范仍要求「推送后确认 CI」,容易误以为需等待远端流水线。

改动:
- AGENTS §6/§7:CI 只本地执行,推送前必须 pnpm ci:local 或本地门禁全绿,禁止等待/依赖远端 CI。
- 06-测试与CI规范 V1.1:新增「本地 CI 流水线(远端不跑 CI)」强制规则;集成/契约运行时机改为推送前本地。
- 01-Git规范 / 07-CodeReview:删除「推送后确认 CI」,改为本地门禁后推送。
- README:明确远端无 runner,ci.yml 仅本地参考/手动触发。
- .gitea/workflows/ci.yml:去掉 push 触发,仅保留 workflow_dispatch,避免推送停留在 queued。

影响面:仅规范与工作流配置,不影响代码/数据。
验证:pnpm format:check / lint 通过;pnpm ci:local 全部门禁通过(237.2s,4 e2e 冒烟 passed,8 个门控用例按环境变量跳过)。
2026-11-04 08:15:06 +08:00
Stev_Wang 3e4bf48841 fix(web): A3 页面对齐选定原型(行业 Tab / 新建向导 / 筛选条)
ci / 质量门禁 (push) Waiting to run
背景:A3 首版实现把行业管理简化为普通树 + 简化详情,新建客户用 Semi 默认 Steps + 下拉框,与用户选定的原型 B 结构差异较大;客户管理筛选区也缺少白底与统一图标间距。

改动:
- 行业管理(B):补头像 + 状态/层级标签、基本信息/子行业/引用 Tab、最近变更时间线、子行业与引用客户列表,右栏白底卡片,删除入口。
- 新建客户(B):补左侧「创建客户/共 4 步」步骤导航(标题 + 说明 + 当前/完成态)、两级行业卡片选择、表单卡头/底部操作条。
- 客户组(B):成员改为「客户/所属行业/状态/负责人/操作」表格并支持移除。
- 客户列表(A):补表头统计、筛选区白底卡片与搜索图标间距。
- 客户详情(A):补右栏快捷操作;字段审批(A):补状态分段、中文类型。
- 主操作 theme=solid、次操作 theme=outline,与原型按钮层级一致。
- 更新 A3 实施报告与设计说明。

影响面:仅 apps/web A3 页面、client.css 与文档;接口、数据、权限不变。
验证:lint / format:check / typecheck / test(shared 131 / api 90 / web 125)/ build 全绿;真实浏览器六页重拍通过。截图:design/a3-ui-v1/impl/。
2026-11-04 08:04:14 +08:00
Stev_Wang 140dc386c1 feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)
背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。

改动:
- 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。
- 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。
- 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。
- 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。
- 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。

影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。
回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。
验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
2026-11-03 21:45:38 +08:00
Stev_Wang 8626996bb1 docs(plan): A2 最终验收通过并确认完成状态
ci / 质量门禁 (push) Canceled after 0s
背景:A2 全部 WBS 已完成,且后续合入了 A1 部门管理补强与角色页 UI 收口;需按当前 main 重新验收并在开发计划确认完成状态。

改动:
- 开发计划 A2 状态行/批次表保持 ✅ 已完成,进度与 §6.3 完成情况更新为最终验收口径;
- 更新最终测试数字:test 331(shared 121 / api 90 / web 120)、test:int 33、A2 e2e 1/1(22.1s);
- 补充收口项(角色管理命名、页头操作右对齐、编辑角色移入页头、移除重复目录抽屉),A2 实施报告同步。

验证:format:check / lint / typecheck / test / test:int 全绿;A2 e2e 最终版 UI 下通过。
2026-11-03 17:10:18 +08:00
Stev_Wang cda4b1825b chore(openapi): 同步部门管理接口的生成类型
ci / 质量门禁 (push) Canceled after 0s
背景:
- 上一批部门管理接口(disable / enable、update、树与字段说明)合入后没有重新导出 OpenAPI,
  apps/web/src/api/generated/openapi.d.ts 仍停留在「部门改名」版本,前端类型与真实契约不一致;
- pnpm ci:local 第 9 步 openapi:export 会自动重生成该文件,导致每次跑完工作区变脏。

改动:
- 执行 pnpm openapi:export,重新生成 apps/web/src/api/generated/openapi.d.ts
  (新增 /departments/{id}/disable、/departments/{id}/enable、update 操作与字段说明)。

影响面:
- 仅前端生成类型,无运行时行为变化;openapi/lingxi.openapi.json 无差异。

回滚方式:
- 回滚本提交即恢复旧生成文件。

验证证据:
- pnpm openapi:diff 通过;
- pnpm ci:local 全部门禁通过。
2026-11-03 16:26:10 +08:00
Stev_Wang 087f27936e fix(ci): 修复本地 CI 端到端冒烟的 demo 会话与 A0 遗留用例
背景:
- .gitea/workflows/ci.yml 在 job env 中设置 VITE_PUBLIC_DEMO_SESSION=true,
  e2e 用 vite preview + 演示会话登录;scripts/ci-local.mjs 没有同步这组 env,
  本地 CI 第 10 步必失败;
- e2e/smoke.spec.ts 是 A0 用例:还在找「请输入账号或邮箱」占位符、断言登录后落在工作台;
  A1 接入真实登录后占位符为「请输入账号或手机号」,且登录默认落地 /system/users。

改动:
- ci-local:构建与 e2e 步骤注入 VITE_PUBLIC_DEMO_SESSION / VITE_PUBLIC_API_BASE_URL
  (与 ci.yml 对齐),只作用于这两个步骤,避免影响单测对真实登录流程的断言;
- turbo.json:声明 globalEnv,保证构建产物随这两个 VITE 变量变化,不复用非 demo 构建缓存;
- smoke.spec:登录占位符改为「请输入账号或手机号」;登录后按 A1 现状断言落地 /system/users,
  再从顶部「工作台」进入首页并校验模块导航;
- README:单独执行 e2e 时补充 demo 会话环境变量,并注明 ci:local 已内置。

影响面:
- 仅本地 CI 与 e2e 冒烟;业务代码与 CI 工作流行为不变(ci.yml 本就在 job env 注入这些变量);
- 本地构建缓存会因 globalEnv 变更失效一次。

回滚方式:
- 回滚本提交即恢复原 ci-local 步骤、冒烟用例与 README。

验证证据:
- pnpm ci:local 全部门禁通过(用时 218.8s);
- e2e smoke 4 项通过(登录 / 403 / 模块切换 / 404;A1 账号周期 7 项按预期 skip)。
2026-11-03 16:25:55 +08:00
Stev_Wang b489cb3152 test(api): 修复集成测试对根部门顺序与文件并行的依赖
ci / 质量门禁 (push) Canceled after 0s
背景:
- pnpm ci:local 的 test:int 在本地真实 PG 上失败,与业务代码无关,是测试自身不稳定:
  1) identity 用例断言部门树第一项根部门必须有子级,但管理员可以新建无子级根部门
     并排在最前(本地已有手工部门「111」,sortOrder=0),该断言不成立;
  2) identity.int.spec 清理用 account startsWith('it.'),会误删 departments.int.spec
     的 it.dep.* 夹具;四个 int 文件默认并行且共用同一个真实 PG,beforeAll 偶发失败。

改动:
- identity.int.spec:部门树断言改为「存在带子级的根部门」,不再依赖根部门顺序;
- vitest.int.config:fileParallelism: false,集成测试文件串行执行,并注释原因。

影响面:
- 仅集成测试的断言与执行方式;业务代码、单测、构建不变;
- test:int 改为串行,耗时略增。

回滚方式:
- 回滚本提交即恢复原断言与并行执行。

验证证据:
- pnpm --filter @lingxi/api test:int 连续 3 次:4 个文件 / 33 项全通过;
- pnpm ci:local 第 1–8 步通过(第 10 步 e2e 为 A0 遗留问题,另行处理);
- pnpm format:check / lint / typecheck / test 通过。
2026-11-03 16:17:21 +08:00
Stev_Wang 4f6ccda6e3 fix(web): 统一用户/角色/部门列表行样式与选中态
ci / 质量门禁 (push) Canceled after 0s
背景:
- 用户、角色列表行为全宽 + 1px 分隔线,选中行与相邻悬停行的高亮背景会盖住分隔线,
  两块色块粘连,看起来间距被挤在一起;
- 部门树为列表内留白 + 圆角行,其左侧 2px 竖条贴行左缘,而用户 / 角色的竖条贴卡片边缘,
  三页选中指示器不一致;部门子级缩进后竖条位置还会随层级漂移;
- 行宽收紧后角色副信息(编码 · 数据范围)会被硬裁掉半个字。

改动:
- 三页列表统一行几何:列表内留白 6px、行间 2px、行圆角 6px,去掉贯穿分隔线;
- 统一取消左侧 2px 竖条,选中态改为品牌浅底 + 品牌色 / 600 字重标题,与侧栏导航一致;
- 行高 64→60,保持 9 条一屏;部门树根级 / 子级行距同步;
- 新建角色页的角色模板列表套用同一套行样式;
- 角色副信息改为块级 text-overflow: ellipsis,右侧统计列 132→112px 归还宽度,
  1440 下角色编码与数据范围不再截断。

影响面:
- 仅管理中心用户 / 角色 / 部门三页与新建角色页的列表视觉;
- 结构与交互不变,接口、权限、路由、数据均不变;≤1280/1080 沿用原响应式收缩规则。

回滚方式:
- 回滚本提交即恢复全宽行 + 1px 分隔线 + 左侧竖条与原行宽。

验证证据:
- pnpm format:check / lint / typecheck 通过;pnpm test:web 120/120 通过;
- Chrome 1440×900 实机截图:选中 + 悬停相邻行留白清晰,三页选中态一致;
- 角色列表副信息溢出检测:overflow: [](无截断)。
2026-11-03 15:55:06 +08:00
Stev_Wang f849e443d7 refactor(web): 侧栏「用户与组织」更名为「用户管理」
背景:
- 管理中心侧栏菜单名为「用户与组织」,与「角色管理」「部门管理」的命名方式不一致;
- 页面标题、面包屑仍沿用旧名,三处展示不统一;
- A1 文档与代码注释中该功能域本来即称「用户管理」。

改动:
- nav-config:菜单 label 改为「用户管理」,description 改为「成员账号、部门归属与登录状态」;
- breadcrumbs:用户新建 / 详情子路由的面包屑 2 处同步;
- UsersPage:页面 h1 同步;
- nav-config.spec / breadcrumbs.spec / e2e a1-identity 的标签与 heading 断言同步;
- 11-前端路由与目录规范、AppLayout 注释同步;PRD P-01 功能名保持「用户与组织管理」不变。

影响面:
- 仅前端文案与对应断言;路由、权限点、接口、数据均不变。

回滚方式:
- 回滚本提交即恢复原「用户与组织」文案与断言。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:web 120/120 通过;
- Chrome 1440×900 实机:侧栏、面包屑、页面标题均显示「用户管理」。
2026-11-03 15:54:13 +08:00
Stev_Wang 3e384febf3 fix(tooling): 给 db:reset 增加显式确认,避免误清本地密码
ci / 质量门禁 (push) Canceled after 0s
背景:
- 本轮部门管理联调后执行了 pnpm db:reset 清理测试数据,清空本地库,
  导致用户手工改过的登录密码被重置回 lingxi2026;
- 之前只保证了 ./dev.sh / pnpm dev / pnpm db:init 不会重置数据,
  db:reset 本身仍可被脚本或助手无提示执行,问题没有真正兜住。

改动:
- 新增 scripts/db-reset.mjs:默认拒绝执行并打印影响;只有显式
  ALLOW_DB_RESET=1 才调用 prisma migrate reset --force;
- package.json 的 db:reset 指向该脚本;
- README / AGENTS §6 §11 / e2e README / A1 实施报告同步:
  日常迁移补种子只用 pnpm db:init,验证/联调中禁止 db:reset;
  e2e 会改写 actor 密码,需使用专用账号或独立测试库。

影响面:
- 确需清库时多一步显式确认;迁移、启动、单测、int 测试不受影响。

回滚方式:
- 回滚本提交即恢复 db:reset 直接执行 prisma migrate reset --force。

验证证据:
- pnpm db:reset 无 ALLOW_DB_RESET 时退出码 1,并打印拦截说明;
- pnpm format:check / lint 通过;node --check scripts/db-reset.mjs 通过。
2026-11-03 15:16:52 +08:00
Stev_Wang 981757b76c docs(plan): 回写 A1 部门管理补强
ci / 质量门禁 (push) Canceled after 0s
背景:
- 部门管理已实现(独立页面 /system/departments、只停用不删除、
  有成员/启用子部门禁止停用),需要同步计划、规范与实施报告。

改动:
- 开发计划:新增 WBS A1-BE-05(部门接口)与 A1-FE-04(部门管理页),
  A1 进度更新为 10/10、测试计数与「补强」说明;附录 C 增加
  /system/departments,§7.1 页面/接口清单更新;版本记录增补。
- A1 实施报告:范围表、契约/后端/前端交付物、测试计数与验证表补充
  部门管理;新增 §2.5 部门管理补强(口径、规则、覆盖范围)与遗留项。
- 11-前端路由与目录规范:管理中心模块表补 /system/departments
  (pages/system/departments/),版本 V1.1。

影响面:
- 仅文档;与已提交的代码行为一致,不改变其它批次范围。

回滚方式:
- 回滚本提交即恢复文档原状;不影响代码。

验证证据:
- pnpm format:check 通过;
- 文档中的测试计数、接口与页面路径与 feat(identity) 提交一致。
2026-11-03 15:02:14 +08:00
Stev_Wang 6661c42e84 feat(identity): 补齐部门管理(两级组织树、停用启用)
ci / 质量门禁 (push) Canceled after 0s
背景:
- A1 只实现了部门树读取与新建 / 改名接口,没有部门管理入口,
  负责人、排序、停用状态都无法维护;
- 用户必须关联部门,部门无法维护会让 A2 的数据范围缺少组织基础。

改动:
- shared:DepartmentNode 增加 status / sortOrder / directMemberCount;
  新增 createDepartmentInput / updateDepartmentInput / departmentTreeQuery 契约;
- api:GET /departments/tree?include_disabled;POST/PATCH /departments
  (名称 / 负责人 / 排序);POST /departments/{id}/disable|enable;
  只停用不删除,有在职成员 / 启用子部门 / 下级在职成员时禁止停用;
  新建 / 编辑用户校验部门必须启用;@Audit 与 OpenAPI 同步;
- web:管理中心新增「部门管理」/system/departments,左两级组织树 +
  右详情 / 内联编辑 + 新建部门/小组 + 停用启用二次确认;显示已停用、
  ?id= 选中可恢复、无 user:manage 只读;用户页部门选择仍只取启用部门;
- 测试:shared 契约、api 单测 + int、web 树/表单/接口单测同步。

影响面:
- 新增部门写接口与页面;GET tree 默认仍只返回启用部门,对用户页无破坏;
- 权限复用 user:view / user:manage,不新增权限点;
- 无数据库迁移(status / leader_user_id / sort_order 列已存在)。

回滚方式:
- 回滚本提交即移除部门管理页与写接口,保留原只读树;数据库无需回滚。

验证证据:
- pnpm format:check / lint / typecheck 全绿;
- pnpm test:shared 121 / api 90 / web 120;test:int 4 files / 33 tests;
- pnpm openapi:diff:无破坏性变更;
- 真实 API + 浏览器(jiang.lan):新建一级部门 / 小组、改名、停用空小组、
  重名 40903、树/详情/停用确认均正常;
  截图 design/a1-ui-v1/shots/impl/70-departments.png。
2026-11-03 15:01:07 +08:00
Stev_Wang f4da1f3c02 refactor(web): 编辑角色主操作移入详情页头并移除页脚
ci / 质量门禁 (push) Canceled after 0s
背景:角色详情页把「编辑角色」放在右下角页脚,与页头的复制/停用分离,主操作位置突兀;页脚只剩一句备注也浪费垂直空间。

改动:
- 页头操作顺序调整为:复制 → 停用/启用(系统角色为改名)→ 编辑角色(实心主按钮);
- 只读用户显示「查看角色」,系统角色也显示「查看角色」(仅名称可改,避免编辑语义过强);
- 移除详情页脚及编辑按钮,权限摘要与绑定用户获得更多高度;
- 更新只读态单测断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测页头操作与主按钮位置正确。
2026-11-03 14:16:39 +08:00
Stev_Wang f5a8afe835 refactor(web): 侧栏与页面模块名由角色权限改为角色管理
ci / 质量门禁 (push) Canceled after 0s
背景:角色权限只描述模块的一部分能力,侧栏同级均为模块级命名(用户与组织 / 客户 / 行业树);角色页实际覆盖角色 CRUD、权限点、数据范围、特殊权限与 SoD。

改动:
- 侧栏、面包屑、页面标题统一为「角色管理」,页面内权限点 / 数据范围 / 特殊权限分区名不变;
- 用户详情与超管授予弹窗中的模块引用同步改为「角色管理」;
- 开发计划页面清单与 A2 实施报告同步;
- 更新导航与面包屑单测、A2 e2e 标题断言。

验证:format:check / lint / typecheck / test 全绿;5173 实测侧栏、面包屑、页标题均为「角色管理」。
2026-11-03 14:03:13 +08:00
Stev_Wang a3419cf5da refactor(web): 收敛角色页权限点目录入口
ci / 质量门禁 (push) Canceled after 0s
背景:列表页页头「权限点目录」与详情面板「查看权限点目录」和编辑器权限点 Tab 信息重复,抽屉只读、无搜索、无操作,且页头出现两个并列动作。

改动:
- 移除列表页页头目录按钮与 PermissionCatalogDrawer 组件及对应样式;
- 详情面板删除「查看权限点目录」入口,保留「已选 n / 30」信息;
- 权限查看统一走编辑器「权限点」Tab(只读用户也可进入查看);
- 同步 A2 实施报告与设计说明。

验证:pnpm format:check / lint / typecheck / test 全绿;5173 实测页头仅保留「新建角色」,详情面板无目录入口。
2026-11-03 13:56:35 +08:00
Stev_Wang fe70a2bcee fix(web): 修复角色列表页头操作按钮未右对齐
ci / 质量门禁 (push) Canceled after 0s
背景:/system/roles 页头标题容器缺少 lx-roles__head-txt 类,且页头未设置 space-between,导致「权限点目录 / 新建角色」紧跟在标题后停在页面中间;编辑器/新建页因标题容器有 flex:1 不受影响。

改动:RolesPage 标题容器补 lx-roles__head-txt;.lx-roles__header 增加 justify-content:space-between,操作区增加 margin-left:auto。

验证:pnpm format:check / lint / typecheck / test 全绿;本地 5173 实测按钮回到右侧(1440×900 截图已更新)。
2026-11-03 13:44:51 +08:00
Stev_Wang 7824bc7b32 fix(dev): 明确启动不重置数据库并新增安全 db:init
ci / 质量门禁 (push) Canceled after 0s
背景:开发机每次重启后被反馈需要重新用默认密码登录并强改,排查确认仓库启动链路(dev.sh / pnpm dev / API / worker / db:seed)均不重置密码;只有 db:reset / migrate reset / 重建数据卷会恢复种子密码。A2 收尾时为干净截图执行过 db:reset,是最近一次重置的直接原因。

改动:
- 新增 pnpm db:init = prisma migrate deploy + 幂等 seed,作为首次/迁移后的安全初始化;不重置数据、不覆盖已有密码。
- README / dev.sh / AGENTS 命令契约 / SEED.md / A1·A2 实施报告明确:日常启动不需要 DB 命令,db:reset 仅用于清库。
- 实测:对已改密的 admin 执行 db:init 后 password_hash 与 must_change_password 均未变化。

验证:pnpm format:check / pnpm lint 通过;db:init 实际执行通过。
2026-11-03 13:37:34 +08:00
Stev_Wang 7fb458f62f docs(plan): 回写 A2 完成状态并记录 H1 权限门决策
ci / 质量门禁 (push) Canceled after 0s
背景:A2 角色权限与数据范围已完成,需回写计划状态、实施报告与架构决策记录。

改动:
- 新增 docs/adr/ADR-001-权限点目录与六类数据范围.md 及 ADR 索引,记录 H1 权限门六项决策。
- 新增 A2 实施报告(范围、交付、门禁、遗留、回滚、下一步)。
- 更新开发计划 §5.2 / §6.3 / 文档状态,并同步根 README 文档索引。
2026-11-03 12:27:15 +08:00
Stev_Wang 61894df401 feat(identity): 完成 A2 角色权限与数据范围全栈落地
背景:A2 开卡前完成 H1 权限门,确认权限点目录、特殊权限、SoD 三对、六类数据范围与超管隐式全集口径(ADR-001)。

改动:
- shared:30 权限点 / 10 模块目录与元数据、特殊权限 6 编码、SoD 三对、角色与权限树契约;/me 改 dataScopes 多角色并集。
- prisma:expand 迁移新增 permissions / data_scopes;种子按原型口径重写角色权限、数据范围与 8 项特殊授予。
- api:角色 CRUD / 复制 / 停用 / 删除、权限点启动同步、SoD 40304、系统角色保护、六类范围 ScopeContext 与 CustomerDirectory 端口、变更审计与 outbox。
- web:方案 B 主从分栏三角色页面(列表 / 编辑器 / 新建),权限树、范围选择、特殊权限二次确认、保存影响、SoD 冲突、只读态与模板预填。
- 测试:角色集成 13 条 + A2 e2e 1 条;A1 全量回归。

验证:format:check / lint / typecheck / test(306)/ test:int(27)/ A2 e2e(1,复跑通过)/ openapi:export 全绿。
2026-11-03 12:26:51 +08:00
Stev_Wang f97f97b0cd refactor(web): 统一模块化路由并将平台更名为管理中心
ci / 质量门禁 (push) Canceled after 0s
背景:
- 落实 11-前端路由与目录规范:URL、导航、面包屑、页面目录一一对应;
- 「平台」更名「管理中心」,模块 key 由 platform 改为 system;
- 原导航模块 key 与 URL 首段不一致(script vs /scripts、asset vs /assets),
  占位页按 URL 解析不到模块,出现「页面不存在」。

改动:
- nav-config:模块 key 改为 URL 首段(scripts / assets / system),平台更名
  管理中心,管理中心全部路径收敛到 /system/*;用户页为 /system/users;
- routes / router:ROUTES 按模块分组;新增 /system/users、/system/users/new、
  /system/users/:id;旧 /users* 与 /platform* 加前端重定向;/system 落到用户列表;
- 页面目录迁移:pages/users → pages/system/users,pages/home → pages/dashboard,
  懒加载与所有引用同步;
- 面包屑、AppLayout、登录回跳、通用占位页(按路径 + 权限解析,无权限进 403)
  与单测 / e2e 路径同步;
- 顺带修复 /scripts/library、/assets/library 等占位页因模块 key 不一致导致的 404。

影响面:
- 仅前端路由与目录;后端 /api/v1/users 等接口与权限点不变;
- 旧书签 /users*、/platform* 由前端重定向兼容一个版本。

回滚方式:
- 回滚本提交即恢复 /users 与 /platform/* 路由、原页面目录与导航命名。

验证证据:
- pnpm --filter @lingxi/web lint / typecheck / test 通过(15 files / 55 tests);
- pnpm build 通过;
- 浏览器:登录后落在 /system/users,导航显示「管理中心」;/users → /system/users,
  /platform/roles → /system/roles;有权限的 /scripts/library、/assets/library
  显示占位页,无权限显示 403;
- A1 e2e 6/6 通过(真实 API + 浏览器)。
2026-11-03 10:31:13 +08:00
Stev_Wang a1e28daec5 docs(routing): 新增前端路由与目录规范并收敛计划路由
ci / 质量门禁 (push) Canceled after 0s
背景:
- 前端路由缺少统一规范:导航模块 key 与 URL 首段不一致,页面目录未镜像
  路由,04 规范中的历史路由示例与实际实现不符;
- 用户确认「平台」更名为「管理中心」,路由统一为 /<模块>/<功能>。

改动:
- 新增 11-前端路由与目录规范:模块与基础路径表、路径语法(复数 kebab-case、
  /new、:id、层级与 query 状态)、pages/<模块>/<功能>/ 目录映射、路由注册、
  旧链重定向、新增页面检查清单;
- 04 §8 的 API-27 改为模块化路由并链接 11;工程规范索引与 AGENTS §9 登记 11;
- 开发计划:平台更名管理中心,附录 C 页面清单收敛为模块化路由,§7.1 / §8.3
  同步,版本记录补「路由规范回写」;
- A1 实施报告路由说明更新为 /system/users(旧 /users* 重定向)。

影响面:
- 仅文档与规范,不改变功能范围、权限点与后端接口;后续批次按 11 执行。

回滚方式:
- 回滚本提交即恢复原附录 C 与 04 §8 描述;代码不受影响。

验证证据:
- pnpm format:check 通过;路由与代码行为见相邻的 web 重构提交。
2026-11-03 10:30:55 +08:00
Stev_Wang f8b6232b4c style(users): 优化搜索框图标间距与详情 Tab 左边距
ci / 质量门禁 (push) Canceled after 0s
背景:
- 用户与组织页搜索框的前缀图标与占位文字仅 8px,视觉偏挤;
- 详情卡片的 Semi Tab 标题默认无内边距,基本资料等标签贴住卡片左边缘。

改动:
- 搜索框前缀图标改为左 10px / 右 12px;
- 详情 Tab bar 左右内边距 20px(1024 断点下与内容一起收紧为 16px),
  使 Tab 标题与下方「身份 / 权限 / 状态」内容左对齐。

影响面:
- 仅用户与组织页样式,不影响交互与接口。

回滚方式:
- 回滚本提交即恢复原间距。

验证证据:
- 1440×900 实测:前缀图标左 268 / 输入文字左 296,Tab 标题与内容均从 677 开始;
- pnpm format:check / web lint / typecheck / test(15 files / 55 tests)通过。
2026-11-03 09:53:08 +08:00
Stev_Wang 2c8dd5040f fix(dev): 修复演示会话导致的登录回跳并默认输出运行日志
ci / 质量门禁 (push) Canceled after 0s
背景:
- dev.sh 会把仓库根 .env 的变量全部导出给子进程,其中
  VITE_PUBLIC_DEMO_SESSION=true 被 Vite 读取后启用演示会话:登录写入
  假 token,进入 /users 后真实接口 401、刷新失败即清会话回登录页;
- 后台启动后终端看不到运行日志,不利于排查。

改动:
- dev.sh 不再向子进程导出 VITE_*,前端环境仍由 Vite 自己管理;
- .env.example 的 VITE_PUBLIC_DEMO_SESSION 默认改为 false(本地
  .env / .env.local 同步);
- ./dev.sh 默认启动后前台跟随日志(Ctrl+C 停止全部),新增
  -d/--detach 后台模式;非交互环境仍默认后台;
- README 同步脚本用法。

影响面:
- 仅开发脚本与示例环境;生产与 CI 不涉及;演示会话仍可通过显式
  VITE_PUBLIC_DEMO_SESSION=true 开启。

回滚方式:
- 回滚本提交即恢复后台启动与 VITE_* 透传行为。

验证证据:
- 浏览器登录会真实请求 POST /api/v1/auth/login,错误账号返回
  「账号或密码不正确」;
- 真实账号登录后停留在 /users 并加载 9 条成员,不再回跳登录页;
- ./dev.sh -f 持续输出 api/web/worker 日志,SIGTERM 后服务与端口
  全部释放;
- bash -n dev.sh 与 pnpm format:check 通过。
2026-11-03 09:45:09 +08:00
Stev_Wang 4f8f7c587d fix(api): 允许 localhost 开发源访问 API
ci / 质量门禁 (push) Canceled after 0s
背景:
- 开发环境从 http://localhost:5173 打开页面时,用户与组织页报
  「成员加载失败 / Failed to fetch」;
- 复现确认:API 的 WEB_ORIGIN 仅含 http://127.0.0.1:5173,
  浏览器以 localhost 为 Origin 的预检响应缺少 Access-Control-Allow-Origin,
  请求被浏览器拦截。

改动:
- config/env.ts 的 WEB_ORIGIN 默认值同时包含 127.0.0.1 与 localhost;
- .env.example 同步为逗号分隔的两个开发源。

影响面:
- 仅开发环境 CORS 白名单,生产仍由部署环境变量显式配置;
- 其他来源仍需按 WEB_ORIGIN 显式加入。

回滚方式:
- 回滚本提交并重启 API,即恢复仅允许 127.0.0.1:5173。

验证证据:
- OPTIONS /api/v1/users 对两个 Origin 均返回对应 Access-Control-Allow-Origin;
- 浏览器从 localhost / 127.0.0.1 发起 /me 均返回 401(网络可达,不再 Failed to fetch);
- pnpm --filter @lingxi/api typecheck / test(10 files / 60 tests)通过。
2026-11-03 09:36:16 +08:00
Stev_Wang 340a8ed176 style(login): 精简登录页宣传区并放大输入框图标间距
ci / 质量门禁 (push) Canceled after 0s
背景:
- 登录页左栏四张比例缩略图在真实页面中观感偏廉价;
- 输入框前缀图标与占位文字间距仅 8px,视觉上偏挤。

改动:
- 左栏移除 9:16 / 1:1 / 16:9 / 4:3 素材缩略及相关数据与样式,保留品牌、定位与三条能力;
- 登录卡片输入框前缀图标改为左 10px / 右 14px,后缀图标左 12px / 右 8px,图标与提示文字拉开距离;
- 清理 login-content 与 pages.css 中不再使用的素材墙代码。

影响面:
- 仅登录页视觉,不影响登录逻辑、接口与权限。

回滚方式:
- 回滚本提交即恢复原素材墙与输入框间距。

验证证据:
- pnpm --filter @lingxi/web lint / typecheck / test 通过(15 files / 55 tests);
- pnpm --filter @lingxi/web build 通过;
- 1440×900 与 1280×900 截图核对,无溢出、图标与文字间距正常。
2026-11-03 07:46:47 +08:00
Stev_Wang f11d6759fb docs(plan): 回写 A1 阶段完成状态
ci / 质量门禁 (push) Canceled after 0s
背景:
- A1 身份认证与用户管理已完成并推送(4d9eb98),开发计划需与执行状态同步。

改动:
- 顶部状态更新为 A0 / A1 已完成、A2 待启动;
- 批次总览 A1 标记为已完成,并补充 A1 进度与验证证据;
- 6.2 A1 WBS 增加状态列并标注完成,补充实施报告链接与遗留;
- 版本记录新增「V0.1(进度回写 A1)」。

影响面:
- 仅计划文档的进度回写,不改变范围、EPD、质量门禁与验收口径。

回滚方式:
- 回滚本提交即恢复「A1 待启动」的表述,无代码与数据影响。

验证证据:
- pnpm format:check 通过;
- 进度数据对应 A1 实施记录:shared 95 / api 60 / web 55,test:int 7,A1 e2e 6/6。
2026-11-03 07:40:13 +08:00
Stev_Wang 46959fd5fc chore(dev): 增加开发机一键启动脚本 dev.sh
ci / 质量门禁 (push) Canceled after 0s
背景:
- 开发机上前端/后端/worker 需要分别启动,端口被旧进程占用时排查成本高;
  PostgreSQL / Redis 已按约定提前以 Docker 启动,脚本不再负责。

改动:
- 新增 ./dev.sh:后台启动 api(3000)/web(5173)/worker,启动前自动检测并结束
  端口占用进程(--no-kill 可改为直接失败),等待健康检查后返回。
- 提供 start/stop/restart/status/logs/clean 子命令;stop 按进程组结束并兜底
  释放端口;支持 --api-only/--web-only/--no-worker/--follow。
- 自动加载仓库根 .env.local/.env 并导出给子进程(worker 读取 REDIS_URL 需要)。
- .dev/ 纳入 gitignore;README 本地开发章节补充脚本用法。

影响面:
- 仅新增开发工具与文档,不影响构建、运行时与 CI;`pnpm dev` 保持原行为。

回滚方式:
- 回滚本提交即可,无数据与配置变更。

验证证据:
- bash -n 通过;
- ./dev.sh start / status / stop / restart / clean / --follow 实测;
- 占用 3000/5173 的假服务被自动结束并成功启动;
- --no-kill、--api-only、--no-worker 行为符合预期。
2026-11-03 07:31:50 +08:00
Stev_Wang 4d9eb980af feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s
背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
2026-11-03 00:03:33 +08:00
Stev_Wang f0d218bd99 docs(plan): 标注 A0 阶段完成状态
ci / 质量门禁 (push) Canceled after 0s
背景:A0 工程骨架与契约基线已完成并通过全部门禁。
改动:开发计划状态改为执行中;M0 批次总览增加状态列并标注 A0 已完成;
A0 WBS 增加状态列与完成说明;回写执行证据与环境/范围偏差。
影响面:仅 M0 进度标注,不改变范围与验收口径。
回滚方式:还原本文件至上一提交。
验证:format:check 通过;A0 门禁与本地 CI 全绿见 A0 实施报告。
2026-11-02 20:27:22 +08:00
Stev_Wang 2f200fc9d9 docs(infra): 接入公网 RustFS 对象存储
ci / 质量门禁 (push) Canceled after 0s
背景:项目方提供公网 RustFS(https://oss.xinsenidea.com,桶 lingxi-dev)。
改动:.env.example 填入 endpoint / 桶名占位;README 与 A0 报告记录
对象存储接入方式与验证结果;真实密钥仅保存在本地 .env / .env.local。
影响面:环境变量样例与文档,无运行时代码变更。
回滚方式:还原 .env.example 与文档即可。
验证:list buckets、HEAD bucket、PUT/GET/DELETE 探针对象均成功;
format:check、lint、typecheck、test 全绿。
2026-11-02 18:32:38 +08:00
Stev_Wang 5628e158c0 fix(infra): 移除本地 S3 容器并改用 check:dev
ci / 质量门禁 (push) Canceled after 0s
背景:本地 PG/Redis 已按要求复用 docker-postgres-1 / docker-redis-1;
S3 容器按用户要求删除,后续接入公网自建 RustFS。

改动:
- 删除 infra/docker-compose.dev.yaml、infra/compose.dev.yaml
- 移除 compose:up / compose:down 脚本,改用 check:dev 预检本机 PG/Redis
- 新增 scripts/check-dev-deps.mjs;.env.example 的 S3_* 改为待填
- README / AGENTS / A0 报告同步本地依赖与对象存储说明

影响面:本地开发依赖与 CI 本地脚本、环境变量样例、文档。
回滚方式:恢复被删除的两个 compose 文件与 package.json 脚本即可。
验证:format:check、lint、pnpm ci:local(含 e2e 4/4)全部通过,用时 106.3s。
2026-11-02 18:26:24 +08:00
Stev_Wang 620cb93a46 fix(infra): 本地 PG/Redis 复用已有 Docker 服务
ci / 质量门禁 (push) Canceled after 0s
背景:此前误将宿主端口冲突当作新建 compose 服务的理由,实际应按技术架构使用本机已有 PostgreSQL/Redis。

改动:
- 删除 lingxi-pg / lingxi-redis 容器及数据卷,不再另起数据库;
- DATABASE_URL 改回 127.0.0.1:5432/lingxi,REDIS_URL 改回 127.0.0.1:6379;
- 在已有 docker-postgres-1 中创建 lingxi 库并执行迁移/seed;
- compose 仅保留无法拉取镜像的 S3 兼容服务;
- README、A0 报告同步依赖来源。

影响面:仅本地开发依赖与文档;代码/接口/数据模型不变。
回滚方式:git revert 本提交,并按旧 compose 重新初始化(不推荐)。
验证:migrate deploy、db:seed、test:int、pnpm ci:local(85.9s)全部通过。
2026-11-02 18:21:09 +08:00
Stev_Wang 68b5ca20f4 ci(ci): 支持本地 CI 并调整开发库凭据
ci / 质量门禁 (push) Canceled after 0s
背景:Gitea 暂未部署 Actions runner,远程 CI 会停留在 queued;本地开发库按约定改用 lingxi / postgres / postgres。

改动:
- 新增 pnpm ci:local,按 CI 工作流顺序执行 compose、格式、Lint、类型、单测、构建、迁移种子、集成、OpenAPI 与 e2e;
- 新增 scripts/e2e.mjs 管理 vite preview 生命周期,e2e 改为单 worker 避免退出挂起;
- openapi:export 生成后自动 Prettier,保持工作区干净;
- compose、.env.example、CI services 数据库凭据统一为 postgres/postgres,库名 lingxi;
- README、AGENTS、A0 报告同步本地 CI 与数据库说明。

影响面:仅本地开发与 CI 执行方式,无业务代码/接口/数据模型变更;本地库已重建为 lingxi/postgres。
回滚方式:git revert 本提交;数据库需按回滚后的 compose 凭据重新初始化。
验证:pnpm ci:local 全绿(158s),format:check、lint、typecheck、openapi:diff 通过。
2026-11-02 18:14:08 +08:00
Stev_Wang e35637cfd7 docs(plan): 更新 A0 报告交付状态
ci / 质量门禁 (push) Canceled after 0s
背景:仓库已改为仅 main 直推,短分支已全部删除。

改动:A0 报告头部由历史实施分支改为最终交付方式。
影响面:仅文档。
回滚方式:git revert 本提交。
验证:format:check 通过。
2026-11-02 17:40:54 +08:00
Stev_Wang dd01b89214 docs(docs): 调整分支策略为仅 main 直推
ci / 质量门禁 (push) Canceled after 0s
背景:用户确认仓库只保留 main,不再创建任何远程分支或走 PR 合并。

改动:
- 01-Git规范 V1.1:仅 main 直推,禁止非 main 分支推送、force push 与 Merge commit;
- AGENTS V1.1:变更流程改为直接提交并推送 main;
- 07-CodeReview:PR 模板改为提交说明,合并流程改为推送与推送后观察;
- 03/06/工程规范 README 同步去除 PR 措辞;
- CI 仅保留 push main 与 workflow_dispatch 触发。

影响面:仅流程与文档,无代码/数据/接口变更。
回滚方式:git revert 本提交即可恢复旧流程。
验证:format:check、lint、typecheck 全绿(10/10)。
2026-11-02 17:40:01 +08:00
wang259797 046dad463e Merge pull request 'docs(plan): 补全开发计划与项目索引' (#1) from docs/dev-plan-v0.1 into main
ci / 质量门禁 (push) Canceled after 0s
Reviewed-on: #1
2026-11-02 09:31:13 +00:00
Stev_Wang 043f0d9962 docs(plan): 补全开发计划与项目索引
ci / 质量门禁 (pull_request) Canceled after 0s
引入 M0–M4 开发计划(含 A0 WBS、门禁与七管理模块专项),并在 README、AGENTS 同步文档索引;保留 A0 已完成状态。
2026-11-02 17:29:59 +08:00
232 changed files with 42966 additions and 647 deletions

No files matched your search

+22 -15
View File
@@ -1,34 +1,41 @@
# 灵犀 · 环境变量示例(入库,禁止写真实密钥) # 灵犀 · 环境变量示例(入库,禁止写真实密钥)
# 使用:cp .env.example .env.local(工程规范 09 ENV-09) # 使用:cp .env.example .env.local(工程规范 09 ENV-09)
# 注意:本地 compose 的宿主端口为 15432/16379/19000/19001,避免与本机既有 PG/Redis 冲突。 # 注意:本地 PostgreSQL/Redis 复用本机已有 Docker 服务(127.0.0.1:5432 / 6379);
# 对象存储为公网 RustFS,本地真实值只写入 .env.local / .env(已 gitignore)。
# ---------- 运行环境 ---------- # ---------- 运行环境 ----------
NODE_ENV=development NODE_ENV=development
LOG_LEVEL=debug LOG_LEVEL=debug
# ---------- 数据库(PostgreSQL 18) ---------- # ---------- 数据库(PostgreSQL 18) ----------
DATABASE_URL=postgresql://lingxi:lingxi_dev_password@127.0.0.1:15432/lingxi_dev?schema=public DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/lingxi?schema=public
# ---------- Redis(7.4) ---------- # ---------- Redis(7.4) ----------
REDIS_URL=redis://127.0.0.1:16379 REDIS_URL=redis://127.0.0.1:6379
# ---------- 对象存储(MinIO,S3 协议) ---------- # ---------- 对象存储(RustFS,S3 协议;A0 统一使用 lingxi-dev 桶) ----------
S3_ENDPOINT=http://127.0.0.1:19000 S3_ENDPOINT=https://oss.xinsenidea.com
S3_REGION=us-east-1 S3_REGION=us-east-1
S3_BUCKET_ASSETS=assets S3_BUCKET_ASSETS=lingxi-dev
S3_BUCKET_RENDERS=renders S3_BUCKET_RENDERS=lingxi-dev
S3_BUCKET_TEMP=temp S3_BUCKET_TEMP=lingxi-dev
S3_ACCESS_KEY=lingxi S3_ACCESS_KEY=<rustfs-access-key>
S3_SECRET_KEY=lingxi_dev_password S3_SECRET_KEY=<rustfs-secret-key>
# ---------- 认证(本地 dev 值,生产由密钥管理注入) ---------- # ---------- 认证(本地 dev 值,生产由密钥管理注入;密钥至少 32 位) ----------
JWT_SECRET=dev-only-jwt-secret-change-me-32chars JWT_ACCESS_SECRET=dev-only-jwt-access-secret-change-me-32
JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32chars JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32
# access / refresh 有效期(秒):15 分钟 / 7 天
JWT_ACCESS_TTL_SECONDS=900
JWT_REFRESH_TTL_SECONDS=604800
# 登录失败阈值与锁定时长(分钟内累计失败达阈值即锁定)
AUTH_MAX_LOGIN_FAILURES=5
AUTH_LOCK_MINUTES=15
# ---------- API ---------- # ---------- API ----------
PORT=3000 PORT=3000
HOST=0.0.0.0 HOST=0.0.0.0
WEB_ORIGIN=http://127.0.0.1:5173 WEB_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
MAX_UPLOAD_MB=500 MAX_UPLOAD_MB=500
# ---------- Worker ---------- # ---------- Worker ----------
@@ -38,7 +45,7 @@ WORKER_CONCURRENCY=2
# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ---------- # ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ----------
VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1 VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1
# A0 登录壳没有接后端鉴权,置 true 时登录页写入本地演示会话以便查看布局;A1 接入真实登录后删除。 # A0 登录壳没有接后端鉴权,置 true 时登录页写入本地演示会话以便查看布局;A1 接入真实登录后删除。
VITE_PUBLIC_DEMO_SESSION=true VITE_PUBLIC_DEMO_SESSION=false
# ---------- 可观测 ---------- # ---------- 可观测 ----------
OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT=
+6 -6
View File
@@ -1,9 +1,9 @@
# 远端 Gitea 未部署 Actions runner:本工作流不随 push 触发,仅供本地参考 / 手动触发。
# 本地等价命令:pnpm ci:local(推送前必须全绿)。
name: ci name: ci
on: on:
push: workflow_dispatch:
branches: [main, 'feat/**']
pull_request:
concurrency: concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
@@ -19,8 +19,8 @@ jobs:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
env: env:
POSTGRES_USER: lingxi POSTGRES_USER: postgres
POSTGRES_PASSWORD: lingxi_dev_password POSTGRES_PASSWORD: postgres
POSTGRES_DB: lingxi_test POSTGRES_DB: lingxi_test
ports: ports:
- 5432:5432 - 5432:5432
@@ -42,7 +42,7 @@ jobs:
env: env:
NODE_ENV: test NODE_ENV: test
LOG_LEVEL: error LOG_LEVEL: error
DATABASE_URL: postgresql://lingxi:lingxi_dev_password@127.0.0.1:5432/lingxi_test?schema=public DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxi_test?schema=public
REDIS_URL: redis://127.0.0.1:6379 REDIS_URL: redis://127.0.0.1:6379
JWT_SECRET: ci-only-access-secret JWT_SECRET: ci-only-access-secret
JWT_REFRESH_SECRET: ci-only-refresh-secret JWT_REFRESH_SECRET: ci-only-refresh-secret
+2
View File
@@ -75,3 +75,5 @@ test-results/
design/ design/
# Vite 缓存 # Vite 缓存
.vite/ .vite/
# dev.sh 运行状态与日志
.dev/
+17 -14
View File
@@ -7,7 +7,7 @@
| --- | --- | | --- | --- |
| 适用对象 | 前端 / 后端 / 测试 / 运维 / AI 工程 / 产品(技术边界) | | 适用对象 | 前端 / 后端 / 测试 / 运维 / AI 工程 / 产品(技术边界) |
| 适用产物 | 代码、配置、脚本、迁移、文档、Prompts | | 适用产物 | 代码、配置、脚本、迁移、文档、Prompts |
| 文档版本 | V1.0(2026-11-02) | | 文档版本 | V1.1(2026-11-02) |
| 事实基线 | PRD `V1.6`、技术架构 `V0.4` | | 事实基线 | PRD `V1.6`、技术架构 `V0.4` |
--- ---
@@ -37,6 +37,7 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| PRD | `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | V1.6 | 需求、业务规则(R-01~R-30)唯一来源 | | PRD | `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | V1.6 | 需求、业务规则(R-01~R-30)唯一来源 |
| 技术架构 | `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | V0.4 | 技术选型、模块、数据、接口唯一来源 | | 技术架构 | `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | V0.4 | 技术选型、模块、数据、接口唯一来源 |
| 开发计划 | `docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` | V0.1 | 全项目 M0–M4 里程碑、批次、WBS、门禁与验收口径(派生文档,**不得改变需求与技术边界**) |
| 立项建议 | `docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md` | V0.1 | 论证背景,**不约束实现** | | 立项建议 | `docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md` | V0.1 | 论证背景,**不约束实现** |
| 工程规范 | `docs/工程规范/` | — | 落地细则(本文件的下游) | | 工程规范 | `docs/工程规范/` | — | 落地细则(本文件的下游) |
| 参考项目 | `项目参考/`、`sucaigongfang` | — | 仅查阅,**不入库、禁止复制业务代码** | | 参考项目 | `项目参考/`、`sucaigongfang` | — | 仅查阅,**不入库、禁止复制业务代码** |
@@ -78,7 +79,7 @@
| 质量 | Vitest · Testcontainers · Playwright · k6 · oasdiff | | 质量 | Vitest · Testcontainers · Playwright · k6 · oasdiff |
| 观测 | Pino · OpenTelemetry | | 观测 | Pino · OpenTelemetry |
> 版本升级单独 PR + 修订记录,**禁止顺手升级**。新增运行时依赖需在 PR 说明必要性(影响面 + 替代方案)。 > 版本升级单独提交 + 修订记录,**禁止顺手升级**。新增运行时依赖需在提交说明必要性(影响面 + 替代方案)。
--- ---
@@ -109,33 +110,33 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
| 场景 | 命令 | | 场景 | 命令 |
| --- | --- | | --- | --- |
| 依赖启动 | `pnpm compose:up` / `pnpm compose:down` | | 本地依赖 | `pnpm check:dev`(检查本机 PostgreSQL 5432 / Redis 6379,不启动容器) |
| 开发 | `pnpm dev` | | 开发 | `pnpm dev` |
| 构建 | `pnpm build` | | 构建 | `pnpm build` |
| 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` | | 质量 | `pnpm lint` · `pnpm lint:fix` · `pnpm format` · `pnpm format:check` · `pnpm typecheck` |
| 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` | | 测试 | `pnpm test` · `pnpm test:int` · `pnpm test:e2e` · `pnpm ci:local`(CI 只本地执行,远端无 runner) |
| 数据库 | `pnpm db:migrate` · `pnpm db:reset`(禁止 `docker compose down -v`) | | 数据库 | `pnpm db:init`(迁移 + 幂等种子,不重置数据)· `pnpm db:migrate` · `pnpm db:reset`(清库重建;默认被拦截,必须 `ALLOW_DB_RESET=1` 显式确认;禁止手工删库 / 删数据卷) |
| 契约 | `pnpm openapi:diff` | | 契约 | `pnpm openapi:diff` |
> 文档、脚本、CI 中出现的命令必须与上表一致,禁止自造别名。 > 文档、脚本、CI 中出现的命令必须与上表一致,禁止自造别名。
--- ---
## 7 变更流程(提测 / 合并前必须全绿) ## 7 变更流程(推送前必须全绿)
1. 从 `main` 拉短分支 → 小步提交(**Conventional Commits,提交信息与 PR 标题一律用中文**,`type` / `scope` 保留英文)。 1. 基于最新 `main` 直接提交,小步 commit(**Conventional Commits,提交信息一律用中文**,`type` / `scope` 保留英文)。
2. 本地门禁:`pnpm lint && pnpm typecheck && pnpm test`。 2. 本地门禁:`pnpm format:check && pnpm lint && pnpm typecheck && pnpm test`。
3. PR 描述必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须**双人 Review**。 3. 提交说明必含:**背景 / 改动 / 影响面 / 回滚方式 / 验证证据**;迁移、权限、AI、导出类变更须在推送前完成双人走查。
4. CI 全绿 + Review 通过 → **Squash Merge**;禁止 force push `main`。 4. 直接 `git push origin main`;禁止 force push `main`,禁止 Merge commit。**远端 Gitea 未部署 Actions runner,线上 CI 不可用,禁止等待/依赖远端 CI**;CI 一律在推送前本地执行(`pnpm ci:local`),本地门禁全绿才能推送。
5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。 5. 发布:staging 验证 → 生产灰度;**迁移先行、代码后行**。
细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review 与 PR](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md) 细则:[Git 规范](docs/工程规范/01-Git规范.md) · [Code Review](docs/工程规范/07-CodeReview与PR规范.md) · [测试与 CI](docs/工程规范/06-测试与CI规范.md)
--- ---
## 8 文档规范(摘要) ## 8 文档规范(摘要)
- 文档与代码**同 PR** 更新;只改文档也要走 PR。 - 文档与代码**同一次提交或相邻提交**更新;只改文档也直接提交到 `main`。
- 新业务规则先进 PRD 并编号(`R-xx`),再进代码。 - 新业务规则先进 PRD 并编号(`R-xx`),再进代码。
- 文档版本 `V<主>.<次>`:主版本 = 业务口径不兼容;次版本 = 增量补充。 - 文档版本 `V<主>.<次>`:主版本 = 业务口径不兼容;次版本 = 增量补充。
- 索引三处必须同步:根 `README.md`、`docs/工程规范/README.md`、本文件 §9。 - 索引三处必须同步:根 `README.md`、`docs/工程规范/README.md`、本文件 §9。
@@ -147,16 +148,17 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
| 主题 | 文档 | 一句话 | | 主题 | 文档 | 一句话 |
| --- | --- | --- | | --- | --- | --- |
| Git / 分支 / 提交 | [01-Git规范](docs/工程规范/01-Git规范.md) | 主干 + 短分支,Conventional Commits(**提交信息用中文**),Squash 合并 | | Git / 分支 / 提交 | [01-Git规范](docs/工程规范/01-Git规范.md) | 仅 `main` 直推,Conventional Commits(**提交信息用中文**),禁止 Merge commit |
| 代码风格 | [02-代码风格](docs/工程规范/02-代码风格.md) | 命名、类型、错误处理、异步、React 约定 | | 代码风格 | [02-代码风格](docs/工程规范/02-代码风格.md) | 命名、类型、错误处理、异步、React 约定 |
| 格式化与 Lint | [03-格式化与Lint](docs/工程规范/03-格式化与Lint.md) | Prettier + ESLint 9 flat,钩子与 CI 门禁 | | 格式化与 Lint | [03-格式化与Lint](docs/工程规范/03-格式化与Lint.md) | Prettier + ESLint 9 flat,钩子与 CI 门禁 |
| API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 | | API 与路由 | [04-API与路由规范](docs/工程规范/04-API与路由规范.md) | REST /api/v1、错误码、鉴权链、任务协议 |
| 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract | | 数据库 | [05-数据库规范](docs/工程规范/05-数据库规范.md) | ULID、customer_id、游标分页、expand/contract |
| 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 | | 测试与 CI | [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md) | 分层测试、权限三态、覆盖率只升不降 |
| Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | PR 模板、双人场景、DoD、合并后观察 | | Code Review | [07-CodeReview与PR规范](docs/工程规范/07-CodeReview与PR规范.md) | 提交说明、双人场景、DoD、本地 CI 后推送 |
| 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 | | 日志与可观测 | [08-日志与可观测性规范](docs/工程规范/08-日志与可观测性规范.md) | Pino 字段、trace_id、审计与日志分离、告警 |
| 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 | | 环境变量与密钥 | [09-环境变量与密钥规范](docs/工程规范/09-环境变量与密钥规范.md) | `.env.example` 入库、启动校验、轮换与泄露处置 |
| 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 | | 资产命名与图层 | [10-资产命名与PSD图层规范](docs/工程规范/10-资产命名与PSD图层规范.md) | 命名占位符、PSD 图层前缀、规格矩阵、低质量标记 |
| 前端路由与目录 | [11-前端路由与目录规范](docs/工程规范/11-前端路由与目录规范.md) | `/模块/功能` 路由、模块表、目录映射、重定向与检查清单 |
--- ---
@@ -178,6 +180,7 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
3. 禁止修改已合并的 `prisma/migrations/`;禁止 `--no-verify` 提交。 3. 禁止修改已合并的 `prisma/migrations/`;禁止 `--no-verify` 提交。
4. 涉及业务口径不确定时,输出 `【待确认 Q-xx】` 并说明影响面,**不要编造**。 4. 涉及业务口径不确定时,输出 `【待确认 Q-xx】` 并说明影响面,**不要编造**。
5. 改动跨 >3 个模块或触及 §10 清单时,先给出方案与影响面,等确认后再动手。 5. 改动跨 >3 个模块或触及 §10 清单时,先给出方案与影响面,等确认后再动手。
6. **禁止在验证 / 联调中执行 `pnpm db:reset`**:它会清空本地库并重置所有账号密码;日常初始化只用 `pnpm db:init`。确需清库须先获得用户明确同意,并显式 `ALLOW_DB_RESET=1`。
--- ---
+38 -8
View File
@@ -15,9 +15,14 @@
| **项目总纲** | [AGENTS.md](AGENTS.md) | V1.0 | 硬约束、技术栈、仓库结构、命令契约、流程 | | **项目总纲** | [AGENTS.md](AGENTS.md) | V1.0 | 硬约束、技术栈、仓库结构、命令契约、流程 |
| **PRD** | [灵犀-信息流广告素材一站式生产平台PRD.md](docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md) | **V1.6(最新)** | 需求、业务规则(R-01~R-30)、验收标准 | | **PRD** | [灵犀-信息流广告素材一站式生产平台PRD.md](docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md) | **V1.6(最新)** | 需求、业务规则(R-01~R-30)、验收标准 |
| **技术架构** | [灵犀_信息流素材生产平台_技术架构_V0.1.md](docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md) | **V0.4(内部版本)** | 技术选型、分层模块、数据模型、接口、ADR | | **技术架构** | [灵犀_信息流素材生产平台_技术架构_V0.1.md](docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md) | **V0.4(内部版本)** | 技术选型、分层模块、数据模型、接口、ADR |
| **开发计划** | [灵犀_信息流素材生产平台_开发计划_V0.1.md](docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md) | **V0.1(初稿)** | 全项目 M0–M4:里程碑、批次、WBS、七管理模块专项、门禁、风险、验收 |
| 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) | | 立项建议 | [灵犀_信息流素材生产平台_立项建议书_V0.1.md](docs/立项建议/灵犀_信息流素材生产平台_立项建议书_V0.1.md) | V0.1 | 背景、目标、投入产出论证(不约束实现) |
| **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 | | **工程规范** | [docs/工程规范/](docs/工程规范/README.md) | V1.0 | Git、代码风格、格式化、API、数据库、测试、Review、日志、密钥、资产命名 |
| **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 | | **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 |
| **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 |
| **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 |
| **A3 报告** | [灵犀_A3客户与行业目录_实施报告_V0.1.md](docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md) | V0.1 | A3 完成范围、两级行业决策、混搭 UI、测试与遗留 |
| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限;ADR-002 两级行业目录) |
| 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** | | 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** |
> 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。 > 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。
@@ -43,7 +48,7 @@
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 | | **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 | | **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 | | **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 | | **平台管理** | 组织账号、客户、角色管理、AI 配置、数据字典、审计、行业包与表单设计器 |
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。 > 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
@@ -58,7 +63,7 @@ apps/web React SPA
packages/ shared · form-engine · render · ai-sdk · ui packages/ shared · form-engine · render · ai-sdk · ui
prisma/ 数据模型与迁移 prisma/ 数据模型与迁移
infra/ compose、Nginx、监控、备份 infra/ compose、Nginx、监控、备份
docs/ PRD / 技术架构 / 立项建议 / 工程规范 docs/ PRD / 技术架构 / 开发计划 / 立项建议 / 工程规范
``` ```
当前已完成 **A0 工程骨架与契约基线**(monorepo、API/Worker/Web 骨架、Prisma 平台表、shared 契约包、UI 包、布局与登录壳、CI 与冒烟测试);业务模块从 A1 起按开发计划推进。目录与依赖方向见 [AGENTS.md §5](AGENTS.md)。 当前已完成 **A0 工程骨架与契约基线**(monorepo、API/Worker/Web 骨架、Prisma 平台表、shared 契约包、UI 包、布局与登录壳、CI 与冒烟测试);业务模块从 A1 起按开发计划推进。目录与依赖方向见 [AGENTS.md §5](AGENTS.md)。
@@ -72,14 +77,36 @@ docs/ PRD / 技术架构 / 立项建议 / 工程规范
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore) cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore)
pnpm compose:up # PostgreSQL 18 / Redis 7.4 / S3 兼容存储 pnpm check:dev # 检查本机 PostgreSQL / Redis 是否可用
pnpm db:migrate # 首次建表;后续由 Prisma 迁移管理 pnpm db:init # 首次/迁移后:应用已有迁移 + 幂等种子(不重置数据、不改已有密码)
pnpm db:seed # 幂等写入 system_configs 基线
pnpm dev # 并行启动 api(3000) / web(5173) / worker pnpm dev # 并行启动 api(3000) / web(5173) / worker
``` ```
- **端口**:为避免与本机已有容器冲突,compose 宿主端口为 PostgreSQL `15432`、Redis `16379`、S3 `19000/19001`,容器内仍是 `5432/6379/9000/9001`;`.env.example` 已按宿主端口给出连接串。 > **日常启动不需要执行任何数据库命令**。`pnpm dev` / `./dev.sh` 都不会迁移、播种或重置数据库,已有账号密码保持不变。
- **对象存储**:当前 Docker 环境拉取不到 `minio/minio` 镜像(registry denied、二进制 410),A0 的 compose 使用 `adobe/s3mock` 并提供 `assets / renders / temp / archive` 四个初始桶;CI 或生产接入真实 MinIO 时只需替换 `infra/docker-compose.dev.yaml` 的 `s3` 服务。 > **只有需要清空本地库时才用 `pnpm db:reset`**:它会清空全部表并重新 seed,admin 等种子账号会回到初始密码 `lingxi2026` 且 `must_change_password=true`。为避免误清本地改过的密码/数据,该命令默认被拦截,必须显式执行:
>
> ```bash
> ALLOW_DB_RESET=1 pnpm db:reset # 确认清库才执行
> ```
>
> 日常迁移 / 补种子请用 `pnpm db:init`(幂等,不改已有密码)。
> 生产环境部署只用 `prisma migrate deploy`(`db:init` 的第一步),**不执行 seed / reset**;用户密码不会因重启或发布被重置。
也可以使用一键脚本(自动检测并释放端口、带健康检查,启动后默认跟随日志):
```bash
./dev.sh # 启动 api + web + worker,并前台跟随日志(Ctrl+C 停止全部)
./dev.sh start -d # 后台启动
./dev.sh status # 端口 / PID / 健康状态
./dev.sh logs [api|web|worker] # 跟踪日志(默认全部)
./dev.sh stop # 停止并释放 3000 / 5173
./dev.sh --help # 全部参数
```
> `dev.sh` 只负责开发服务,不启动 PostgreSQL / Redis 或任何 Docker 容器;这两者按约定在开发前已就绪。
- **本地数据库**:直接复用本机已有 Docker 服务 `docker-postgres-1`(PostgreSQL 18,`127.0.0.1:5432`),库名 `lingxi`,用户 `postgres`,密码 `postgres`;Redis 复用 `docker-redis-1`(`127.0.0.1:6379`)。请先确认这两个容器已启动,本仓库不会另起 PG/Redis。
- **对象存储**:使用公网 RustFS(`https://oss.xinsenidea.com`,桶 `lingxi-dev`);A0 的 `assets / renders / temp` 三类产物暂共用该桶,待 A1 按前缀拆分。真实 access key / secret 只写入本地 `.env.local`(已 gitignore),仓库内只保留占位。
- **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。 - **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。
- **演示登录**:A0 尚未接鉴权(A1 实现),`VITE_PUBLIC_DEMO_SESSION=true` 时登录页使用本地演示会话进入工作台;A1 接入真实登录后删除。 - **演示登录**:A0 尚未接鉴权(A1 实现),`VITE_PUBLIC_DEMO_SESSION=true` 时登录页使用本地演示会话进入工作台;A1 接入真实登录后删除。
@@ -87,10 +114,13 @@ pnpm dev # 并行启动 api(3000) / web(5173) / worker
```bash ```bash
pnpm lint && pnpm typecheck && pnpm test # 本地门禁 pnpm lint && pnpm typecheck && pnpm test # 本地门禁
pnpm build && pnpm test:e2e # 构建与端到端冒烟 VITE_PUBLIC_DEMO_SESSION=true pnpm test:e2e # 构建 + 端到端冒烟(demo 会话)
pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查 pnpm openapi:export && pnpm openapi:diff # 契约快照与破坏性变更检查
pnpm ci:local # 本地跑完整 CI 门禁(已内置 e2e 所需 env)
``` ```
> **远端 Gitea 未部署 Actions runner,线上 CI 不可用**。所有 CI 一律在本地执行:推送前跑 `pnpm ci:local`(或至少 `format:check + lint + typecheck + test`)。`.gitea/workflows/ci.yml` 仅作本地流水线参考 / 手动触发,**不随 push 自动运行**。
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。 命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。当前 UI 采用「顶部模块菜单 + 左侧功能菜单」布局,视觉方向为 **A · 云白蓝(传统企业后台 + 现代浅色质感)**。
--- ---
+2
View File
@@ -17,9 +17,11 @@
"@lingxi/shared": "workspace:*", "@lingxi/shared": "workspace:*",
"@nestjs/common": "11.2.8", "@nestjs/common": "11.2.8",
"@nestjs/core": "11.2.8", "@nestjs/core": "11.2.8",
"@nestjs/jwt": "12.0.3",
"@nestjs/platform-fastify": "11.2.8", "@nestjs/platform-fastify": "11.2.8",
"@nestjs/swagger": "11.4.7", "@nestjs/swagger": "11.4.7",
"@prisma/client": "6.19.3", "@prisma/client": "6.19.3",
"argon2": "0.45.1",
"dotenv": "18.0.9", "dotenv": "18.0.9",
"fastify": "5.12.7", "fastify": "5.12.7",
"ioredis": "5.11.1", "ioredis": "5.11.1",
+6
View File
@@ -2,10 +2,13 @@ import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/config.module'; import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env'; import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module'; import { PrismaModule } from './infra/prisma/prisma.module';
import { RedisModule } from './infra/redis/redis.module'; import { RedisModule } from './infra/redis/redis.module';
import { ClientModule } from './modules/client/client.module';
import { IdentityModule } from './modules/identity/identity.module';
import { PlatformModule } from './modules/platform/platform.module'; import { PlatformModule } from './modules/platform/platform.module';
@Module({ @Module({
@@ -45,6 +48,9 @@ import { PlatformModule } from './modules/platform/platform.module';
}), }),
PrismaModule, PrismaModule,
RedisModule, RedisModule,
SecurityModule,
IdentityModule,
ClientModule,
PlatformModule, PlatformModule,
], ],
}) })
+28
View File
@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
/** 装配冒烟:不监听端口,验证 env / 模块 / 守卫链 / Swagger 装配可完成 */
describe('bootstrap.createApp', () => {
let created: CreatedApiApp;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
});
afterAll(async () => {
await created.app.close();
});
it('应用装配成功,健康检查公开(不因守卫 401)', async () => {
expect(created.config.port).toBeGreaterThan(0);
expect(created.config.jwtAccessSecret.length).toBeGreaterThanOrEqual(32);
const response = await created.app.inject({ method: 'GET', url: '/health' });
expect([200, 503]).toContain(response.statusCode);
});
});
@@ -1,6 +1,6 @@
import { ErrorCode } from '@lingxi/shared'; import { ErrorCode } from '@lingxi/shared';
import { type PipeTransform } from '@nestjs/common'; import { type PipeTransform } from '@nestjs/common';
import type { ZodSchema } from 'zod'; import type { ZodType, ZodTypeDef } from 'zod';
import { AppException } from '../errors/app-exception'; import { AppException } from '../errors/app-exception';
@@ -9,7 +9,7 @@ import { AppException } from '../errors/app-exception';
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。 * 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
*/ */
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> { export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {} constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
transform(value: unknown): T { transform(value: unknown): T {
const result = this.schema.safeParse(value); const result = this.schema.safeParse(value);
@@ -0,0 +1,131 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
Injectable,
Logger,
type NestInterceptor,
} from '@nestjs/common';
import { defer, type Observable } from 'rxjs';
import { tap } from 'rxjs';
import { AUDIT_ACTION_KEY } from './decorators';
import {
type AuditDetail,
buildRequestContext,
type RequestContext,
RequestContextService,
type RequestWithTrace,
} from './request-context';
interface AuditParams {
id?: string;
}
interface AuditPayload {
action: string;
result: 'success' | 'failure';
actorId: string | null;
account: string | null;
ip: string | null;
userAgent: string | null;
targetType: string | null;
targetId: string | null;
before?: unknown;
after?: unknown;
changes?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
traceId: string;
durationMs: number;
error?: string;
}
function actionOf(context: ExecutionContext): string | null {
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
return typeof action === 'string' ? action : null;
}
/**
* 全局请求上下文 + @Audit 结构化日志。
*
* A2 起角色 / 权限变更由业务 Service 通过 `RequestContextService.patchAudit` 写入
* 前后值、影响用户数等明细;本拦截器统一输出一条结构化 Pino 日志(含操作人 / IP / UA /
* trace_id),A5 审计表落地后在此追加落库(装饰器签名不变)。
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
private readonly logger = new Logger('Audit');
constructor(
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context
.switchToHttp()
.getRequest<RequestWithTrace & { params?: AuditParams }>();
const ctx = buildRequestContext(request);
const action = actionOf(context);
const targetId = request.params?.id ?? null;
return defer(() =>
this.requestContext.run(ctx, () => {
if (action === null) {
return next.handle();
}
return next.handle().pipe(
tap({
next: () => {
this.emit(ctx, action, 'success', targetId);
},
error: (error: unknown) => {
this.emit(ctx, action, 'failure', targetId, error);
},
}),
);
}),
);
}
private emit(
ctx: RequestContext,
action: string,
result: AuditPayload['result'],
defaultTargetId: string | null,
error?: unknown,
): void {
const detail: AuditDetail = ctx.audit ?? {};
const payload: AuditPayload = {
action,
result,
actorId: ctx.userId,
account: ctx.account,
ip: ctx.ip,
userAgent: ctx.userAgent,
targetType: detail.targetType ?? null,
targetId: detail.targetId ?? defaultTargetId,
...(detail.before === undefined ? {} : { before: detail.before }),
...(detail.after === undefined ? {} : { after: detail.after }),
...(detail.changes === undefined ? {} : { changes: detail.changes }),
...(detail.affectedUserCount === undefined
? {}
: { affectedUserCount: detail.affectedUserCount }),
...(detail.affectedUserIds === undefined ? {} : { affectedUserIds: detail.affectedUserIds }),
...(detail.reason === undefined ? {} : { reason: detail.reason }),
traceId: ctx.traceId,
durationMs: Date.now() - ctx.startedAt,
...(error === undefined
? {}
: { error: error instanceof Error ? error.message : '未知错误' }),
};
this.logger.log(JSON.stringify(payload));
}
}
/** 供 @Audit 断言测试使用:读取处理器上的审计动作名 */
export function auditActionOf(handler: unknown): string | null {
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, handler as object) as unknown;
return typeof action === 'string' ? action : null;
}
@@ -0,0 +1,50 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import type { FastifyRequest } from 'fastify';
import type { ScopeContext } from './scope/scope-context';
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
export interface JwtAccessPayload {
sub: string;
typ: 'access';
}
/** refresh token 载荷:jti 对应 refresh_tokens 主键,fam 为旋转家族(重放时整族吊销) */
export interface JwtRefreshPayload {
sub: string;
typ: 'refresh';
jti: string;
fam: string;
}
/** 守卫解析出的鉴权上下文(挂在 request.auth,并写入 AsyncLocalStorage) */
export interface AuthContext {
userId: string;
account: string;
status: UserStatus;
roles: RoleSummary[];
permissions: string[];
/**
* 角色声明的数据范围(未解析客户集合;多角色,每个角色一种类型)。
* 超管角色 → `[{ type: 'all' }]`(隐式全集)。
*/
dataScopes: DataScope[];
/** 用户所在部门 + 其下级部门 id(两级组织;用于解析部门类范围) */
departmentIds: string[];
/** 是否拥有 `is_system` 超管角色(隐式拥有全部权限点 / 特殊权限 / 数据范围) */
isSuperAdmin: boolean;
mustChangePassword: boolean;
}
export interface AuthenticatedRequest extends FastifyRequest {
auth?: AuthContext;
/** ScopeGuard 解析后的请求作用域(Repository 客户维度过滤的唯一来源) */
scope?: ScopeContext;
}
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
export interface AuthRequestMeta {
ip: string | null;
userAgent: string | null;
traceId: string;
}
@@ -0,0 +1,79 @@
import { PERMISSION_CATALOG, SPECIAL_PERMISSIONS } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { PermissionCatalogService } from './permission-catalog.service';
function makeService(): {
service: PermissionCatalogService;
upsert: ReturnType<typeof vi.fn>;
} {
const upsert = vi.fn().mockResolvedValue({});
const prisma = { permission: { upsert } };
return {
service: new PermissionCatalogService(prisma as never),
upsert,
};
}
describe('PermissionCatalogService', () => {
it('启动同步:30 个权限点逐个幂等 upsert(不删除历史行)', async () => {
const { service, upsert } = makeService();
const count = await service.sync();
expect(count).toBe(30);
expect(upsert).toHaveBeenCalledTimes(30);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { code: 'role:view' },
}),
);
expect(upsert.mock.calls[0]?.[0]).toEqual({
where: { code: PERMISSION_CATALOG[0]?.code },
update: expect.objectContaining({ module: PERMISSION_CATALOG[0]?.module }),
create: expect.objectContaining({ code: PERMISSION_CATALOG[0]?.code }),
});
});
it('启动同步失败只告警不阻断(OpenAPI 导出 / 无 DB 命令可继续)', async () => {
const { service, upsert } = makeService();
upsert.mockRejectedValueOnce(new Error('db unavailable'));
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
});
it('权限树:10 模块(与 UI 原型顺序一致)+ 6 特殊权限 + 3 对 SoD', () => {
const { service } = makeService();
const tree = service.buildTree();
expect(tree.totalPermissions).toBe(30);
expect(tree.modules).toHaveLength(10);
expect(tree.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(tree.modules.map((module) => module.moduleLabel)).toEqual([
'脚本',
'素材',
'资产',
'导出交付',
'客户',
'客户组',
'组织与角色',
'行业包',
'平台配置',
'审计',
]);
expect(tree.modules.flatMap((module) => module.permissions)).toHaveLength(30);
expect(tree.specialPermissions).toHaveLength(6);
expect(tree.specialPermissions.map((item) => item.code).sort()).toEqual(
[...SPECIAL_PERMISSIONS].sort(),
);
expect(tree.sodPairs).toHaveLength(3);
expect(tree.sodPairs[0]?.permissions).toEqual(['script:write', 'script:review']);
});
});
@@ -0,0 +1,112 @@
import {
PERMISSION_CATALOG,
type PermissionModule,
permissionsByModule,
type PermissionTree,
SOD_PAIRS,
SPECIAL_PERMISSIONS,
} from '@lingxi/shared';
import { Inject, Injectable, Logger, type OnApplicationBootstrap } from '@nestjs/common';
import type { PrismaService } from '../../../infra/prisma/prisma.service';
import { PRISMA } from '../../../infra/tokens';
/**
* 权限点注册与同步(A2-BE-02)。
*
* 唯一事实源是 `@lingxi/shared` 的 `PERMISSION_CATALOG`:启动时把目录幂等 upsert 进
* `permissions` 表(只增不删),供管理端检索 / 审计 / 后续统计 join;权限树本身仍从
* 代码目录生成(避免「DB 里有、代码里没有」的幽灵权限点)。
*
* 启动同步失败只告警不阻断(OpenAPI 导出、无 DB 的本地命令不应因此失败);
* 生产健康检查与告警另行覆盖。
*/
@Injectable()
export class PermissionCatalogService implements OnApplicationBootstrap {
private readonly logger = new Logger('PermissionCatalog');
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async onApplicationBootstrap(): Promise<void> {
try {
const count = await this.sync();
this.logger.log(`权限点目录已同步:${String(count)} 个`);
} catch (error) {
this.logger.warn(
`权限点目录同步失败(不阻断启动):${error instanceof Error ? error.message : '未知错误'}`,
);
}
}
/** 幂等 upsert:更新元数据、补新增,不删除目录之外的历史行(只增不删) */
async sync(): Promise<number> {
for (const item of PERMISSION_CATALOG) {
await this.prisma.permission.upsert({
where: { code: item.code },
update: {
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
create: {
code: item.code,
module: item.module,
moduleLabel: item.moduleLabel,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
sortOrder: item.sortOrder,
},
});
}
return PERMISSION_CATALOG.length;
}
/** 权限树(10 模块 + 特殊权限汇总 + SoD 对):直接由代码目录生成 */
buildTree(): PermissionTree {
const modules: {
module: PermissionModule;
moduleLabel: string;
sortOrder: number;
permissions: {
code: string;
name: string;
description: string;
isSpecial: boolean;
}[];
}[] = permissionsByModule().map((group) => ({
module: group.module,
moduleLabel: group.moduleLabel,
sortOrder: group.sortOrder,
permissions: group.permissions.map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: item.isSpecial,
})),
}));
const specialCodeSet = new Set<string>(SPECIAL_PERMISSIONS);
const specialPermissions = PERMISSION_CATALOG.filter((item) =>
specialCodeSet.has(item.code),
).map((item) => ({
code: item.code,
name: item.name,
description: item.description,
isSpecial: true,
}));
return {
modules,
specialPermissions,
sodPairs: SOD_PAIRS.map((pair) => ({
id: pair.id,
label: pair.label,
description: pair.description,
permissions: [pair.permissions[0], pair.permissions[1]],
})),
totalPermissions: PERMISSION_CATALOG.length,
};
}
}
@@ -0,0 +1,34 @@
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
export const IS_PUBLIC_KEY = 'auth:public';
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'auth:permissions';
/** 声明接口所需权限点(任一满足即通过,多个权限点之间为 OR) */
export const RequirePermissions = (...permissions: string[]): MethodDecorator & ClassDecorator =>
SetMetadata(PERMISSIONS_KEY, permissions);
export const AUDIT_ACTION_KEY = 'audit:action';
/** 关键动作审计:先写结构化日志(A5 落库与检索接审计表后再扩展) */
export const Audit = (action: string): MethodDecorator & ClassDecorator =>
SetMetadata(AUDIT_ACTION_KEY, action);
/** 取当前登录上下文;未登录(@Public 接口)返回 null */
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthContext | null => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.auth ?? null;
},
);
/** 取 ScopeGuard 解析后的数据范围上下文;未登录 / 未解析返回 null */
export const CurrentScope = createParamDecorator(
(_data: unknown, context: ExecutionContext): ScopeContext | null => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.scope ?? null;
},
);
+190
View File
@@ -0,0 +1,190 @@
import { ErrorCode, SPECIAL_PERMISSIONS } from '@lingxi/shared';
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppException } from '../errors/app-exception';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
'/api/v1/me',
'/api/v1/auth/change-password',
'/api/v1/auth/logout',
'/api/v1/auth/refresh',
];
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
function extractBearer(header: string | undefined): string | null {
if (!header?.startsWith('Bearer ')) {
return null;
}
const token = header.slice('Bearer '.length).trim();
return token.length > 0 ? token : null;
}
function isMustChangeAllowed(url: string): boolean {
const path = url.split('?')[0] ?? url;
return MUST_CHANGE_ALLOWED_PATHS.includes(path);
}
function isPublic(reflector: Reflector, context: ExecutionContext): boolean {
const value: unknown = reflector.getAllAndOverride(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return value === true;
}
function requiredPermissions(reflector: Reflector, context: ExecutionContext): string[] {
const value: unknown = reflector.getAllAndOverride(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!Array.isArray(value)) {
return [];
}
return value.filter((code): code is string => typeof code === 'string');
}
function authOf(context: ExecutionContext): AuthContext | null {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.auth ?? null;
}
function requireAuth(context: ExecutionContext): AuthContext {
const auth = authOf(context);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 1) JWT 校验 → 每请求解析权限 → 强制改密拦截。
* 守卫顺序由 SecurityModule 的 APP_GUARD 注册顺序保证:
* JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(TokenService) private readonly tokens: TokenService,
@Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isPublic(this.reflector, context)) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = extractBearer(request.headers.authorization);
if (token === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
const payload = this.tokens.verifyAccess(token);
const auth = await this.permissions.resolveAuth(payload.sub);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
if (auth.status === 'disabled') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
if (auth.mustChangePassword && !isMustChangeAllowed(request.url)) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '请先修改初始密码' });
}
request.auth = auth;
this.requestContext.patch({
userId: auth.userId,
account: auth.account,
permissions: auth.permissions,
});
return true;
}
}
/** 2) 普通权限点校验;特殊权限留给 SpecialPermissionGuard(40301 与 40303 分开) */
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (isPublic(this.reflector, context)) {
return true;
}
const normal = requiredPermissions(this.reflector, context).filter(
(code) => !SPECIAL_PERMISSION_SET.has(code),
);
if (normal.length === 0) {
return true;
}
const auth = requireAuth(context);
// 多个权限点之间为 OR(任一满足即通过,见 @RequirePermissions 注释)
if (!normal.some((code) => auth.permissions.includes(code))) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { required: normal } });
}
return true;
}
}
/** 3) 特殊权限(导出 / AI 配置 / 审计导出 / 行业发布 / 客户字段审批)单独校验 */
@Injectable()
export class SpecialPermissionGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (isPublic(this.reflector, context)) {
return true;
}
const special = requiredPermissions(this.reflector, context).filter((code) =>
SPECIAL_PERMISSION_SET.has(code),
);
if (special.length === 0) {
return true;
}
const auth = requireAuth(context);
if (!special.some((code) => auth.permissions.includes(code))) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
details: { required: special },
});
}
return true;
}
}
/**
* 4) 数据范围守卫:解析 `ScopeContext`(六类范围 → 可见客户集合),注入 `request.scope`
* 与请求上下文;Repository 必须通过 `customerScopeWhere(ctx)` 强制客户维度过滤。
*
* 口径(技术架构 §5.5 / §7.1「失败处理」):解析失败一律 403(40302),不降级为「全部客户」;
* 公开接口(登录 / 健康检查)不解析。
*/
@Injectable()
export class ScopeGuard implements CanActivate {
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(ScopeResolver) private readonly resolver: ScopeResolver,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isPublic(this.reflector, context)) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const auth = authOf(context);
if (auth === null) {
return true;
}
const scope = await this.resolver.resolve(auth);
request.scope = scope;
this.requestContext.patch({ scope });
return true;
}
}
@@ -0,0 +1,134 @@
import {
DATA_SCOPE_TYPES,
type DataScope,
type DataScopeType,
isPermissionCode,
PERMISSION_CODES,
type RoleSummary,
type UserStatus,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import type { AuthContext } from './auth.types';
/** 鉴权解析需要的用户关联:部门(父级 + 下级)、角色(含权限点与数据范围声明) */
export type UserAuthRecord = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true; children: true } };
roles: {
include: { role: { include: { permissions: true; dataScopes: true } } };
};
};
}>;
type RoleEntity = UserAuthRecord['roles'][number]['role'];
function toRoleSummary(role: RoleEntity): RoleSummary {
return {
id: role.id,
code: role.code,
name: role.name,
isSystem: role.isSystem,
...(role.description.length > 0 ? { description: role.description } : {}),
};
}
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
return user.roles.filter((item) => item.role.status === 'ACTIVE');
}
/**
* 合并多角色的数据范围声明:同一类型取并集(targetIds 去重排序),按六类固定顺序输出。
* 一个角色只允许一种类型(A2 契约),但历史数据可能有多行(customer_groups 多客户组),
* 因此按 scope_type 聚合后再跨角色合并。
*/
export function mergeRoleDataScopes(roles: readonly RoleEntity[]): DataScope[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const role of roles) {
for (const scope of role.dataScopes) {
const type = scope.scopeType as DataScopeType;
const targets = byType.get(type) ?? new Set<string>();
if (scope.targetId !== null) {
targets.add(scope.targetId);
}
byType.set(type, targets);
}
}
const ordered: DataScope[] = [];
for (const type of DATA_SCOPE_TYPES) {
const targets = byType.get(type);
if (targets === undefined) {
continue;
}
ordered.push({
type,
...(type === 'customer_groups' ? { targetIds: [...targets].sort() } : {}),
});
}
return ordered;
}
/** 用户部门子树 id(自身在前,最多两级):scope 解析需要「本部门 / 本部门及下级」 */
export function departmentSubtreeIds(user: UserAuthRecord): string[] {
return [user.department.id, ...user.department.children.map((child) => child.id)];
}
/**
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
* 保证角色变更(含停用)在下一次请求立即生效(A1-BE-04 / A2-BE-05 验收项)。
*
* 超管(`is_system = true`)隐式拥有全部权限点与特殊权限,`role_permissions` 不参与解析;
* 其余角色取启用角色权限点并集,未登记编码被丢弃(防「幽灵权限点」)。
*/
@Injectable()
export class PermissionService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async resolveAuth(userId: string): Promise<AuthContext | null> {
const user = await this.findAuthRecord(userId);
if (user === null) {
return null;
}
return this.toAuthContext(user);
}
async findAuthRecord(userId: string): Promise<UserAuthRecord | null> {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
},
});
}
toAuthContext(user: UserAuthRecord): AuthContext {
const roles = activeRolesOf(user);
const roleEntities = roles.map((item) => item.role);
const isSuperAdmin = roleEntities.some((role) => role.isSystem);
const permissions = isSuperAdmin
? [...PERMISSION_CODES]
: [
...new Set(
roleEntities
.flatMap((role) => role.permissions.map((item) => item.permissionCode))
.filter((code) => isPermissionCode(code)),
),
].sort();
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
return {
userId: user.id,
account: user.account,
status,
roles: roleEntities.map((role) => toRoleSummary(role)),
permissions,
dataScopes: isSuperAdmin ? [{ type: 'all' }] : mergeRoleDataScopes(roleEntities),
departmentIds: departmentSubtreeIds(user),
isSuperAdmin,
mustChangePassword: user.mustChangePassword,
};
}
}
@@ -0,0 +1,99 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { ulid } from '@lingxi/shared';
import { Injectable } from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.types';
import type { ScopeContext } from './scope/scope-context';
/**
* 变更审计明细(A2-BE-05):由业务 Service 通过 `RequestContextService.patch({ audit })` 写入,
* AuditInterceptor 在请求结束时统一输出结构化 Pino 日志(A5 再落审计表)。
*
* 权限 / 角色类变更必须带:操作人(context.userId)、IP(context.ip)、
* 变更前后值(before / after)、影响用户数(affectedUserCount / affectedUserIds)。
*/
export interface AuditDetail {
targetType?: string;
targetId?: string;
before?: unknown;
after?: unknown;
affectedUserCount?: number;
affectedUserIds?: string[];
reason?: string;
/** 追加的变更摘要(如权限点 diff),与 before / after 并存 */
changes?: unknown;
}
/** 单次请求上下文:trace_id / 当前用户 / 权限快照 / 数据范围 / 审计明细 */
export interface RequestContext {
traceId: string;
userId: string | null;
account: string | null;
permissions: string[] | null;
scope: ScopeContext | null;
audit: AuditDetail | null;
ip: string | null;
userAgent: string | null;
method: string;
url: string;
startedAt: number;
}
export type RequestWithTrace = AuthenticatedRequest & { id?: string };
function traceIdOf(request: RequestWithTrace): string {
return typeof request.id === 'string' && request.id.length > 0 ? request.id : ulid();
}
/** 由请求构造上下文(与 Pino requestId 同源,x-request-id 透传) */
export function buildRequestContext(request: RequestWithTrace): RequestContext {
const auth = request.auth;
return {
traceId: traceIdOf(request),
userId: auth?.userId ?? null,
account: auth?.account ?? null,
permissions: auth?.permissions ?? null,
scope: request.scope ?? null,
audit: null,
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
method: request.method,
url: request.url,
startedAt: Date.now(),
};
}
/**
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
* 业务 Service 可 `get()` 读取 trace_id / 当前用户 / ScopeContext,无需逐层透传。
*/
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestContext>();
run<T>(context: RequestContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestContext | null {
return this.storage.getStore() ?? null;
}
patch(partial: Partial<RequestContext>): void {
const store = this.storage.getStore();
if (store === undefined) {
return;
}
Object.assign(store, partial);
}
/** 追加审计明细(多次 patch 时浅合并,便于业务 Service 逐步补充) */
patchAudit(detail: Partial<AuditDetail>): void {
const store = this.storage.getStore();
if (store === undefined) {
return;
}
store.audit = { ...(store.audit ?? {}), ...detail };
}
}
@@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
/**
* 客户目录端口(A2-BE-04 / 技术架构 §5.5)。
*
* 数据范围的可见客户集合由「客户目录」解析;A2 只有角色与范围声明,客户 / 客户组 /
* 客户负责人属 A3(client 模块)。为避免 A2 依赖未建表的客户模块,这里定义可替换端口:
* - A2 注册 `EmptyCustomerDirectory`:所有方法返回空集(不越权,不需要降级);
* - A3 落地后用真实实现替换 provider(注入 token 不变),ScopeResolver 无需修改。
*
* 约定:端口只读;返回客户 id 列表,由 ScopeResolver 做去重与并集。
*/
export const CUSTOMER_DIRECTORY = Symbol('CUSTOMER_DIRECTORY');
export interface CustomerDirectory {
/** 「负责客户」范围:客户负责人 = 本人 */
findOwnedCustomerIds(userId: string): Promise<string[]>;
/** 「指定客户组」范围:客户组下全部客户 */
findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]>;
/** 「本部门 / 本部门及下级」范围:部门子树内负责人所属客户 */
findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]>;
/** 「本人创建」范围:创建人 = 本人 */
findCreatedCustomerIds(userId: string): Promise<string[]>;
}
/**
* A2 占位实现:A3 前没有任何客户数据,所有范围解析为空集。
* 空集意味着「看不到任何客户」,是安全的默认值(解析失败宁可不可用也不越权)。
*/
@Injectable()
export class EmptyCustomerDirectory implements CustomerDirectory {
findOwnedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
findCustomerGroupCustomerIds(_customerGroupIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findDepartmentCustomerIds(_departmentIds: readonly string[]): Promise<string[]> {
return Promise.resolve([]);
}
findCreatedCustomerIds(_userId: string): Promise<string[]> {
return Promise.resolve([]);
}
}
@@ -0,0 +1,72 @@
import { type DataScope, ErrorCode } from '@lingxi/shared';
import { AppException } from '../../errors/app-exception';
/**
* 请求作用域上下文(技术架构 §5.5):登录用户 + 多角色并集后的可见客户集合。
*
* 解析顺序(技术架构 §7.1):
* 1. JWT / 账号状态由 JwtAuthGuard 校验;
* 2. 角色数据范围 → 客户集合(六类,CustomerDirectory 端口解析);
* 3. 多角色取并集;任一角色为「全部客户」即全部;
* 4. 单个资源请求显式指定客户时,必须落在集合内。
*
* 失败处理:解析失败一律 403(40302),**不降级为全部客户**。
*/
export interface ScopeContext {
userId: string;
/** 是否拥有 is_system 超管角色(隐式全部数据范围) */
isSuperAdmin: boolean;
/** 解析后的数据范围(每个元素带 customerIds;超管为 [{ type: 'all' }]) */
dataScopes: DataScope[];
/** null = 全部客户可见;否则为可见客户 id 集合(已排序去重;A3 前为空数组) */
customerIds: readonly string[] | null;
}
/**
* Repository 客户维度过滤参数(Prisma where 片段)。
* 使用方必须把返回值展开进 where:`where: { ...customerScopeWhere(ctx), ... }`,
* 禁止「先查全量再内存过滤」(AGENTS §3.5)。
*/
export interface CustomerScopeWhere {
customerId?: { in: string[] };
}
/** 是否可见全部客户 */
export function isAllCustomers(ctx: ScopeContext): boolean {
return ctx.customerIds === null;
}
/** 构造客户维度强制过滤片段:全部客户返回空对象,受限范围返回 `{ customerId: { in } }` */
export function customerScopeWhere(ctx: ScopeContext): CustomerScopeWhere {
return ctx.customerIds === null ? {} : { customerId: { in: [...ctx.customerIds] } };
}
/** 判断单个客户是否在范围内(用于单条资源的 404 / 403 语义选择) */
export function isCustomerVisible(ctx: ScopeContext, customerId: string): boolean {
return ctx.customerIds === null || ctx.customerIds.includes(customerId);
}
/**
* 断言对指定客户有写权限;范围外抛 40302(不泄露资源是否存在)。
* 单条**读**接口按 §10.3 应返回 404(避免探测存在性),调用方可先用 isCustomerVisible 判定。
*/
export function assertCustomerAccess(ctx: ScopeContext, customerId: string): void {
if (!isCustomerVisible(ctx, customerId)) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '客户不在当前数据范围之内',
});
}
}
/** 断言存在客户维度上下文(Repository 方法入口守卫,防止漏注入) */
export function requireScopeContext(ctx: ScopeContext | null | undefined): ScopeContext {
if (ctx === null || ctx === undefined) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '缺少数据范围上下文,已拒绝访问',
});
}
return ctx;
}
@@ -0,0 +1,135 @@
import { DATA_SCOPE_TYPES, type DataScope, type DataScopeType, ErrorCode } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../errors/app-exception';
import type { AuthContext } from '../auth.types';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import type { ScopeContext } from './scope-context';
/** 范围声明去重后的内部结构:一个角色一种类型,但多角色可能带来同类型的多个 targetIds */
interface ScopeDeclaration {
type: DataScopeType;
targetIds: string[];
}
function mergeDeclarations(scopes: readonly DataScope[]): ScopeDeclaration[] {
const byType = new Map<DataScopeType, Set<string>>();
for (const scope of scopes) {
const existing = byType.get(scope.type) ?? new Set<string>();
for (const targetId of scope.targetIds ?? []) {
existing.add(targetId);
}
byType.set(scope.type, existing);
}
return [...byType.entries()]
.map(([type, targetIdSet]) => ({
type,
targetIds: [...targetIdSet].sort(),
}))
.sort(
(left, right) => DATA_SCOPE_TYPES.indexOf(left.type) - DATA_SCOPE_TYPES.indexOf(right.type),
);
}
function uniqueSorted(ids: readonly string[]): string[] {
return [...new Set(ids)].sort();
}
/**
* 数据范围解析器(A2-BE-04):把角色声明的六类范围解析为 `ScopeContext`。
*
* - 超管(is_system 角色)隐式全集:`[{ type: 'all' }]`;
* - 任一角色为「全部客户」→ 并集为全部客户;
* - 多个角色取并集(customerIds 去重排序);
* - 没有任何范围声明时按最小权限「本人创建」解析,不放行全部;
* - 端口调用失败 → 40302(不降级)。
*/
@Injectable()
export class ScopeResolver {
constructor(@Inject(CUSTOMER_DIRECTORY) private readonly customers: CustomerDirectory) {}
async resolve(
auth: Pick<AuthContext, 'userId' | 'isSuperAdmin' | 'dataScopes' | 'departmentIds'>,
): Promise<ScopeContext> {
if (auth.isSuperAdmin) {
return {
userId: auth.userId,
isSuperAdmin: true,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
const declarations =
mergeDeclarations(auth.dataScopes).length > 0
? mergeDeclarations(auth.dataScopes)
: [{ type: 'created_by_me' as const, targetIds: [] }];
if (declarations.some((declaration) => declaration.type === 'all')) {
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: [{ type: 'all' }],
customerIds: null,
};
}
try {
return await this.buildScopedContext(auth, declarations);
} catch (error) {
if (error instanceof AppException) {
throw error;
}
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
}
private async buildScopedContext(
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
declarations: readonly ScopeDeclaration[],
): Promise<ScopeContext> {
const union = new Set<string>();
const resolved: DataScope[] = [];
for (const declaration of declarations) {
const ids = uniqueSorted(await this.customerIdsFor(declaration, auth));
for (const id of ids) {
union.add(id);
}
resolved.push({
type: declaration.type,
...(declaration.targetIds.length > 0 ? { targetIds: declaration.targetIds } : {}),
customerIds: ids,
});
}
return {
userId: auth.userId,
isSuperAdmin: false,
dataScopes: resolved,
customerIds: [...union].sort(),
};
}
private async customerIdsFor(
declaration: ScopeDeclaration,
auth: Pick<AuthContext, 'userId' | 'departmentIds'>,
): Promise<string[]> {
switch (declaration.type) {
case 'all':
return [];
case 'customer_groups':
return this.customers.findCustomerGroupCustomerIds(declaration.targetIds);
case 'owned_customers':
return this.customers.findOwnedCustomerIds(auth.userId);
case 'created_by_me':
return this.customers.findCreatedCustomerIds(auth.userId);
case 'department': {
const ownDepartmentId = auth.departmentIds[0];
return ownDepartmentId === undefined
? []
: this.customers.findDepartmentCustomerIds([ownDepartmentId]);
}
case 'department_and_children':
return this.customers.findDepartmentCustomerIds(auth.departmentIds);
}
}
}
@@ -0,0 +1,189 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../errors/app-exception';
import { CUSTOMER_DIRECTORY, type CustomerDirectory } from './customer-directory';
import {
assertCustomerAccess,
customerScopeWhere,
isAllCustomers,
isCustomerVisible,
requireScopeContext,
type ScopeContext,
} from './scope-context';
import { ScopeResolver } from './scope-resolver.service';
function directoryMock(): {
directory: CustomerDirectory;
owned: ReturnType<typeof vi.fn>;
groups: ReturnType<typeof vi.fn>;
departments: ReturnType<typeof vi.fn>;
created: ReturnType<typeof vi.fn>;
} {
const owned = vi.fn().mockResolvedValue(['c-owned']);
const groups = vi.fn().mockResolvedValue(['c-group-1']);
const departments = vi.fn().mockResolvedValue(['c-dept']);
const created = vi.fn().mockResolvedValue(['c-created']);
return {
directory: {
findOwnedCustomerIds: owned,
findCustomerGroupCustomerIds: groups,
findDepartmentCustomerIds: departments,
findCreatedCustomerIds: created,
},
owned,
groups,
departments,
created,
};
}
function contextOf(overrides: Partial<ScopeContext> = {}): ScopeContext {
return {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
customerIds: [],
...overrides,
};
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('ScopeResolver', () => {
const baseAuth = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [],
departmentIds: ['d1', 'd1-child'],
};
it('超管隐式全集:[{ type: all }],不调用客户目录', async () => {
const { directory, owned } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, isSuperAdmin: true });
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
expect(owned).not.toHaveBeenCalled();
});
it('任一角色为全部客户 → 并集为全部客户', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'owned_customers' }, { type: 'all' }],
});
expect(scope.customerIds).toBeNull();
expect(scope.dataScopes).toEqual([{ type: 'all' }]);
});
it('多角色并集:同一类型 targetIds 合并、customerIds 去重排序、按六类固定顺序输出', async () => {
const { directory, groups, departments } = directoryMock();
departments.mockResolvedValue(['c-dept', 'c-dept-2']);
groups.mockResolvedValue(['c-group-1', 'c-group-2']);
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [
{ type: 'customer_groups', targetIds: ['g2'] },
{ type: 'department_and_children' },
{ type: 'customer_groups', targetIds: ['g1'] },
],
});
expect(scope.customerIds).toEqual(['c-dept', 'c-dept-2', 'c-group-1', 'c-group-2']);
expect(scope.dataScopes).toEqual([
{ type: 'department_and_children', customerIds: ['c-dept', 'c-dept-2'] },
{
type: 'customer_groups',
targetIds: ['g1', 'g2'],
customerIds: ['c-group-1', 'c-group-2'],
},
]);
expect(departments).toHaveBeenCalledWith(['d1', 'd1-child']);
});
it('「本部门」只用用户本部门;「指定客户组」透传 targetIds;「负责客户」「本人创建」按用户解析', async () => {
const { directory, groups, departments, owned, created } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({
...baseAuth,
dataScopes: [{ type: 'department' }, { type: 'customer_groups', targetIds: ['g1'] }],
});
expect(departments).toHaveBeenCalledWith(['d1']);
expect(groups).toHaveBeenCalledWith(['g1']);
expect(scope.dataScopes[0]?.type).toBe('department');
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] });
expect(owned).toHaveBeenCalledWith('u1');
created.mockResolvedValue([]);
await resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'created_by_me' }] });
expect(created).toHaveBeenCalledWith('u1');
});
it('没有任何范围声明 → 按最小权限「本人创建」解析,不分全量', async () => {
const { directory } = directoryMock();
const resolver = new ScopeResolver(directory);
const scope = await resolver.resolve({ ...baseAuth, dataScopes: [] });
expect(scope.customerIds).toEqual(['c-created']);
expect(scope.dataScopes[0]?.type).toBe('created_by_me');
});
it('客户目录端口异常 → 40302,不降级为全部客户', async () => {
const { directory, owned } = directoryMock();
owned.mockRejectedValueOnce(new Error('db down'));
const resolver = new ScopeResolver(directory);
await expectRejected(
resolver.resolve({ ...baseAuth, dataScopes: [{ type: 'owned_customers' }] }),
ErrorCode.OUT_OF_SCOPE,
);
});
});
describe('scope-context 帮助函数', () => {
it('customerScopeWhere:全部客户返回空片段,受限范围返回 customerId IN', () => {
expect(customerScopeWhere(contextOf({ customerIds: null }))).toEqual({});
expect(customerScopeWhere(contextOf({ customerIds: ['c1', 'c2'] }))).toEqual({
customerId: { in: ['c1', 'c2'] },
});
});
it('isAllCustomers / isCustomerVisible', () => {
expect(isAllCustomers(contextOf({ customerIds: null }))).toBe(true);
expect(isAllCustomers(contextOf())).toBe(false);
expect(isCustomerVisible(contextOf({ customerIds: null }), 'any')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c1')).toBe(true);
expect(isCustomerVisible(contextOf({ customerIds: ['c1'] }), 'c2')).toBe(false);
});
it('assertCustomerAccess:范围外抛 40302;requireScopeContext 缺上下文抛 40302', () => {
expect(() => {
assertCustomerAccess(contextOf({ customerIds: null }), 'c1');
}).not.toThrow();
expect(() => {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c1');
}).not.toThrow();
let thrown: unknown;
try {
assertCustomerAccess(contextOf({ customerIds: ['c1'] }), 'c2');
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AppException);
expect((thrown as AppException).code).toBe(ErrorCode.OUT_OF_SCOPE);
expect(() => requireScopeContext(null)).toThrow(AppException);
expect(requireScopeContext(contextOf()).userId).toBe('u1');
});
it('CUSTOMER_DIRECTORY 端口 token 唯一', () => {
expect(typeof CUSTOMER_DIRECTORY).toBe('symbol');
});
});
@@ -0,0 +1,48 @@
import { Global, Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AuditInterceptor } from './audit.interceptor';
import { PermissionCatalogService } from './catalog/permission-catalog.service';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
/**
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*
* `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3);
* ScopeResolver / 守卫链只依赖端口,不感知客户模块。
*/
@Global()
@Module({
imports: [JwtModule.register({})],
providers: [
RequestContextService,
PermissionService,
PermissionCatalogService,
PermissionGuard,
ScopeResolver,
TokenService,
AuditInterceptor,
JwtAuthGuard,
SpecialPermissionGuard,
ScopeGuard,
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
{ provide: APP_GUARD, useExisting: ScopeGuard },
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
],
exports: [
RequestContextService,
PermissionService,
PermissionCatalogService,
ScopeResolver,
TokenService,
],
})
export class SecurityModule {}
@@ -0,0 +1,347 @@
import 'reflect-metadata';
import { ErrorCode } from '@lingxi/shared';
import type { ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, vi } from 'vitest';
import { testConfig } from '../../testing/test-app';
import { AppException } from '../errors/app-exception';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import type { UserAuthRecord } from './permission.service';
import { PermissionService } from './permission.service';
import type { RequestContextService } from './request-context';
import type { ScopeContext } from './scope/scope-context';
import type { ScopeResolver } from './scope/scope-resolver.service';
import { TokenService } from './token.service';
const jwt = new JwtService({});
const tokens = new TokenService(jwt, testConfig);
function contextOf(request: Partial<AuthenticatedRequest>): ExecutionContext {
const handler = { name: 'handler' };
const cls = { name: 'controller' };
return {
getHandler: () => handler,
getClass: () => cls,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
function reflectorFor(metadata: Record<string, unknown>): {
getAllAndOverride: (key: string) => unknown;
} {
return { getAllAndOverride: (key: string) => metadata[key] };
}
function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
return {
userId: 'u1',
account: 'chen.mo',
status: 'active',
roles: [],
permissions: ['user:view'],
dataScopes: [{ type: 'department_and_children' }],
departmentIds: ['d1'],
isSuperAdmin: false,
mustChangePassword: false,
...overrides,
};
}
function expectCode(fn: () => unknown, code: ErrorCode): void {
try {
fn();
expect.unreachable('应抛出异常');
} catch (error) {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
}
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
function authRecord(
status: 'ACTIVE' | 'DISABLED' = 'ACTIVE',
roleCodes: string[] = ['platform_admin'],
): UserAuthRecord {
return {
id: 'u1',
account: 'chen.mo',
name: '陈默',
phone: '13866202046',
email: 'chen.mo@lingxi.cn',
passwordHash: 'hash',
departmentId: 'd1',
jobTitle: null,
status,
mustChangePassword: false,
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
lastLoginIp: null,
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
department: {
id: 'd1',
code: 'content-g1',
name: '素材一组',
parentId: 'd0',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
parent: {
id: 'd0',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
children: [],
},
roles: roleCodes.map((code, index) => ({
userId: 'u1',
roleId: `r${String(index)}`,
createdAt: new Date(),
role: {
id: `r${String(index)}`,
code,
name: code,
description: '',
isSystem: code === 'super_admin',
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
],
dataScopes: [],
},
})),
};
}
describe('TokenService', () => {
it('access token 可签发并校验,载荷只含 userId', () => {
const token = tokens.issueAccess('u1');
const payload = tokens.verifyAccess(token);
expect(payload.sub).toBe('u1');
expect(payload.typ).toBe('access');
});
it('refresh token 带 jti 与家族,类型不可混用', () => {
const refresh = tokens.issueRefresh('u1', 'jti1', 'fam1');
const payload = tokens.verifyRefresh(refresh);
expect(payload.jti).toBe('jti1');
expect(payload.fam).toBe('fam1');
expectCode(() => tokens.verifyAccess(refresh), ErrorCode.SIGNATURE_INVALID);
});
it('过期 token 返回 40102,篡改 token 返回 40103', () => {
const expired = jwt.sign(
{ sub: 'u1', typ: 'access' },
{ secret: testConfig.jwtAccessSecret, expiresIn: -10 },
);
expectCode(() => tokens.verifyAccess(expired), ErrorCode.TOKEN_EXPIRED);
expectCode(
() => tokens.verifyAccess(`${tokens.issueAccess('u1')}x`),
ErrorCode.SIGNATURE_INVALID,
);
expectCode(() => tokens.verifyAccess('not-a-jwt'), ErrorCode.SIGNATURE_INVALID);
});
it('refresh 哈希稳定且 access TTL 来自配置', () => {
expect(tokens.hashToken('abc')).toBe(tokens.hashToken('abc'));
expect(tokens.hashToken('abc')).not.toBe(tokens.hashToken('abd'));
expect(tokens.accessTtlSeconds).toBe(900);
expect(tokens.refreshTtlSeconds).toBe(604800);
});
});
describe('PermissionService', () => {
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
const service = new PermissionService({} as never);
const context = service.toAuthContext(authRecord());
expect(context.permissions).toEqual(['user:manage', 'user:view']);
expect(context.status).toBe('active');
expect(context.roles).toHaveLength(1);
expect(context.isSuperAdmin).toBe(false);
expect(context.dataScopes).toEqual([]);
expect(context.departmentIds).toEqual(['d1']);
});
it('超管隐式全集:权限点 / 特殊权限不依赖 role_permissions', () => {
const record = authRecord('ACTIVE', ['super_admin']);
const role = record.roles[0];
if (role === undefined) {
throw new Error('fixture 缺少角色');
}
role.role.permissions = [];
const service = new PermissionService({} as never);
const context = service.toAuthContext(record);
expect(context.isSuperAdmin).toBe(true);
expect(context.permissions).toHaveLength(30);
expect(context.permissions).toContain('audit:export');
expect(context.dataScopes).toEqual([{ type: 'all' }]);
});
it('resolveAuth 用户不存在返回 null', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(null) } };
const service = new PermissionService(prisma as never);
await expect(service.resolveAuth('missing')).resolves.toBeNull();
});
it('resolveAuth 读取关联角色并返回上下文', async () => {
const prisma = {
user: { findUnique: vi.fn().mockResolvedValue(authRecord('ACTIVE', ['designer'])) },
};
const service = new PermissionService(prisma as never);
const context = await service.resolveAuth('u1');
expect(context?.userId).toBe('u1');
expect(context?.isSuperAdmin).toBe(false);
expect(context?.roles[0]?.code).toBe('designer');
});
});
describe('JwtAuthGuard', () => {
const permissions = { resolveAuth: vi.fn() };
const patch = vi.fn();
const requestContext = { patch } as unknown as RequestContextService;
function guardOf(metadata: Record<string, unknown>): JwtAuthGuard {
return new JwtAuthGuard(
reflectorFor(metadata) as never,
tokens,
permissions as unknown as PermissionService,
requestContext,
);
}
it('@Public 直接放行', async () => {
const guard = guardOf({ [IS_PUBLIC_KEY]: true });
await expect(guard.canActivate(contextOf({}))).resolves.toBe(true);
});
it('缺少 Bearer token 返回 40101', async () => {
const guard = guardOf({});
await expectRejected(guard.canActivate(contextOf({ headers: {} })), ErrorCode.UNAUTHENTICATED);
});
it('账号停用返回 40301,未改初始密码返回 40903', async () => {
const disabled = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ status: 'disabled' }));
await expectRejected(
disabled.canActivate(
contextOf({ headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` } }),
),
ErrorCode.PERMISSION_DENIED,
);
const mustChange = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ mustChangePassword: true }));
await expectRejected(
mustChange.canActivate(
contextOf({
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
url: '/api/v1/users',
}),
),
ErrorCode.STATE_NOT_ALLOWED,
);
});
it('成功时挂载 request.auth 并写入上下文', async () => {
const guard = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture());
const request = {
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
url: '/api/v1/users',
} as unknown as AuthenticatedRequest;
await guard.canActivate(contextOf(request));
expect(request.auth?.userId).toBe('u1');
expect(patch).toHaveBeenCalled();
});
});
describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
const auth = authFixture();
const request = { auth } as AuthenticatedRequest;
it('缺普通权限点返回 40301,特殊权限留给下一守卫', () => {
const guard = new PermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['user:manage'] }) as never,
);
expect(() => guard.canActivate(contextOf(request))).toThrow(AppException);
try {
guard.canActivate(contextOf(request));
} catch (error) {
expect((error as AppException).code).toBe(ErrorCode.PERMISSION_DENIED);
}
const specialOnly = new PermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
);
expect(specialOnly.canActivate(contextOf(request))).toBe(true);
});
it('特殊权限缺失返回 40303,已授予则通过', () => {
const missing = new SpecialPermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
);
expectCode(
() => missing.canActivate(contextOf(request)),
ErrorCode.SPECIAL_PERMISSION_REQUIRED,
);
const granted = new SpecialPermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never,
);
expect(granted.canActivate(contextOf(request))).toBe(true);
});
it('未登录时普通守卫返回 40101;ScopeGuard 解析并注入 scope', async () => {
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
const scope: ScopeContext = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [{ type: 'owned_customers', customerIds: [] }],
customerIds: [],
};
const resolver = { resolve: vi.fn().mockResolvedValue(scope) } as unknown as ScopeResolver;
const scopeGuard = new ScopeGuard(reflectorFor({}) as never, resolver, {
patch: vi.fn(),
} as unknown as RequestContextService);
const scopedRequest = { auth } as AuthenticatedRequest;
await expect(scopeGuard.canActivate(contextOf(scopedRequest))).resolves.toBe(true);
expect(scopedRequest.scope).toEqual(scope);
// 未登录(无 auth)直接放行,由 JwtAuthGuard 负责 401
await expect(scopeGuard.canActivate(contextOf({}))).resolves.toBe(true);
});
it('@Audit 元数据可读取', () => {
const handler = (): void => undefined;
Reflect.defineMetadata(AUDIT_ACTION_KEY, 'identity.user.create', handler);
expect(Reflect.getMetadata(AUDIT_ACTION_KEY, handler)).toBe('identity.user.create');
});
});
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto';
import { ErrorCode } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { AppException } from '../errors/app-exception';
import type { JwtAccessPayload, JwtRefreshPayload } from './auth.types';
type TokenType = 'access' | 'refresh';
function mapTokenError(error: unknown): AppException {
if (error instanceof AppException) {
return error;
}
if (error instanceof Error && error.name === 'TokenExpiredError') {
return new AppException({ code: ErrorCode.TOKEN_EXPIRED });
}
return new AppException({ code: ErrorCode.SIGNATURE_INVALID });
}
function assertTokenType<T extends { typ: TokenType }>(payload: T, expected: TokenType): T {
if (payload.typ !== expected) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '令牌类型不正确' });
}
return payload;
}
/**
* 令牌签发与校验。
* - access / refresh 使用不同密钥,避免 refresh 当 access 使用;
* - refresh 载荷带 jti + fam,服务端 refresh_tokens 表保存哈希与吊销状态。
*/
@Injectable()
export class TokenService {
constructor(
@Inject(JwtService) private readonly jwt: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
issueAccess(userId: string): string {
const payload: JwtAccessPayload = { sub: userId, typ: 'access' };
return this.jwt.sign(payload, {
secret: this.config.jwtAccessSecret,
expiresIn: this.config.jwtAccessTtlSeconds,
});
}
issueRefresh(userId: string, jti: string, familyId: string): string {
const payload: JwtRefreshPayload = { sub: userId, typ: 'refresh', jti, fam: familyId };
return this.jwt.sign(payload, {
secret: this.config.jwtRefreshSecret,
expiresIn: this.config.jwtRefreshTtlSeconds,
});
}
verifyAccess(token: string): JwtAccessPayload {
return assertTokenType(this.decodeAccess(token), 'access');
}
verifyRefresh(token: string): JwtRefreshPayload {
return assertTokenType(this.decodeRefresh(token), 'refresh');
}
hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
get accessTtlSeconds(): number {
return this.config.jwtAccessTtlSeconds;
}
get refreshTtlSeconds(): number {
return this.config.jwtRefreshTtlSeconds;
}
private decodeAccess(token: string): JwtAccessPayload {
try {
return this.jwt.verify<JwtAccessPayload>(token, { secret: this.config.jwtAccessSecret });
} catch (error) {
throw mapTokenError(error);
}
}
private decodeRefresh(token: string): JwtRefreshPayload {
try {
return this.jwt.verify<JwtRefreshPayload>(token, { secret: this.config.jwtRefreshSecret });
} catch (error) {
throw mapTokenError(error);
}
}
}
+19
View File
@@ -7,6 +7,8 @@ const validEnv: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test', DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
REDIS_URL: 'redis://127.0.0.1:16379', REDIS_URL: 'redis://127.0.0.1:16379',
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173', WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
JWT_ACCESS_SECRET: 'test-access-secret-at-least-32-characters',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters',
}; };
describe('loadEnv', () => { describe('loadEnv', () => {
@@ -20,6 +22,23 @@ describe('loadEnv', () => {
expect(config.maxUploadMb).toBe(500); expect(config.maxUploadMb).toBe(500);
expect(config.featureVideo).toBe(false); expect(config.featureVideo).toBe(false);
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']); expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
expect(config.jwtAccessTtlSeconds).toBe(900);
expect(config.jwtRefreshTtlSeconds).toBe(604800);
expect(config.authMaxLoginFailures).toBe(5);
expect(config.authLockMinutes).toBe(15);
});
it('缺少 JWT 密钥时抛错并带字段名', () => {
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: undefined })).toThrow(
/JWT_ACCESS_SECRET/,
);
expect(() => loadEnv({ ...validEnv, JWT_REFRESH_SECRET: undefined })).toThrow(
/JWT_REFRESH_SECRET/,
);
});
it('JWT 密钥过短会被拒绝', () => {
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: 'short' })).toThrow(/JWT_ACCESS_SECRET/);
}); });
it('字符串数字会被转成 number', () => { it('字符串数字会被转成 number', () => {
+20 -1
View File
@@ -19,12 +19,19 @@ const envSchema = z.object({
(value) => value.startsWith('redis://') || value.startsWith('rediss://'), (value) => value.startsWith('redis://') || value.startsWith('rediss://'),
'REDIS_URL 必须是 redis:// 连接串', 'REDIS_URL 必须是 redis:// 连接串',
), ),
WEB_ORIGIN: z.string().default('http://127.0.0.1:5173'), WEB_ORIGIN: z.string().default('http://127.0.0.1:5173,http://localhost:5173'),
MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500), MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500),
FEATURE_VIDEO: z FEATURE_VIDEO: z
.enum(['true', 'false']) .enum(['true', 'false'])
.default('false') .default('false')
.transform((value) => value === 'true'), .transform((value) => value === 'true'),
// ---------- 认证(A1 identity) ----------
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET 至少 32 位'),
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET 至少 32 位'),
JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().min(3600).max(2592000).default(604800),
AUTH_MAX_LOGIN_FAILURES: z.coerce.number().int().min(1).max(20).default(5),
AUTH_LOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(15),
}); });
export type RawEnv = z.infer<typeof envSchema>; export type RawEnv = z.infer<typeof envSchema>;
@@ -40,6 +47,12 @@ export interface AppConfig {
webOrigins: string[]; webOrigins: string[];
maxUploadMb: number; maxUploadMb: number;
featureVideo: boolean; featureVideo: boolean;
jwtAccessSecret: string;
jwtRefreshSecret: string;
jwtAccessTtlSeconds: number;
jwtRefreshTtlSeconds: number;
authMaxLoginFailures: number;
authLockMinutes: number;
} }
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig { export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
@@ -64,5 +77,11 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
.filter((origin) => origin.length > 0), .filter((origin) => origin.length > 0),
maxUploadMb: env.MAX_UPLOAD_MB, maxUploadMb: env.MAX_UPLOAD_MB,
featureVideo: env.FEATURE_VIDEO, featureVideo: env.FEATURE_VIDEO,
jwtAccessSecret: env.JWT_ACCESS_SECRET,
jwtRefreshSecret: env.JWT_REFRESH_SECRET,
jwtAccessTtlSeconds: env.JWT_ACCESS_TTL_SECONDS,
jwtRefreshTtlSeconds: env.JWT_REFRESH_TTL_SECONDS,
authMaxLoginFailures: env.AUTH_MAX_LOGIN_FAILURES,
authLockMinutes: env.AUTH_LOCK_MINUTES,
}; };
} }
+8 -3
View File
@@ -5,13 +5,18 @@ import { config as loadDotenvFile } from 'dotenv';
/** /**
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。 * 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
* 同时向上查找两级目录,兼容「从 apps/api 或包内跑 vitest」的工作目录差异。
* 只做加载,不做校验(校验见 config/env.ts)。 * 只做加载,不做校验(校验见 config/env.ts)。
*/ */
export function loadDotenv(): void { export function loadDotenv(): void {
const starts = [process.cwd(), resolve(process.cwd(), '..'), resolve(process.cwd(), '..', '..')];
const directories = [...new Set(starts.map((item) => resolve(item)))];
for (const file of ['.env.local', '.env']) { for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file); for (const directory of directories) {
if (existsSync(path)) { const path = resolve(directory, file);
loadDotenvFile({ path }); if (existsSync(path)) {
loadDotenvFile({ path });
}
} }
} }
} }
@@ -0,0 +1,451 @@
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const PREFIX = 'itqa3.';
const API = API_PREFIX;
loadDotenv();
const prisma = new PrismaClient();
interface TokenBody {
accessToken: string;
}
interface JsonResult<T> {
status: number;
body: T;
}
interface FixtureUser {
id: string;
account: string;
}
let phoneCounter = 0;
let app: CreatedApiApp['app'];
let superToken = '';
let scopedToken = '';
let approverToken = '';
let scopedUserId = '';
let rootIndustryId = '';
let leafIndustryId = '';
let customerId = '';
let groupId = '';
const createdUserIds: string[] = [];
const createdRoleIds: string[] = [];
function nextPhone(): string {
phoneCounter += 1;
return `136${String(phoneCounter).padStart(8, '0')}`;
}
async function createRole(
suffix: string,
permissions: string[],
dataScope: { type: string; targetId?: string },
): Promise<string> {
const id = ulid();
await prisma.role.create({
data: {
id,
code: `${PREFIX}role_${suffix}`,
name: `${PREFIX}角色-${suffix}`,
description: 'A3-QA-01 集成测试角色',
status: 'ACTIVE',
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
dataScopes: {
create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }],
},
},
});
createdRoleIds.push(id);
return id;
}
async function createUser(suffix: string, roleId: string): Promise<FixtureUser> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `A3 集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: nextPhone(),
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
createdUserIds.push(id);
return { id, account };
}
async function login(account: string): Promise<string> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
if (response.statusCode !== 200 && response.statusCode !== 201) {
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
}
return response.json<TokenBody>().accessToken;
}
async function call<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
const body = response.body.length === 0 ? (null as T) : response.json<T>();
return { status: response.statusCode, body };
}
beforeAll(async () => {
await prisma.customerField.deleteMany({
where: { customer: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customerGroupMember.deleteMany({
where: { group: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } });
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
await createUser('admin', superAdminRole.id);
const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], {
type: 'created_by_me',
});
const scopedUser = await createUser('scoped', scopedRole);
scopedUserId = scopedUser.id;
const approverRole = await createRole(
'approver',
[Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE],
{ type: 'all' },
);
await createUser('approver', approverRole);
const created = await createApp();
app = created.app;
await app.init();
const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } });
if (adminRow === null) throw new Error('fixture user missing');
superToken = await login(`${PREFIX}admin`);
scopedToken = await login(`${PREFIX}scoped`);
approverToken = await login(`${PREFIX}approver`);
});
afterAll(async () => {
await prisma.customerField.deleteMany({
where: { customer: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customerGroupMember.deleteMany({
where: { group: { name: { startsWith: 'ITQA3-' } } },
});
await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } });
if (createdRoleIds.length > 0) {
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
}
await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } });
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
if (app) await app.close();
await prisma.$disconnect();
});
describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => {
it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => {
const root = await call<{ id: string; level: number; status: string }>(
'POST',
'/industries',
superToken,
{
name: 'ITQA3-电商',
sortOrder: 10,
},
);
expect(root.status).toBe(201);
expect(root.body.level).toBe(1);
rootIndustryId = root.body.id;
const child = await call<{ id: string; parentId: string; level: number }>(
'POST',
'/industries',
superToken,
{
name: 'ITQA3-服饰',
parentId: rootIndustryId,
sortOrder: 10,
},
);
expect(child.status).toBe(201);
expect(child.body.level).toBe(2);
leafIndustryId = child.body.id;
const duplicate = await call<{ code: number }>('POST', '/industries', superToken, {
name: 'ITQA3-电商',
});
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
const blockedStop = await call<{ code: number }>(
'PATCH',
`/industries/${rootIndustryId}/status`,
superToken,
{ status: 'disabled' },
);
expect(blockedStop.status).toBe(409);
expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const blockedDelete = await call<{ code: number }>(
'DELETE',
`/industries/${rootIndustryId}`,
superToken,
);
expect(blockedDelete.status).toBe(422);
expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
});
it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => {
const missingShort = await call<{ code: number }>('POST', '/customers', superToken, {
industryId: leafIndustryId,
name: 'ITQA3-花间集',
ownerUserId: scopedUserId,
});
expect(missingShort.status).toBe(400);
expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID);
const customer = await call<{ id: string; genFieldCount: number }>(
'POST',
'/customers',
superToken,
{
industryId: leafIndustryId,
name: 'ITQA3-花间集',
shortName: 'HJ3',
ownerUserId: scopedUserId,
baseFields: [{ key: '门店', value: '沪-001' }],
},
);
expect(customer.status).toBe(201);
customerId = customer.body.id;
expect(customer.body.genFieldCount).toBe(0);
const duplicate = await call<{ code: number }>('POST', '/customers', superToken, {
industryId: leafIndustryId,
name: 'ITQA3-花间集',
shortName: 'HJ3B',
ownerUserId: scopedUserId,
});
expect(duplicate.status).toBe(409);
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
const crossCustomer = await call<{ code: number }>(
'GET',
`/customers/${customerId}`,
scopedToken,
);
expect(crossCustomer.status).toBe(404);
expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND);
});
it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => {
const group = await call<{ id: string; memberCount: number }>(
'POST',
'/customer-groups',
superToken,
{
name: 'ITQA3-客户组',
description: 'A3 集成测试客户组',
customerIds: [customerId],
},
);
expect(group.status).toBe(201);
groupId = group.body.id;
expect(group.body.memberCount).toBe(1);
const again = await call<{ memberCount: number }>(
'POST',
`/customer-groups/${groupId}/members`,
superToken,
{
addCustomerIds: [customerId],
removeCustomerIds: [],
},
);
expect(again.status).toBe(200);
expect(again.body.memberCount).toBe(1);
const groupRole = await createRole('group', [Permission.CUSTOMER_READ], {
type: 'customer_groups',
targetId: groupId,
});
await createUser('group', groupRole);
const groupToken = await login(`${PREFIX}group`);
const visible = await call<{ items: { id: string }[] }>(
'GET',
`/customers?limit=50`,
groupToken,
);
expect(visible.status).toBe(200);
expect(visible.body.items.some((item) => item.id === customerId)).toBe(true);
});
it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => {
const apply = await call<{ id: string; status: string; entersGeneration: boolean }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: 'ITQA3-活动编码',
code: 'itqa3_campaign',
type: 'text',
purpose: '活动素材命名与检索',
entersGeneration: true,
},
);
expect(apply.status).toBe(201);
expect(apply.body.status).toBe('pending');
expect(apply.body.entersGeneration).toBe(true);
const pending = await call<{ items: { id: string; status: string }[] }>(
'GET',
'/customer-fields?status=pending&limit=50',
approverToken,
);
expect(pending.status).toBe(200);
expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true);
const rejectWithoutReason = await call<{ code: number }>(
'POST',
`/customer-fields/${apply.body.id}/approve`,
approverToken,
{ decision: 'reject' },
);
expect(rejectWithoutReason.status).toBe(400);
expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID);
const approve = await call<{ status: string }>(
'POST',
`/customer-fields/${apply.body.id}/approve`,
approverToken,
{ decision: 'approve' },
);
expect(approve.status).toBe(200);
expect(approve.body.status).toBe('active');
for (let index = 0; index < 4; index += 1) {
const extra = await call<{ code?: number }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: `ITQA3-字段${index}`,
code: `itqa3_extra_${index}`,
type: 'text',
purpose: '补足进入生成字段上限',
entersGeneration: true,
},
);
expect(extra.status).toBe(201);
}
const overflow = await call<{ code: number }>(
'POST',
`/customers/${customerId}/fields`,
superToken,
{
name: 'ITQA3-超限字段',
code: 'itqa3_overflow',
type: 'text',
purpose: '第六个进入生成字段应被拒绝',
entersGeneration: true,
},
);
expect(overflow.status).toBe(400);
expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID);
});
it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => {
const forbidden = await call<{ code: number }>(
'POST',
`/customers/${customerId}/transfer`,
scopedToken,
{
ownerUserId: scopedUserId,
},
);
expect(forbidden.status).toBe(403);
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
const transferred = await call<{ ownerUserId: string }>(
'POST',
`/customers/${customerId}/transfer`,
superToken,
{ ownerUserId: createdUserIds[0] },
);
expect(transferred.status).toBe(200);
expect(transferred.body.ownerUserId).toBe(createdUserIds[0]);
});
it('客户状态机:暂停 / 结束只读', async () => {
const paused = await call<{ status: string }>(
'PATCH',
`/customers/${customerId}/status`,
superToken,
{
status: 'paused',
},
);
expect(paused.status).toBe(200);
expect(paused.body.status).toBe('paused');
const editPaused = await call<{ code: number }>(
'PATCH',
`/customers/${customerId}`,
superToken,
{
shortName: 'HJ3C',
},
);
expect(editPaused.status).toBe(409);
expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const ended = await call<{ status: string }>(
'PATCH',
`/customers/${customerId}/status`,
superToken,
{
status: 'ended',
},
);
expect(ended.status).toBe(200);
expect(ended.body.status).toBe('ended');
const editEnded = await call<{ code: number }>(
'PATCH',
`/customers/${customerId}`,
superToken,
{
shortName: 'HJ3D',
},
);
expect(editEnded.status).toBe(409);
});
});
@@ -0,0 +1,241 @@
import {
baseFieldSchema,
type CustomerDetail,
type CustomerField,
type CustomerGroupDetail,
type CustomerGroupSummary,
customerPreferencesSchema,
type CustomerStatus,
type CustomerSummary,
type IndustryNode,
type IndustryStatus,
} from '@lingxi/shared';
import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client';
import { z } from 'zod';
export function iso(value: Date): string {
return value.toISOString();
}
export function isoOrNull(value: Date | null): string | null {
return value === null ? null : value.toISOString();
}
export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus {
return value === 'ACTIVE' ? 'active' : 'disabled';
}
export function mapCustomerStatus(value: string): CustomerStatus {
if (value === 'PAUSED') return 'paused';
if (value === 'ENDED') return 'ended';
return 'active';
}
export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] {
if (value === 'ACTIVE') return 'active';
if (value === 'REJECTED') return 'rejected';
if (value === 'DISABLED') return 'disabled';
return 'pending';
}
export interface IndustryRow {
id: string;
parentId: string | null;
level: number;
name: string;
sortOrder: number;
status: PrismaIndustryStatus;
remark: string;
updatedAt: Date;
}
export function toIndustryNode(
row: IndustryRow,
customerCount: number,
assetCount = 0,
): IndustryNode {
return {
id: row.id,
parentId: row.parentId,
level: row.level === 2 ? 2 : 1,
name: row.name,
sortOrder: row.sortOrder,
status: mapIndustryStatus(row.status),
remark: row.remark,
customerCount,
assetCount,
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerSummaryRow {
id: string;
name: string;
shortName: string;
industryId: string;
status: string;
ownerUserId: string;
updatedAt: Date;
industry: { name: string; parent: { name: string } | null };
owner: { name: string };
customFields: unknown;
groupMembers: { group: { id: string; name: string } }[];
fields: { entersGeneration: boolean; status: CustomerFieldStatus }[];
}
function industryPath(row: Pick<CustomerSummaryRow, 'industry'>): string {
return row.industry.parent === null
? row.industry.name
: `${row.industry.parent.name} / ${row.industry.name}`;
}
function genFieldCount(row: Pick<CustomerSummaryRow, 'fields'>): number {
return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length;
}
const baseFieldsSchema = z.array(baseFieldSchema);
export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary {
return {
id: row.id,
name: row.name,
shortName: row.shortName,
industryId: row.industryId,
industryName: row.industry.name,
industryPath: industryPath(row),
status: mapCustomerStatus(row.status),
ownerUserId: row.ownerUserId,
ownerName: row.owner.name,
groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })),
baseFieldCount: parseBaseFields(row.customFields).length,
genFieldCount: genFieldCount(row),
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerDetailRow extends CustomerSummaryRow {
contactName: string | null;
contactPhone: string | null;
preferences: unknown;
customFields: unknown;
remark: string | null;
createdAt: Date;
fields: CustomerFieldRow[];
}
export interface CustomerFieldRow {
id: string;
customerId: string;
name: string;
code: string;
type: string;
options: unknown;
purpose: string;
entersGeneration: boolean;
status: CustomerFieldStatus;
applicant: { name: string };
approver: { name: string } | null;
appliedAt: Date;
approvedAt: Date | null;
rejectReason: string | null;
}
const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']);
const optionsSchema = z.array(z.string());
export function toCustomerField(row: CustomerFieldRow): CustomerField {
const options = optionsSchema.safeParse(row.options);
return {
id: row.id,
customerId: row.customerId,
name: row.name,
code: row.code,
type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text',
options: options.success ? options.data : [],
purpose: row.purpose,
entersGeneration: row.entersGeneration,
status: mapFieldStatus(row.status),
applicantName: row.applicant.name,
approverName: row.approver?.name ?? null,
appliedAt: iso(row.appliedAt),
approvedAt: isoOrNull(row.approvedAt),
rejectReason: row.rejectReason,
};
}
function parseBaseFields(value: unknown): CustomerDetail['baseFields'] {
const parsed = baseFieldsSchema.safeParse(value);
return parsed.success ? parsed.data : [];
}
function parsePreferences(value: unknown): CustomerDetail['preferences'] {
const parsed = customerPreferencesSchema.safeParse(value);
return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] };
}
export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail {
const baseFields = parseBaseFields(row.customFields);
return {
...toCustomerSummary({
...row,
fields: row.fields,
}),
baseFieldCount: baseFields.length,
contactName: row.contactName,
contactPhone: row.contactPhone,
preferences: parsePreferences(row.preferences),
baseFields,
fields: row.fields.map((field) => toCustomerField(field)),
remark: row.remark,
createdAt: iso(row.createdAt),
};
}
export interface CustomerGroupRow {
id: string;
name: string;
description: string;
updatedAt: Date;
_count: { members: number };
}
export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary {
return {
id: row.id,
name: row.name,
description: row.description,
memberCount: row._count.members,
updatedAt: iso(row.updatedAt),
};
}
export interface CustomerGroupMemberRow {
customer: {
id: string;
name: string;
shortName: string;
status: string;
industry: { name: string; parent: { name: string } | null };
owner: { name: string };
};
}
export function toCustomerGroupDetail(
row: CustomerGroupRow,
members: CustomerGroupMemberRow[],
): CustomerGroupDetail {
return {
...toCustomerGroupSummary(row),
members: members.map((member) => ({
id: member.customer.id,
name: member.customer.name,
shortName: member.customer.shortName,
industryPath:
member.customer.industry.parent === null
? member.customer.industry.name
: `${member.customer.industry.parent.name} / ${member.customer.industry.name}`,
status: mapCustomerStatus(member.customer.status),
ownerName: member.customer.owner.name,
})),
};
}
@@ -0,0 +1,43 @@
import { Global, Module } from '@nestjs/common';
import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory';
import {
CustomerFieldsController,
CustomerFieldsOfCustomerController,
} from './customer-fields.controller';
import { CustomerFieldsService } from './customer-fields.service';
import { CustomerGroupsController } from './customer-groups.controller';
import { CustomerGroupsService } from './customer-groups.service';
import { CustomersController, CustomerTransferController } from './customers.controller';
import { CustomersService } from './customers.service';
import { IndustriesController } from './industries.controller';
import { IndustriesService } from './industries.service';
import { PrismaCustomerDirectory } from './prisma-customer-directory.service';
/**
* client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。
*
* 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类
* 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。
*/
@Global()
@Module({
controllers: [
IndustriesController,
CustomersController,
CustomerTransferController,
CustomerGroupsController,
CustomerFieldsController,
CustomerFieldsOfCustomerController,
],
providers: [
IndustriesService,
CustomersService,
CustomerGroupsService,
CustomerFieldsService,
PrismaCustomerDirectory,
{ provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory },
],
exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService],
})
export class ClientModule {}
@@ -0,0 +1,93 @@
import {
type ApplyCustomerFieldInput,
applyCustomerFieldInputSchema,
type ApproveCustomerFieldInput,
approveCustomerFieldInputSchema,
type CustomerField,
type CustomerFieldListItem,
type CustomerFieldListQuery,
customerFieldListQuerySchema,
ErrorCode,
type ListResponse,
Permission,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import {
Audit,
CurrentScope,
CurrentUser,
RequirePermissions,
} from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { CustomerFieldsService } from './customer-fields.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
return auth;
}
@ApiTags('client-customer-fields')
@ApiBearerAuth()
@Controller('customer-fields')
export class CustomerFieldsController {
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ)
@ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' })
list(
@Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery,
@CurrentScope() scope: ScopeContext | null,
): Promise<ListResponse<CustomerFieldListItem>> {
return this.fields.list(query, scope);
}
@Post(':id/approve')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE)
@Audit('client.customer_field.approve')
@ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' })
approve(
@Param('id') id: string,
@Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField> {
return this.fields.approve(id, input, requireAuth(auth).userId, scope);
}
}
@ApiTags('client-customer-fields')
@ApiBearerAuth()
@Controller('customers')
export class CustomerFieldsOfCustomerController {
constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {}
@Get(':id/fields')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' })
list(
@Param('id') id: string,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField[]> {
return this.fields.listByCustomer(id, scope);
}
@Post(':id/fields')
@RequirePermissions(Permission.CUSTOMER_FIELD_APPLY)
@Audit('client.customer_field.apply')
@ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' })
apply(
@Param('id') id: string,
@Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerField> {
return this.fields.apply(id, input, requireAuth(auth).userId, scope);
}
}
@@ -0,0 +1,244 @@
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
import {
type ApplyCustomerFieldInput,
type ApproveCustomerFieldInput,
CUSTOMER_GEN_FIELD_LIMIT,
type CustomerField,
type CustomerFieldListItem,
type CustomerFieldListQuery,
ErrorCode,
type ListResponse,
ulid,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
const FIELD_INCLUDE = {
applicant: { select: { name: true } },
approver: { select: { name: true } },
} satisfies Prisma.CustomerFieldInclude;
const FIELD_LIST_INCLUDE = {
...FIELD_INCLUDE,
customer: {
select: {
id: true,
name: true,
shortName: true,
industry: { include: { parent: true } },
},
},
} satisfies Prisma.CustomerFieldInclude;
function industryPath(customer: {
industry: { name: string; parent: { name: string } | null };
}): string {
return customer.industry.parent === null
? customer.industry.name
: `${customer.industry.parent.name} / ${customer.industry.name}`;
}
/**
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
* 本表只承载进入生成 / 需要审批的字段(≤5)。
*/
@Injectable()
export class CustomerFieldsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(
query: CustomerFieldListQuery,
scopeInput: ScopeContext | null,
): Promise<ListResponse<CustomerFieldListItem>> {
const scope = requireScopeContext(scopeInput);
const rows = await this.prisma.customerField.findMany({
where: {
customer: {
deletedAt: null,
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
},
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
...(query.status === 'all'
? {}
: {
status:
query.status === 'pending'
? 'PENDING'
: query.status === 'active'
? 'ACTIVE'
: query.status === 'rejected'
? 'REJECTED'
: 'DISABLED',
}),
},
include: FIELD_LIST_INCLUDE,
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => ({
...toCustomerField(row as CustomerFieldRow),
customerName: row.customer.name,
customerShortName: row.customer.shortName,
industryPath: industryPath(row.customer),
})),
next_cursor: next,
};
}
async listByCustomer(
customerId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField[]> {
const scope = requireScopeContext(scopeInput);
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const rows = await this.prisma.customerField.findMany({
where: { customerId, customer: { deletedAt: null } },
include: FIELD_INCLUDE,
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
});
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
}
async apply(
customerId: string,
input: ApplyCustomerFieldInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField> {
const scope = requireScopeContext(scopeInput);
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, deletedAt: null },
select: { id: true, status: true },
});
if (customer === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
if (customer.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
});
}
const duplicate = await this.prisma.customerField.findUnique({
where: { customerId_code: { customerId, code: input.code } },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该客户下字段编码已存在',
});
}
if (input.entersGeneration) {
const used = await this.genUsedCount(customerId);
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`,
});
}
}
const id = ulid();
await this.prisma.customerField.create({
data: {
id,
customerId,
name: input.name,
code: input.code,
type: input.type,
options: input.options,
purpose: input.purpose,
entersGeneration: input.entersGeneration,
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
applicantUserId: actorId,
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
},
});
const row = await this.prisma.customerField.findUniqueOrThrow({
where: { id },
include: FIELD_INCLUDE,
});
return toCustomerField(row);
}
async approve(
id: string,
input: ApproveCustomerFieldInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerField> {
const scope = requireScopeContext(scopeInput);
const existing = await this.prisma.customerField.findUnique({
where: { id },
include: { customer: { select: { id: true, deletedAt: true } } },
});
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
if (existing === null || existing.customer.deletedAt !== null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
}
if (!isCustomerVisible(scope, existing.customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
}
if (existing.status !== 'PENDING') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
}
if (input.decision === 'approve' && existing.entersGeneration) {
const used = await this.genUsedCount(existing.customerId, id);
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`,
});
}
}
await this.prisma.customerField.update({
where: { id },
data:
input.decision === 'approve'
? {
status: 'ACTIVE',
approverUserId: actorId,
approvedAt: new Date(),
rejectReason: null,
}
: {
status: 'REJECTED',
approverUserId: actorId,
approvedAt: new Date(),
rejectReason: input.reason ?? '',
},
});
const row = await this.prisma.customerField.findUniqueOrThrow({
where: { id },
include: FIELD_INCLUDE,
});
return toCustomerField(row);
}
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
return this.prisma.customerField.count({
where: {
customerId,
entersGeneration: true,
status: { not: 'REJECTED' },
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
},
});
}
}
@@ -0,0 +1,97 @@
import {
type CreateCustomerGroupInput,
createCustomerGroupInputSchema,
type CustomerGroupDetail,
type CustomerGroupListQuery,
customerGroupListQuerySchema,
type CustomerGroupSummary,
type ListResponse,
Permission,
type UpdateCustomerGroupInput,
updateCustomerGroupInputSchema,
type UpdateCustomerGroupMembersInput,
updateCustomerGroupMembersInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { CustomerGroupsService } from './customer-groups.service';
@ApiTags('client-customer-groups')
@ApiBearerAuth()
@Controller('customer-groups')
export class CustomerGroupsController {
constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' })
list(
@Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery,
): Promise<ListResponse<CustomerGroupSummary>> {
return this.groups.list(query);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@ApiOperation({ summary: '客户组详情与成员' })
detail(@Param('id') id: string): Promise<CustomerGroupDetail> {
return this.groups.detail(id);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.create')
@ApiOperation({ summary: '新建客户组' })
create(
@Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
return this.groups.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.update')
@ApiOperation({ summary: '更新客户组名称 / 说明' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
return this.groups.update(id, input);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.delete')
@ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' })
async remove(@Param('id') id: string): Promise<void> {
await this.groups.remove(id);
}
@Post(':id/members')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE)
@Audit('client.customer_group.members')
@ApiOperation({ summary: '批量添加 / 移除成员(幂等)' })
updateMembers(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema))
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
return this.groups.updateMembers(id, input);
}
}
@@ -0,0 +1,187 @@
import {
type CreateCustomerGroupInput,
type CustomerGroupDetail,
type CustomerGroupListQuery,
type CustomerGroupSummary,
ErrorCode,
type ListResponse,
ulid,
type UpdateCustomerGroupInput,
type UpdateCustomerGroupMembersInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import {
type CustomerGroupRow,
toCustomerGroupDetail,
toCustomerGroupSummary,
} from './client.mapper';
const GROUP_INCLUDE = {
_count: { select: { members: true } },
} satisfies Prisma.CustomerGroupInclude;
const GROUP_MEMBER_INCLUDE = {
customer: {
select: {
id: true,
name: true,
shortName: true,
status: true,
industry: { include: { parent: true } },
owner: { select: { name: true } },
},
},
} satisfies Prisma.CustomerGroupMemberInclude;
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
@Injectable()
export class CustomerGroupsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
const rows = await this.prisma.customerGroup.findMany({
where:
query.keyword === undefined
? {}
: { name: { contains: query.keyword, mode: 'insensitive' } },
include: GROUP_INCLUDE,
orderBy: { updatedAt: 'desc' },
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)),
next_cursor: next,
};
}
async detail(id: string): Promise<CustomerGroupDetail> {
const row = await this.findByIdOrThrow(id);
const members = await this.prisma.customerGroupMember.findMany({
where: { groupId: id },
include: GROUP_MEMBER_INCLUDE,
orderBy: { createdAt: 'asc' },
});
return toCustomerGroupDetail(row, members);
}
async create(input: CreateCustomerGroupInput): Promise<CustomerGroupDetail> {
await this.assertNameUnique(input.name, null);
const id = ulid();
try {
await this.prisma.customerGroup.create({
data: {
id,
name: input.name,
description: input.description ?? '',
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
}
throw error;
}
if (input.customerIds !== undefined && input.customerIds.length > 0) {
await this.addMembers(id, input.customerIds);
}
return this.detail(id);
}
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
await this.prisma.customerGroup.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.description === undefined ? {} : { description: input.description }),
},
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
await this.findByIdOrThrow(id);
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
if (referenced > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围',
});
}
await this.prisma.customerGroup.delete({ where: { id } });
}
async updateMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
}),
this.prisma.customerGroupMember.createMany({
data: [...new Set(input.addCustomerIds)].map((customerId) => ({
groupId: id,
customerId,
})),
skipDuplicates: true,
}),
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
]);
return this.detail(id);
}
private async addMembers(id: string, customerIds: readonly string[]): Promise<void> {
await this.assertCustomersExist(customerIds);
await this.prisma.customerGroupMember.createMany({
data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })),
skipDuplicates: true,
});
}
private async assertCustomersExist(customerIds: readonly string[]): Promise<void> {
const found = await this.prisma.customer.findMany({
where: { id: { in: [...new Set(customerIds)] }, deletedAt: null },
select: { id: true },
});
if (found.length !== new Set(customerIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' });
}
}
private async findByIdOrThrow(id: string): Promise<CustomerGroupRow> {
const row = await this.prisma.customerGroup.findUnique({
where: { id },
include: GROUP_INCLUDE,
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' });
}
return row;
}
private async assertNameUnique(name: string, excludeId: string | null): Promise<void> {
const duplicate = await this.prisma.customerGroup.findFirst({
where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) },
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' });
}
}
}
@@ -0,0 +1,178 @@
import {
type CreateCustomerInput,
createCustomerInputSchema,
type CustomerDetail,
type CustomerListQuery,
customerListQuerySchema,
type CustomerSummary,
ErrorCode,
type ListResponse,
Permission,
type SetCustomerGroupsInput,
setCustomerGroupsInputSchema,
type SetCustomerStatusInput,
setCustomerStatusInputSchema,
type TransferCustomerInput,
transferCustomerInputSchema,
type TransferCustomersInput,
transferCustomersInputSchema,
type UpdateCustomerInput,
updateCustomerInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import {
Audit,
CurrentScope,
CurrentUser,
RequirePermissions,
} from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { CustomersService } from './customers.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
@ApiTags('client-customers')
@ApiBearerAuth()
@Controller('customers')
export class CustomersController {
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' })
list(
@Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery,
@CurrentScope() scope: ScopeContext | null,
): Promise<ListResponse<CustomerSummary>> {
return this.customers.list(query, scope);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '客户详情(跨客户访问统一 404)' })
detail(
@Param('id') id: string,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.detail(id, scope);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.create')
@ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' })
create(
@Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.create(input, requireAuth(auth).userId, scope);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.update')
@ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.update(id, input, scope, requireAuth(auth).permissions);
}
@Patch(':id/status')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.status')
@ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' })
setStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.setStatus(id, input, scope);
}
@Post(':id/transfer')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_TRANSFER)
@Audit('client.customer.transfer')
@ApiOperation({ summary: '变更客户负责人(写审计)' })
transfer(
@Param('id') id: string,
@Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput,
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.transfer(id, input, scope, requireAuth(auth).permissions);
}
@Put(':id/groups')
@RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE)
@Audit('client.customer.groups')
@ApiOperation({ summary: '设置客户所属客户组(覆盖式)' })
setGroups(
@Param('id') id: string,
@Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput,
@CurrentScope() scope: ScopeContext | null,
): Promise<CustomerDetail> {
return this.customers.setGroups(id, input, scope);
}
@Post(':id/apply-industry-pack')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.customer.apply_industry_pack')
@ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' })
applyIndustryPack(): never {
return this.customers.applyIndustryPack();
}
}
/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */
@ApiTags('client-customers')
@ApiBearerAuth()
@Controller('users')
export class CustomerTransferController {
constructor(@Inject(CustomersService) private readonly customers: CustomersService) {}
@Post(':id/transfer-customers')
@HttpCode(200)
@RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE)
@Audit('identity.user.transfer_customers')
@ApiOperation({ summary: '把用户名下客户转交给另一负责人' })
async transfer(
@Param('id') fromUserId: string,
@Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput,
@CurrentUser() auth: AuthContext | null,
): Promise<{ transferred: number }> {
return this.customers.transferByUser(
fromUserId,
input.toUserId,
input.customerIds,
requireAuth(auth).permissions,
);
}
}
@@ -0,0 +1,414 @@
/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */
import {
type CreateCustomerInput,
CUSTOMER_BASE_FIELD_LIMIT,
type CustomerDetail,
type CustomerListQuery,
type CustomerSummary,
ErrorCode,
type ListResponse,
Permission,
type SetCustomerGroupsInput,
type SetCustomerStatusInput,
type TransferCustomerInput,
ulid,
type UpdateCustomerInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
import { IndustriesService } from './industries.service';
const CUSTOMER_INCLUDE = {
industry: { include: { parent: true } },
owner: { select: { name: true } },
groupMembers: { include: { group: true } },
fields: { select: { entersGeneration: true, status: true } },
} satisfies Prisma.CustomerInclude;
const CUSTOMER_DETAIL_INCLUDE = {
...CUSTOMER_INCLUDE,
fields: {
include: { applicant: { select: { name: true } }, approver: { select: { name: true } } },
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
},
} satisfies Prisma.CustomerInclude;
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput {
return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } };
}
function hasPermission(permissions: readonly string[], code: string): boolean {
return permissions.includes(code);
}
/**
* 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、
* 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。
*/
@Injectable()
export class CustomersService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(IndustriesService) private readonly industries: IndustriesService,
) {}
async list(
query: CustomerListQuery,
scopeInput: ScopeContext | null,
): Promise<ListResponse<CustomerSummary>> {
const scope = requireScopeContext(scopeInput);
const industryIds =
query.industry_id === undefined
? undefined
: await this.industrySubtreeIds(query.industry_id);
const rows = await this.prisma.customer.findMany({
where: {
deletedAt: null,
...scopeWhere(scope),
...(query.keyword === undefined
? {}
: {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ shortName: { contains: query.keyword, mode: 'insensitive' } },
],
}),
...(industryIds === undefined ? {} : { industryId: { in: industryIds } }),
...(query.status === 'all'
? {}
: {
status:
query.status === 'active'
? 'ACTIVE'
: query.status === 'paused'
? 'PAUSED'
: 'ENDED',
}),
...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }),
...(query.group_id === undefined
? {}
: { groupMembers: { some: { groupId: query.group_id } } }),
},
include: CUSTOMER_INCLUDE,
orderBy: { id: 'desc' },
take: query.limit + 1,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return {
items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)),
next_cursor: next,
};
}
async detail(id: string, scopeInput: ScopeContext | null): Promise<CustomerDetail> {
return this.findVisibleOrThrow(id, requireScopeContext(scopeInput));
}
async create(
input: CreateCustomerInput,
actorId: string,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
await this.industries.assertLeafIndustry(input.industryId);
await this.ensureOwner(input.ownerUserId);
const baseFields = input.baseFields ?? [];
this.assertBaseFieldLimit(baseFields.length);
const id = ulid();
try {
await this.prisma.customer.create({
data: {
id,
industryId: input.industryId,
name: input.name,
shortName: input.shortName,
ownerUserId: input.ownerUserId,
contactName: input.contactName ?? null,
contactPhone: input.contactPhone ?? null,
preferences: input.preferences ?? {},
customFields: baseFields,
remark: input.remark ?? null,
createdBy: actorId,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该末级行业下已存在同名客户',
});
}
throw error;
}
return this.detail(id, scope);
}
async update(
id: string,
input: UpdateCustomerInput,
scopeInput: ScopeContext | null,
permissions: readonly string[],
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档',
});
}
if (
input.ownerUserId !== undefined &&
input.ownerUserId !== existing.ownerUserId &&
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)
) {
throw new AppException({
code: ErrorCode.PERMISSION_DENIED,
message: '变更负责人需要 customer:transfer 权限',
});
}
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
await this.ensureOwner(input.ownerUserId);
}
if (input.baseFields !== undefined) this.assertBaseFieldLimit(input.baseFields.length);
if (input.name !== undefined && input.name !== existing.name) {
const duplicate = await this.prisma.customer.findFirst({
where: {
industryId: existing.industryId,
name: input.name,
id: { not: id },
deletedAt: null,
},
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: '该末级行业下已存在同名客户',
});
}
}
await this.prisma.customer.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.shortName === undefined ? {} : { shortName: input.shortName }),
...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }),
...(input.contactName === undefined ? {} : { contactName: input.contactName }),
...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }),
...(input.preferences === undefined ? {} : { preferences: input.preferences }),
...(input.baseFields === undefined ? {} : { customFields: input.baseFields }),
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
return this.detail(id, scope);
}
async setStatus(
id: string,
input: SetCustomerStatusInput,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status === 'ENDED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '客户已结束,不能再变更状态',
});
}
if (existing.status === input.status) {
return this.detail(id, scope);
}
const target =
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
await this.prisma.customer.update({ where: { id }, data: { status: target } });
return this.detail(id, scope);
}
async transfer(
id: string,
input: TransferCustomerInput,
scopeInput: ScopeContext | null,
permissions: readonly string[],
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
message: '缺少客户转交权限',
});
}
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (existing.status === 'ENDED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '客户已结束,不能转交',
});
}
await this.ensureOwner(input.ownerUserId);
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
return this.detail(id, scope);
}
/** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */
async transferByUser(
fromUserId: string,
toUserId: string,
customerIds: readonly string[] | undefined,
permissions: readonly string[],
): Promise<{ transferred: number }> {
if (
!hasPermission(permissions, Permission.CUSTOMER_TRANSFER) &&
!hasPermission(permissions, Permission.USER_MANAGE)
) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' });
}
await this.ensureOwner(toUserId);
const result = await this.prisma.customer.updateMany({
where: {
ownerUserId: fromUserId,
deletedAt: null,
status: { not: 'ENDED' },
...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }),
},
data: { ownerUserId: toUserId },
});
return { transferred: result.count };
}
async setGroups(
id: string,
input: SetCustomerGroupsInput,
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
await this.findRow(id);
this.assertVisible(scope, id);
if (input.groupIds.length > 0) {
const found = await this.prisma.customerGroup.findMany({
where: { id: { in: input.groupIds } },
select: { id: true },
});
if (found.length !== new Set(input.groupIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
}
}
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
this.prisma.customerGroupMember.createMany({
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
}),
]);
return this.detail(id, scope);
}
applyIndustryPack(): never {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '行业资产包将在 B0 批次提供;当前可先完成客户创建',
});
}
private async findRow(id: string): Promise<{
id: string;
status: string;
ownerUserId: string;
industryId: string;
name: string;
}> {
const row = await this.prisma.customer.findFirst({
where: { id, deletedAt: null },
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
return row;
}
private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise<CustomerDetail> {
if (!isCustomerVisible(scope, id)) {
// A3-BE-05:跨客户访问统一 404,不暴露存在性
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const row = await this.prisma.customer.findFirst({
where: { id, deletedAt: null },
include: CUSTOMER_DETAIL_INCLUDE,
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
return toCustomerDetail(row);
}
private assertVisible(scope: ScopeContext, id: string): void {
if (!isCustomerVisible(scope, id)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
}
private async ensureOwner(userId: string): Promise<void> {
const user = await this.prisma.user.findFirst({
where: { id: userId, status: 'ACTIVE' },
select: { id: true },
});
if (user === null) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '客户负责人不存在或已停用',
});
}
}
private assertBaseFieldLimit(count: number): void {
if (count > CUSTOMER_BASE_FIELD_LIMIT) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`,
});
}
}
private async industrySubtreeIds(industryId: string): Promise<string[]> {
const industry = await this.prisma.industry.findUnique({
where: { id: industryId },
select: { id: true },
});
if (industry === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
}
const children = await this.prisma.industry.findMany({
where: { parentId: industryId },
select: { id: true },
});
return [industryId, ...children.map((child) => child.id)];
}
/** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */
async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise<void> {
if (!isCustomerVisible(scope, customerId)) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } });
if (count === 0) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
}
}
}
@@ -0,0 +1,95 @@
import {
type CreateIndustryInput,
createIndustryInputSchema,
type IndustryDetail,
type IndustryListQuery,
industryListQuerySchema,
type IndustryTreeItem,
type ListResponse,
Permission,
type SetIndustryStatusInput,
setIndustryStatusInputSchema,
type UpdateIndustryInput,
updateIndustryInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { IndustriesService } from './industries.service';
@ApiTags('client-industries')
@ApiBearerAuth()
@Controller('industries')
export class IndustriesController {
constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {}
@Get()
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '两级行业树(一级 → 二级)' })
list(
@Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery,
): Promise<ListResponse<IndustryTreeItem>> {
return this.industries.list(query);
}
@Get(':id')
@RequirePermissions(Permission.CUSTOMER_READ)
@ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' })
detail(@Param('id') id: string): Promise<IndustryDetail> {
return this.industries.detail(id);
}
@Post()
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.create')
@ApiOperation({ summary: '新建一级 / 二级行业' })
create(
@Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput,
): Promise<IndustryDetail> {
return this.industries.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.update')
@ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' })
update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput,
): Promise<IndustryDetail> {
return this.industries.update(id, input);
}
@Patch(':id/status')
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.status')
@ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' })
setStatus(
@Param('id') id: string,
@Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput,
): Promise<IndustryDetail> {
return this.industries.setStatus(id, input);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.CUSTOMER_MANAGE)
@Audit('client.industry.delete')
@ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' })
async remove(@Param('id') id: string): Promise<void> {
await this.industries.remove(id);
}
}
@@ -0,0 +1,282 @@
import {
type CreateIndustryInput,
ErrorCode,
type IndustryDetail,
type IndustryListQuery,
type IndustryReferences,
type IndustryTreeItem,
type ListResponse,
type SetIndustryStatusInput,
ulid,
type UpdateIndustryInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { type IndustryRow, toIndustryNode } from './client.mapper';
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
}
/**
* 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。
* 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。
*/
@Injectable()
export class IndustriesService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
const rows = await this.prisma.industry.findMany({
where: {
...(query.keyword === undefined
? {}
: { name: { contains: query.keyword, mode: 'insensitive' } }),
...(query.status === 'all'
? {}
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
...(query.parent_id === undefined ? {} : { parentId: query.parent_id }),
},
orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
});
const counts = await this.customerCounts();
const roots = rows.filter((row) => row.level === 1);
const childrenByParent = new Map<string, IndustryRow[]>();
for (const row of rows) {
if (row.level !== 2 || row.parentId === null) continue;
const list = childrenByParent.get(row.parentId) ?? [];
list.push(row);
childrenByParent.set(row.parentId, list);
}
const items = roots.map((root) => {
const children = (childrenByParent.get(root.id) ?? []).map((child) =>
toIndustryNode(child, counts.get(child.id) ?? 0),
);
const own = counts.get(root.id) ?? 0;
const total = children.reduce((sum, child) => sum + child.customerCount, own);
return {
...toIndustryNode(root, total),
children,
};
});
return { items, next_cursor: null };
}
async detail(id: string): Promise<IndustryDetail> {
const row = await this.findOrThrow(id);
const parent =
row.parentId === null
? null
: await this.prisma.industry.findUnique({ where: { id: row.parentId } });
const references = await this.references(id);
return {
...toIndustryNode(row, references.customerCount),
parentName: parent?.name ?? null,
references,
};
}
async references(id: string): Promise<IndustryReferences> {
const children = await this.prisma.industry.findMany({
where: { parentId: id },
select: { id: true, status: true },
});
const customerWhere: Prisma.CustomerWhereInput = {
deletedAt: null,
OR: [{ industryId: id }, { industry: { parentId: id } }],
};
const [customerCount, customers] = await Promise.all([
this.prisma.customer.count({ where: customerWhere }),
this.prisma.customer.findMany({
where: customerWhere,
select: { id: true, name: true, status: true },
take: 20,
orderBy: { updatedAt: 'desc' },
}),
]);
return {
childCount: children.length,
activeChildCount: children.filter((child) => child.status === 'ACTIVE').length,
customerCount,
customers: customers.map((customer) => ({
id: customer.id,
name: customer.name,
status:
customer.status === 'PAUSED'
? 'paused'
: customer.status === 'ENDED'
? 'ended'
: 'active',
})),
assetCount: 0,
};
}
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
const parent = await this.findOrThrow(input.parentId);
if (parent.level !== 1) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: '行业最多两级,二级行业下不能再建子行业',
});
}
}
await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null);
const id = ulid();
try {
await this.prisma.industry.create({
data: {
id,
parentId: level === 1 ? null : (input.parentId ?? null),
level,
name: input.name,
sortOrder: input.sortOrder,
remark: input.remark,
},
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业',
});
}
throw error;
}
return this.detail(id);
}
async update(id: string, input: UpdateIndustryInput): Promise<IndustryDetail> {
const existing = await this.findOrThrow(id);
if (input.name !== undefined && input.name !== existing.name) {
await this.assertNameUnique(input.name, existing.parentId, id);
}
await this.prisma.industry.update({
where: { id },
data: {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
return this.detail(id);
}
async setStatus(id: string, input: SetIndustryStatusInput): Promise<IndustryDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'DISABLED' && input.status === 'active') {
const parent =
existing.parentId === null
? null
: await this.prisma.industry.findUnique({ where: { id: existing.parentId } });
if (parent !== null && parent.status === 'DISABLED') {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '上级行业已停用,请先启用上级行业',
});
}
}
if (input.status === 'disabled') {
const activeChildren = await this.prisma.industry.count({
where: { parentId: id, status: 'ACTIVE' },
});
if (activeChildren > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`,
});
}
}
await this.prisma.industry.update({
where: { id },
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
const references = await this.references(id);
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`,
details: references,
});
}
try {
await this.prisma.industry.delete({ where: { id } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '该行业仍被引用,无法删除,只能停用',
});
}
throw error;
}
}
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */
async assertLeafIndustry(id: string): Promise<void> {
const industry = await this.findOrThrow(id);
if (industry.status === 'DISABLED') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '行业已停用,新建客户不可选择',
});
}
if (industry.level === 1) {
const childCount = await this.prisma.industry.count({ where: { parentId: id } });
if (childCount > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '该一级行业下存在二级行业,客户只能归属到末级行业',
});
}
}
}
private async findOrThrow(id: string): Promise<IndustryRow> {
const row = await this.prisma.industry.findUnique({ where: { id } });
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
}
return row;
}
private async assertNameUnique(
name: string,
parentId: string | null,
excludeId: string | null,
): Promise<void> {
const duplicate = await this.prisma.industry.findFirst({
where: {
name,
parentId,
...(excludeId === null ? {} : { id: { not: excludeId } }),
},
select: { id: true },
});
if (duplicate !== null) {
throw new AppException({
code: ErrorCode.DUPLICATE_SUBMIT,
message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业',
});
}
}
private async customerCounts(): Promise<Map<string, number>> {
const grouped = await this.prisma.customer.groupBy({
by: ['industryId'],
where: { deletedAt: null },
_count: { _all: true },
});
return new Map(grouped.map((item) => [item.industryId, item._count._all]));
}
}
@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import type { CustomerDirectory } from '../../common/security/scope/customer-directory';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
/**
* 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。
* 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。
*/
@Injectable()
export class PrismaCustomerDirectory implements CustomerDirectory {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async findOwnedCustomerIds(userId: string): Promise<string[]> {
const rows = await this.prisma.customer.findMany({
where: { ownerUserId: userId, deletedAt: null },
select: { id: true },
});
return rows.map((row) => row.id);
}
async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise<string[]> {
if (customerGroupIds.length === 0) return [];
const rows = await this.prisma.customerGroupMember.findMany({
where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } },
select: { customerId: true },
});
return [...new Set(rows.map((row) => row.customerId))].sort();
}
async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise<string[]> {
if (departmentIds.length === 0) return [];
const rows = await this.prisma.customer.findMany({
where: {
deletedAt: null,
owner: { departmentId: { in: [...departmentIds] } },
},
select: { id: true },
});
return rows.map((row) => row.id);
}
async findCreatedCustomerIds(userId: string): Promise<string[]> {
const rows = await this.prisma.customer.findMany({
where: { createdBy: userId, deletedAt: null },
select: { id: true },
});
return rows.map((row) => row.id);
}
}
@@ -0,0 +1,86 @@
import {
changePasswordInputSchema,
ErrorCode,
type LoginInput,
loginInputSchema,
type LoginResponse,
type LogoutInput,
logoutInputSchema,
type RefreshInput,
refreshInputSchema,
type TokenPair,
} from '@lingxi/shared';
import { Body, Controller, HttpCode, Inject, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyRequest } from 'fastify';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext, AuthRequestMeta } from '../../common/security/auth.types';
import { Audit, CurrentUser, Public } from '../../common/security/decorators';
import { AuthService } from './auth.service';
function metaOf(request: FastifyRequest & { id?: string }): AuthRequestMeta {
return {
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
traceId: typeof request.id === 'string' ? request.id : '',
};
}
/** 认证与会话:登录(返回 tokenPair + me)、刷新旋转、登出、强制改密 */
@ApiTags('identity-auth')
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly auth: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
@ApiOperation({
summary: '账号密码登录',
description: '返回 accessToken / refreshToken / expiresIn / me',
})
async login(
@Body(new ZodValidationPipe(loginInputSchema)) input: LoginInput,
@Req() request: FastifyRequest,
): Promise<LoginResponse> {
return this.auth.login(input, metaOf(request));
}
@Public()
@Post('refresh')
@HttpCode(200)
@ApiOperation({ summary: '刷新令牌(旋转,旧 refresh 立即失效)' })
async refresh(
@Body(new ZodValidationPipe(refreshInputSchema)) input: RefreshInput,
): Promise<TokenPair> {
return this.auth.refresh(input);
}
@Public()
@Post('logout')
@HttpCode(204)
@ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' })
async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise<void> {
await this.auth.logout(input);
}
@Post('change-password')
@HttpCode(204)
@Audit('identity.auth.change-password')
@ApiOperation({ summary: '修改密码(首登强改)' })
async changePassword(
@Body(new ZodValidationPipe(changePasswordInputSchema))
input: {
currentPassword: string;
newPassword: string;
},
@CurrentUser() auth: AuthContext | null,
): Promise<void> {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
await this.auth.changePassword(auth.userId, input);
}
}
@@ -0,0 +1,271 @@
import { ErrorCode } from '@lingxi/shared';
import { JwtService } from '@nestjs/jwt';
import type { User } from '@prisma/client';
import { describe, expect, it, type Mock, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { PermissionService } from '../../common/security/permission.service';
import type { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
import { TokenService } from '../../common/security/token.service';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { testConfig } from '../../testing/test-app';
import { AuthService } from './auth.service';
import type { LoginFailureService } from './login-failure.service';
import type { MeService } from './me.service';
const tokens = new TokenService(new JwtService({}), testConfig);
function userFixture(overrides: Partial<User> = {}): User {
const base: User = {
id: 'u1',
account: 'chen.mo',
email: 'chen.mo@lingxi.cn',
name: '陈默',
phone: '13866202046',
passwordHash: 'hash',
departmentId: 'g1',
jobTitle: null,
status: 'ACTIVE',
mustChangePassword: false,
lastLoginAt: null,
lastLoginIp: null,
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
};
return { ...base, ...overrides };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
interface PrismaMock {
user: { findFirst: Mock; findUnique: Mock; update: Mock };
refreshToken: { create: Mock; findUnique: Mock; updateMany: Mock };
}
interface Harness {
service: AuthService;
prisma: PrismaMock;
failures: ReturnType<typeof failureMock>;
passwords: ReturnType<typeof passwordMock>;
permissions: { resolveAuth: Mock };
me: { getMe: Mock };
}
function failureMock(): {
status: ReturnType<typeof vi.fn>;
record: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
} {
return {
status: vi.fn().mockResolvedValue({ locked: false, remaining: 5 }),
record: vi.fn().mockResolvedValue(1),
clear: vi.fn().mockResolvedValue(undefined),
};
}
function passwordMock(): {
verify: ReturnType<typeof vi.fn>;
hash: ReturnType<typeof vi.fn>;
} {
return { verify: vi.fn().mockResolvedValue(true), hash: vi.fn().mockResolvedValue('new-hash') };
}
function harness(): Harness {
const prisma: PrismaMock = {
user: {
findFirst: vi.fn().mockResolvedValue(userFixture()),
findUnique: vi.fn().mockResolvedValue(userFixture()),
update: vi.fn().mockResolvedValue(userFixture()),
},
refreshToken: {
create: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
};
const failures = failureMock();
const passwords = passwordMock();
const permissions = {
resolveAuth: vi.fn().mockResolvedValue({
userId: 'u1',
account: 'chen.mo',
status: 'active',
roles: [],
permissions: ['user:manage'],
dataScopes: [{ type: 'all' }],
departmentIds: ['d1'],
isSuperAdmin: true,
mustChangePassword: false,
}),
};
const scopes = { resolve: vi.fn().mockResolvedValue({ dataScopes: [{ type: 'all' }] }) };
const me = {
getMe: vi.fn().mockResolvedValue({
user: { id: 'u1' },
roles: [],
permissions: [],
dataScopes: [{ type: 'all' }],
mustChangePassword: false,
}),
};
const service = new AuthService(
prisma as unknown as PrismaService,
testConfig,
tokens,
passwords,
failures as unknown as LoginFailureService,
permissions as unknown as PermissionService,
scopes as unknown as ScopeResolver,
me as unknown as MeService,
);
return { service, prisma, failures, passwords, permissions, me };
}
const meta = { ip: '127.0.0.1', userAgent: 'vitest', traceId: 'trace-1' };
describe('AuthService.login', () => {
it('成功登录返回 tokenPair + me,并清空失败计数、记录登录时间', async () => {
const { service, prisma, failures } = harness();
const result = await service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta);
expect(result.accessToken.length).toBeGreaterThan(0);
expect(result.refreshToken.length).toBeGreaterThan(0);
expect(result.me).toBeDefined();
expect(failures.clear).toHaveBeenCalledWith('chen.mo');
expect(prisma.user.update).toHaveBeenCalled();
});
it('锁定期间拒绝登录(42901);账号不存在与密码错误统一返回 40101', async () => {
const locked = harness();
locked.failures.status.mockResolvedValueOnce({ locked: true, remaining: 0 });
await expectRejected(
locked.service.login({ account: 'chen.mo', password: 'x' }, meta),
ErrorCode.RATE_LIMITED,
);
const missing = harness();
missing.prisma.user.findFirst.mockResolvedValueOnce(null);
await expectRejected(
missing.service.login({ account: 'nobody', password: 'x' }, meta),
ErrorCode.UNAUTHENTICATED,
);
expect(missing.failures.record).toHaveBeenCalled();
const wrong = harness();
wrong.passwords.verify.mockResolvedValueOnce(false);
await expectRejected(
wrong.service.login({ account: 'chen.mo', password: 'bad-pass1' }, meta),
ErrorCode.UNAUTHENTICATED,
);
});
it('停用账号拒绝登录(40301)', async () => {
const { service, prisma } = harness();
prisma.user.findFirst.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
await expectRejected(
service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta),
ErrorCode.PERMISSION_DENIED,
);
});
});
describe('AuthService.refresh / logout', () => {
it('刷新旋转成功并吊销旧 jti', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
const pair = await service.refresh({ refreshToken });
expect(pair.refreshToken).not.toBe(refreshToken);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(prisma.refreshToken.create).toHaveBeenCalled();
});
it('已吊销 refresh 重放:整族吊销并返回 40101', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: new Date(),
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
await expectRejected(service.refresh({ refreshToken }), ErrorCode.UNAUTHENTICATED);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { familyId: 'fam1', revokedAt: null } }),
);
});
it('登出按 refresh 家族吊销;无 token 时按用户吊销', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
await service.logout({ refreshToken });
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
await service.logout({}, 'u1');
expect(prisma.refreshToken.updateMany).toHaveBeenCalledTimes(2);
});
});
describe('AuthService.changePassword', () => {
it('当前密码错误返回 40101;成功则改密并吊销全部会话', async () => {
const wrong = harness();
wrong.passwords.verify.mockResolvedValueOnce(false);
await expectRejected(
wrong.service.changePassword('u1', { currentPassword: 'wrong', newPassword: 'abc12345' }),
ErrorCode.UNAUTHENTICATED,
);
const { service, prisma } = harness();
await service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'abc12345' });
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ mustChangePassword: false }) }),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
});
it('新旧密码相同返回 42201', async () => {
const { service } = harness();
await expectRejected(
service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'lingxi2026' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
});
@@ -0,0 +1,229 @@
import {
type ChangePasswordInput,
ErrorCode,
type LoginInput,
type LoginResponse,
type LogoutInput,
type RefreshInput,
type TokenPair,
ulid,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { RefreshToken, User } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { AuthRequestMeta } from '../../common/security/auth.types';
import { PermissionService } from '../../common/security/permission.service';
import { ScopeResolver } from '../../common/security/scope/scope-resolver.service';
import { TokenService } from '../../common/security/token.service';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
/** 认证与会话:登录、刷新旋转、登出、强制改密 */
@Injectable()
export class AuthService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(TokenService) private readonly tokens: TokenService,
@Inject(PasswordService) private readonly passwords: PasswordService,
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
@Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(ScopeResolver) private readonly scopes: ScopeResolver,
@Inject(MeService) private readonly me: MeService,
) {}
async login(input: LoginInput, meta: AuthRequestMeta): Promise<LoginResponse> {
const account = input.account.trim().toLowerCase();
const status = await this.failures.status(account);
if (status.locked) {
throw this.lockedError();
}
const user = await this.findByAccount(account);
if (user === null) {
await this.failures.record(account);
throw this.badCredentials();
}
const matched = await this.passwords.verify(user.passwordHash, input.password);
if (!matched) {
await this.recordFailure(account);
throw this.badCredentials();
}
if (user.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
await this.failures.clear(account);
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
});
const auth = await this.permissions.resolveAuth(user.id);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
}
const scope = await this.scopes.resolve(auth);
const { pair } = await this.createTokenPair(user.id, null, meta);
const me = await this.me.getMe(user.id, auth.permissions, scope);
return { ...pair, me };
}
async refresh(input: RefreshInput): Promise<TokenPair> {
const payload = this.tokens.verifyRefresh(input.refreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
if (record === null) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
}
if (record.tokenHash !== this.tokens.hashToken(input.refreshToken)) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
}
if (record.revokedAt !== null) {
await this.revokeFamily(record.familyId);
throw new AppException({
code: ErrorCode.UNAUTHENTICATED,
message: '刷新令牌已失效,请重新登录',
});
}
if (record.expiresAt.getTime() <= Date.now()) {
throw new AppException({ code: ErrorCode.TOKEN_EXPIRED });
}
const user = await this.prisma.user.findUnique({ where: { id: record.userId } });
if (user === null || user.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
return this.rotate(record);
}
async logout(input: LogoutInput, userId?: string): Promise<void> {
if (input.refreshToken !== undefined) {
await this.revokeByToken(input.refreshToken);
return;
}
if (userId !== undefined) {
await this.revokeAllForUser(userId);
}
}
async changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
const matched = await this.passwords.verify(user.passwordHash, input.currentPassword);
if (!matched) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '当前密码不正确' });
}
if (input.currentPassword === input.newPassword) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '新密码不能与当前密码相同',
});
}
const passwordHash = await this.passwords.hash(input.newPassword);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false },
});
await this.revokeAllForUser(userId);
}
private async findByAccount(account: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { OR: [{ account }, { email: account }] },
});
}
private async recordFailure(account: string): Promise<void> {
const failures = await this.failures.record(account);
if (failures >= this.config.authMaxLoginFailures) {
throw this.lockedError();
}
}
private badCredentials(): AppException {
return new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '账号或密码不正确' });
}
private lockedError(): AppException {
return new AppException({
code: ErrorCode.RATE_LIMITED,
message: `登录失败次数过多,请 ${String(this.config.authLockMinutes)} 分钟后重试`,
});
}
private async createTokenPair(
userId: string,
familyId: string | null,
meta: AuthRequestMeta,
): Promise<{ pair: TokenPair; jti: string }> {
const jti = ulid();
const family = familyId ?? ulid();
const accessToken = this.tokens.issueAccess(userId);
const refreshToken = this.tokens.issueRefresh(userId, jti, family);
await this.prisma.refreshToken.create({
data: {
id: jti,
userId,
familyId: family,
tokenHash: this.tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + this.tokens.refreshTtlSeconds * 1000),
ip: meta.ip,
userAgent: meta.userAgent,
},
});
return { pair: { accessToken, refreshToken, expiresIn: this.tokens.accessTtlSeconds }, jti };
}
/** 刷新旋转:旧 token 原子吊销(revokedAt 为空才成功),新 token 同族;重放则整族吊销 */
private async rotate(record: RefreshToken): Promise<TokenPair> {
const { pair, jti } = await this.createTokenPair(record.userId, record.familyId, {
ip: record.ip,
userAgent: record.userAgent,
traceId: '',
});
const updated = await this.prisma.refreshToken.updateMany({
where: { id: record.id, revokedAt: null },
data: { revokedAt: new Date(), replacedById: jti },
});
if (updated.count === 0) {
await this.revokeFamily(record.familyId);
throw new AppException({
code: ErrorCode.UNAUTHENTICATED,
message: '刷新令牌已失效,请重新登录',
});
}
return pair;
}
private async revokeByToken(token: string): Promise<void> {
let payload;
try {
payload = this.tokens.verifyRefresh(token);
} catch {
return;
}
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
if (record === null) {
return;
}
await this.revokeFamily(record.familyId);
}
private async revokeFamily(familyId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
private async revokeAllForUser(userId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
}
@@ -0,0 +1,93 @@
import {
type CreateDepartmentInput,
createDepartmentInputSchema,
type DepartmentNode,
type DepartmentTreeQuery,
departmentTreeQuerySchema,
Permission,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
/** 组织架构:两级部门树 + 新建 / 更新 / 停用 / 启用(不提供删除) */
@ApiTags('identity-departments')
@ApiBearerAuth()
@Controller('departments')
export class DepartmentsController {
constructor(@Inject(DepartmentsService) private readonly departments: DepartmentsService) {}
@Get('tree')
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({
summary: '部门树(两级)',
description:
'memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true',
})
@ApiQuery({ name: 'include_disabled', required: false, enum: ['true', 'false'] })
async tree(
@Query(new ZodValidationPipe(departmentTreeQuerySchema)) query: DepartmentTreeQuery,
): Promise<{ items: DepartmentTreeNode[] }> {
return { items: await this.departments.tree(query.include_disabled) };
}
@Post()
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.create')
@ApiOperation({
summary: '新建部门 / 小组',
description: '最多两级;父级必须启用;负责人必须是启用用户;同级重名返回 40903',
})
async create(
@Body(new ZodValidationPipe(createDepartmentInputSchema)) input: CreateDepartmentInput,
): Promise<{ id: string }> {
return this.departments.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.update')
@ApiOperation({
summary: '更新部门(名称 / 负责人 / 排序)',
description: '上级关系创建后不可移动;负责人必须是启用用户',
})
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateDepartmentInputSchema)) input: UpdateDepartmentInput,
): Promise<{ id: string }> {
await this.departments.update(id, input);
return { id };
}
@Post(':id/disable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.disable')
@ApiOperation({
summary: '停用部门',
description:
'本部门仍有在职成员、或一级部门存在启用中的下级小组 / 下级在职成员时返回 42201;已停用返回 40903',
})
async disable(
@Param('id') id: string,
): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.disable(id);
}
@Post(':id/enable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.enable')
@ApiOperation({
summary: '启用部门',
description: '上级必须启用;已启用返回 40903',
})
async enable(@Param('id') id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
return this.departments.enable(id);
}
}
@@ -0,0 +1,284 @@
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const API = API_PREFIX;
const SUFFIX = String(Date.now()).slice(-6);
const prisma = new PrismaClient();
interface Node {
id: string;
name: string;
parentId: string | null;
status: 'active' | 'disabled';
sortOrder: number;
memberCount: number;
directMemberCount: number;
leader: { id: string; name: string } | null;
children: Node[];
}
interface ErrorBody {
code: number;
message: string;
}
function departmentName(label: string): string {
return `测试${label}${SUFFIX}`;
}
async function api(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<{ status: number; body: unknown }> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json() };
}
describe('departments 集成(真实 PG)', () => {
let created: CreatedApiApp;
let token = '';
let managerId = '';
let rootId = '';
let childId = '';
let siblingId = '';
let disabledRootId = '';
async function createDept(
name: string,
parentId: string | null,
extra: Record<string, unknown> = {},
): Promise<string> {
const response = await api(created.app, 'POST', '/departments', token, {
name,
parentId,
...extra,
});
expect(response.status).toBe(201);
return (response.body as { id: string }).id;
}
async function tree(includeDisabled: boolean): Promise<Node[]> {
const response = await api(
created.app,
'GET',
`/departments/tree?include_disabled=${String(includeDisabled)}`,
token,
);
expect(response.status).toBe(200);
return (response.body as { items: Node[] }).items;
}
function findNode(nodes: Node[], id: string): Node | undefined {
for (const node of nodes) {
if (node.id === id) return node;
const found = findNode(node.children, id);
if (found !== undefined) return found;
}
return undefined;
}
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'tech' } });
const role = await prisma.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
managerId = ulid();
const account = `it.dep.${SUFFIX}`;
await prisma.user.create({
data: {
id: managerId,
name: '部门集成测试',
account,
email: `${account}@lingxi.cn`,
phone: `136${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
const login = await created.app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
expect(login.statusCode).toBe(200);
token = login.json<{ accessToken: string }>().accessToken;
rootId = await createDept(departmentName('一级部门'), null);
childId = await createDept(departmentName('小组A'), rootId);
siblingId = await createDept(departmentName('小组B'), rootId);
const disabledRoot = await prisma.department.create({
data: {
id: ulid(),
code: `test-disabled-${SUFFIX}`,
name: departmentName('停用部门'),
status: 'DISABLED',
},
select: { id: true },
});
disabledRootId = disabledRoot.id;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: 'it.dep.' } } });
// 只清理本测试创建的部门(先子后父),避免误删种子 / 其他用例的数据
await prisma.department.deleteMany({
where: { name: { startsWith: '测试' }, parentId: { not: null } },
});
await prisma.department.deleteMany({ where: { name: { startsWith: '测试' } } });
await prisma.$disconnect();
await created.app.close();
});
it('部门树:默认过滤停用,include_disabled=true 返回停用节点与新增字段', async () => {
const activeTree = await tree(false);
expect(findNode(activeTree, disabledRootId)).toBeUndefined();
const root = findNode(activeTree, rootId);
expect(root).toBeDefined();
expect(root?.status).toBe('active');
expect(root?.sortOrder).toBe(0);
expect(root?.directMemberCount).toBe(0);
expect(root?.memberCount).toBe(0);
expect(root?.children.map((child) => child.id).sort()).toEqual([childId, siblingId].sort());
const fullTree = await tree(true);
expect(findNode(fullTree, disabledRootId)?.status).toBe('disabled');
});
it('新建:两级可建、超两级 42201、同级重名 40903、负责人非法 42201', async () => {
const tooDeep = await api(created.app, 'POST', '/departments', token, {
name: departmentName('三级组'),
parentId: childId,
});
expect(tooDeep.status).toBe(422);
expect((tooDeep.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
const duplicated = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组A'),
parentId: rootId,
});
expect(duplicated.status).toBe(409);
expect((duplicated.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const badLeader = await api(created.app, 'POST', '/departments', token, {
name: departmentName('小组C'),
parentId: rootId,
leaderUserId: ulid(),
});
expect(badLeader.status).toBe(422);
expect((badLeader.body as ErrorBody).code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION);
});
it('更新:名称 / 排序 / 负责人生效,重名 40903', async () => {
const renamed = departmentName('小组A改');
const response = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: renamed,
sortOrder: 3,
leaderUserId: managerId,
});
expect(response.status).toBe(200);
const root = findNode(await tree(false), rootId);
const child = root?.children.find((item) => item.id === childId);
expect(child?.name).toBe(renamed);
expect(child?.sortOrder).toBe(3);
expect(child?.leader?.id).toBe(managerId);
const conflict = await api(created.app, 'PATCH', `/departments/${childId}`, token, {
name: departmentName('小组B'),
});
expect(conflict.status).toBe(409);
});
it('停用:有在职成员 42201;存在启用子部门 42201;全部停用后可停用一级部门', async () => {
const member = await prisma.user.create({
data: {
id: ulid(),
name: '临时成员',
account: `it.dep.member.${SUFFIX}`,
email: `it.dep.member.${SUFFIX}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: childId,
mustChangePassword: false,
},
select: { id: true },
});
const blockedByMember = await api(
created.app,
'POST',
`/departments/${childId}/disable`,
token,
);
expect(blockedByMember.status).toBe(422);
expect((blockedByMember.body as ErrorBody).message).toContain('在职成员');
const blockedByChild = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(blockedByChild.status).toBe(422);
expect((blockedByChild.body as ErrorBody).message).toMatch(/下级小组|在职成员/);
await prisma.user.delete({ where: { id: member.id } });
const disabledChild = await api(created.app, 'POST', `/departments/${childId}/disable`, token);
expect(disabledChild.status).toBe(200);
expect((disabledChild.body as { status: string }).status).toBe('disabled');
expect(findNode(await tree(false), childId)).toBeUndefined();
const disabledSibling = await api(
created.app,
'POST',
`/departments/${siblingId}/disable`,
token,
);
expect(disabledSibling.status).toBe(200);
const disabledRoot = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(disabledRoot.status).toBe(200);
const again = await api(created.app, 'POST', `/departments/${rootId}/disable`, token);
expect(again.status).toBe(409);
expect((again.body as ErrorBody).code).toBe(ErrorCode.STATE_NOT_ALLOWED);
});
it('启用:上级停用 42201;启用后上级可启用,子部门可再启用', async () => {
const childBlocked = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childBlocked.status).toBe(422);
expect((childBlocked.body as ErrorBody).message).toContain('上级部门');
const rootEnabled = await api(created.app, 'POST', `/departments/${rootId}/enable`, token);
expect(rootEnabled.status).toBe(200);
const childEnabled = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(childEnabled.status).toBe(200);
expect(findNode(await tree(false), childId)?.status).toBe('active');
const again = await api(created.app, 'POST', `/departments/${childId}/enable`, token);
expect(again.status).toBe(409);
});
it('不提供删除接口(DELETE 返回 404)', async () => {
const response = await api(created.app, 'DELETE', `/departments/${childId}`, token);
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,317 @@
import {
type CreateDepartmentInput,
type DepartmentNode,
ErrorCode,
ulid,
type UpdateDepartmentInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
/** 带负责人的部门行(树查询 select 结果) */
type DepartmentRow = Prisma.DepartmentGetPayload<{
include: { leader: { select: { id: true; name: true } } };
}>;
/** 部门树节点:契约节点 + 子节点(最多两级) */
export interface DepartmentTreeNode extends DepartmentNode {
children: DepartmentTreeNode[];
}
/** 状态在 DB 中用大写枚举值(与 users/roles 一致),API 契约用小写 */
const DB_ACTIVE = 'ACTIVE';
const DB_DISABLED = 'DISABLED';
const DUPLICATE_NAME_MESSAGE = '同级部门名称已存在';
function toApiStatus(status: string): DepartmentNode['status'] {
return status === DB_ACTIVE ? 'active' : 'disabled';
}
function codeOf(name: string): string {
const normalized = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return `${normalized.length > 0 ? normalized.slice(0, 40) : 'dept'}-${ulid().slice(-6).toLowerCase()}`;
}
function mapDepartmentError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: DUPLICATE_NAME_MESSAGE });
}
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
}
/** 组织架构:两级部门树、新建、更新、停用 / 启用(不提供删除) */
@Injectable()
export class DepartmentsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async tree(includeDisabled: boolean): Promise<DepartmentTreeNode[]> {
const [departments, counts, lineage] = await Promise.all([
this.prisma.department.findMany({
where: includeDisabled ? {} : { status: DB_ACTIVE },
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: { leader: { select: { id: true, name: true } } },
}),
this.prisma.user.groupBy({
by: ['departmentId'],
where: { status: 'ACTIVE' },
_count: { _all: true },
}),
// 全量父子关系:即使子部门被过滤,也要把其直属在职成员计入一级部门 memberCount
this.prisma.department.findMany({ select: { id: true, parentId: true } }),
]);
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
return this.buildTree(departments, byDept, lineage);
}
private buildTree(
departments: DepartmentRow[],
byDept: Map<string, number>,
lineage: { id: string; parentId: string | null }[],
): DepartmentTreeNode[] {
const nodes = new Map<string, DepartmentTreeNode>();
for (const department of departments) {
const direct = byDept.get(department.id) ?? 0;
nodes.set(department.id, {
id: department.id,
name: department.name,
parentId: department.parentId,
leader: department.leader,
status: toApiStatus(department.status),
sortOrder: department.sortOrder,
memberCount: direct,
directMemberCount: direct,
createdAt: department.createdAt.toISOString(),
children: [],
});
}
const roots: DepartmentTreeNode[] = [];
for (const node of nodes.values()) {
if (node.parentId === null) {
roots.push(node);
continue;
}
nodes.get(node.parentId)?.children.push(node);
}
const childDirect = new Map<string, number>();
for (const row of lineage) {
if (row.parentId !== null) {
childDirect.set(
row.parentId,
(childDirect.get(row.parentId) ?? 0) + (byDept.get(row.id) ?? 0),
);
}
}
for (const root of roots) {
root.memberCount += childDirect.get(root.id) ?? 0;
}
return roots;
}
async create(input: CreateDepartmentInput): Promise<{ id: string }> {
await this.assertParentUsable(input.parentId);
await this.assertLeaderUsable(input.leaderUserId);
await this.assertNameFree(input.name, input.parentId, null);
try {
const created = await this.prisma.department.create({
data: {
id: ulid(),
name: input.name,
parentId: input.parentId,
leaderUserId: input.leaderUserId ?? null,
sortOrder: input.sortOrder ?? 0,
status: DB_ACTIVE,
code: codeOf(input.name),
},
select: { id: true },
});
return created;
} catch (error) {
throw mapDepartmentError(error);
}
}
async update(id: string, input: UpdateDepartmentInput): Promise<void> {
const existing = await this.findOrThrow(id);
if (input.leaderUserId !== undefined) {
await this.assertLeaderUsable(input.leaderUserId);
}
if (input.name !== undefined) {
await this.assertNameFree(input.name, existing.parentId, id);
}
const data: Prisma.DepartmentUpdateInput = {
...(input.name === undefined ? {} : { name: input.name }),
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
...(input.leaderUserId === undefined
? {}
: {
leader:
input.leaderUserId === null
? { disconnect: true }
: { connect: { id: input.leaderUserId } },
}),
};
try {
await this.prisma.department.update({ where: { id }, data });
} catch (error) {
throw mapDepartmentError(error);
}
}
async disable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status !== DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是停用状态' });
}
await this.assertNoActiveMembers(existing);
await this.prisma.department.update({ where: { id }, data: { status: DB_DISABLED } });
return { id, status: 'disabled' };
}
async enable(id: string): Promise<{ id: string; status: DepartmentNode['status'] }> {
const existing = await this.findOrThrow(id);
if (existing.status === DB_ACTIVE) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '部门已是启用状态' });
}
await this.assertParentActiveForEnable(existing.parentId);
await this.prisma.department.update({ where: { id }, data: { status: DB_ACTIVE } });
return { id, status: 'active' };
}
private async findOrThrow(id: string): Promise<{
id: string;
parentId: string | null;
status: string;
}> {
const department = await this.prisma.department.findUnique({
where: { id },
select: { id: true, parentId: true, status: true },
});
if (department === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
}
return department;
}
private async assertParentUsable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { parentId: true, status: true },
});
if (parent === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
}
if (parent.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,无法在其下新建部门',
});
}
if (parent.parentId !== null) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '部门最多两级,不能在小组下再建部门',
});
}
}
private async assertLeaderUsable(leaderUserId: string | null | undefined): Promise<void> {
if (leaderUserId === null || leaderUserId === undefined) {
return;
}
const user = await this.prisma.user.findUnique({
where: { id: leaderUserId },
select: { status: true },
});
if (user?.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '负责人必须是启用状态的用户',
});
}
}
private async assertNameFree(
name: string,
parentId: string | null,
excludeId: string | null,
): Promise<void> {
const duplicated = await this.prisma.department.findFirst({
where: {
parentId,
name,
...(excludeId === null ? {} : { id: { not: excludeId } }),
},
select: { id: true },
});
if (duplicated !== null) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: DUPLICATE_NAME_MESSAGE,
});
}
}
private async assertNoActiveMembers(department: {
id: string;
parentId: string | null;
}): Promise<void> {
const direct = await this.prisma.user.count({
where: { departmentId: department.id, status: 'ACTIVE' },
});
if (direct > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `部门下仍有 ${String(direct)} 名在职成员,请先调整成员所属部门后再停用`,
});
}
if (department.parentId !== null) {
return;
}
const activeChildren = await this.prisma.department.count({
where: { parentId: department.id, status: DB_ACTIVE },
});
if (activeChildren > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `存在 ${String(activeChildren)} 个启用中的下级小组,请先停用下级小组`,
});
}
const childMembers = await this.prisma.user.count({
where: { status: 'ACTIVE', department: { parentId: department.id } },
});
if (childMembers > 0) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: `下级小组仍有 ${String(childMembers)} 名在职成员,请先调整成员所属部门`,
});
}
}
private async assertParentActiveForEnable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({
where: { id: parentId },
select: { status: true },
});
if (parent?.status !== DB_ACTIVE) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '上级部门已停用,请先启用上级部门',
});
}
}
}
@@ -0,0 +1,647 @@
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const ACCOUNT_PREFIX = 'it.';
const API = API_PREFIX;
const prisma = new PrismaClient();
interface FixtureUser {
id: string;
account: string;
}
interface MeBody {
permissions: string[];
dataScopes: { type: string; customerIds?: string[] }[];
user: { id: string; phone?: string };
mustChangePassword: boolean;
}
interface TokenBody {
accessToken: string;
refreshToken: string;
expiresIn: number;
me: MeBody;
}
interface ErrorBody {
code: number;
message: string;
}
async function createFixtureUser(suffix: string, roleCode: string): Promise<FixtureUser> {
const [department, role] = await Promise.all([
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
]);
const account = `${ACCOUNT_PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: `137${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
return { id, account };
}
async function login(
app: CreatedApiApp['app'],
account: string,
): Promise<{ status: number; body: TokenBody }> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
return { status: response.statusCode, body: response.json<TokenBody>() };
}
async function authed(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH',
url: string,
token: string,
payload?: unknown,
): Promise<number> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return response.statusCode;
}
async function setRole(userId: string, roleCode: string): Promise<void> {
const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } });
await prisma.userRole.deleteMany({ where: { userId } });
await prisma.userRole.create({ data: { userId, roleId: role.id } });
}
interface JsonRequestResult<T> {
status: number;
body: T;
}
async function callJson<T>(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
url: string,
token: string,
payload?: unknown,
): Promise<JsonRequestResult<T>> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return { status: response.statusCode, body: response.json<T>() };
}
const ROLE_NAME_PREFIX = 'IT-角色-';
/** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */
const createdRoleIds: string[] = [];
/** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */
async function createTestRole(input: {
name: string;
permissions?: string[];
isSystem?: boolean;
dataScope?: { type: string; targetIds?: string[] };
status?: 'ACTIVE' | 'DISABLED';
}): Promise<{ id: string; name: string }> {
const id = ulid();
const role = await prisma.role.create({
data: {
id,
code: `it_role_${id.toLowerCase()}`,
name: `${ROLE_NAME_PREFIX}${input.name}`,
description: '集成测试角色',
isSystem: input.isSystem ?? false,
status: input.status ?? 'ACTIVE',
permissions: {
create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })),
},
dataScopes: {
create: [
{
id: ulid(),
scopeType: input.dataScope?.type ?? 'created_by_me',
targetId: input.dataScope?.targetIds?.[0] ?? null,
},
],
},
},
});
createdRoleIds.push(role.id);
return { id: role.id, name: role.name };
}
async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise<FixtureUser> {
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
const account = `${ACCOUNT_PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: `138${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId }] },
},
});
return { id, account };
}
describe('identity 集成(真实 PG / Redis)', () => {
let created: CreatedApiApp;
let manager: FixtureUser;
let viewer: FixtureUser;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
manager = await createFixtureUser('manager', 'platform_admin');
viewer = await createFixtureUser('viewer', 'designer');
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } });
if (createdRoleIds.length > 0) {
await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } });
}
await prisma.$disconnect();
await created.app.close();
});
it('登录返回 tokenPair + me,且权限点来自角色', async () => {
const { status, body } = await login(created.app, manager.account);
expect(status).toBe(200);
expect(body.refreshToken.length).toBeGreaterThan(0);
expect(body.expiresIn).toBeGreaterThan(0);
expect(body.me.permissions).toContain('user:manage');
expect(body.me.mustChangePassword).toBe(false);
expect(body.me.user.phone).toBeDefined();
});
it('/me 与登录上下文一致;列表只回 phoneMasked,详情对 user:manage 回全号', async () => {
const { body } = await login(created.app, manager.account);
const meResponse = await created.app.inject({
method: 'GET',
url: `${API}/me`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(meResponse.statusCode).toBe(200);
const me = meResponse.json<{ user: { account: string } }>();
expect(me.user.account).toBe(manager.account);
const listResponse = await created.app.inject({
method: 'GET',
url: `${API}/users?limit=5`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json<{
items: Record<string, unknown>[];
next_cursor: string | null;
}>();
expect(list.items.length).toBeGreaterThan(0);
expect(list.items[0]?.phoneMasked).toMatch(/^\d{3}\*{4}\d{4}$/);
expect(list.items[0]?.phone).toBeUndefined();
expect(list.next_cursor === null || typeof list.next_cursor === 'string').toBe(true);
const detailResponse = await created.app.inject({
method: 'GET',
url: `${API}/users/${manager.id}`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(detailResponse.statusCode).toBe(200);
const detail = detailResponse.json<{ phone?: string }>();
expect(detail.phone).toBeDefined();
});
it('无 user:view 权限返回 40301;授予角色后下一次请求立即生效', async () => {
const { body } = await login(created.app, viewer.account);
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(403);
await setRole(viewer.id, 'platform_admin');
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(200);
});
it('刷新旋转:旧 refresh 立即失效,重放触发整族吊销', async () => {
const { body } = await login(created.app, manager.account);
const refreshed = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(refreshed.statusCode).toBe(200);
const next = refreshed.json<{ refreshToken: string }>();
expect(next.refreshToken).not.toBe(body.refreshToken);
const replay = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(replay.statusCode).toBe(401);
const revoked = replay.json<ErrorBody>();
expect(revoked.code).toBe(ErrorCode.UNAUTHENTICATED);
});
it('停用后旧 access token 立即失效,且无法再登录', async () => {
const { body } = await login(created.app, viewer.account);
const managerLogin = await login(created.app, manager.account);
const managerDisabled = await created.app.inject({
method: 'POST',
url: `${API}/users/${viewer.id}/disable`,
headers: { authorization: `Bearer ${managerLogin.body.accessToken}` },
payload: { reason: '集成测试停用' },
});
expect(managerDisabled.statusCode).toBe(200);
const meAfter = await created.app.inject({
method: 'GET',
url: `${API}/me`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(meAfter.statusCode).toBe(403);
expect(meAfter.json<ErrorBody>().code).toBe(ErrorCode.PERMISSION_DENIED);
const relogin = await login(created.app, viewer.account);
expect(relogin.status).toBe(403);
});
it('登出后 refresh 不可用;部门树与角色列表可读', async () => {
const { body } = await login(created.app, manager.account);
const logout = await created.app.inject({
method: 'POST',
url: `${API}/auth/logout`,
payload: { refreshToken: body.refreshToken },
});
expect(logout.statusCode).toBe(204);
const refreshAfter = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(refreshAfter.statusCode).toBe(401);
const tree = await created.app.inject({
method: 'GET',
url: `${API}/departments/tree`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(tree.statusCode).toBe(200);
const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>();
expect(treeBody.items.length).toBeGreaterThanOrEqual(3);
// 根部门按 sortOrder 排序,管理员可新建无子级的根部门并排在最前;
// 这里只校验树里存在带子级的根部门,不依赖当前第一项(本地手工数据会让测试失真)。
expect(treeBody.items.some((item) => item.children.length > 0)).toBe(true);
const roles = await created.app.inject({
method: 'GET',
url: `${API}/roles`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(roles.statusCode).toBe(200);
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
});
// -------------------------------------------------------------------------
// A2:角色权限、权限树、SoD、数据范围、停用即时生效
// -------------------------------------------------------------------------
it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
modules: { module: string; moduleLabel: string }[];
specialPermissions: { code: string }[];
sodPairs: { id: string }[];
totalPermissions: number;
}>(created.app, 'GET', '/permissions', body.accessToken);
expect(response.status).toBe(200);
expect(response.body.totalPermissions).toBe(30);
expect(response.body.modules.map((module) => module.module)).toEqual([
'script',
'material',
'asset',
'delivery',
'customer',
'customer_group',
'identity',
'industry',
'platform',
'audit',
]);
expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组');
expect(response.body.specialPermissions).toHaveLength(6);
expect(response.body.sodPairs).toHaveLength(3);
// 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响)
const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } });
const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id);
const designerLogin = await login(created.app, designerOnly.account);
const denied = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
designerLogin.body.accessToken,
);
expect(denied.status).toBe(403);
expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED);
});
it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => {
const { body } = await login(created.app, manager.account);
const response = await callJson<{
items: {
id: string;
code: string;
name: string;
isSystem: boolean;
permissionCount: number;
specialPermissionCount: number;
userCount: number;
dataScope: { type: string };
}[];
}>(created.app, 'GET', '/roles', body.accessToken);
expect(response.status).toBe(200);
const codes = response.body.items.map((item) => item.code);
expect(codes).not.toContain('review_lead');
expect(codes).toContain('material_operator');
const superAdmin = response.body.items.find((item) => item.code === 'super_admin');
expect(superAdmin?.isSystem).toBe(true);
expect(superAdmin?.permissionCount).toBe(24);
expect(superAdmin?.specialPermissionCount).toBe(6);
expect(superAdmin?.dataScope).toEqual({ type: 'all' });
const all = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles?status=all',
body.accessToken,
);
expect(all.body.items.map((item) => item.code)).toContain('review_lead');
// A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选)
const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] });
const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id);
const selectorLogin = await login(created.app, selector.account);
const selectorRoles = await callJson<{ items: { code: string }[] }>(
created.app,
'GET',
'/roles',
selectorLogin.body.accessToken,
);
expect(selectorRoles.status).toBe(200);
expect(selectorRoles.body.items.length).toBeGreaterThan(0);
const selectorPermissions = await callJson<{ code: number }>(
created.app,
'GET',
'/permissions',
selectorLogin.body.accessToken,
);
expect(selectorPermissions.status).toBe(403);
});
it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => {
const { body } = await login(created.app, manager.account);
const createdRole = await callJson<{
role: {
id: string;
code: string;
status: string;
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
boundUsers: unknown[];
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'POST', '/roles', body.accessToken, {
name: `${ROLE_NAME_PREFIX}内容运营`,
permissions: ['script:read'],
dataScope: { type: 'department' },
});
expect(createdRole.status).toBe(201);
createdRoleIds.push(createdRole.body.role.id);
expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/);
expect(createdRole.body.role.permissions).toEqual(['script:read']);
expect(createdRole.body.role.specialPermissions).toEqual([]);
expect(createdRole.body.role.dataScope).toEqual({ type: 'department' });
expect(createdRole.body.affectedUserCount).toBe(0);
const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}互斥冲突`,
permissions: ['script:write', 'script:review'],
dataScope: { type: 'all' },
},
);
expect(sod.status).toBe(403);
expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION);
expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review');
const specialInNormal = await callJson<{ code: number }>(
created.app,
'POST',
'/roles',
body.accessToken,
{
name: `${ROLE_NAME_PREFIX}特殊权限错位`,
permissions: ['delivery:export'],
dataScope: { type: 'all' },
},
);
expect(specialInNormal.status).toBe(400);
expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID);
});
it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({
name: '待编辑',
permissions: ['script:read'],
dataScope: { type: 'department' },
});
const boundUser = await createFixtureUserWithRoleId('role-edit', role.id);
const patched = await callJson<{
role: {
permissions: string[];
specialPermissions: string[];
dataScope: { type: string };
status: string;
};
affectedUserCount: number;
warnings: string[];
}>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, {
permissions: ['script:read', 'user:view'],
specialPermissions: ['delivery:export'],
dataScope: { type: 'owned_customers' },
});
expect(patched.status).toBe(200);
expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']);
expect(patched.body.role.specialPermissions).toEqual(['delivery:export']);
expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' });
expect(patched.body.affectedUserCount).toBe(1);
const outbox = await prisma.outboxEvent.findFirst({
where: { aggregateId: role.id, eventType: 'role.permissions.changed' },
orderBy: { occurredAt: 'desc' },
});
expect(outbox).not.toBeNull();
const payload = outbox?.payloadJson as {
roleId: string;
affectedUserIds: string[];
changes: { permissions: { added: string[]; removed: string[] } };
};
expect(payload.roleId).toBe(role.id);
expect(payload.affectedUserIds).toEqual([boundUser.id]);
expect(payload.changes.permissions.added).toEqual(['user:view']);
// 系统角色保护:改权限 40903,仅显示名可改
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const protectedUpdate = await callJson<{ code: number }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ permissions: ['user:view'] },
);
expect(protectedUpdate.status).toBe(409);
expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const renamed = await callJson<{ role: { name: string } }>(
created.app,
'PATCH',
`/roles/${superAdminRole.id}`,
body.accessToken,
{ name: '超级管理员' },
);
expect(renamed.status).toBe(200);
expect(renamed.body.role.name).toBe('超级管理员');
});
it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => {
const { body } = await login(created.app, manager.account);
const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } });
const systemDelete = await callJson<{ code: number }>(
created.app,
'DELETE',
`/roles/${superAdminRole.id}`,
body.accessToken,
);
expect(systemDelete.status).toBe(409);
expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
const bound = await createTestRole({ name: '有绑定' });
await createFixtureUserWithRoleId('role-bound', bound.id);
const boundDelete = await callJson<{ code: number; details: { userCount: number } }>(
created.app,
'DELETE',
`/roles/${bound.id}`,
body.accessToken,
);
expect(boundDelete.status).toBe(409);
expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
expect(boundDelete.body.details.userCount).toBe(1);
const free = await createTestRole({ name: '无绑定' });
const freeDelete = await created.app.inject({
method: 'DELETE',
url: `${API}/roles/${free.id}`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(freeDelete.statusCode).toBe(204);
const gone = await prisma.role.findUnique({ where: { id: free.id } });
expect(gone).toBeNull();
});
it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => {
const { body } = await login(created.app, manager.account);
const role = await createTestRole({ name: '待停用', permissions: ['user:view'] });
const user = await createFixtureUserWithRoleId('role-disable', role.id);
const userLogin = await login(created.app, user.account);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'disabled', reason: '集成测试停用' },
);
expect(disabled.status).toBe(200);
expect(disabled.body.role.status).toBe('disabled');
expect(disabled.body.affectedUserCount).toBe(1);
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403);
const enabled = await callJson<{ role: { status: string } }>(
created.app,
'PATCH',
`/roles/${role.id}`,
body.accessToken,
{ status: 'enabled' },
);
expect(enabled.body.role.status).toBe('active');
// 再启用后同一 access token 恢复权限(每请求解析,无缓存)
expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200);
});
it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => {
const adminLogin = await login(created.app, manager.account);
expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]);
const adminMe = await callJson<MeBody>(created.app, 'GET', '/me', adminLogin.body.accessToken);
expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]);
const ownedRole = await createTestRole({
name: '负责客户范围',
permissions: ['user:view'],
dataScope: { type: 'owned_customers' },
});
const deptRole = await createTestRole({
name: '部门范围',
permissions: ['customer:read'],
dataScope: { type: 'department' },
});
const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id);
await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } });
const userLogin = await login(created.app, user.account);
expect(userLogin.status).toBe(200);
const scopes = userLogin.body.me.dataScopes;
expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']);
for (const scope of scopes) {
expect(Array.isArray(scope.customerIds)).toBe(true);
}
// A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户
expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]);
});
});
@@ -0,0 +1,40 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service';
import { MeController } from './me.controller';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { PermissionsController } from './permissions.controller';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
/**
* identity 模块(技术架构 §3):组织与账号、认证与会话、角色绑定、/me 权限上下文。
* 守卫链与请求上下文由 @Global SecurityModule 提供,本模块不重复注册。
*/
@Module({
controllers: [
AuthController,
MeController,
UsersController,
DepartmentsController,
RolesController,
PermissionsController,
],
providers: [
AuthService,
MeService,
PasswordService,
LoginFailureService,
UsersService,
DepartmentsService,
RolesService,
],
})
export class IdentityModule {}
@@ -0,0 +1,620 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service';
import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { RolesService } from './roles.service';
import { maskPhone, toUserDetail, toUserSummary } from './user.mapper';
import { UsersService } from './users.service';
function userFixture(overrides: Partial<UserAuthRecord> = {}): UserAuthRecord {
const base: UserAuthRecord = {
id: 'u1',
account: 'chen.mo',
email: 'chen.mo@lingxi.cn',
name: '陈默',
phone: '13866202046',
passwordHash: 'hash',
departmentId: 'g1',
jobTitle: '主管',
status: 'ACTIVE',
mustChangePassword: false,
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
lastLoginIp: '192.168.12.46',
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
department: {
id: 'g1',
code: 'content-g1',
name: '素材一组',
parentId: 'd1',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
parent: {
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
children: [],
},
roles: [
{
userId: 'u1',
roleId: 'r1',
createdAt: new Date(),
role: {
id: 'r1',
code: 'material_operator',
name: '素材运营',
description: '素材生产',
isSystem: false,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [],
dataScopes: [],
},
},
{
userId: 'u1',
roleId: 'r2',
createdAt: new Date(),
role: {
id: 'r2',
code: 'account_owner',
name: '客户负责人',
description: '',
isSystem: false,
status: 'DISABLED',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [],
dataScopes: [],
},
},
],
};
return { ...base, ...overrides };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('user.mapper', () => {
it('手机号脱敏与部门 / 小组拆分', () => {
expect(maskPhone('13866202046')).toBe('138****2046');
const summary = toUserSummary(userFixture());
expect(summary.departmentId).toBe('d1');
expect(summary.departmentName).toBe('内容生产中心');
expect(summary.groupId).toBe('g1');
expect(summary.groupName).toBe('素材一组');
expect(summary.status).toBe('active');
expect(summary.roles).toHaveLength(1);
expect(summary.roles[0]?.code).toBe('material_operator');
expect(summary.lastLoginAt).toBe('2026-11-02T01:41:00.000Z');
});
it('一级部门直属:groupId 为空;详情按权限决定是否给全号', () => {
const direct = toUserSummary(
userFixture({
department: {
...userFixture().department,
id: 'd1',
name: '平台技术部',
parentId: null,
parent: null,
},
}),
);
expect(direct.groupId).toBeNull();
expect(direct.departmentName).toBe('平台技术部');
const withPhone = toUserDetail(userFixture(), true);
const withoutPhone = toUserDetail(userFixture(), false);
expect(withPhone.phone).toBe('13866202046');
expect(withoutPhone.phone).toBeUndefined();
expect(withPhone.mustChangePassword).toBe(false);
});
});
describe('PasswordService', () => {
it('哈希后可校验,错误口令与非法哈希返回 false', async () => {
const service = new PasswordService();
const hash = await service.hash('lingxi2026');
await expect(service.verify(hash, 'lingxi2026')).resolves.toBe(true);
await expect(service.verify(hash, 'wrong-pass1')).resolves.toBe(false);
await expect(service.verify('not-a-hash', 'x')).resolves.toBe(false);
});
});
describe('LoginFailureService', () => {
function fakeRedis(): {
service: LoginFailureService;
store: Map<string, number>;
del: () => void;
} {
const store = new Map<string, number>();
const client = {
status: 'ready',
connect: vi.fn().mockResolvedValue(undefined),
get: vi.fn((key: string) => Promise.resolve(store.has(key) ? String(store.get(key)) : null)),
del: vi.fn((key: string) => {
store.delete(key);
return Promise.resolve(1);
}),
multi: vi.fn(() => {
let key = '';
const api = {
incr: (target: string) => {
key = target;
store.set(key, (store.get(key) ?? 0) + 1);
return api;
},
expire: () => api,
exec: () => Promise.resolve([[null, store.get(key) ?? 0]]),
};
return api;
}),
};
const service = new LoginFailureService(
{ getClient: () => client } as never,
{ authMaxLoginFailures: 3, authLockMinutes: 15 } as never,
);
return {
service,
store,
del: () => {
store.clear();
},
};
}
it('累计失败达阈值后锁定,成功登录清零', async () => {
const { service, store, del } = fakeRedis();
await expect(service.status('Chen.Mo')).resolves.toEqual({ locked: false, remaining: 3 });
await service.record('chen.mo');
await service.record('chen.mo');
await expect(service.count('chen.mo')).resolves.toBe(2);
await expect(service.status('chen.mo')).resolves.toEqual({ locked: false, remaining: 1 });
await service.record('chen.mo');
await expect(service.status('chen.mo')).resolves.toEqual({ locked: true, remaining: 0 });
await service.clear('chen.mo');
await expect(service.count('chen.mo')).resolves.toBe(0);
del();
expect(store.size).toBe(0);
});
});
describe('UsersService', () => {
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
function serviceOf(prisma: Record<string, unknown>): UsersService {
return new UsersService(
prisma as unknown as PrismaService,
passwords as unknown as PasswordService,
);
}
it('详情:user:manage 才带手机号全号', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = serviceOf(prisma);
const withPhone = await service.detail('u1', ['user:manage']);
const withoutPhone = await service.detail('u1', ['user:view']);
expect(withPhone.phone).toBe('13866202046');
expect(withoutPhone.phone).toBeUndefined();
});
it('列表:游标分页、状态默认 active、排序白名单', async () => {
const rows = [userFixture({ id: 'u1' }), userFixture({ id: 'u2' }), userFixture({ id: 'u3' })];
const findMany = vi.fn().mockResolvedValue(rows);
const service = serviceOf({ user: { findMany } });
const result = await service.list({
limit: 2,
status: 'active',
sort: 'last_login',
order: 'desc',
});
expect(result.items).toHaveLength(2);
expect(result.next_cursor).toBe('u2');
const args = findMany.mock.calls[0]?.[0] as {
where: { status: string };
orderBy: unknown[];
take: number;
};
expect(args.where.status).toBe('ACTIVE');
expect(args.take).toBe(3);
expect(args.orderBy).toEqual([{ lastLoginAt: { sort: 'desc', nulls: 'last' } }, { id: 'asc' }]);
});
it('停用:重复停用 40903,停用自己 42201,客户转交占位 42201', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = serviceOf(prisma);
await expectRejected(service.disable('u1', {}, 'u1', []), ErrorCode.BUSINESS_RULE_VIOLATION);
const disabled = userFixture({ status: 'DISABLED' });
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
});
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
const transaction = vi.fn().mockResolvedValue([]);
const findUnique = vi
.fn()
.mockResolvedValueOnce(userFixture())
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
const service = serviceOf({
user: { findUnique, update: vi.fn().mockResolvedValue(userFixture()) },
refreshToken: { updateMany: vi.fn() },
$transaction: transaction,
});
const detail = await service.disable('u1', { reason: '离职' }, 'u9', ['user:manage']);
expect(detail.status).toBe('disabled');
expect(transaction).toHaveBeenCalledTimes(1);
const enableService = serviceOf({
user: {
findUnique: vi
.fn()
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }))
.mockResolvedValueOnce(userFixture()),
update: vi.fn().mockResolvedValue(userFixture()),
},
});
const enabled = await enableService.enable('u1', ['user:manage']);
expect(enabled.status).toBe('active');
});
});
describe('DepartmentsService', () => {
function serviceOf(prisma: Record<string, unknown>): DepartmentsService {
return new DepartmentsService(prisma as unknown as PrismaService);
}
function departmentRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 0,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: null,
...overrides,
};
}
it('部门树:默认只返回启用部门;memberCount 含下级、directMemberCount 仅直属', async () => {
const rows = [
departmentRow({ id: 'd1', name: '内容生产中心' }),
departmentRow({ id: 'g1', name: '素材一组', parentId: 'd1' }),
];
const findMany = vi.fn().mockResolvedValue(rows);
const service = serviceOf({
department: { findMany },
user: {
groupBy: vi.fn().mockResolvedValue([
{ departmentId: 'd1', _count: { _all: 3 } },
{ departmentId: 'g1', _count: { _all: 5 } },
]),
},
});
const tree = await service.tree(false);
expect(findMany.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ where: { status: 'ACTIVE' } }),
);
expect(tree).toHaveLength(1);
expect(tree[0]?.memberCount).toBe(8);
expect(tree[0]?.directMemberCount).toBe(3);
expect(tree[0]?.children[0]?.name).toBe('素材一组');
expect(tree[0]?.children[0]?.memberCount).toBe(5);
expect(tree[0]?.status).toBe('active');
});
it('部门树:include_disabled=true 时包含停用部门且状态映射为 disabled', async () => {
const service = serviceOf({
department: {
findMany: vi
.fn()
.mockResolvedValue([
departmentRow({ id: 'd1' }),
departmentRow({ id: 'd2', status: 'DISABLED' }),
]),
},
user: { groupBy: vi.fn().mockResolvedValue([]) },
});
const tree = await service.tree(true);
expect(tree).toHaveLength(2);
expect(tree[1]?.status).toBe('disabled');
expect(tree[0]?.sortOrder).toBe(0);
});
it('新建:父级不存在 40001、父级停用 / 超两级 42201、负责人非法 42201', async () => {
await expectRejected(
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).create({
name: '新部门',
parentId: 'missing',
}),
ErrorCode.PARAM_INVALID,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: null, status: 'DISABLED' }),
},
}).create({ name: '新小组', parentId: 'd1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue({ parentId: 'd0', status: 'ACTIVE' }),
},
}).create({ name: '三级组', parentId: 'g1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: { findFirst: vi.fn() },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'DISABLED' }) },
}).create({ name: '新部门', parentId: null, leaderUserId: 'u-disabled' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('新建:同级重名 40903,成功后落库 sortOrder / status / 负责人', async () => {
const duplicated = serviceOf({
department: { findFirst: vi.fn().mockResolvedValue({ id: 'other' }) },
});
await expectRejected(
duplicated.create({ name: '内容生产中心', parentId: null }),
ErrorCode.STATE_NOT_ALLOWED,
);
const create = vi.fn().mockResolvedValue({ id: 'new-id' });
const service = serviceOf({
department: { findFirst: vi.fn().mockResolvedValue(null), create },
user: { findUnique: vi.fn().mockResolvedValue({ status: 'ACTIVE' }) },
});
await expect(
service.create({ name: '内容生产中心', parentId: null, leaderUserId: 'u1', sortOrder: 5 }),
).resolves.toEqual({ id: 'new-id' });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ sortOrder: 5, status: 'ACTIVE', leaderUserId: 'u1' }),
}),
);
});
it('更新:不存在 40401、负责人非法 42201、重名 40903、支持清空负责人', async () => {
await expectRejected(
serviceOf({ department: { findUnique: vi.fn().mockResolvedValue(null) } }).update('missing', {
name: '新名',
}),
ErrorCode.NOT_FOUND,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { findUnique: vi.fn().mockResolvedValue(null) },
}).update('d1', { leaderUserId: 'u-missing' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
findFirst: vi.fn().mockResolvedValue({ id: 'other' }),
},
}).update('d1', { name: '旧部门' }),
ErrorCode.STATE_NOT_ALLOWED,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()), update },
});
await service.update('d1', { leaderUserId: null, sortOrder: 2 });
expect(update).toHaveBeenCalledWith({
where: { id: 'd1' },
data: { sortOrder: 2, leader: { disconnect: true } },
});
});
it('停用:已停用 40903;本部门有在职成员 / 启用子部门 / 下级成员分别 42201', async () => {
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
},
}).disable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
user: { count: vi.fn().mockResolvedValue(2) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(1),
},
user: { count: vi.fn().mockResolvedValue(0) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
},
user: { count: vi.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(3) },
}).disable('d1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('停用成功:无成员无启用子部门时落库 DISABLED;不提供删除方法', async () => {
const update = vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' }));
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow()),
count: vi.fn().mockResolvedValue(0),
update,
},
user: { count: vi.fn().mockResolvedValue(0) },
});
await expect(service.disable('d1')).resolves.toEqual({ id: 'd1', status: 'disabled' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'DISABLED' } });
expect('delete' in service).toBe(false);
});
it('启用:已启用 40903、上级停用 42201、成功后落库 ACTIVE', async () => {
await expectRejected(
serviceOf({
department: { findUnique: vi.fn().mockResolvedValue(departmentRow()) },
}).enable('d1'),
ErrorCode.STATE_NOT_ALLOWED,
);
await expectRejected(
serviceOf({
department: {
findUnique: vi
.fn()
.mockResolvedValueOnce(departmentRow({ status: 'DISABLED', parentId: 'd0' }))
.mockResolvedValueOnce({ status: 'DISABLED' }),
},
}).enable('g1'),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
const update = vi.fn().mockResolvedValue(departmentRow());
const service = serviceOf({
department: {
findUnique: vi.fn().mockResolvedValue(departmentRow({ status: 'DISABLED' })),
update,
},
});
await expect(service.enable('d1')).resolves.toEqual({ id: 'd1', status: 'active' });
expect(update).toHaveBeenCalledWith({ where: { id: 'd1' }, data: { status: 'ACTIVE' } });
});
});
describe('MeService / RolesService', () => {
const scope = {
userId: 'u1',
isSuperAdmin: false,
dataScopes: [{ type: 'department_and_children' as const, customerIds: [] }],
customerIds: [] as string[],
};
it('/me:手机号按 user:manage 返回,dataScopes 来自 ScopeResolver', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = new MeService(prisma as unknown as PrismaService);
const me = await service.getMe('u1', ['user:manage'], scope);
expect(me.user.phone).toBe('13866202046');
expect(me.permissions).toEqual(['user:manage']);
expect(me.roles).toHaveLength(1);
expect(me.dataScopes).toEqual([{ type: 'department_and_children', customerIds: [] }]);
const meWithout = await service.getMe('u1', [], scope);
expect(meWithout.user.phone).toBeUndefined();
await expectRejected(
new MeService({
user: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService).getMe('u1', [], scope),
ErrorCode.UNAUTHENTICATED,
);
await expectRejected(
new MeService(prisma as unknown as PrismaService).getMe('u1', [], null),
ErrorCode.OUT_OF_SCOPE,
);
});
it('角色列表:聚合权限点统计、数据范围与用户数(默认只查启用角色)', async () => {
const prisma = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: 'r1',
code: 'reviewer',
name: '审核员',
description: '审核',
isSystem: false,
status: 'ACTIVE',
createdAt: new Date('2026-11-01T00:00:00.000Z'),
updatedAt: new Date('2026-11-02T00:00:00.000Z'),
permissions: [
{ permissionCode: 'script:read' },
{ permissionCode: 'script:review' },
{ permissionCode: 'delivery:export' },
],
dataScopes: [{ scopeType: 'department_and_children', targetId: null }],
_count: { users: 3 },
},
]),
},
};
const service = new RolesService(
prisma as unknown as PrismaService,
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
);
const result = await service.list({
status: 'active',
sort: 'created_at',
order: 'desc',
limit: 20,
});
expect(result.next_cursor).toBeNull();
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
id: 'r1',
code: 'reviewer',
status: 'active',
permissionCount: 2,
specialPermissionCount: 1,
userCount: 3,
dataScope: { type: 'department_and_children' },
});
});
});
@@ -0,0 +1,69 @@
import { Inject, Injectable } from '@nestjs/common';
import type Redis from 'ioredis';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import type { RedisService } from '../../infra/redis/redis.service';
import { REDIS } from '../../infra/tokens';
/**
* 登录失败计数(Redis):窗口内累计失败达阈值即锁定,成功登录清零。
* key 按账号归一化,避免大小写绕过;TTL 即锁定时长(默认 15 分钟)。
*/
@Injectable()
export class LoginFailureService {
constructor(
@Inject(REDIS) private readonly redis: RedisService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private key(account: string): string {
return `auth:login-fail:${account.trim().toLowerCase()}`;
}
private get client(): Redis {
return this.redis.getClient();
}
/** ioredis 为 lazyConnect:首次命令前显式连接,避免 enableOfflineQueue=false 直接报错 */
private async connected(): Promise<Redis> {
const client = this.client;
if (client.status === 'wait') {
await client.connect();
}
return client;
}
async count(account: string): Promise<number> {
const client = await this.connected();
const value = await client.get(this.key(account));
if (value === null) {
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
/** 是否已达锁定阈值;未达阈值时返回剩余可尝试次数 */
async status(account: string): Promise<{ locked: boolean; remaining: number }> {
const failures = await this.count(account);
const remaining = Math.max(0, this.config.authMaxLoginFailures - failures);
return { locked: failures >= this.config.authMaxLoginFailures, remaining };
}
async record(account: string): Promise<number> {
const key = this.key(account);
const client = await this.connected();
const multi = client.multi();
multi.incr(key);
multi.expire(key, this.config.authLockMinutes * 60);
const result = await multi.exec();
const incr = result?.[0]?.[1];
return typeof incr === 'number' ? incr : 0;
}
async clear(account: string): Promise<void> {
const client = await this.connected();
await client.del(this.key(account));
}
}
@@ -0,0 +1,29 @@
import { ErrorCode, type MeResponse } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import type { AuthContext } from '../../common/security/auth.types';
import { CurrentScope, CurrentUser } from '../../common/security/decorators';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import { MeService } from './me.service';
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制与数据范围展示 */
@ApiTags('identity-me')
@ApiBearerAuth()
@Controller('me')
export class MeController {
constructor(@Inject(MeService) private readonly me: MeService) {}
@Get()
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围(多角色并集)' })
async current(
@CurrentUser() auth: AuthContext | null,
@CurrentScope() scope: ScopeContext | null,
): Promise<MeResponse> {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return this.me.getMe(auth.userId, auth.permissions, scope);
}
}
@@ -0,0 +1,58 @@
import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../common/errors/app-exception';
import { activeRolesOf, type UserAuthRecord } from '../../common/security/permission.service';
import type { ScopeContext } from '../../common/security/scope/scope-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { toRoleSummary, toUserSummary } from './user.mapper';
/**
* /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围(多角色并集)。
* `dataScopes` 由 ScopeResolver 解析(每个元素带 customerIds;超管为 [{ type: 'all' }])。
*/
@Injectable()
export class MeService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async getMe(
userId: string,
permissions: string[],
scope: ScopeContext | null,
): Promise<MeResponse> {
const user = await this.findUser(userId);
if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
}
if (scope === null) {
throw new AppException({
code: ErrorCode.OUT_OF_SCOPE,
message: '数据范围解析失败,已拒绝访问',
});
}
const roles = activeRolesOf(user);
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
return {
user: {
...toUserSummary(user),
...(canSeePhone ? { phone: user.phone } : {}),
},
roles: roles.map((item) => toRoleSummary(item.role)),
permissions,
dataScopes: scope.dataScopes,
mustChangePassword: user.mustChangePassword,
};
}
/** /me 每次都读 DB(不缓存旧权限),角色变更下一次请求即生效 */
private async findUser(userId: string): Promise<UserAuthRecord | null> {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true, children: true } },
roles: { include: { role: { include: { permissions: true, dataScopes: true } } } },
},
});
}
}
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import argon2 from 'argon2';
/** Argon2id 口令哈希与校验;参数取库默认(内存 64MB / 3 轮),后续可按环境调优 */
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<string> {
return argon2.hash(plain, { type: argon2.argon2id });
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
return false;
}
}
}
@@ -0,0 +1,29 @@
import { Permission, type PermissionTree } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PermissionCatalogService } from '../../common/security/catalog/permission-catalog.service';
import { RequirePermissions } from '../../common/security/decorators';
/**
* 权限点目录(A2-BE-02):前端角色编辑器据此渲染「模块 × 操作」勾选树、
* 特殊权限区域与 SoD 互斥对高亮。目录由 `@lingxi/shared` 代码注册,不读业务 DB。
*/
@ApiTags('identity-permissions')
@ApiBearerAuth()
@Controller('permissions')
export class PermissionsController {
constructor(
@Inject(PermissionCatalogService) private readonly catalog: PermissionCatalogService,
) {}
@Get()
@RequirePermissions(Permission.ROLE_VIEW)
@ApiOperation({
summary: '权限点目录(10 模块树 + 特殊权限 + SoD 对)',
description: '模块顺序与 design/a2-ui-v1/brief.md 对齐;特殊权限默认关闭、单独授予',
})
getTree(): PermissionTree {
return this.catalog.buildTree();
}
}
@@ -0,0 +1,109 @@
import {
type CreateRoleInput,
createRoleInputSchema,
ErrorCode,
Permission,
type RoleDetail,
type RoleListQuery,
roleListQuerySchema,
type RoleListResponse,
type RoleSaveResult,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Inject,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
import { RolesService } from './roles.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 角色管理(A2-BE-01 / 03 / 05)。
*
* 兼容 A1:`GET /roles` 同时接受 `role:view` 与 `user:view`(用户管理页的角色多选),
* 默认只返回启用角色,返回项包含 `RoleSummary` 的 id / code / name / isSystem / description。
*/
@ApiTags('identity-roles')
@ApiBearerAuth()
@Controller('roles')
export class RolesController {
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
@Get()
@RequirePermissions(Permission.ROLE_VIEW, Permission.USER_VIEW)
@ApiOperation({
summary: '角色列表(游标分页 + 筛选 + 排序)',
description: '默认 status=active(兼容 A1 用户管理的角色多选);含权限点统计、用户数与数据范围',
})
async list(
@Query(new ZodValidationPipe(roleListQuerySchema)) query: RoleListQuery,
): Promise<RoleListResponse> {
return this.roles.list(query);
}
@Get(':id')
@RequirePermissions(Permission.ROLE_VIEW)
@ApiOperation({ summary: '角色详情(权限点、特殊权限、数据范围、绑定用户)' })
async detail(@Param('id') id: string): Promise<RoleDetail> {
return this.roles.detail(id);
}
@Post()
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.create')
@ApiOperation({
summary: '新建角色',
description: '权限点默认全空、code 自动生成;保存即做 SoD 校验(40304);特殊权限单独授予',
})
async create(
@Body(new ZodValidationPipe(createRoleInputSchema)) input: CreateRoleInput,
@CurrentUser() auth: AuthContext | null,
): Promise<RoleSaveResult> {
return this.roles.create(input, requireAuth(auth).userId);
}
@Patch(':id')
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.update')
@ApiOperation({
summary: '更新角色 / 启停',
description: '系统角色仅显示名可改;status=enabled/disabled;返回 affectedUserCount 与软提示',
})
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateRoleInputSchema)) input: UpdateRoleInput,
@CurrentUser() auth: AuthContext | null,
): Promise<RoleSaveResult> {
return this.roles.update(id, input, requireAuth(auth).userId);
}
@Delete(':id')
@HttpCode(204)
@RequirePermissions(Permission.ROLE_MANAGE)
@Audit('identity.role.delete')
@ApiOperation({ summary: '删除角色', description: '系统角色拒绝;有绑定用户(40903)只能停用' })
async remove(@Param('id') id: string, @CurrentUser() auth: AuthContext | null): Promise<void> {
await this.roles.remove(id, requireAuth(auth).userId);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,328 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { RolesService } from './roles.service';
const NOW = new Date('2026-11-03T02:00:00.000Z');
function roleFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'r1',
code: 'copywriter',
name: '文案策划',
description: '脚本撰写',
isSystem: false,
status: 'ACTIVE',
createdAt: NOW,
updatedAt: NOW,
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'script:write' }],
dataScopes: [{ scopeType: 'department', targetId: null }],
users: [
{
createdAt: NOW,
user: {
id: 'u1',
name: '周岸',
account: 'zhou.an',
phone: '13755205520',
status: 'ACTIVE',
department: { name: '素材二组' },
},
},
{
createdAt: NOW,
user: {
id: 'u2',
name: '林清',
account: 'lin.qing',
phone: '13912347712',
status: 'DISABLED',
department: { name: '设计组' },
},
},
],
_count: { users: 2 },
...overrides,
};
}
interface Harness {
service: RolesService;
prisma: {
role: {
findFirst: ReturnType<typeof vi.fn>;
findUnique: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
user: { count: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>;
tx: {
role: {
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
rolePermission: {
deleteMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>;
};
dataScope: { deleteMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn> };
outboxEvent: { create: ReturnType<typeof vi.fn> };
};
};
requestContext: { get: ReturnType<typeof vi.fn>; patchAudit: ReturnType<typeof vi.fn> };
}
function harness(): Harness {
const tx = {
role: { create: vi.fn().mockResolvedValue({}), update: vi.fn(), delete: vi.fn() },
rolePermission: { deleteMany: vi.fn(), createMany: vi.fn() },
dataScope: { deleteMany: vi.fn(), createMany: vi.fn() },
outboxEvent: { create: vi.fn().mockResolvedValue({}) },
};
const prisma = {
role: {
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(null),
count: vi.fn().mockResolvedValue(9),
},
user: { count: vi.fn().mockResolvedValue(2) },
$transaction: vi.fn(async (callback: unknown) => {
if (typeof callback === 'function') {
return (callback as (client: typeof tx) => Promise<unknown>)(tx);
}
return Promise.all(callback as Promise<unknown>[]);
}),
tx,
};
const requestContext = {
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
patchAudit: vi.fn(),
};
return {
service: new RolesService(
prisma as unknown as PrismaService,
requestContext as unknown as RequestContextService,
),
prisma,
requestContext,
};
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('RolesService.create', () => {
it('默认全空:code 自动生成、权限点为空、写审计与 outbox', async () => {
const { service, prisma, requestContext } = harness();
prisma.role.findUnique.mockImplementation((args: { where: { id: string } }) =>
Promise.resolve(
roleFixture({ id: args.where.id, code: 'custom_x', permissions: [], users: [] }),
),
);
const result = await service.create(
{ name: '新运营组', dataScope: { type: 'created_by_me' } },
'actor-1',
);
const createdArgs = prisma.tx.role.create.mock.calls[0]?.[0] as { data: { code: string } };
expect(createdArgs.data.code).toMatch(/^custom_[0-9a-z]{26}$/);
expect(result.role.permissions).toEqual([]);
expect(result.affectedUserCount).toBe(0);
expect(result.warnings).toEqual([]);
const createArgs = prisma.tx.role.create.mock.calls[0]?.[0] as {
data: { code: string; permissions: { create: unknown[] } };
};
expect(createArgs.data.permissions.create).toEqual([]);
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
data: { eventType: string; payloadJson: { action: string; roleId: string } };
};
expect(outboxArgs.data.eventType).toBe('role.permissions.changed');
expect(outboxArgs.data.payloadJson.action).toBe('created');
expect(requestContext.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({ targetType: 'role', affectedUserCount: 0 }),
);
});
it('角色名称重复返回 40902', async () => {
const { service, prisma } = harness();
prisma.role.findFirst.mockResolvedValueOnce({ id: 'other' });
await expectRejected(
service.create({ name: '文案策划', dataScope: { type: 'all' } }, 'actor-1'),
ErrorCode.DUPLICATE_SUBMIT,
);
});
it('SoD 命中返回 40304 并给出冲突对', async () => {
const { service } = harness();
let thrown: unknown;
try {
await service.create(
{
name: '冲突角色',
permissions: ['script:write', 'script:review'],
dataScope: { type: 'department' },
},
'actor-1',
);
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(AppException);
expect((thrown as AppException).code).toBe(ErrorCode.SOD_VIOLATION);
expect((thrown as AppException).details).toMatchObject({
conflicts: [{ pairId: 'script-write-review' }],
});
});
it('特殊权限混入 permissions 返回 40001', async () => {
const { service } = harness();
await expectRejected(
service.create(
{
name: '导出角色',
permissions: ['delivery:export'],
dataScope: { type: 'all' },
},
'actor-1',
),
ErrorCode.PARAM_INVALID,
);
});
});
describe('RolesService.update', () => {
it('系统角色仅允许改显示名:改权限 / 状态返回 40903,改名放行', async () => {
const { service, prisma } = harness();
const systemRole = roleFixture({ code: 'super_admin', isSystem: true });
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
await expectRejected(
service.update('r1', { permissions: ['user:view'] }, 'actor-1'),
ErrorCode.STATE_NOT_ALLOWED,
);
prisma.role.findUnique.mockResolvedValueOnce(systemRole);
await expectRejected(
service.update('r1', { status: 'disabled' }, 'actor-1'),
ErrorCode.STATE_NOT_ALLOWED,
);
prisma.role.findUnique
.mockResolvedValueOnce(systemRole)
.mockResolvedValueOnce(
roleFixture({ code: 'super_admin', isSystem: true, name: '新超管名' }),
);
const result = await service.update('r1', { name: '新超管名' }, 'actor-1');
expect(result.role.name).toBe('新超管名');
});
it('更新权限:返回受影响用户数、outbox 携带 diff、审计含前后值', async () => {
const { service, prisma, requestContext } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
roleFixture({
permissions: [{ permissionCode: 'script:read' }, { permissionCode: 'user:view' }],
}),
);
const result = await service.update(
'r1',
{ permissions: ['script:read', 'user:view'], status: 'disabled', reason: '岗位合并' },
'actor-1',
);
expect(result.affectedUserCount).toBe(2);
expect(result.role.specialPermissions).toEqual([]);
const updateArgs = prisma.tx.role.update.mock.calls[0]?.[0] as { data: { status: string } };
expect(updateArgs.data.status).toBe('DISABLED');
const outboxArgs = prisma.tx.outboxEvent.create.mock.calls[0]?.[0] as {
data: {
payloadJson: {
changes: { permissions: { added: string[]; removed: string[] } };
affectedUserIds: string[];
};
};
};
expect(outboxArgs.data.payloadJson.changes.permissions.added).toEqual(['user:view']);
expect(outboxArgs.data.payloadJson.changes.permissions.removed).toEqual(['script:write']);
expect(outboxArgs.data.payloadJson.affectedUserIds).toEqual(['u1', 'u2']);
expect(requestContext.patchAudit).toHaveBeenCalledWith(
expect.objectContaining({
affectedUserCount: 2,
affectedUserIds: ['u1', 'u2'],
reason: '岗位合并',
}),
);
});
it('更新数据范围:替换 data_scopes 行;customer_groups 多个客户组写多行', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture()).mockResolvedValueOnce(
roleFixture({
dataScopes: [
{ scopeType: 'customer_groups', targetId: 'g1' },
{ scopeType: 'customer_groups', targetId: 'g2' },
],
}),
);
const groups = ['01HZZZZZZZZZZZZZZZZZZZZZZ1', '01HZZZZZZZZZZZZZZZZZZZZZZ2'];
const result = await service.update(
'r1',
{ dataScope: { type: 'customer_groups', targetIds: groups } },
'actor-1',
);
expect(result.role.dataScope).toEqual({ type: 'customer_groups', targetIds: ['g1', 'g2'] });
const createManyArgs = prisma.tx.dataScope.createMany.mock.calls[0]?.[0] as {
data: { roleId: string; scopeType: string; targetId: string }[];
};
expect(createManyArgs.data).toHaveLength(2);
expect(createManyArgs.data[0]?.scopeType).toBe('customer_groups');
});
});
describe('RolesService.remove', () => {
it('系统角色不可删除(40903)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ isSystem: true }));
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
});
it('有绑定用户只能停用(40903,附 userCount)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture());
await expectRejected(service.remove('r1', 'actor-1'), ErrorCode.STATE_NOT_ALLOWED);
});
it('无绑定用户删除成功并写 outbox(先事件后删除)', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(roleFixture({ users: [], _count: { users: 0 } }));
await service.remove('r1', 'actor-1');
expect(prisma.tx.role.delete).toHaveBeenCalledWith({ where: { id: 'r1' } });
expect(prisma.tx.outboxEvent.create).toHaveBeenCalledTimes(1);
});
});
describe('RolesService.detail', () => {
it('角色不存在返回 40401', async () => {
const { service } = harness();
await expectRejected(service.detail('missing'), ErrorCode.NOT_FOUND);
});
it('超管详情隐式展示全部权限点(30)与 all 范围', async () => {
const { service, prisma } = harness();
prisma.role.findUnique.mockResolvedValueOnce(
roleFixture({ code: 'super_admin', isSystem: true, permissions: [], dataScopes: [] }),
);
const detail = await service.detail('r1');
expect(detail.permissions).toHaveLength(24);
expect(detail.specialPermissions).toHaveLength(6);
expect(detail.dataScope).toEqual({ type: 'all' });
});
});
@@ -0,0 +1,749 @@
import {
type CreateRoleInput,
DATA_SCOPE_TYPES,
type DataScope,
type DataScopeType,
ErrorCode,
findSodConflicts,
isPermissionCode,
isSpecialPermission,
type ListResponse,
PERMISSION_CODES,
type PermissionCode,
ROLE_SOFT_LIMIT,
type RoleBoundUser,
type RoleDetail,
type RoleListItem,
type RoleListQuery,
type RoleSaveResult,
splitPermissions,
SUPER_ADMIN_SOFT_LIMIT,
ulid,
type UpdateRoleInput,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import { RequestContextService } from '../../common/security/request-context';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { iso, maskPhone } from './user.mapper';
const ROLE_LIST_INCLUDE = {
permissions: { select: { permissionCode: true } },
dataScopes: { select: { scopeType: true, targetId: true } },
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
const ROLE_DETAIL_INCLUDE = {
permissions: { select: { permissionCode: true } },
dataScopes: { select: { scopeType: true, targetId: true } },
users: {
select: {
createdAt: true,
user: {
select: {
id: true,
name: true,
account: true,
phone: true,
status: true,
department: { select: { name: true } },
},
},
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { users: true } },
} satisfies Prisma.RoleInclude;
type RoleWithList = Prisma.RoleGetPayload<{ include: typeof ROLE_LIST_INCLUDE }>;
type RoleWithDetail = Prisma.RoleGetPayload<{ include: typeof ROLE_DETAIL_INCLUDE }>;
interface RolePermissionSnapshot {
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
dataScope: DataScope;
status: 'active' | 'disabled';
}
interface PermissionDiff {
added: PermissionCode[];
removed: PermissionCode[];
specialAdded: PermissionCode[];
specialRemoved: PermissionCode[];
}
interface CreatePersistArgs {
id: string;
code: string;
input: CreateRoleInput;
permissions: PermissionCode[];
specialPermissions: PermissionCode[];
diff: PermissionDiff;
actorId: string;
traceId: string | null;
}
interface UpdatePersistArgs {
id: string;
existing: RoleWithDetail;
input: UpdateRoleInput;
after: RolePermissionSnapshot;
diff: PermissionDiff;
affectedUserIds: string[];
nextName: string;
nextDescription: string;
beforeDataScopeType: string;
actorId: string;
traceId: string | null;
}
interface OutboxEventArgs {
action: 'created' | 'updated' | 'deleted';
roleId: string;
roleCode: string;
roleName: string;
actorId: string;
traceId: string | null;
diff: PermissionDiff;
affectedUserIds: string[];
dataScopeBefore: string | null;
dataScopeAfter: string | null;
}
function splitCodes(codes: readonly PermissionCode[]): {
normal: PermissionCode[];
special: PermissionCode[];
} {
const normal: PermissionCode[] = [];
const special: PermissionCode[] = [];
for (const code of codes) {
if (isSpecialPermission(code)) {
special.push(code);
} else {
normal.push(code);
}
}
return { normal: normal.sort(), special: special.sort() };
}
function effectiveCodes(role: {
isSystem: boolean;
permissions: { permissionCode: string }[];
}): PermissionCode[] {
if (role.isSystem) {
return [...PERMISSION_CODES];
}
return role.permissions
.map((item) => item.permissionCode)
.filter(isPermissionCode)
.sort();
}
/** 一个角色只允许一种范围类型(A2 契约):多行(customer_groups 多客户组)聚合为单值 */
function aggregateDataScope(
rows: readonly { scopeType: string; targetId: string | null }[],
isSystem: boolean,
): DataScope {
if (isSystem) {
return { type: 'all' };
}
if (rows.length === 0) {
return { type: 'created_by_me' };
}
const ordered = [...rows].sort(
(left, right) =>
DATA_SCOPE_TYPES.indexOf(left.scopeType as DataScopeType) -
DATA_SCOPE_TYPES.indexOf(right.scopeType as DataScopeType),
);
const type = (ordered[0]?.scopeType ?? 'created_by_me') as DataScopeType;
if (type !== 'customer_groups') {
return { type };
}
const targetIds = [
...new Set(
rows
.filter((row) => row.scopeType === type)
.flatMap((row) => (row.targetId === null ? [] : [row.targetId])),
),
].sort();
return targetIds.length > 0 ? { type, targetIds } : { type };
}
function toBoundUser(row: RoleWithDetail['users'][number]): RoleBoundUser {
return {
id: row.user.id,
name: row.user.name,
account: row.user.account,
phoneMasked: maskPhone(row.user.phone),
departmentName: row.user.department.name,
status: row.user.status === 'ACTIVE' ? 'active' : 'disabled',
};
}
function toListItem(role: RoleWithList): RoleListItem {
const codes = splitCodes(effectiveCodes(role));
return {
id: role.id,
code: role.code,
name: role.name,
description: role.description,
isSystem: role.isSystem,
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
permissionCount: codes.normal.length,
specialPermissionCount: codes.special.length,
userCount: role._count.users,
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
createdAt: iso(role.createdAt),
updatedAt: iso(role.updatedAt),
};
}
function toDetail(role: RoleWithDetail): RoleDetail {
const listItem = toListItem(role);
const codes = splitCodes(effectiveCodes(role));
return {
...listItem,
permissions: codes.normal,
specialPermissions: codes.special,
boundUsers: role.users.map((row) => toBoundUser(row)),
};
}
function diffCodes(before: RolePermissionSnapshot, after: RolePermissionSnapshot): PermissionDiff {
const beforeNormal = new Set(before.permissions);
const afterNormal = new Set(after.permissions);
const beforeSpecial = new Set(before.specialPermissions);
const afterSpecial = new Set(after.specialPermissions);
return {
added: after.permissions.filter((code) => !beforeNormal.has(code)).sort(),
removed: before.permissions.filter((code) => !afterNormal.has(code)).sort(),
specialAdded: after.specialPermissions.filter((code) => !beforeSpecial.has(code)).sort(),
specialRemoved: before.specialPermissions.filter((code) => !afterSpecial.has(code)).sort(),
};
}
/**
* 角色管理(A2-BE-01 / 03 / 05):
* - 列表 / 详情(目录统计 + 数据范围 + 绑定用户);
* - 新建(权限默认全空、code 自动生成、SoD 保存即校验);
* - 更新(含启用 / 停用;系统角色仅显示名可改);
* - 删除(系统角色拒绝;有绑定用户 40903);
* - 变更审计(前后值 diff + 影响用户数)与 outbox `role.permissions.changed` 占位通知。
*/
@Injectable()
export class RolesService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
const where: Prisma.RoleWhereInput = {};
if (query.status !== 'all') {
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
}
if (query.keyword !== undefined && query.keyword.length > 0) {
where.OR = [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ code: { contains: query.keyword, mode: 'insensitive' } },
];
}
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
const orderBy: Prisma.RoleOrderByWithRelationInput[] =
query.sort === 'name'
? [{ name: direction }, { id: 'asc' }]
: [{ createdAt: direction }, { id: 'asc' }];
const rows = await this.prisma.role.findMany({
where,
orderBy,
take: query.limit + 1,
include: ROLE_LIST_INCLUDE,
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return { items: page.map((row) => toListItem(row)), next_cursor: next };
}
async detail(id: string): Promise<RoleDetail> {
const role = await this.prisma.role.findUnique({
where: { id },
include: ROLE_DETAIL_INCLUDE,
});
if (role === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
return toDetail(role);
}
async create(input: CreateRoleInput, actorId: string): Promise<RoleSaveResult> {
await this.ensureNameAvailable(input.name);
const { permissions, specialPermissions } = this.validatePermissions(
input.permissions ?? [],
input.specialPermissions ?? [],
);
this.assertSod({
permissions,
specialPermissions,
dataScope: input.dataScope,
status: 'active',
});
const id = ulid();
const traceId = this.requestContext.get()?.traceId ?? null;
const diff: PermissionDiff = {
added: permissions,
removed: [],
specialAdded: specialPermissions,
specialRemoved: [],
};
const code = `custom_${ulid().toLowerCase()}`;
await this.prisma.$transaction((tx) =>
this.persistCreate(tx, {
id,
code,
input,
permissions,
specialPermissions,
diff,
actorId,
traceId,
}),
);
const role = await this.detail(id);
this.recordCreateAudit(role, diff);
return { role, affectedUserCount: 0, warnings: await this.buildWarnings(role.code) };
}
async update(id: string, input: UpdateRoleInput, actorId: string): Promise<RoleSaveResult> {
const existing = await this.prisma.role.findUnique({
where: { id },
include: ROLE_DETAIL_INCLUDE,
});
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
this.assertSystemRoleMutable(existing, input);
if (input.name !== undefined && input.name !== existing.name) {
await this.ensureNameAvailable(input.name, id);
}
const before = this.snapshotOf(existing);
const after = this.mergeSnapshot(before, input);
// 超管豁免 SoD(系统角色不可改权限,隐式全集必然「违反」互斥对)
if (!existing.isSystem) {
this.assertSod(after);
}
const diff = diffCodes(before, after);
const affectedUserIds = existing.users.map((row) => row.user.id);
await this.prisma.$transaction((tx) =>
this.persistUpdate(tx, {
id,
existing,
input,
after,
diff,
affectedUserIds,
nextName: input.name ?? existing.name,
nextDescription: input.description ?? existing.description,
beforeDataScopeType: before.dataScope.type,
actorId,
traceId: this.requestContext.get()?.traceId ?? null,
}),
);
const role = await this.detail(id);
this.recordUpdateAudit({ existing, before, role, affectedUserIds, diff, input });
return {
role,
affectedUserCount: affectedUserIds.length,
warnings: await this.buildWarnings(role.code),
};
}
async remove(id: string, actorId: string): Promise<void> {
const existing = await this.prisma.role.findUnique({
where: { id },
include: { _count: { select: { users: true } } },
});
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '角色不存在' });
}
if (existing.isSystem) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '系统角色不可删除' });
}
if (existing._count.users > 0) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: `角色已绑定 ${String(existing._count.users)} 名用户,只能停用`,
details: { userCount: existing._count.users },
});
}
await this.prisma.$transaction((tx) =>
this.persistRemove(tx, {
id,
existing,
actorId,
traceId: this.requestContext.get()?.traceId ?? null,
}),
);
this.requestContext.patchAudit({
targetType: 'role',
targetId: id,
before: { name: existing.name, code: existing.code, status: existing.status },
after: null,
affectedUserCount: 0,
affectedUserIds: [],
});
}
private async persistCreate(
tx: Prisma.TransactionClient,
args: CreatePersistArgs,
): Promise<void> {
await tx.role.create({
data: {
id: args.id,
code: args.code,
name: args.input.name,
description: args.input.description ?? '',
isSystem: false,
status: 'ACTIVE',
permissions: {
create: [...args.permissions, ...args.specialPermissions].map((permissionCode) => ({
permissionCode,
})),
},
dataScopes: {
create: this.dataScopeRows(
args.input.dataScope.type,
args.input.dataScope.targetIds ?? [],
),
},
},
});
await this.writeOutboxEvent(tx, {
action: 'created',
roleId: args.id,
roleCode: args.code,
roleName: args.input.name,
actorId: args.actorId,
traceId: args.traceId,
diff: args.diff,
affectedUserIds: [],
dataScopeBefore: null,
dataScopeAfter: args.input.dataScope.type,
});
}
private recordCreateAudit(role: RoleDetail, diff: PermissionDiff): void {
this.requestContext.patchAudit({
targetType: 'role',
targetId: role.id,
before: null,
after: this.auditSnapshot(role),
affectedUserCount: 0,
affectedUserIds: [],
changes: diff,
});
}
private async persistUpdate(
tx: Prisma.TransactionClient,
args: UpdatePersistArgs,
): Promise<void> {
await this.applyRoleUpdate(tx, args);
await this.writeOutboxEvent(tx, {
action: 'updated',
roleId: args.id,
roleCode: args.existing.code,
roleName: args.nextName,
actorId: args.actorId,
traceId: args.traceId,
diff: args.diff,
affectedUserIds: args.affectedUserIds,
dataScopeBefore:
args.existing.dataScopes.length > 0
? (args.existing.dataScopes[0]?.scopeType ?? null)
: null,
dataScopeAfter: args.after.dataScope.type,
});
}
private async applyRoleUpdate(
tx: Prisma.TransactionClient,
args: UpdatePersistArgs,
): Promise<void> {
await tx.role.update({
where: { id: args.id },
data: {
name: args.nextName,
description: args.nextDescription,
status: args.after.status === 'active' ? 'ACTIVE' : 'DISABLED',
},
});
if (args.input.permissions !== undefined || args.input.specialPermissions !== undefined) {
await tx.rolePermission.deleteMany({ where: { roleId: args.id } });
await tx.rolePermission.createMany({
data: [...args.after.permissions, ...args.after.specialPermissions].map(
(permissionCode) => ({ roleId: args.id, permissionCode }),
),
});
}
if (args.input.dataScope !== undefined) {
await tx.dataScope.deleteMany({ where: { roleId: args.id } });
await tx.dataScope.createMany({
data: this.dataScopeRows(
args.input.dataScope.type,
args.input.dataScope.targetIds ?? [],
).map((row) => ({ ...row, roleId: args.id })),
});
}
}
private recordUpdateAudit(args: {
existing: RoleWithDetail;
before: RolePermissionSnapshot;
role: RoleDetail;
affectedUserIds: string[];
diff: PermissionDiff;
input: UpdateRoleInput;
}): void {
this.requestContext.patchAudit({
targetType: 'role',
targetId: args.role.id,
before: {
name: args.existing.name,
description: args.existing.description,
permissions: args.before.permissions,
specialPermissions: args.before.specialPermissions,
dataScope: args.before.dataScope,
status: args.before.status,
},
after: this.auditSnapshot(args.role),
affectedUserCount: args.affectedUserIds.length,
affectedUserIds: args.affectedUserIds,
changes: args.diff,
...(args.input.reason === undefined ? {} : { reason: args.input.reason }),
});
}
private async persistRemove(
tx: Prisma.TransactionClient,
args: {
id: string;
existing: { code: string; name: string };
actorId: string;
traceId: string | null;
},
): Promise<void> {
await this.writeOutboxEvent(tx, {
action: 'deleted',
roleId: args.id,
roleCode: args.existing.code,
roleName: args.existing.name,
actorId: args.actorId,
traceId: args.traceId,
diff: { added: [], removed: [], specialAdded: [], specialRemoved: [] },
affectedUserIds: [],
dataScopeBefore: null,
dataScopeAfter: null,
});
await tx.role.delete({ where: { id: args.id } });
}
private async writeOutboxEvent(
tx: Prisma.TransactionClient,
args: OutboxEventArgs,
): Promise<void> {
await tx.outboxEvent.create({
data: {
id: ulid(),
aggregateType: 'role',
aggregateId: args.roleId,
eventType: 'role.permissions.changed',
payloadJson: this.eventPayload(args),
...(args.traceId === null ? {} : { traceId: args.traceId }),
},
});
}
private mergeSnapshot(
before: RolePermissionSnapshot,
input: UpdateRoleInput,
): RolePermissionSnapshot {
return {
permissions:
input.permissions === undefined
? before.permissions
: this.validatePermissions(input.permissions, []).permissions,
specialPermissions:
input.specialPermissions === undefined
? before.specialPermissions
: this.validatePermissions([], input.specialPermissions).specialPermissions,
dataScope: input.dataScope ?? before.dataScope,
status:
input.status === undefined
? before.status
: input.status === 'enabled'
? 'active'
: 'disabled',
};
}
private auditSnapshot(role: RoleDetail): Record<string, unknown> {
return {
name: role.name,
permissions: role.permissions,
specialPermissions: role.specialPermissions,
dataScope: role.dataScope,
status: role.status,
};
}
private snapshotOf(role: RoleWithDetail): RolePermissionSnapshot {
const codes = splitCodes(effectiveCodes(role));
return {
permissions: codes.normal,
specialPermissions: codes.special,
dataScope: aggregateDataScope(role.dataScopes, role.isSystem),
status: role.status === 'ACTIVE' ? 'active' : 'disabled',
};
}
private assertSystemRoleMutable(role: { isSystem: boolean }, input: UpdateRoleInput): void {
if (!role.isSystem) {
return;
}
const touchesProtected =
input.description !== undefined ||
input.permissions !== undefined ||
input.specialPermissions !== undefined ||
input.dataScope !== undefined ||
input.status !== undefined;
if (touchesProtected) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: '系统角色不可修改权限 / 数据范围 / 状态,仅显示名可改',
});
}
}
private validatePermissions(
normalInput: readonly string[],
specialInput: readonly string[],
): { permissions: PermissionCode[]; specialPermissions: PermissionCode[] } {
let parsedNormal: PermissionCode[];
let parsedSpecial: PermissionCode[];
try {
const combined = splitPermissions([...normalInput, ...specialInput]);
parsedNormal = combined.permissions;
parsedSpecial = combined.specialPermissions;
} catch {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在未登记的权限点编码' });
}
const normalSet = new Set<string>(parsedNormal);
for (const code of normalInput) {
if (!normalSet.has(code)) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `特殊权限 ${code} 必须通过 specialPermissions 单独授予`,
});
}
}
const specialSet = new Set<string>(parsedSpecial);
for (const code of specialInput) {
if (!specialSet.has(code)) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `普通权限 ${code} 不能放入 specialPermissions`,
});
}
}
return { permissions: parsedNormal, specialPermissions: parsedSpecial };
}
private assertSod(snapshot: RolePermissionSnapshot): void {
const conflicts = findSodConflicts([...snapshot.permissions, ...snapshot.specialPermissions]);
if (conflicts.length > 0) {
throw new AppException({
code: ErrorCode.SOD_VIOLATION,
message: `违反职责互斥:${conflicts.map((conflict) => conflict.label).join(';')}`,
details: { conflicts },
});
}
}
private dataScopeRows(
type: DataScopeType,
targetIds: readonly string[],
): { id: string; scopeType: string; targetId: string | null }[] {
if (type !== 'customer_groups' || targetIds.length === 0) {
return [{ id: ulid(), scopeType: type, targetId: null }];
}
return targetIds.map((targetId) => ({ id: ulid(), scopeType: type, targetId }));
}
private async ensureNameAvailable(name: string, excludeId?: string): Promise<void> {
const existing = await this.prisma.role.findFirst({
where: { name, ...(excludeId === undefined ? {} : { NOT: { id: excludeId } }) },
select: { id: true },
});
if (existing !== null) {
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '角色名称已存在' });
}
}
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
private async buildWarnings(roleCode: string): Promise<string[]> {
const warnings: string[] = [];
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
if (activeRoleCount > ROLE_SOFT_LIMIT) {
warnings.push(
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
);
}
if (roleCode === 'super_admin') {
const superAdminCount = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
});
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
warnings.push(
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
);
}
}
return warnings;
}
private eventPayload(input: {
action: 'created' | 'updated' | 'deleted';
roleId: string;
roleCode: string;
roleName: string;
actorId: string;
traceId: string | null;
diff: PermissionDiff;
affectedUserIds: string[];
dataScopeBefore: string | null;
dataScopeAfter: string | null;
}): Prisma.InputJsonObject {
return {
action: input.action,
roleId: input.roleId,
roleCode: input.roleCode,
roleName: input.roleName,
actorId: input.actorId,
traceId: input.traceId,
changes: {
permissions: { added: input.diff.added, removed: input.diff.removed },
specialPermissions: {
added: input.diff.specialAdded,
removed: input.diff.specialRemoved,
},
dataScope: { before: input.dataScopeBefore, after: input.dataScopeAfter },
},
affectedUserIds: input.affectedUserIds,
};
}
}
@@ -0,0 +1,86 @@
import type { RoleSummary, UserDetail, UserSummary } from '@lingxi/shared';
import type { Prisma, Role, UserStatus as PrismaUserStatus } from '@prisma/client';
/** 用户列表 / 详情需要的关联数据(部门含父级、角色) */
export type UserWithRelations = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true } };
roles: { include: { role: true } };
};
}>;
export function iso(value: Date): string {
return value.toISOString();
}
export function isoOrNull(value: Date | null): string | null {
return value === null ? null : value.toISOString();
}
/** 手机号脱敏:138****2046;全号只在具备 user:manage 时由详情接口附带 */
export function maskPhone(phone: string): string {
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
}
export function toRoleSummary(role: Role): RoleSummary {
return {
id: role.id,
code: role.code,
name: role.name,
isSystem: role.isSystem,
...(role.description.length > 0 ? { description: role.description } : {}),
};
}
export function toApiStatus(status: PrismaUserStatus): UserSummary['status'] {
return status === 'ACTIVE' ? 'active' : 'disabled';
}
function departmentFields(
user: UserWithRelations,
): Pick<UserSummary, 'departmentId' | 'departmentName' | 'groupId' | 'groupName'> {
const leaf = user.department;
const parent = leaf.parent;
if (leaf.parentId !== null && parent !== null) {
return {
departmentId: parent.id,
departmentName: parent.name,
groupId: leaf.id,
groupName: leaf.name,
};
}
return {
departmentId: leaf.id,
departmentName: leaf.name,
groupId: null,
groupName: null,
};
}
export function toUserSummary(user: UserWithRelations): UserSummary {
return {
id: user.id,
name: user.name,
phoneMasked: maskPhone(user.phone),
account: user.account,
...departmentFields(user),
roles: user.roles
.filter((item) => item.role.status === 'ACTIVE')
.map((item) => toRoleSummary(item.role)),
status: toApiStatus(user.status),
lastLoginAt: isoOrNull(user.lastLoginAt),
createdAt: iso(user.createdAt),
};
}
/** 详情:phone 全号仅调用方具备 user:manage 时返回(契约铁律) */
export function toUserDetail(user: UserWithRelations, canSeePhone: boolean): UserDetail {
return {
...toUserSummary(user),
...(canSeePhone ? { phone: user.phone } : {}),
mustChangePassword: user.mustChangePassword,
updatedAt: iso(user.updatedAt),
lastLoginIp: user.lastLoginIp,
disabledAt: isoOrNull(user.disabledAt),
};
}
@@ -0,0 +1,112 @@
import {
type CreateUserInput,
createUserInputSchema,
type DisableUserInput,
disableUserInputSchema,
ErrorCode,
type ListResponse,
Permission,
type UpdateUserInput,
updateUserInputSchema,
type UserDetail,
type UserListQuery,
userListQuerySchema,
type UserSummary,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
import { UsersService } from './users.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/** 用户管理:/users 前缀(A1 前端路由同为 /users;全局前缀 /api/v1) */
@ApiTags('identity-users')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(@Inject(UsersService) private readonly users: UsersService) {}
@Get()
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({
summary: '用户列表(游标分页)',
description: 'status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码',
})
async list(
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
@CurrentUser() auth: AuthContext | null,
): Promise<ListResponse<UserSummary>> {
requireAuth(auth);
return this.users.list(query);
}
@Get(':id')
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '用户详情', description: '仅 user:manage 返回 phone 全号' })
async detail(
@Param('id') id: string,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.detail(id, requireAuth(auth).permissions);
}
@Post()
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.create')
@ApiOperation({ summary: '新建用户', description: '初始密码由管理员设定,首登强制修改' })
async create(
@Body(new ZodValidationPipe(createUserInputSchema)) input: CreateUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
const current = requireAuth(auth);
return this.users.create(input, current.userId, current.permissions);
}
@Patch(':id')
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.update')
@ApiOperation({ summary: '更新用户(不含账号 / 状态 / 密码)' })
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserInputSchema)) input: UpdateUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.update(id, input, requireAuth(auth).permissions);
}
@Post(':id/disable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.disable')
@ApiOperation({ summary: '停用用户', description: '停用后旧令牌立即失效;可指定客户转交人' })
async disable(
@Param('id') id: string,
@Body(new ZodValidationPipe(disableUserInputSchema)) input: DisableUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
const current = requireAuth(auth);
return this.users.disable(id, input, current.userId, current.permissions);
}
@Post(':id/enable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.enable')
@ApiOperation({ summary: '启用用户' })
async enable(
@Param('id') id: string,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.enable(id, requireAuth(auth).permissions);
}
}
@@ -0,0 +1,295 @@
import {
type CreateUserInput,
type DisableUserInput,
ErrorCode,
type ListResponse,
Permission,
SUPER_ADMIN_SOFT_LIMIT,
ulid,
type UpdateUserInput,
type UserDetail,
type UserListQuery,
type UserSummary,
} from '@lingxi/shared';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { PasswordService } from './password.service';
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
const USER_INCLUDE = {
department: { include: { parent: true } },
roles: { include: { role: true } },
} satisfies Prisma.UserInclude;
function hasPermission(permissions: string[], code: string): boolean {
return permissions.includes(code);
}
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
@Injectable()
export class UsersService {
private readonly logger = new Logger('UsersService');
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(PasswordService) private readonly passwords: PasswordService,
) {}
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
const where = this.buildWhere(query);
const orderBy = this.buildOrderBy(query);
const cursor = query.cursor;
const rows = await this.prisma.user.findMany({
where,
orderBy,
take: query.limit + 1,
include: USER_INCLUDE,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return { items: page.map((row) => toUserSummary(row)), next_cursor: next };
}
async detail(id: string, permissions: string[]): Promise<UserDetail> {
const user = await this.findOrThrow(id);
return toUserDetail(user, hasPermission(permissions, Permission.USER_MANAGE));
}
async create(
input: CreateUserInput,
actorId: string,
permissions: string[],
): Promise<UserDetail> {
await this.ensureDepartment(input.departmentId);
await this.ensureRoles(input.roleIds);
const account = input.account.trim().toLowerCase();
const email = `${account}@lingxi.cn`;
await this.ensureUnique({ account, phone: input.phone });
const passwordHash = await this.passwords.hash(input.password);
const id = ulid();
try {
await this.prisma.$transaction([
this.prisma.user.create({
data: {
id,
name: input.name,
account,
email,
phone: input.phone,
passwordHash,
departmentId: input.departmentId,
mustChangePassword: true,
createdBy: actorId,
...(input.remark === undefined ? {} : { remark: input.remark }),
roles: { create: input.roleIds.map((roleId) => ({ roleId })) },
},
}),
]);
} catch (error) {
throw this.mapUniqueError(error);
}
await this.warnIfSuperAdminExceeded(input.roleIds);
return this.detail(id, permissions);
}
async update(id: string, input: UpdateUserInput, permissions: string[]): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (input.departmentId !== undefined) {
await this.ensureDepartment(input.departmentId);
}
if (input.roleIds !== undefined) {
await this.ensureRoles(input.roleIds);
}
if (input.phone !== undefined && input.phone !== existing.phone) {
await this.ensureUnique({ phone: input.phone });
}
const data: Prisma.UserUpdateInput = {
...(input.name === undefined ? {} : { name: input.name }),
...(input.phone === undefined ? {} : { phone: input.phone }),
...(input.departmentId === undefined
? {}
: { department: { connect: { id: input.departmentId } } }),
...(input.remark === undefined ? {} : { remark: input.remark }),
};
try {
await this.prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id }, data });
if (input.roleIds !== undefined) {
await tx.userRole.deleteMany({ where: { userId: id } });
await tx.userRole.createMany({
data: input.roleIds.map((roleId) => ({ userId: id, roleId })),
});
}
});
} catch (error) {
throw this.mapUniqueError(error);
}
if (input.roleIds !== undefined) {
await this.warnIfSuperAdminExceeded(input.roleIds);
}
return this.detail(id, permissions);
}
async disable(
id: string,
input: DisableUserInput,
actorId: string,
permissions: string[],
): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是停用状态' });
}
if (existing.id === actorId) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '不能停用当前登录账号',
});
}
await this.prisma.$transaction([
this.prisma.user.update({
where: { id },
data: {
status: 'DISABLED',
disabledAt: new Date(),
...(input.reason === undefined ? {} : { remark: input.reason }),
},
}),
this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
}),
]);
return this.detail(id, permissions);
}
async enable(id: string, permissions: string[]): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'ACTIVE') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是启用状态' });
}
await this.prisma.user.update({
where: { id },
data: { status: 'ACTIVE', disabledAt: null },
});
return this.detail(id, permissions);
}
/**
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
*/
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
const superAdminRole = await this.prisma.role.findUnique({
where: { code: 'super_admin' },
select: { id: true },
});
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
return;
}
const count = await this.prisma.user.count({
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
});
if (count > SUPER_ADMIN_SOFT_LIMIT) {
this.logger.warn(
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
);
}
}
private async findOrThrow(id: string): Promise<UserWithRelations> {
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
if (user === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '用户不存在' });
}
return user;
}
private async ensureDepartment(departmentId: string): Promise<void> {
const department = await this.prisma.department.findUnique({
where: { id: departmentId },
select: { status: true },
});
if (department === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
}
if (department.status !== 'ACTIVE') {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '所属部门已停用,请选择启用中的部门',
});
}
}
private async ensureRoles(roleIds: string[]): Promise<void> {
const count = await this.prisma.role.count({
where: { id: { in: roleIds }, status: 'ACTIVE' },
});
if (count !== new Set(roleIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '角色不存在或已停用' });
}
}
private async ensureUnique(input: { account?: string; phone?: string }): Promise<void> {
const existing = await this.prisma.user.findFirst({
where: {
OR: [
...(input.account === undefined ? [] : [{ account: input.account }]),
...(input.phone === undefined ? [] : [{ phone: input.phone }]),
],
},
select: { account: true, phone: true },
});
if (existing !== null) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: existing.account === input.account ? '登录账号已存在' : '手机号已存在',
});
}
}
private mapUniqueError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号或手机号已存在' });
}
return error instanceof AppException
? error
: new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '用户保存失败' });
}
private buildWhere(query: UserListQuery): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
if (query.status !== 'all') {
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
}
if (query.keyword !== undefined && query.keyword.length > 0) {
where.OR = [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ account: { contains: query.keyword, mode: 'insensitive' } },
{ phone: { contains: query.keyword } },
];
}
if (query.department_id !== undefined) {
where.department = {
OR: [{ id: query.department_id }, { parentId: query.department_id }],
};
}
if (query.role_id !== undefined) {
where.roles = { some: { roleId: query.role_id } };
}
return where;
}
private buildOrderBy(query: UserListQuery): Prisma.UserOrderByWithRelationInput[] {
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
if (query.sort === 'created_at') {
return [{ createdAt: direction }, { id: 'asc' }];
}
return [{ lastLoginAt: { sort: direction, nulls: 'last' } }, { id: 'asc' }];
}
}
@@ -1,10 +1,12 @@
import { Controller, Get, Inject } from '@nestjs/common'; import { Controller, Get, Inject } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../../../common/security/decorators';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
import { type HealthReport, HealthResponseDto } from './health.types'; import { type HealthReport, HealthResponseDto } from './health.types';
@ApiTags('platform') @ApiTags('platform')
@Public()
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
constructor(@Inject(HealthService) private readonly healthService: HealthService) {} constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
+6
View File
@@ -23,6 +23,12 @@ export const testConfig: AppConfig = {
webOrigins: ['http://127.0.0.1:5173'], webOrigins: ['http://127.0.0.1:5173'],
maxUploadMb: 500, maxUploadMb: 500,
featureVideo: false, featureVideo: false,
jwtAccessSecret: 'test-only-jwt-access-secret-32-characters',
jwtRefreshSecret: 'test-only-jwt-refresh-secret-32-characters',
jwtAccessTtlSeconds: 900,
jwtRefreshTtlSeconds: 604800,
authMaxLoginFailures: 5,
authLockMinutes: 15,
}; };
@Controller('__test') @Controller('__test')
+11 -1
View File
@@ -9,7 +9,17 @@ export default defineConfig({
provider: 'v8', provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'], reporter: ['text', 'lcov', 'json-summary'],
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'], exclude: [
'src/**/*.spec.ts',
'src/**/*.int.spec.ts',
'src/main.ts',
'src/scripts/**',
// 控制器 / 模块为薄装配层:行为由集成测试(test:int)覆盖,不计入单测覆盖率
'src/**/*.controller.ts',
'src/**/*.module.ts',
'src/**/*.types.ts',
'src/testing/**',
],
thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 }, thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 },
}, },
}, },
+3
View File
@@ -6,5 +6,8 @@ export default defineConfig({
include: ['src/**/*.int.spec.ts'], include: ['src/**/*.int.spec.ts'],
testTimeout: 30_000, testTimeout: 30_000,
hookTimeout: 30_000, hookTimeout: 30_000,
// 集成测试共用本地真实 PG / Redis,且各文件会清理带前缀的夹具(如 it.* 与 it.dep.* 重叠)。
// 文件级并行会让跨文件的夹具清理 / 数据断言互相干扰,因此串行执行。
fileParallelism: false,
}, },
}); });
+1
View File
@@ -8,6 +8,7 @@
"build": "tsc -p tsconfig.json --noEmit && vite build", "build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview --port 4173 --strictPort", "preview": "vite preview --port 4173 --strictPort",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --max-warnings=0",
"test": "vitest run --coverage" "test": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -16,7 +16,7 @@ function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
useAuthStore.getState().setSession({ useAuthStore.getState().setSession({
accessToken, accessToken,
refreshToken, refreshToken,
user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' }, user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' },
permissions: [], permissions: [],
}); });
} }
+12 -9
View File
@@ -108,6 +108,13 @@ async function toApiError(response: Response): Promise<ApiError> {
}); });
} }
/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */
function failRefresh(): false {
useAuthStore.getState().clearSession();
unauthorizedHandler?.();
return false;
}
let refreshPromise: Promise<boolean> | null = null; let refreshPromise: Promise<boolean> | null = null;
/** 单飞刷新:并发 401 只触发一次 /auth/refresh */ /** 单飞刷新:并发 401 只触发一次 /auth/refresh */
@@ -119,9 +126,9 @@ export function refreshSession(): Promise<boolean> {
} }
async function doRefresh(): Promise<boolean> { async function doRefresh(): Promise<boolean> {
const { refreshToken, clearSession } = useAuthStore.getState(); const { refreshToken } = useAuthStore.getState();
if (refreshToken === null) { if (refreshToken === null) {
return false; return failRefresh();
} }
let response: Response; let response: Response;
try { try {
@@ -131,12 +138,10 @@ async function doRefresh(): Promise<boolean> {
body: JSON.stringify({ refreshToken }), body: JSON.stringify({ refreshToken }),
}); });
} catch { } catch {
return false; return failRefresh();
} }
if (!response.ok) { if (!response.ok) {
clearSession(); return failRefresh();
unauthorizedHandler?.();
return false;
} }
const payload: unknown = await response.json().catch(() => null); const payload: unknown = await response.json().catch(() => null);
const accessToken = const accessToken =
@@ -148,9 +153,7 @@ async function doRefresh(): Promise<boolean> {
? payload.refreshToken ? payload.refreshToken
: null; : null;
if (typeof accessToken !== 'string' || accessToken.length === 0) { if (typeof accessToken !== 'string' || accessToken.length === 0) {
clearSession(); return failRefresh();
unauthorizedHandler?.();
return false;
} }
useAuthStore.getState().setTokens({ useAuthStore.getState().setTokens({
accessToken, accessToken,
+236
View File
@@ -0,0 +1,236 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { setUnauthorizedHandler } from './client';
import {
applyCustomerField,
applyIndustryPack,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from './customers';
const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const NOW = '2026-11-03T10:00:00.000Z';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(status === 204 ? null : JSON.stringify(body), {
status,
headers: status === 204 ? undefined : { 'Content-Type': 'application/json' },
});
const industryNode = {
id: ID,
parentId: null,
level: 1,
name: '电商',
sortOrder: 10,
status: 'active',
remark: '',
customerCount: 1,
assetCount: 0,
updatedAt: NOW,
};
const industryTree = { ...industryNode, children: [] };
const industryDetail = {
...industryNode,
parentName: null,
references: {
childCount: 0,
activeChildCount: 0,
customerCount: 1,
customers: [],
assetCount: 0,
},
};
const customerSummary = {
id: ID,
name: '花间集',
shortName: 'HJ',
industryId: ID,
industryName: '服饰鞋包',
industryPath: '电商 / 服饰鞋包',
status: 'active',
ownerUserId: ID2,
ownerName: '陈默',
groups: [],
baseFieldCount: 1,
genFieldCount: 1,
updatedAt: NOW,
};
const customerDetail = {
...customerSummary,
contactName: '林晓',
contactPhone: '138****2210',
preferences: { naming: '', directory: '', specs: [], taboo: [] },
baseFields: [{ key: '门店', value: '沪-001' }],
fields: [],
remark: null,
createdAt: NOW,
};
const groupSummary = {
id: ID,
name: '电商核心组',
description: '',
memberCount: 0,
updatedAt: NOW,
};
const groupDetail = { ...groupSummary, members: [] };
const field = {
id: ID,
customerId: ID2,
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
status: 'pending',
applicantName: '陈默',
approverName: null,
appliedAt: NOW,
approvedAt: null,
rejectReason: null,
};
const fieldListItem = {
...field,
customerName: '花间集',
customerShortName: 'HJ',
industryPath: '电商 / 服饰鞋包',
};
function lastCall(): { url: string; init: RequestInit | undefined } {
const fetchMock = vi.mocked(globalThis.fetch);
const call = fetchMock.mock.calls.at(-1);
const input = call?.[0];
const url =
typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? '');
return { url, init: call?.[1] };
}
function lastBody(): unknown {
const body = lastCall().init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ID2, name: '陈默', account: 'chen.mo' },
permissions: ['customer:read'],
});
});
describe('客户与行业接口封装', () => {
it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail, 201))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(industryDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchIndustryList({ keyword: '电商', limit: 100 });
expect(lastCall().url).toContain('/industries?keyword=');
await fetchIndustryDetail(ID);
await createIndustry({ name: '电商', sortOrder: 10, remark: '' });
expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' });
await updateIndustry(ID, { name: '电商2' });
await setIndustryStatus(ID, { status: 'disabled' });
await deleteIndustry(ID);
expect(lastCall().url).toContain(`/industries/${ID}`);
});
it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail, 201))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse(customerDetail))
.mockResolvedValueOnce(jsonResponse({}, 200));
await fetchCustomerList({ status: 'active', limit: 100 });
expect(lastCall().url).toContain('/customers?status=active');
await fetchCustomerDetail(ID);
await createCustomer({
industryId: ID,
name: '花间集',
shortName: 'HJ',
ownerUserId: ID2,
});
await updateCustomer(ID, { shortName: 'HJ2' });
await setCustomerStatus(ID, { status: 'paused' });
await transferCustomer(ID, { ownerUserId: ID2 });
await setCustomerGroups(ID, { groupIds: [ID] });
await applyIndustryPack(ID);
expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`);
});
it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail, 201))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(groupDetail))
.mockResolvedValueOnce(jsonResponse(undefined, 204));
await fetchCustomerGroupList();
await fetchCustomerGroupDetail(ID);
await createCustomerGroup({ name: '电商核心组', description: '' });
await updateCustomerGroup(ID, { description: '说明' });
await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] });
await deleteCustomerGroup(ID);
});
it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null }))
.mockResolvedValueOnce(jsonResponse([field]))
.mockResolvedValueOnce(jsonResponse(field, 201))
.mockResolvedValueOnce(jsonResponse(field));
await fetchCustomerFieldList({ status: 'pending', limit: 100 });
await fetchCustomerFields(ID);
await applyCustomerField(ID, {
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '检索',
entersGeneration: true,
});
await approveCustomerField(ID, { decision: 'approve' });
expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`);
});
});
+277
View File
@@ -0,0 +1,277 @@
import {
type ApplyCustomerFieldInput,
applyCustomerFieldInputSchema,
type ApproveCustomerFieldInput,
approveCustomerFieldInputSchema,
type CreateCustomerGroupInput,
createCustomerGroupInputSchema,
type CreateCustomerInput,
createCustomerInputSchema,
type CreateIndustryInput,
createIndustryInputSchema,
type CustomerDetail,
customerDetailSchema,
type CustomerField,
type CustomerFieldListItem,
customerFieldListItemSchema,
type CustomerFieldListQuery,
customerFieldSchema,
type CustomerGroupDetail,
customerGroupDetailSchema,
type CustomerGroupListQuery,
type CustomerGroupSummary,
customerGroupSummarySchema,
type CustomerListQuery,
type CustomerSummary,
customerSummarySchema,
type IndustryDetail,
industryDetailSchema,
type IndustryListQuery,
type IndustryTreeItem,
industryTreeItemSchema,
listResponseSchema,
type SetCustomerGroupsInput,
setCustomerGroupsInputSchema,
type SetCustomerStatusInput,
setCustomerStatusInputSchema,
type SetIndustryStatusInput,
setIndustryStatusInputSchema,
type TransferCustomerInput,
transferCustomerInputSchema,
type UpdateCustomerGroupInput,
updateCustomerGroupInputSchema,
type UpdateCustomerGroupMembersInput,
updateCustomerGroupMembersInputSchema,
type UpdateCustomerInput,
updateCustomerInputSchema,
type UpdateIndustryInput,
updateIndustryInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const industryListResponseSchema = listResponseSchema(industryTreeItemSchema);
const customerListResponseSchema = listResponseSchema(customerSummarySchema);
const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema);
const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema);
function searchParams(entries: Record<string, string | number | undefined>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(entries)) {
if (value !== undefined && value !== '') params.set(key, String(value));
}
const search = params.toString();
return search === '' ? '' : `?${search}`;
}
export async function fetchIndustryList(
query: Partial<IndustryListQuery> = {},
): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.industries}${searchParams({
keyword: query.keyword,
status: query.status,
parent_id: query.parent_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return industryListResponseSchema.parse(payload);
}
export async function fetchIndustryDetail(id: string): Promise<IndustryDetail> {
const payload: unknown = await request(ENDPOINTS.industry(id));
return industryDetailSchema.parse(payload);
}
export async function createIndustry(input: CreateIndustryInput): Promise<IndustryDetail> {
const body = createIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body });
return industryDetailSchema.parse(payload);
}
export async function updateIndustry(
id: string,
input: UpdateIndustryInput,
): Promise<IndustryDetail> {
const body = updateIndustryInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function setIndustryStatus(
id: string,
input: SetIndustryStatusInput,
): Promise<IndustryDetail> {
const body = setIndustryStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body });
return industryDetailSchema.parse(payload);
}
export async function deleteIndustry(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.industry(id), { method: 'DELETE' });
}
export async function fetchCustomerList(
query: Partial<CustomerListQuery> = {},
): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customers}${searchParams({
keyword: query.keyword,
industry_id: query.industry_id,
status: query.status,
owner_user_id: query.owner_user_id,
group_id: query.group_id,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return customerListResponseSchema.parse(payload);
}
export async function fetchCustomerDetail(id: string): Promise<CustomerDetail> {
const payload: unknown = await request(ENDPOINTS.customer(id));
return customerDetailSchema.parse(payload);
}
export async function createCustomer(input: CreateCustomerInput): Promise<CustomerDetail> {
const body = createCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function updateCustomer(
id: string,
input: UpdateCustomerInput,
): Promise<CustomerDetail> {
const body = updateCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerStatus(
id: string,
input: SetCustomerStatusInput,
): Promise<CustomerDetail> {
const body = setCustomerStatusInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body });
return customerDetailSchema.parse(payload);
}
export async function transferCustomer(
id: string,
input: TransferCustomerInput,
): Promise<CustomerDetail> {
const body = transferCustomerInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body });
return customerDetailSchema.parse(payload);
}
export async function setCustomerGroups(
id: string,
input: SetCustomerGroupsInput,
): Promise<CustomerDetail> {
const body = setCustomerGroupsInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body });
return customerDetailSchema.parse(payload);
}
export async function applyIndustryPack(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerApplyPack(id), { method: 'POST' });
}
export async function fetchCustomerGroupList(
query: Partial<CustomerGroupListQuery> = {},
): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`,
);
return groupListResponseSchema.parse(payload);
}
export async function fetchCustomerGroupDetail(id: string): Promise<CustomerGroupDetail> {
const payload: unknown = await request(ENDPOINTS.customerGroup(id));
return customerGroupDetailSchema.parse(payload);
}
export async function createCustomerGroup(
input: CreateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = createCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroup(
id: string,
input: UpdateCustomerGroupInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body });
return customerGroupDetailSchema.parse(payload);
}
export async function updateCustomerGroupMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
const body = updateCustomerGroupMembersInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), {
method: 'POST',
body,
});
return customerGroupDetailSchema.parse(payload);
}
export async function deleteCustomerGroup(id: string): Promise<void> {
await request<unknown>(ENDPOINTS.customerGroup(id), { method: 'DELETE' });
}
export async function fetchCustomerFieldList(
query: Partial<CustomerFieldListQuery> = {},
): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
const payload: unknown = await request(
`${ENDPOINTS.customerFields}${searchParams({
customer_id: query.customer_id,
status: query.status,
limit: query.limit,
cursor: query.cursor,
})}`,
);
return fieldListResponseSchema.parse(payload);
}
export async function fetchCustomerFields(customerId: string): Promise<CustomerField[]> {
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId));
const schema = listResponseSchema(customerFieldSchema);
// 后端该接口返回数组;列表信封仅用于 /customer-fields
if (Array.isArray(payload)) {
return payload.map((item) => customerFieldSchema.parse(item));
}
return schema.parse(payload).items;
}
export async function applyCustomerField(
customerId: string,
input: ApplyCustomerFieldInput,
): Promise<CustomerField> {
const body = applyCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
export async function approveCustomerField(
id: string,
input: ApproveCustomerFieldInput,
): Promise<CustomerField> {
const body = approveCustomerFieldInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), {
method: 'POST',
body,
});
return customerFieldSchema.parse(payload);
}
+127
View File
@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import {
createDepartment,
disableDepartment,
enableDepartment,
fetchDepartments,
updateDepartment,
} from './identity';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROOT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const CHILD_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const root = {
id: ROOT_ID,
name: '内容生产中心',
parentId: null,
leader: { id: USER_ID, name: '陈默' },
status: 'active',
sortOrder: 1,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-11-01T00:00:00.000Z',
children: [
{
id: CHILD_ID,
name: '素材一组',
parentId: ROOT_ID,
leader: null,
status: 'disabled',
sortOrder: 2,
memberCount: 9,
directMemberCount: 9,
createdAt: '2026-11-01T00:00:00.000Z',
children: [],
},
],
};
function calls(): { url: string; init: RequestInit | undefined }[] {
return vi.mocked(globalThis.fetch).mock.calls.map((call) => {
const input = call[0];
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return { url, init: call[1] };
});
}
function lastBody(): unknown {
const body = calls().at(-1)?.init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
describe('部门接口', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view', 'user:manage'],
});
});
it('部门树默认只取启用部门,includeDisabled 时带 query', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [root] }))
.mockResolvedValueOnce(jsonResponse({ items: [root] }));
const activeOnly = await fetchDepartments();
expect(String(calls()[0]?.url)).toContain('/departments/tree?include_disabled=false');
expect(activeOnly.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
expect(activeOnly[1]?.status).toBe('disabled');
await fetchDepartments(true);
expect(String(calls()[1]?.url)).toContain('/departments/tree?include_disabled=true');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('新建返回部门 id,请求走 POST /departments', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ id: CHILD_ID }));
await expect(
createDepartment({
name: '设计组',
parentId: ROOT_ID,
leaderUserId: null,
sortOrder: 3,
}),
).resolves.toBe(CHILD_ID);
expect(calls()[0]?.init?.method).toBe('POST');
expect(String(calls()[0]?.url)).toContain('/departments');
expect(lastBody()).toEqual({
name: '设计组',
parentId: ROOT_ID,
leaderUserId: null,
sortOrder: 3,
});
});
it('更新 / 停用 / 启用使用约定方法与路径', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
await updateDepartment(CHILD_ID, { name: '设计组', leaderUserId: null });
await disableDepartment(CHILD_ID);
await enableDepartment(CHILD_ID);
const requestLines = calls().map((call) => {
const url = new URL(call.url);
return `${call.init?.method ?? 'GET'} ${url.pathname.replace('/api/v1', '')}`;
});
expect(requestLines).toEqual([
`PATCH /departments/${CHILD_ID}`,
`POST /departments/${CHILD_ID}/disable`,
`POST /departments/${CHILD_ID}/enable`,
]);
expect(lastBody()).toBeUndefined();
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* identity 模块接口路径(唯一出处)。
* 依据:技术架构 §6.3.1、开发计划附录 D。
* 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。
*/
const USERS = '/users';
export const ENDPOINTS = {
login: '/auth/login',
refresh: '/auth/refresh',
logout: '/auth/logout',
changePassword: '/auth/change-password',
me: '/me',
users: USERS,
user: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}`,
userDisable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/disable`,
userEnable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/enable`,
userCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/customers`,
userTransferCustomers: (userId: string): string =>
`${USERS}/${encodeURIComponent(userId)}/transfer-customers`,
departments: '/departments/tree',
departmentsRoot: '/departments',
department: (departmentId: string): string => `/departments/${encodeURIComponent(departmentId)}`,
departmentDisable: (departmentId: string): string =>
`/departments/${encodeURIComponent(departmentId)}/disable`,
departmentEnable: (departmentId: string): string =>
`/departments/${encodeURIComponent(departmentId)}/enable`,
roles: '/roles',
role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`,
permissions: '/permissions',
// A3 client
industries: '/industries',
industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`,
industryStatus: (industryId: string): string =>
`/industries/${encodeURIComponent(industryId)}/status`,
customers: '/customers',
customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`,
customerStatus: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/status`,
customerTransfer: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/transfer`,
customerGroups: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/groups`,
customerApplyPack: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/apply-industry-pack`,
customerGroupsList: '/customer-groups',
customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`,
customerGroupMembers: (groupId: string): string =>
`/customer-groups/${encodeURIComponent(groupId)}/members`,
customerFields: '/customer-fields',
customerFieldsOf: (customerId: string): string =>
`/customers/${encodeURIComponent(customerId)}/fields`,
customerFieldApprove: (fieldId: string): string =>
`/customer-fields/${encodeURIComponent(fieldId)}/approve`,
} as const;
File diff suppressed because it is too large. Load diff
+205
View File
@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { ApiError, request, setUnauthorizedHandler } from './client';
import {
changePassword,
createUser,
disableUser,
enableUser,
fetchDepartments,
fetchMe,
fetchRoles,
fetchUserCustomers,
login,
logout,
transferCustomers,
updateUser,
} from './identity';
import { fetchUsers } from './users';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3';
const userSummary = {
id: USER_ID,
name: '陈默',
phoneMasked: '138****2046',
account: 'chen.mo',
departmentId: GROUP_ID,
departmentName: '内容生产中心',
groupId: GROUP_ID,
groupName: '素材一组',
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
status: 'active',
lastLoginAt: '2026-11-02T09:41:00.000Z',
createdAt: '2026-09-30T18:40:00.000Z',
};
const me = {
user: userSummary,
roles: userSummary.roles,
permissions: ['user:view'],
dataScopes: [{ type: 'all' }],
mustChangePassword: false,
};
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
function calls(): { url: string; init: RequestInit | undefined }[] {
const fetchMock = vi.mocked(globalThis.fetch);
return fetchMock.mock.calls.map((call) => ({
url: toUrl(call[0]),
init: call[1],
}));
}
function lastBody(): unknown {
const last = calls().at(-1);
const body = last?.init?.body;
return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined;
}
describe('identity 接口封装', () => {
beforeEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
useAuthStore.getState().clearSession();
setUnauthorizedHandler(null);
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view'],
});
});
it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse({
accessToken: 'a',
refreshToken: 'r',
expiresIn: 3600,
me,
}),
);
const response = await login({ account: 'chen.mo', password: 'Lx123456' });
expect(response.me.permissions).toEqual(['user:view']);
expect(calls()[0]?.init?.method).toBe('POST');
expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' });
});
it('/me、部门与角色列表按共享 schema 解析', async () => {
const department = {
id: GROUP_ID,
name: '内容生产中心',
parentId: null,
leader: null,
status: 'active',
sortOrder: 1,
memberCount: 19,
directMemberCount: 4,
createdAt: '2026-09-01T00:00:00.000Z',
children: [
{
id: USER_ID,
name: '素材一组',
parentId: GROUP_ID,
leader: null,
status: 'active',
sortOrder: 1,
memberCount: 9,
directMemberCount: 9,
createdAt: '2026-09-01T00:00:00.000Z',
children: [],
},
],
};
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse(me))
.mockResolvedValueOnce(jsonResponse({ items: [department] }))
.mockResolvedValueOnce(jsonResponse({ items: userSummary.roles }));
expect((await fetchMe()).user.name).toBe('陈默');
const departments = await fetchDepartments();
expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']);
expect((await fetchRoles())[0]?.name).toBe('素材运营');
});
it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' }))
.mockResolvedValueOnce(jsonResponse({ id: USER_ID }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }]))
.mockResolvedValueOnce(new Response(null, { status: 204 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
const list = await fetchUsers({ limit: 10, status: 'active' });
expect(list.next_cursor).toBe('cursor-2');
expect(
await createUser({
name: '测试成员',
phone: '13900001111',
account: 'test.member',
password: 'Lx123456',
departmentId: GROUP_ID,
roleIds: [ROLE_ID],
}),
).toBe(USER_ID);
await updateUser(USER_ID, { name: '陈默' });
await disableUser(USER_ID, { reason: '离职' });
await enableUser(USER_ID);
await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID });
expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居');
await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' });
await logout('refresh-token');
const paths = calls().map((call) => ({
url: call.url.replace('http://127.0.0.1:3000/api/v1', ''),
method: call.init?.method ?? 'GET',
}));
expect(paths).toContainEqual({ url: '/users', method: 'POST' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' });
expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' });
expect(paths).toContainEqual({
url: `/users/${USER_ID}/transfer-customers`,
method: 'POST',
});
expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' });
expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' });
});
it('刷新失败时清理会话并触发跳登录', async () => {
const handler = vi.fn();
setUnauthorizedHandler(handler);
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
}),
);
useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' });
await expect(request('/me')).rejects.toBeInstanceOf(ApiError);
expect(handler).toHaveBeenCalled();
expect(useAuthStore.getState().accessToken).toBeNull();
});
});
+169
View File
@@ -0,0 +1,169 @@
import {
type ChangePasswordInput,
changePasswordInputSchema,
type CreateDepartmentInput,
createDepartmentInputSchema,
type CreateUserInput,
createUserInputSchema,
type DepartmentNode,
departmentNodeSchema,
type DisableUserInput,
disableUserInputSchema,
type LoginInput,
loginInputSchema,
type LoginResponse,
loginResponseSchema,
type MeResponse,
meResponseSchema,
type RoleSummary,
roleSummarySchema,
type TransferCustomersInput,
transferCustomersInputSchema,
type UpdateDepartmentInput,
updateDepartmentInputSchema,
type UpdateUserInput,
updateUserInputSchema,
} from '@lingxi/shared';
import { z } from 'zod';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
/** 客户摘要:A3 客户模块落地前的转交占位契约(仅 id + name) */
export interface CustomerSummary {
id: string;
name: string;
}
const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) });
const createdIdSchema = z.object({ id: z.string().min(1) }).passthrough();
/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */
function parseList<T>(payload: unknown, itemSchema: z.ZodType<T>): T[] {
const candidate: unknown[] = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.items)
? payload.items
: [];
return candidate.map((item) => itemSchema.parse(item));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
export async function login(input: LoginInput): Promise<LoginResponse> {
const body = loginInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.login, { method: 'POST', auth: false, body });
return loginResponseSchema.parse(payload);
}
export async function logout(refreshToken: string | null): Promise<void> {
const body: { refreshToken?: string } = refreshToken === null ? {} : { refreshToken };
await request<unknown>(ENDPOINTS.logout, { method: 'POST', body });
}
export async function changePassword(input: ChangePasswordInput): Promise<void> {
const body = changePasswordInputSchema.parse(input);
await request<unknown>(ENDPOINTS.changePassword, { method: 'POST', body });
}
export async function fetchMe(): Promise<MeResponse> {
const payload: unknown = await request(ENDPOINTS.me);
return meResponseSchema.parse(payload);
}
/**
* 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用。
* includeDisabled=true 时包含已停用部门(部门管理页需要),默认只看启用。
*/
export async function fetchDepartments(includeDisabled = false): Promise<DepartmentNode[]> {
const search = includeDisabled ? '?include_disabled=true' : '?include_disabled=false';
const payload: unknown = await request(`${ENDPOINTS.departments}${search}`);
const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload;
if (!Array.isArray(raw)) {
return [];
}
const flat: DepartmentNode[] = [];
const walk = (nodes: unknown[]): void => {
for (const node of nodes) {
if (!isRecord(node)) {
continue;
}
flat.push(departmentNodeSchema.parse(node));
const children: unknown = node.children;
if (Array.isArray(children)) {
walk(children);
}
}
};
walk(raw);
return flat;
}
export async function createDepartment(input: CreateDepartmentInput): Promise<string> {
const body = createDepartmentInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.departmentsRoot, { method: 'POST', body });
const parsed = createdIdSchema.safeParse(payload);
if (!parsed.success) {
throw new Error('部门创建成功但未返回部门 ID');
}
return parsed.data.id;
}
export async function updateDepartment(
departmentId: string,
input: UpdateDepartmentInput,
): Promise<void> {
const body = updateDepartmentInputSchema.parse(input);
await request<unknown>(ENDPOINTS.department(departmentId), { method: 'PATCH', body });
}
export async function disableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentDisable(departmentId), { method: 'POST' });
}
export async function enableDepartment(departmentId: string): Promise<void> {
await request<unknown>(ENDPOINTS.departmentEnable(departmentId), { method: 'POST' });
}
export async function fetchRoles(): Promise<RoleSummary[]> {
const payload: unknown = await request(ENDPOINTS.roles);
return parseList(payload, roleSummarySchema);
}
/** 新建用户:返回新用户 id;后端若未返回 id,则由调用方回落到列表页 */
export async function createUser(input: CreateUserInput): Promise<string | null> {
const body = createUserInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body });
const parsed = createdIdSchema.safeParse(payload);
return parsed.success ? parsed.data.id : null;
}
export async function updateUser(userId: string, input: UpdateUserInput): Promise<void> {
const body = updateUserInputSchema.parse(input);
await request<unknown>(ENDPOINTS.user(userId), { method: 'PATCH', body });
}
export async function disableUser(userId: string, input: DisableUserInput): Promise<void> {
const body = disableUserInputSchema.parse(input);
await request<unknown>(ENDPOINTS.userDisable(userId), { method: 'POST', body });
}
export async function enableUser(userId: string): Promise<void> {
await request<unknown>(ENDPOINTS.userEnable(userId), { method: 'POST' });
}
export async function transferCustomers(
userId: string,
input: TransferCustomersInput,
): Promise<void> {
const body = transferCustomersInputSchema.parse(input);
await request<unknown>(ENDPOINTS.userTransferCustomers(userId), { method: 'POST', body });
}
/** 负责客户列表(A3 接口落地前调用会 404,由 hook 降级为空态) */
export async function fetchUserCustomers(userId: string): Promise<CustomerSummary[]> {
const payload: unknown = await request(ENDPOINTS.userCustomers(userId));
return parseList(payload, customerSummarySchema);
}
+29
View File
@@ -0,0 +1,29 @@
import type { RoleListQueryInput } from './roles';
import type { UserListQueryInput } from './users';
/** TanStack Query 缓存键(唯一出处,避免散落字符串) */
export const queryKeys = {
me: ['me'] as const,
users: ['users'] as const,
userList: (query: UserListQueryInput) => ['users', 'list', query] as const,
user: (userId: string) => ['users', 'detail', userId] as const,
userCustomers: (userId: string) => ['users', 'customers', userId] as const,
departments: (includeDisabled: boolean) => ['departments', { includeDisabled }] as const,
roles: ['roles'] as const,
roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const,
role: (roleId: string) => ['roles', 'detail', roleId] as const,
permissions: ['permissions'] as const,
// A3 client
industries: ['industries'] as const,
industryList: (query: object) => ['industries', 'list', query] as const,
industry: (industryId: string) => ['industries', 'detail', industryId] as const,
customers: ['customers'] as const,
customerList: (query: object) => ['customers', 'list', query] as const,
customer: (customerId: string) => ['customers', 'detail', customerId] as const,
customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const,
customerGroups: ['customer-groups'] as const,
customerGroupList: (query: object) => ['customer-groups', 'list', query] as const,
customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const,
customerFields: (query: object) => ['customer-fields', 'list', query] as const,
customerFieldsRoot: ['customer-fields'] as const,
};
+210
View File
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import {
createRole,
deleteRole,
fetchPermissionTree,
fetchRoleDetail,
fetchRoleList,
toRoleListSearchParams,
updateRole,
} from './roles';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const roleListItem = {
id: ROLE_ID,
code: 'reviewer',
name: '审核员',
description: '脚本与素材审核',
isSystem: false,
status: 'active',
permissionCount: 4,
specialPermissionCount: 0,
userCount: 3,
dataScope: { type: 'department_and_children' },
createdAt: '2026-10-27T02:40:00.000Z',
updatedAt: '2026-10-27T02:40:00.000Z',
};
const roleDetail = {
...roleListItem,
permissions: ['script:read', 'script:review', 'material:read', 'material:review'],
specialPermissions: [],
boundUsers: [
{
id: USER_ID,
name: '赵一诺',
account: 'zhao.yinuo',
phoneMasked: '136****3188',
departmentName: '内容生产中心 / 素材二组',
status: 'active',
},
],
};
const permissionTree = {
modules: [
{
module: 'script',
moduleLabel: '脚本',
sortOrder: 10,
permissions: [
{ code: 'script:read', name: '查看脚本', description: '脚本库', isSpecial: false },
{ code: 'script:review', name: '审核脚本', description: '入库审核', isSpecial: false },
],
},
{
module: 'delivery',
moduleLabel: '导出交付',
sortOrder: 40,
permissions: [
{ code: 'delivery:read', name: '查看交付记录', description: '交付清单', isSpecial: false },
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
},
],
specialPermissions: [
{ code: 'delivery:export', name: '发起导出', description: '数据出口', isSpecial: true },
],
sodPairs: [
{
id: 'script-write-review',
label: '脚本生成 × 脚本审核',
description: '同一角色不得同时拥有',
permissions: ['script:write', 'script:review'],
},
],
totalPermissions: 4,
};
const saveResult = {
role: roleDetail,
affectedUserCount: 3,
warnings: [],
};
const jsonResponse = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
function bodyOf(init: RequestInit | undefined): unknown {
return typeof init?.body === 'string' ? JSON.parse(init.body) : null;
}
describe('角色列表请求参数', () => {
it('按后端 query 约定拼装 snake_case 参数,空关键词不传', () => {
const params = toRoleListSearchParams({
limit: 100,
status: 'all',
sort: 'created_at',
order: 'desc',
keyword: ' 审核 ',
});
expect(params.toString()).toBe(
'limit=100&keyword=%E5%AE%A1%E6%A0%B8&status=all&sort=created_at&order=desc',
);
const empty = toRoleListSearchParams({ limit: 20, status: 'all', keyword: '' });
expect(empty.has('keyword')).toBe(false);
expect(empty.has('cursor')).toBe(false);
});
it('带游标时完整输出', () => {
const params = toRoleListSearchParams({ limit: 20, status: 'disabled', cursor: 'cursor-2' });
expect(params.get('cursor')).toBe('cursor-2');
expect(params.get('status')).toBe('disabled');
});
});
describe('角色 API 客户端', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: ROLE_ID, name: '灵犀管理员', account: 'admin' },
permissions: ['role:view'],
});
vi.restoreAllMocks();
});
it('解析角色列表信封与详情', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(jsonResponse({ items: [roleListItem], next_cursor: 'cursor-2' }))
.mockResolvedValueOnce(jsonResponse(roleDetail));
const list = await fetchRoleList({ limit: 100, status: 'all' });
expect(list.items).toHaveLength(1);
expect(list.next_cursor).toBe('cursor-2');
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/roles?limit=100&status=all');
const detail = await fetchRoleDetail(ROLE_ID);
expect(detail.permissions).toContain('script:review');
expect(detail.boundUsers[0]?.name).toBe('赵一诺');
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/roles/${ROLE_ID}`);
});
it('解析权限点目录', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(permissionTree));
const tree = await fetchPermissionTree();
expect(tree.totalPermissions).toBe(4);
expect(tree.specialPermissions[0]?.code).toBe('delivery:export');
});
it('新建角色以 camelCase body 提交并解析保存结果', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
const result = await createRole({
name: '审核员',
description: '脚本与素材审核',
permissions: ['script:read', 'script:review'],
dataScope: { type: 'department_and_children' },
});
expect(result.affectedUserCount).toBe(3);
const init = fetchMock.mock.calls[0]?.[1];
expect(init?.method).toBe('POST');
expect(bodyOf(init)).toEqual({
name: '审核员',
description: '脚本与素材审核',
permissions: ['script:read', 'script:review'],
dataScope: { type: 'department_and_children' },
});
});
it('更新角色状态与停用原因', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(saveResult));
await updateRole(ROLE_ID, { status: 'disabled', reason: '岗位调整' });
const init = fetchMock.mock.calls[0]?.[1];
expect(init?.method).toBe('PATCH');
expect(bodyOf(init)).toEqual({ status: 'disabled', reason: '岗位调整' });
});
it('删除角色返回 204', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response(null, { status: 204 }));
await expect(deleteRole(ROLE_ID)).resolves.toBeUndefined();
expect(fetchMock.mock.calls[0]?.[1]?.method).toBe('DELETE');
});
it('响应用共享 schema 校验:非法权限点编码直接报错', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
jsonResponse({ ...roleDetail, permissions: ['ghost:permission'] }),
);
await expect(fetchRoleDetail(ROLE_ID)).rejects.toThrow();
});
});
+82
View File
@@ -0,0 +1,82 @@
import {
type CreateRoleInput,
createRoleInputSchema,
type PermissionTree,
permissionTreeSchema,
type RoleDetail,
roleDetailSchema,
type RoleListQuery,
type RoleListResponse,
roleListResponseSchema,
type RoleSaveResult,
roleSaveResultSchema,
type UpdateRoleInput,
updateRoleInputSchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
/** 角色管理列表查询(query 参数 snake_case;角色管理页始终传全量状态) */
export interface RoleListQueryInput extends Partial<RoleListQuery> {
limit: number;
cursor?: string;
/** 排除指定角色 id(模板 / 复制来源场景由前端过滤,接口不感知) */
excludeRoleId?: string;
}
/** 组装 GET /roles 的 query 参数;空值不落 query,避免后端严格校验报错 */
export function toRoleListSearchParams(query: RoleListQueryInput): URLSearchParams {
const params = new URLSearchParams();
params.set('limit', String(query.limit));
if (query.cursor !== undefined && query.cursor !== '') {
params.set('cursor', query.cursor);
}
if (query.keyword !== undefined && query.keyword.trim() !== '') {
params.set('keyword', query.keyword.trim());
}
if (query.status !== undefined) {
params.set('status', query.status);
}
if (query.sort !== undefined) {
params.set('sort', query.sort);
}
if (query.order !== undefined) {
params.set('order', query.order);
}
return params;
}
export async function fetchRoleList(query: RoleListQueryInput): Promise<RoleListResponse> {
const search = toRoleListSearchParams(query).toString();
const path = search === '' ? ENDPOINTS.roles : `${ENDPOINTS.roles}?${search}`;
const payload: unknown = await request(path);
return roleListResponseSchema.parse(payload);
}
export async function fetchRoleDetail(roleId: string): Promise<RoleDetail> {
const payload: unknown = await request(ENDPOINTS.role(roleId));
return roleDetailSchema.parse(payload);
}
/** GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD(与后端同源) */
export async function fetchPermissionTree(): Promise<PermissionTree> {
const payload: unknown = await request(ENDPOINTS.permissions);
return permissionTreeSchema.parse(payload);
}
export async function createRole(input: CreateRoleInput): Promise<RoleSaveResult> {
const body = createRoleInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.roles, { method: 'POST', body });
return roleSaveResultSchema.parse(payload);
}
export async function updateRole(roleId: string, input: UpdateRoleInput): Promise<RoleSaveResult> {
const body = updateRoleInputSchema.parse(input);
const payload: unknown = await request(ENDPOINTS.role(roleId), { method: 'PATCH', body });
return roleSaveResultSchema.parse(payload);
}
export async function deleteRole(roleId: string): Promise<void> {
await request<unknown>(ENDPOINTS.role(roleId), { method: 'DELETE' });
}
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { fetchUserDetail, fetchUsers, toUserListSearchParams } from './users';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
const DEPT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1';
const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2';
const summary = {
id: USER_ID,
name: '陈默',
phoneMasked: '138****2046',
account: 'chen.mo',
departmentId: DEPT_ID,
departmentName: '内容生产中心',
groupId: null,
groupName: '素材一组',
roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }],
status: 'active',
lastLoginAt: '2026-11-02T09:41:00.000Z',
createdAt: '2026-09-30T18:40:00.000Z',
};
describe('用户列表请求参数', () => {
it('按后端 query 约定拼装 snake_case 参数,空值不传', () => {
const params = toUserListSearchParams({
limit: 10,
status: 'active',
sort: 'last_login',
order: 'desc',
keyword: ' 陈 ',
});
expect(params.get('limit')).toBe('10');
expect(params.get('status')).toBe('active');
expect(params.get('sort')).toBe('last_login');
expect(params.get('order')).toBe('desc');
expect(params.get('keyword')).toBe('陈');
expect(params.has('department_id')).toBe(false);
expect(params.has('cursor')).toBe(false);
});
it('带游标与部门/角色筛选时完整输出', () => {
const params = toUserListSearchParams({
limit: 10,
cursor: 'cursor-2',
status: 'all',
sort: 'created_at',
order: 'asc',
department_id: DEPT_ID,
role_id: ROLE_ID,
});
expect(params.toString()).toBe(
`limit=10&cursor=cursor-2&department_id=${DEPT_ID}&role_id=${ROLE_ID}&status=all&sort=created_at&order=asc`,
);
});
});
function toUrl(input: string | URL | Request | undefined): string {
if (typeof input === 'string') {
return input;
}
if (input instanceof URL) {
return input.href;
}
return input?.url ?? '';
}
describe('用户列表请求', () => {
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '陈默', account: 'chen.mo' },
permissions: ['user:view'],
});
});
it('解析列表信封与详情', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
new Response(JSON.stringify({ items: [summary], next_cursor: 'cursor-2' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
...summary,
phone: '13800002046',
mustChangePassword: true,
updatedAt: '2026-11-02T09:41:00.000Z',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
);
const list = await fetchUsers({ limit: 10, status: 'active' });
expect(list.items).toHaveLength(1);
expect(list.next_cursor).toBe('cursor-2');
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/users?limit=10&status=active');
const detail = await fetchUserDetail(USER_ID);
expect(detail.phone).toBe('13800002046');
expect(detail.mustChangePassword).toBe(true);
expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/users/${USER_ID}`);
});
});
+60
View File
@@ -0,0 +1,60 @@
import {
type ListResponse,
listResponseSchema,
type UserDetail,
userDetailSchema,
type UserListQuery,
type UserSummary,
userSummarySchema,
} from '@lingxi/shared';
import { request } from './client';
import { ENDPOINTS } from './endpoints';
const userListResponseSchema = listResponseSchema(userSummarySchema);
/** 用户列表筛选与游标分页查询(query 参数 snake_case) */
export interface UserListQueryInput extends Partial<UserListQuery> {
limit: number;
cursor?: string;
}
/** 组装 GET /users 的 query 参数;空值不落 query,避免后端严格校验报错 */
export function toUserListSearchParams(query: UserListQueryInput): URLSearchParams {
const params = new URLSearchParams();
params.set('limit', String(query.limit));
if (query.cursor !== undefined && query.cursor !== '') {
params.set('cursor', query.cursor);
}
if (query.keyword !== undefined && query.keyword.trim() !== '') {
params.set('keyword', query.keyword.trim());
}
if (query.department_id !== undefined) {
params.set('department_id', query.department_id);
}
if (query.role_id !== undefined) {
params.set('role_id', query.role_id);
}
if (query.status !== undefined) {
params.set('status', query.status);
}
if (query.sort !== undefined) {
params.set('sort', query.sort);
}
if (query.order !== undefined) {
params.set('order', query.order);
}
return params;
}
export async function fetchUsers(query: UserListQueryInput): Promise<ListResponse<UserSummary>> {
const search = toUserListSearchParams(query).toString();
const path = search === '' ? ENDPOINTS.users : `${ENDPOINTS.users}?${search}`;
const payload: unknown = await request(path);
return userListResponseSchema.parse(payload);
}
export async function fetchUserDetail(userId: string): Promise<UserDetail> {
const payload: unknown = await request(ENDPOINTS.user(userId));
return userDetailSchema.parse(payload);
}
+19
View File
@@ -0,0 +1,19 @@
import type { PermissionCode } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { usePermission } from '@/hooks/use-permission';
export interface CanProps {
/** 需要的权限点(数组时按 mode 组合判断) */
permission: PermissionCode | readonly PermissionCode[];
mode?: 'all' | 'any';
/** 无权限时的渲染内容;默认不渲染(按钮级控制要求「不渲染」而非隐藏) */
fallback?: ReactNode;
children: ReactNode;
}
/** 按钮级权限控制:无权限时不渲染子树 */
export function Can({ permission, mode = 'all', fallback = null, children }: CanProps): ReactNode {
const allowed = usePermission(permission, mode);
return allowed ? children : fallback;
}
@@ -0,0 +1,119 @@
/* eslint-disable max-lines-per-function -- 弹窗按字段顺序展开,拆分后状态传递反而更繁琐 */
import { Button, Input, Modal } from '@douyinfe/semi-ui-19';
import { IconLock } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useChangePasswordForm } from './use-change-password-form';
export interface MustChangePasswordModalProps {
/** 改密成功:调用方负责清会话并停留在登录页 */
onCompleted: () => void;
/** 取消 / 关闭:调用方负责清会话回登录表单 */
onCancel: () => void;
}
function Field({
label,
value,
error,
disabled,
placeholder,
onChange,
}: {
label: string;
value: string;
error: string | null;
disabled: boolean;
placeholder: string;
onChange: (value: string) => void;
}): ReactNode {
return (
<label className="lx-field">
<span className="lx-field__label">{label}</span>
<Input
type="password"
size="large"
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={onChange}
/>
{error === null ? null : <span className="lx-field__error">{error}</span>}
</label>
);
}
/**
* 首登强制改密:只在登录流程内渲染(不进入 AppLayout),
* 改密成功后由登录页清会话并要求用新密码重新登录。
*/
export function MustChangePasswordModal({
onCompleted,
onCancel,
}: MustChangePasswordModalProps): ReactNode {
const form = useChangePasswordForm();
const handleSubmit = (): void => {
void form.submit().then((ok) => {
if (ok) {
onCompleted();
}
});
};
return (
<Modal
visible
closable={!form.submitting}
maskClosable={false}
title="首次登录,请修改初始密码"
width={440}
onCancel={onCancel}
footer={
<div className="lx-must-change__footer">
<Button onClick={onCancel} disabled={form.submitting}>
取消
</Button>
<Button
theme="solid"
type="primary"
loading={form.submitting}
onClick={handleSubmit}
data-testid="change-password-submit"
>
更新密码
</Button>
</div>
}
>
<div className="lx-must-change">
<p className="lx-must-change__hint">
<IconLock size={16} />
为保障账号安全,初始密码仅用于首次登录;新密码需 8–64 位且同时包含字母与数字。
</p>
<Field
label="当前密码"
value={form.currentPassword}
error={form.currentError}
disabled={form.submitting}
placeholder="请输入初始密码"
onChange={form.setCurrentPassword}
/>
<Field
label="新密码"
value={form.newPassword}
error={form.newError}
disabled={form.submitting}
placeholder="请输入新密码"
onChange={form.setNewPassword}
/>
{form.submitError === null ? null : (
<p className="lx-must-change__error">{form.submitError}</p>
)}
<p className="lx-must-change__hint is-muted">
修改成功后当前会话将退出,请使用新密码重新登录。
</p>
</div>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '@/api/client';
import { fetchMe } from '@/api/identity';
import { useAuthStore } from '@/stores/auth-store';
// @lingxi/ui 的骨架/错误态依赖 Semi(lottie),jsdom 无 canvas;守卫单测只关心跳转逻辑
vi.mock('@lingxi/ui', () => ({
PageSkeleton: () => <div data-testid="skeleton" />,
ErrorState: () => <div data-testid="error-state" />,
}));
vi.mock('@/api/identity', () => ({
fetchMe: vi.fn(),
}));
import { RequireAuth } from './RequireAuth';
const mockedFetchMe = vi.mocked(fetchMe);
function renderGuard(entry: string): void {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route
path="/system/users"
element={
<RequireAuth>
<div>受保护页面</div>
</RequireAuth>
}
/>
<Route path="/login" element={<div data-testid="login-route">登录页</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('登录守卫', () => {
afterEach(() => {
cleanup();
window.localStorage.clear();
});
beforeEach(() => {
mockedFetchMe.mockReset();
useAuthStore.getState().clearSession();
});
it('未登录时跳登录页并携带 redirect', async () => {
renderGuard('/system/users');
expect(await screen.findByTestId('login-route')).toBeTruthy();
expect(screen.queryByText('受保护页面')).toBeNull();
});
it('会话处于「未改初始密码」时清会话并回登录页(后端 40903)', async () => {
mockedFetchMe.mockRejectedValue(
new ApiError({ code: 40903, status: 409, message: '请先修改初始密码' }),
);
useAuthStore.getState().setSession({
accessToken: 'real-token',
refreshToken: 'refresh',
user: { id: '01HZZZZZZZZZZZZZZZZZZZZZZZ', name: '管理员', account: 'admin' },
permissions: ['user:view'],
mustChangePassword: true,
});
renderGuard('/system/users');
expect(await screen.findByTestId('login-route')).toBeTruthy();
expect(useAuthStore.getState().accessToken).toBeNull();
});
it('已登录(演示会话)时直接渲染受保护内容', () => {
useAuthStore.getState().signInDemo();
expect(useAuthStore.getState().accessToken).not.toBeNull();
renderGuard('/system/users');
expect(screen.getByText('受保护页面')).toBeTruthy();
});
});
@@ -0,0 +1,83 @@
/* eslint-disable max-lines-per-function -- 守卫串联 /me 同步、401 与 409 兜底,集中便于追踪 */
import { ErrorState, PageSkeleton } from '@lingxi/ui';
import { type ReactNode, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router';
import {
isAccountDisabledError,
isMustChangePasswordError,
isUnauthorizedError,
useMeQuery,
} from '@/hooks/queries/use-me';
import { loginPath } from '@/routes';
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
export interface RequireAuthProps {
children: ReactNode;
}
/** 登录守卫:未登录跳登录页(带 redirect),已登录则拉 /me 后再渲染外壳 */
export function RequireAuth({ children }: RequireAuthProps): ReactNode {
const location = useLocation();
const accessToken = useAuthStore((state) => state.accessToken);
const syncFromMe = useAuthStore((state) => state.syncFromMe);
const clearSession = useAuthStore((state) => state.clearSession);
const meQuery = useMeQuery();
const from = `${location.pathname}${location.search}`;
const mustChangeBlocked = isMustChangePasswordError(meQuery.error);
useEffect(() => {
if (meQuery.data !== undefined) {
syncFromMe(meQuery.data);
}
}, [meQuery.data, syncFromMe]);
// 刷新页面时若会话仍处于「未改初始密码」状态:清会话回登录页完成改密,避免卡在 409
useEffect(() => {
if (mustChangeBlocked) {
clearSession();
}
}, [mustChangeBlocked, clearSession]);
if (accessToken === null) {
return <Navigate to={loginPath(from)} replace />;
}
if (isDemoSession(accessToken)) {
return children;
}
if (meQuery.isPending) {
return (
<div className="lx-auth-loading" data-testid="auth-loading">
<PageSkeleton rows={10} />
</div>
);
}
if (mustChangeBlocked) {
return <Navigate to={loginPath(from)} replace />;
}
if (meQuery.isError) {
if (isAccountDisabledError(meQuery.error)) {
// 会话在登录页按 reason 清理,避免先 clearSession 触发「未登录」分支把 reason 丢掉
const base = loginPath(from);
return (
<Navigate to={`${base}${base.includes('?') ? '&' : '?'}reason=account-disabled`} replace />
);
}
if (isUnauthorizedError(meQuery.error)) {
clearSession();
return <Navigate to={loginPath(from)} replace />;
}
return (
<div className="lx-auth-loading">
<ErrorState
title="权限信息加载失败"
message={meQuery.error.message}
onRetry={() => {
void meQuery.refetch();
}}
/>
</div>
);
}
return children;
}
@@ -0,0 +1,55 @@
import { Permission } from '@lingxi/shared';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { RequirePermission } from './RequirePermission';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
function renderGuard(permissions: string[]): void {
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
permissions,
});
render(
<MemoryRouter initialEntries={['/system/users']}>
<Routes>
<Route
path="/system/users"
element={
<RequirePermission permission={Permission.USER_VIEW}>
<div>用户页面</div>
</RequirePermission>
}
/>
<Route path="/403" element={<div>无权限页面</div>} />
</Routes>
</MemoryRouter>,
);
}
describe('路由权限守卫', () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
window.localStorage.clear();
useAuthStore.getState().clearSession();
});
it('有 user:view 时渲染子路由', () => {
renderGuard([Permission.USER_VIEW]);
expect(screen.getByText('用户页面')).toBeTruthy();
});
it('无权限时跳转 403,不渲染子路由', () => {
renderGuard([Permission.SCRIPT_READ]);
expect(screen.queryByText('用户页面')).toBeNull();
expect(screen.getByText('无权限页面')).toBeTruthy();
});
});
@@ -0,0 +1,22 @@
import type { PermissionCode } from '@lingxi/shared';
import type { ReactNode } from 'react';
import { Navigate } from 'react-router';
import { usePermission } from '@/hooks/use-permission';
import { ROUTES } from '@/routes';
export interface RequirePermissionProps {
permission: PermissionCode | readonly PermissionCode[];
mode?: 'all' | 'any';
children: ReactNode;
}
/** 路由级权限守卫:缺少权限点时跳 403(权限来自 /me 或登录响应) */
export function RequirePermission({
permission,
mode = 'all',
children,
}: RequirePermissionProps): ReactNode {
const allowed = usePermission(permission, mode);
return allowed ? children : <Navigate to={ROUTES.forbidden} replace />;
}
@@ -0,0 +1,68 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '@/api/client';
import { changePassword } from '@/api/identity';
import { useChangePasswordForm } from './use-change-password-form';
vi.mock('@/api/identity', () => ({
changePassword: vi.fn(),
}));
const mockedChangePassword = vi.mocked(changePassword);
describe('首登强改密码表单', () => {
beforeEach(() => {
mockedChangePassword.mockReset();
});
it('当前密码错误(后端 401)显示在字段下且不返回成功', async () => {
mockedChangePassword.mockRejectedValue(
new ApiError({ code: 40101, status: 401, message: '当前密码不正确' }),
);
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('wrong-pass1');
result.current.setNewPassword('Lingxi2026a');
});
let ok = true;
await act(async () => {
ok = await result.current.submit();
});
expect(ok).toBe(false);
expect(result.current.currentError).toBe('当前密码不正确,请重新输入');
expect(result.current.submitError).toBeNull();
});
it('校验不过时不发请求', async () => {
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('lingxi2026');
result.current.setNewPassword('short');
});
await act(async () => {
await result.current.submit();
});
expect(mockedChangePassword).not.toHaveBeenCalled();
expect(result.current.newError).toBeTruthy();
});
it('改密成功返回 true,由调用方负责清会话', async () => {
mockedChangePassword.mockResolvedValue();
const { result } = renderHook(() => useChangePasswordForm());
act(() => {
result.current.setCurrentPassword('lingxi2026');
result.current.setNewPassword('Lingxi2026a');
});
let ok = false;
await act(async () => {
ok = await result.current.submit();
});
expect(ok).toBe(true);
expect(mockedChangePassword).toHaveBeenCalledWith({
currentPassword: 'lingxi2026',
newPassword: 'Lingxi2026a',
});
});
});
@@ -0,0 +1,88 @@
/* eslint-disable max-lines-per-function -- 表单校验与提交流程集中在一个 Hook 便于追踪 */
import { changePasswordInputSchema, passwordSchema } from '@lingxi/shared';
import { useState } from 'react';
import { ApiError } from '@/api/client';
import { changePassword } from '@/api/identity';
export interface ChangePasswordFormState {
currentPassword: string;
newPassword: string;
currentError: string | null;
newError: string | null;
submitting: boolean;
submitError: string | null;
setCurrentPassword: (value: string) => void;
setNewPassword: (value: string) => void;
/** 提交成功返回 true(由调用方决定清会话/跳转),失败返回 false */
submit: () => Promise<boolean>;
}
function fieldError(message: string | undefined): string | null {
return message ?? null;
}
function isWrongCurrentPassword(error: unknown): boolean {
return error instanceof ApiError && (error.status === 401 || error.code === 40101);
}
/** 首次登录强制改密:密码规则与后端共用 shared 的 zod schema;不改会话,副作用交给调用方 */
export function useChangePasswordForm(): ChangePasswordFormState {
const [currentPassword, setCurrent] = useState('');
const [newPassword, setNew] = useState('');
const [currentError, setCurrentError] = useState<string | null>(null);
const [newError, setNewError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const submit = async (): Promise<boolean> => {
const parsed = changePasswordInputSchema.safeParse({ currentPassword, newPassword });
if (!parsed.success) {
const issues = parsed.error.issues;
setCurrentError(
fieldError(issues.find((issue) => issue.path[0] === 'currentPassword')?.message),
);
setNewError(fieldError(issues.find((issue) => issue.path[0] === 'newPassword')?.message));
return false;
}
const passwordCheck = passwordSchema.safeParse(newPassword);
if (!passwordCheck.success) {
setNewError(fieldError(passwordCheck.error.issues[0]?.message));
return false;
}
setSubmitting(true);
setSubmitError(null);
try {
await changePassword(parsed.data);
return true;
} catch (error) {
if (isWrongCurrentPassword(error)) {
// 后端对「当前密码不正确」返回 401;按字段错误展示,不触发全局登出
setCurrentError('当前密码不正确,请重新输入');
} else {
setSubmitError(error instanceof Error ? error.message : '修改密码失败,请稍后重试');
}
return false;
} finally {
setSubmitting(false);
}
};
return {
currentPassword,
newPassword,
currentError,
newError,
submitting,
submitError,
setCurrentPassword: (value) => {
setCurrent(value);
setCurrentError(null);
},
setNewPassword: (value) => {
setNew(value);
setNewError(null);
},
submit,
};
}
+353
View File
@@ -0,0 +1,353 @@
import type {
ApplyCustomerFieldInput,
ApproveCustomerFieldInput,
CreateCustomerGroupInput,
CreateCustomerInput,
CreateIndustryInput,
CustomerDetail,
CustomerField,
CustomerFieldListItem,
CustomerFieldListQuery,
CustomerGroupDetail,
CustomerGroupSummary,
CustomerListQuery,
CustomerSummary,
IndustryDetail,
IndustryListQuery,
IndustryTreeItem,
SetCustomerGroupsInput,
SetCustomerStatusInput,
SetIndustryStatusInput,
TransferCustomerInput,
UpdateCustomerGroupInput,
UpdateCustomerGroupMembersInput,
UpdateCustomerInput,
UpdateIndustryInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
applyCustomerField,
approveCustomerField,
createCustomer,
createCustomerGroup,
createIndustry,
deleteCustomerGroup,
deleteIndustry,
fetchCustomerDetail,
fetchCustomerFieldList,
fetchCustomerFields,
fetchCustomerGroupDetail,
fetchCustomerGroupList,
fetchCustomerList,
fetchIndustryDetail,
fetchIndustryList,
setCustomerGroups,
setCustomerStatus,
setIndustryStatus,
transferCustomer,
updateCustomer,
updateCustomerGroup,
updateCustomerGroupMembers,
updateIndustry,
} from '@/api/customers';
import { queryKeys } from '@/api/query-keys';
export function useIndustriesQuery(
query: Partial<IndustryListQuery> = {},
): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.industryList(query),
queryFn: () => fetchIndustryList(query),
staleTime: 10_000,
});
}
export function useIndustryDetailQuery(id: string | null): UseQueryResult<IndustryDetail> {
return useQuery({
queryKey: queryKeys.industry(id ?? ''),
queryFn: () => fetchIndustryDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomersQuery(
query: Partial<CustomerListQuery> = {},
): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerList(query),
queryFn: () => fetchCustomerList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerDetailQuery(id: string | null): UseQueryResult<CustomerDetail> {
return useQuery({
queryKey: queryKeys.customer(id ?? ''),
queryFn: () => fetchCustomerDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerGroupsQuery(): UseQueryResult<{
items: CustomerGroupSummary[];
next_cursor: string | null;
}> {
const query = { limit: 100 };
return useQuery({
queryKey: queryKeys.customerGroupList(query),
queryFn: () => fetchCustomerGroupList(query),
staleTime: 10_000,
});
}
export function useCustomerGroupDetailQuery(
id: string | null,
): UseQueryResult<CustomerGroupDetail> {
return useQuery({
queryKey: queryKeys.customerGroup(id ?? ''),
queryFn: () => fetchCustomerGroupDetail(id ?? ''),
enabled: id !== null && id !== '',
});
}
export function useCustomerFieldsQuery(
query: Partial<CustomerFieldListQuery> = {},
): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> {
return useQuery({
queryKey: queryKeys.customerFields(query),
queryFn: () => fetchCustomerFieldList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useCustomerFieldsOfQuery(
customerId: string | null,
): UseQueryResult<CustomerField[]> {
return useQuery({
queryKey: queryKeys.customerFieldsOf(customerId ?? ''),
queryFn: () => fetchCustomerFields(customerId ?? ''),
enabled: customerId !== null && customerId !== '',
});
}
function useInvalidateClient(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.industries });
await queryClient.invalidateQueries({ queryKey: queryKeys.customers });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups });
await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot });
};
}
export function useCreateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
CreateIndustryInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateIndustryMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: UpdateIndustryInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateIndustry(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetIndustryStatusMutation(): UseMutationResult<
IndustryDetail,
Error,
{ id: string; input: SetIndustryStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setIndustryStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteIndustryMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteIndustry,
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
CreateCustomerInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomer,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: UpdateCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerStatusMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerStatusInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerStatus(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useTransferCustomerMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: TransferCustomerInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => transferCustomer(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useSetCustomerGroupsMutation(): UseMutationResult<
CustomerDetail,
Error,
{ id: string; input: SetCustomerGroupsInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => setCustomerGroups(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useCreateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
CreateCustomerGroupInput
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: createCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroup(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateCustomerGroupMembersMutation(): UseMutationResult<
CustomerGroupDetail,
Error,
{ id: string; input: UpdateCustomerGroupMembersInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDeleteCustomerGroupMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: deleteCustomerGroup,
onSuccess: async () => {
await invalidate();
},
});
}
export function useApplyCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ customerId: string; input: ApplyCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useApproveCustomerFieldMutation(): UseMutationResult<
CustomerField,
Error,
{ id: string; input: ApproveCustomerFieldInput }
> {
const invalidate = useInvalidateClient();
return useMutation({
mutationFn: ({ id, input }) => approveCustomerField(id, input),
onSuccess: async () => {
await invalidate();
},
});
}
+100
View File
@@ -0,0 +1,100 @@
import type {
CreateDepartmentInput,
DepartmentNode,
RoleSummary,
UpdateDepartmentInput,
} from '@lingxi/shared';
import {
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
createDepartment,
disableDepartment,
enableDepartment,
fetchDepartments,
fetchRoles,
updateDepartment,
} from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
/** 部门树:默认只取启用部门(用户页选择器);部门管理页传 includeDisabled=true */
export function useDepartmentsQuery(includeDisabled = false): UseQueryResult<DepartmentNode[]> {
return useQuery({
queryKey: queryKeys.departments(includeDisabled),
queryFn: () => fetchDepartments(includeDisabled),
staleTime: 5 * 60_000,
});
}
export function useRolesQuery(): UseQueryResult<RoleSummary[]> {
return useQuery({
queryKey: queryKeys.roles,
queryFn: fetchRoles,
staleTime: 5 * 60_000,
});
}
/** 部门写操作:失效部门树与用户缓存(用户列表/详情的部门名称与人数会变) */
function useInvalidateDirectory(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['departments'] }),
queryClient.invalidateQueries({ queryKey: queryKeys.users }),
]);
};
}
export function useCreateDepartmentMutation(): UseMutationResult<
string,
Error,
CreateDepartmentInput
> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (input: CreateDepartmentInput) => createDepartment(input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateDepartmentMutation(): UseMutationResult<
void,
Error,
{ departmentId: string; input: UpdateDepartmentInput }
> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: ({ departmentId, input }: { departmentId: string; input: UpdateDepartmentInput }) =>
updateDepartment(departmentId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDisableDepartmentMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => disableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
export function useEnableDepartmentMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateDirectory();
return useMutation({
mutationFn: (departmentId: string) => enableDepartment(departmentId),
onSuccess: async () => {
await invalidate();
},
});
}
+34
View File
@@ -0,0 +1,34 @@
import type { MeResponse } from '@lingxi/shared';
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import { ApiError } from '@/api/client';
import { fetchMe } from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
import { isDemoSession, useAuthStore } from '@/stores/auth-store';
/** /me 全局缓存:守卫与权限 Hook 共用;演示会话不请求后端 */
export function useMeQuery(): UseQueryResult<MeResponse> {
const accessToken = useAuthStore((state) => state.accessToken);
const enabled = accessToken !== null && !isDemoSession(accessToken);
return useQuery({
queryKey: queryKeys.me,
queryFn: fetchMe,
enabled,
retry: false,
staleTime: 60_000,
});
}
export function isUnauthorizedError(error: unknown): boolean {
return error instanceof ApiError && error.status === 401;
}
/** 账号被停用:/me 由登录守卫返回 40301,此时旧会话应视为失效并回登录页 */
export function isAccountDisabledError(error: unknown): boolean {
return error instanceof ApiError && (error.status === 403 || error.code === 40301);
}
/** 后端守卫:初始密码未修改时,除改密外的接口统一返回 40903「请先修改初始密码」 */
export function isMustChangePasswordError(error: unknown): boolean {
return error instanceof ApiError && (error.status === 409 || error.code === 40903);
}
+98
View File
@@ -0,0 +1,98 @@
import type {
CreateRoleInput,
PermissionTree,
RoleDetail,
RoleListResponse,
RoleSaveResult,
UpdateRoleInput,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { queryKeys } from '@/api/query-keys';
import {
createRole,
deleteRole,
fetchPermissionTree,
fetchRoleDetail,
fetchRoleList,
type RoleListQueryInput,
updateRole,
} from '@/api/roles';
export function useRoleListQuery(query: RoleListQueryInput): UseQueryResult<RoleListResponse> {
return useQuery({
queryKey: queryKeys.roleList(query),
queryFn: () => fetchRoleList(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useRoleDetailQuery(roleId: string | null): UseQueryResult<RoleDetail> {
return useQuery({
queryKey: queryKeys.role(roleId ?? ''),
queryFn: () => fetchRoleDetail(roleId ?? ''),
enabled: roleId !== null && roleId !== '',
});
}
/** 权限点目录(10 模块 / 30 点 / 6 特殊 / 3 对 SoD):登录后长期复用 */
export function usePermissionTreeQuery(): UseQueryResult<PermissionTree> {
return useQuery({
queryKey: queryKeys.permissions,
queryFn: fetchPermissionTree,
staleTime: 30 * 60_000,
});
}
function useInvalidateRoles(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.roles });
};
}
export function useCreateRoleMutation(): UseMutationResult<RoleSaveResult, Error, CreateRoleInput> {
const invalidate = useInvalidateRoles();
return useMutation({
mutationFn: (input: CreateRoleInput) => createRole(input),
onSuccess: async (result) => {
await invalidate();
return result;
},
});
}
export function useUpdateRoleMutation(): UseMutationResult<
RoleSaveResult,
Error,
{ roleId: string; input: UpdateRoleInput }
> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ roleId, input }: { roleId: string; input: UpdateRoleInput }) =>
updateRole(roleId, input),
onSuccess: (result) => {
queryClient.setQueryData(queryKeys.role(result.role.id), result.role);
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
},
});
}
export function useDeleteRoleMutation(): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (roleId: string) => deleteRole(roleId),
onSuccess: (_result, roleId) => {
queryClient.removeQueries({ queryKey: queryKeys.role(roleId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.roles });
},
});
}
+152
View File
@@ -0,0 +1,152 @@
import {
type CreateUserInput,
type DisableUserInput,
type ListResponse,
type TransferCustomersInput,
type UpdateUserInput,
type UserDetail,
type UserSummary,
} from '@lingxi/shared';
import {
keepPreviousData,
useMutation,
type UseMutationResult,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import { ApiError } from '@/api/client';
import {
createUser,
type CustomerSummary,
disableUser,
enableUser,
fetchUserCustomers,
transferCustomers,
updateUser,
} from '@/api/identity';
import { queryKeys } from '@/api/query-keys';
import { fetchUserDetail, fetchUsers, type UserListQueryInput } from '@/api/users';
/** 转交/停用弹窗的接收人候选:一次拉取在职成员(上限 100),长时间复用 */
export function useUserCandidatesQuery(
enabled: boolean,
): UseQueryResult<ListResponse<UserSummary>> {
return useQuery({
queryKey: [...queryKeys.users, 'candidates'] as const,
queryFn: () => fetchUsers({ limit: 100, status: 'active', sort: 'last_login', order: 'desc' }),
enabled,
staleTime: 60_000,
});
}
export function useUserListQuery(
query: UserListQueryInput,
): UseQueryResult<ListResponse<UserSummary>> {
return useQuery({
queryKey: queryKeys.userList(query),
queryFn: () => fetchUsers(query),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
}
export function useUserDetailQuery(userId: string | null): UseQueryResult<UserDetail> {
return useQuery({
queryKey: queryKeys.user(userId ?? ''),
queryFn: () => fetchUserDetail(userId ?? ''),
enabled: userId !== null && userId !== '',
});
}
/** 负责客户:A3 客户模块接口未落地时降级为空列表,不阻塞详情其它 Tab */
export function useUserCustomersQuery(userId: string | null): UseQueryResult<CustomerSummary[]> {
return useQuery({
queryKey: queryKeys.userCustomers(userId ?? ''),
queryFn: async () => {
try {
return await fetchUserCustomers(userId ?? '');
} catch (error) {
if (error instanceof ApiError && (error.status === 404 || error.status === 422)) {
return [];
}
throw error;
}
},
enabled: userId !== null && userId !== '',
retry: false,
});
}
function useInvalidateUsers(): () => Promise<void> {
const queryClient = useQueryClient();
return async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.users });
};
}
export function useCreateUserMutation(): UseMutationResult<string | null, Error, CreateUserInput> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: (input: CreateUserInput) => createUser(input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useUpdateUserMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: UpdateUserInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: UpdateUserInput }) =>
updateUser(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useDisableUserMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: DisableUserInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: DisableUserInput }) =>
disableUser(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
export function useEnableUserMutation(): UseMutationResult<void, Error, string> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: (userId: string) => enableUser(userId),
onSuccess: async () => {
await invalidate();
},
});
}
export function useTransferCustomersMutation(): UseMutationResult<
void,
Error,
{ userId: string; input: TransferCustomersInput }
> {
const invalidate = useInvalidateUsers();
return useMutation({
mutationFn: ({ userId, input }: { userId: string; input: TransferCustomersInput }) =>
transferCustomers(userId, input),
onSuccess: async () => {
await invalidate();
},
});
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
/** 输入防抖:UI 即时反馈,请求参数延后生效 */
export function useDebouncedValue<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebounced(value);
}, delay);
return () => {
window.clearTimeout(timer);
};
}, [value, delay]);
return debounced;
}
+26
View File
@@ -0,0 +1,26 @@
import { useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { logout } from '@/api/identity';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store';
/** 登出:先吊销服务端会话(失败不阻塞),再清本地会话与查询缓存 */
export function useLogout(): () => void {
const navigate = useNavigate();
const queryClient = useQueryClient();
const refreshToken = useAuthStore((state) => state.refreshToken);
const clearSession = useAuthStore((state) => state.clearSession);
return useCallback(() => {
const finish = (): void => {
clearSession();
queryClient.clear();
void navigate(ROUTES.login, { replace: true });
};
void logout(refreshToken)
.catch(() => undefined)
.finally(finish);
}, [clearSession, navigate, queryClient, refreshToken]);
}
+55
View File
@@ -0,0 +1,55 @@
import { Permission } from '@lingxi/shared';
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { can, usePermission, usePermissions } from './use-permission';
const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ';
function signIn(permissions: string[]): void {
act(() => {
useAuthStore.getState().setSession({
accessToken: 'token',
user: { id: USER_ID, name: '测试用户', account: 'test.user' },
permissions,
});
});
}
describe('权限判断', () => {
beforeEach(() => {
window.localStorage.clear();
act(() => {
useAuthStore.getState().clearSession();
});
});
it('can 支持单权限、全部命中与任一命中', () => {
expect(can([Permission.USER_VIEW], Permission.USER_VIEW)).toBe(true);
expect(can([], Permission.USER_VIEW)).toBe(false);
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE])).toBe(false);
expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE], 'any')).toBe(
true,
);
expect(can([], [])).toBe(true);
});
it('usePermission 随 auth store 权限变化更新', () => {
const view = renderHook(() => usePermission(Permission.USER_VIEW));
expect(view.result.current).toBe(false);
signIn([Permission.USER_VIEW]);
expect(view.result.current).toBe(true);
signIn([]);
expect(view.result.current).toBe(false);
});
it('usePermissions 返回当前权限集合', () => {
signIn([Permission.USER_VIEW, Permission.USER_MANAGE]);
const perms = renderHook(() => usePermissions());
expect(perms.result.current).toEqual([Permission.USER_VIEW, Permission.USER_MANAGE]);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { PermissionCode } from '@lingxi/shared';
import { useAuthStore } from '@/stores/auth-store';
/** 纯函数:权限点判断(便于单测与在非组件上下文复用) */
export function can(
permissions: readonly string[],
permission: PermissionCode | readonly PermissionCode[],
mode: 'all' | 'any' = 'all',
): boolean {
const required = typeof permission === 'string' ? [permission] : permission;
if (required.length === 0) {
return true;
}
return mode === 'all'
? required.every((code) => permissions.includes(code))
: required.some((code) => permissions.includes(code));
}
/** 当前登录用户的权限点集合(/me 或登录响应写入 auth store) */
export function usePermissions(): readonly string[] {
return useAuthStore((state) => state.permissions);
}
/** 单个(或一组)权限点判断;`mode='all'` 要求全部命中 */
export function usePermission(
permission: PermissionCode | readonly PermissionCode[],
mode: 'all' | 'any' = 'all',
): boolean {
const permissions = usePermissions();
return can(permissions, permission, mode);
}
+10 -1
View File
@@ -11,6 +11,15 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav'; import { SideNav } from './SideNav';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
/** 用户管理、部门管理为「满高主从布局」,由页面自己控制内部滚动 */
function isFlushRoute(pathname: string): boolean {
return (
pathname === ROUTES.system.users ||
pathname.startsWith(`${ROUTES.system.users}/`) ||
pathname === ROUTES.system.departments
);
}
export function AppLayout(): ReactNode { export function AppLayout(): ReactNode {
const location = useLocation(); const location = useLocation();
const permissions = useAuthStore((state) => state.permissions); const permissions = useAuthStore((state) => state.permissions);
@@ -46,7 +55,7 @@ export function AppLayout(): ReactNode {
/> />
<main className="lx-workspace"> <main className="lx-workspace">
<PageBreadcrumbs pathname={location.pathname} /> <PageBreadcrumbs pathname={location.pathname} />
<div className="lx-page"> <div className={isFlushRoute(location.pathname) ? 'lx-page lx-page--flush' : 'lx-page'}>
<Suspense fallback={<PageSkeleton rows={8} />}> <Suspense fallback={<PageSkeleton rows={8} />}>
<Outlet /> <Outlet />
</Suspense> </Suspense>
+5 -1
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs';
import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps { export interface PageBreadcrumbsProps {
@@ -8,7 +9,10 @@ export interface PageBreadcrumbsProps {
} }
export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode { export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
const entries = getBreadcrumbs(NAV_MODULES, pathname); const entries =
getUserSectionTrail(pathname) ??
getRoleSectionTrail(pathname) ??
getBreadcrumbs(NAV_MODULES, pathname);
return ( return (
<nav className="lx-breadcrumbs" aria-label="面包屑"> <nav className="lx-breadcrumbs" aria-label="面包屑">
{entries.map((entry, index) => { {entries.map((entry, index) => {
+6 -14
View File
@@ -2,8 +2,8 @@ import { IconChevronDown, IconLogout, IconNotification, IconSearch } from '@ling
import { type ReactNode, useEffect, useRef, useState } from 'react'; import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router'; import { useLocation, useNavigate } from 'react-router';
import { useLogout } from '@/hooks/use-logout';
import type { NavModule } from '@/navigation/nav-config'; import type { NavModule } from '@/navigation/nav-config';
import { ROUTES } from '@/routes';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
export interface TopBarProps { export interface TopBarProps {
@@ -42,10 +42,9 @@ function ModuleTabs({
} }
function UserMenu(): ReactNode { function UserMenu(): ReactNode {
const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const clearSession = useAuthStore((state) => state.clearSession); const logout = useLogout();
const [openPath, setOpenPath] = useState<string | null>(null); const [openPath, setOpenPath] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null); const menuRef = useRef<HTMLDivElement | null>(null);
const open = openPath === location.pathname; const open = openPath === location.pathname;
@@ -80,25 +79,18 @@ function UserMenu(): ReactNode {
</span> </span>
<IconChevronDown size={14} className="lx-user__chevron" /> <IconChevronDown size={14} className="lx-user__chevron" />
</button> </button>
<UserMenuDropdown <UserMenuDropdown open={open} account={user?.account ?? '—'} onLogout={logout} />
open={open}
email={user?.email ?? '—'}
onLogout={() => {
clearSession();
void navigate(ROUTES.login, { replace: true });
}}
/>
</div> </div>
); );
} }
function UserMenuDropdown({ function UserMenuDropdown({
open, open,
email, account,
onLogout, onLogout,
}: { }: {
open: boolean; open: boolean;
email: string; account: string;
onLogout: () => void; onLogout: () => void;
}): ReactNode { }): ReactNode {
if (!open) { if (!open) {
@@ -106,7 +98,7 @@ function UserMenuDropdown({
} }
return ( return (
<div className="lx-user__menu" role="menu"> <div className="lx-user__menu" role="menu">
<div className="lx-user__menu-header">{email}</div> <div className="lx-user__menu-header">{account}</div>
<button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}> <button type="button" role="menuitem" className="lx-user__menu-item" onClick={onLogout}>
<IconLogout size={16} /> <IconLogout size={16} />
退出登录 退出登录
+12 -2
View File
@@ -5,6 +5,11 @@ import '@lingxi/ui/styles.css';
import '@/styles/global.css'; import '@/styles/global.css';
import '@/styles/layout.css'; import '@/styles/layout.css';
import '@/styles/pages.css'; import '@/styles/pages.css';
import '@/styles/users.css';
import '@/styles/roles.css';
import '@/styles/role-editor.css';
import '@/styles/departments.css';
import '@/styles/client.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui'; import { localeZhCN } from '@lingxi/ui';
@@ -16,10 +21,15 @@ import { RouterProvider } from 'react-router';
import { setUnauthorizedHandler } from '@/api/client'; import { setUnauthorizedHandler } from '@/api/client';
import { queryClient } from '@/api/query-client'; import { queryClient } from '@/api/query-client';
import { router } from '@/router'; import { router } from '@/router';
import { ROUTES } from '@/routes'; import { loginPath, ROUTES } from '@/routes';
setUnauthorizedHandler(() => { setUnauthorizedHandler(() => {
void router.navigate(ROUTES.login, { replace: true }); const { pathname, search } = router.state.location;
queryClient.clear();
if (pathname === ROUTES.login) {
return;
}
void router.navigate(loginPath(`${pathname}${search}`), { replace: true });
}); });
const container = document.getElementById('root'); const container = document.getElementById('root');
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more