Compare commits
46
Commits
75cedbe97b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50afaee49c | ||
|
|
c90a036f82 | ||
|
|
3e4bf48841 | ||
|
|
140dc386c1 | ||
|
|
8626996bb1 | ||
|
|
cda4b1825b | ||
|
|
087f27936e | ||
|
|
b489cb3152 | ||
|
|
4f6ccda6e3 | ||
|
|
f849e443d7 | ||
|
|
3e384febf3 | ||
|
|
981757b76c | ||
|
|
6661c42e84 | ||
|
|
f4da1f3c02 | ||
|
|
f5a8afe835 | ||
|
|
a3419cf5da | ||
|
|
fe70a2bcee | ||
|
|
7824bc7b32 | ||
|
|
7fb458f62f | ||
|
|
61894df401 | ||
|
|
f97f97b0cd | ||
|
|
a1e28daec5 | ||
|
|
f8b6232b4c | ||
|
|
2c8dd5040f | ||
|
|
4f8f7c587d | ||
|
|
340a8ed176 | ||
|
|
f11d6759fb | ||
|
|
46959fd5fc | ||
|
|
4d9eb980af | ||
|
|
f0d218bd99 | ||
|
|
2f200fc9d9 | ||
|
|
5628e158c0 | ||
|
|
620cb93a46 | ||
|
|
68b5ca20f4 | ||
|
|
e35637cfd7 | ||
|
|
dd01b89214 | ||
|
|
046dad463e | ||
|
|
043f0d9962 | ||
|
|
0f54f87c87 | ||
|
|
63d8889bee | ||
|
|
98a5343e53 | ||
|
|
1876c56008 | ||
|
|
073d210a92 | ||
|
|
23067edb8f | ||
|
|
09dbb27b66 | ||
|
|
b3a2a843bb |
No files matched your search
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 灵犀 · 环境变量示例(入库,禁止写真实密钥)
|
||||||
|
# 使用:cp .env.example .env.local(工程规范 09 ENV-09)
|
||||||
|
# 注意:本地 PostgreSQL/Redis 复用本机已有 Docker 服务(127.0.0.1:5432 / 6379);
|
||||||
|
# 对象存储为公网 RustFS,本地真实值只写入 .env.local / .env(已 gitignore)。
|
||||||
|
|
||||||
|
# ---------- 运行环境 ----------
|
||||||
|
NODE_ENV=development
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# ---------- 数据库(PostgreSQL 18) ----------
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/lingxi?schema=public
|
||||||
|
|
||||||
|
# ---------- Redis(7.4) ----------
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
# ---------- 对象存储(RustFS,S3 协议;A0 统一使用 lingxi-dev 桶) ----------
|
||||||
|
S3_ENDPOINT=https://oss.xinsenidea.com
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
S3_BUCKET_ASSETS=lingxi-dev
|
||||||
|
S3_BUCKET_RENDERS=lingxi-dev
|
||||||
|
S3_BUCKET_TEMP=lingxi-dev
|
||||||
|
S3_ACCESS_KEY=<rustfs-access-key>
|
||||||
|
S3_SECRET_KEY=<rustfs-secret-key>
|
||||||
|
|
||||||
|
# ---------- 认证(本地 dev 值,生产由密钥管理注入;密钥至少 32 位) ----------
|
||||||
|
JWT_ACCESS_SECRET=dev-only-jwt-access-secret-change-me-32
|
||||||
|
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 ----------
|
||||||
|
PORT=3000
|
||||||
|
HOST=0.0.0.0
|
||||||
|
WEB_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||||
|
MAX_UPLOAD_MB=500
|
||||||
|
|
||||||
|
# ---------- Worker ----------
|
||||||
|
QUEUE=default
|
||||||
|
WORKER_CONCURRENCY=2
|
||||||
|
|
||||||
|
# ---------- 前端(只能暴露 VITE_PUBLIC_* 前缀,ENV-07) ----------
|
||||||
|
VITE_PUBLIC_API_BASE_URL=http://127.0.0.1:3000/api/v1
|
||||||
|
# A0 登录壳没有接后端鉴权,置 true 时登录页写入本地演示会话以便查看布局;A1 接入真实登录后删除。
|
||||||
|
VITE_PUBLIC_DEMO_SESSION=false
|
||||||
|
|
||||||
|
# ---------- 可观测 ----------
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
|
||||||
|
# ---------- 功能开关 ----------
|
||||||
|
FEATURE_VIDEO=false
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# 远端 Gitea 未部署 Actions runner:本工作流不随 push 触发,仅供本地参考 / 手动触发。
|
||||||
|
# 本地等价命令:pnpm ci:local(推送前必须全绿)。
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: 质量门禁
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: lingxi_test
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U lingxi -d lingxi_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
redis:
|
||||||
|
image: redis:7.4-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 3s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
|
LOG_LEVEL: error
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/lingxi_test?schema=public
|
||||||
|
REDIS_URL: redis://127.0.0.1:6379
|
||||||
|
JWT_SECRET: ci-only-access-secret
|
||||||
|
JWT_REFRESH_SECRET: ci-only-refresh-secret
|
||||||
|
WEB_ORIGIN: http://127.0.0.1:4173
|
||||||
|
VITE_PUBLIC_API_BASE_URL: http://127.0.0.1:3000/api/v1
|
||||||
|
VITE_PUBLIC_DEMO_SESSION: 'true'
|
||||||
|
PLAYWRIGHT_CHANNEL: ''
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 检出代码
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 安装 pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10.34.9
|
||||||
|
|
||||||
|
- name: 安装 Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24.15.0
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: 安装依赖
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 格式检查
|
||||||
|
run: pnpm format:check
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: 类型检查
|
||||||
|
run: pnpm typecheck
|
||||||
|
|
||||||
|
- name: 单元测试与覆盖率
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: 构建
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: 数据库迁移
|
||||||
|
run: pnpm exec prisma migrate deploy
|
||||||
|
|
||||||
|
- name: 集成测试
|
||||||
|
run: pnpm test:int
|
||||||
|
|
||||||
|
- name: 导出 OpenAPI 快照并检查破坏性变更
|
||||||
|
run: |
|
||||||
|
pnpm openapi:export
|
||||||
|
pnpm openapi:diff
|
||||||
|
|
||||||
|
- name: 安装 Playwright 浏览器
|
||||||
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: 端到端冒烟
|
||||||
|
run: pnpm test:e2e
|
||||||
|
|
||||||
|
- name: 归档测试产物
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-artifacts
|
||||||
|
if-no-files-found: ignore
|
||||||
|
path: |
|
||||||
|
coverage
|
||||||
|
apps/*/coverage
|
||||||
|
packages/*/coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
openapi/lingxi.openapi.json
|
||||||
+11
@@ -62,7 +62,18 @@ temp/
|
|||||||
~$*
|
~$*
|
||||||
*.~lock.*
|
*.~lock.*
|
||||||
|
|
||||||
|
# ========== 测试产物 ==========
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|
||||||
# ========== 项目自定义 ==========
|
# ========== 项目自定义 ==========
|
||||||
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
|
# 参考项目:仅本地参照,不纳入版本控制、不推送到远程仓库
|
||||||
参考项目/
|
参考项目/
|
||||||
项目参考/
|
项目参考/
|
||||||
|
# 沙箱环境下的本地 pnpm 10 启动器与设计探索产物
|
||||||
|
.tools/
|
||||||
|
design/
|
||||||
|
# Vite 缓存
|
||||||
|
.vite/
|
||||||
|
# dev.sh 运行状态与日志
|
||||||
|
.dev/
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec commitlint --edit "$1"
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
pnpm typecheck
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const quote = (file) => JSON.stringify(file);
|
||||||
|
|
||||||
|
// 生成的声明文件(如 openapi.d.ts)被 ESLint 忽略,跳过 lint 只做格式化,
|
||||||
|
// 避免 lint-staged 因 "File ignored" 警告而阻断提交。
|
||||||
|
const lintScripts = (filenames) => {
|
||||||
|
const lintable = filenames.filter((file) => !file.endsWith('.d.ts'));
|
||||||
|
const commands = [];
|
||||||
|
if (lintable.length > 0) {
|
||||||
|
commands.push(`eslint --fix --max-warnings=0 ${lintable.map(quote).join(' ')}`);
|
||||||
|
}
|
||||||
|
commands.push(`prettier --write ${filenames.map(quote).join(' ')}`);
|
||||||
|
return commands;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'*.{ts,tsx,js,mjs,cjs}': lintScripts,
|
||||||
|
'*.{json,md,css,scss,yml,yaml}': ['prettier --write'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.turbo/
|
||||||
|
.tools/
|
||||||
|
design/
|
||||||
|
docs/
|
||||||
|
项目参考/
|
||||||
|
openapi/
|
||||||
|
prisma/migrations/
|
||||||
|
pnpm-lock.yaml
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# 项目总纲保持原排版,避免格式化产生无关 diff
|
||||||
|
AGENTS.md
|
||||||
@@ -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`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,18 @@
|
|||||||
## 文档索引
|
## 文档索引
|
||||||
|
|
||||||
| 文档 | 路径 | 版本 | 作用 |
|
| 文档 | 路径 | 版本 | 作用 |
|
||||||
| --- | --- | --- | --- |
|
| ------------ | ------------------------------------------------------------------------------------------------------ | -------------------- | ---------------------------------------------------------------------- |
|
||||||
| **项目总纲** | [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 完成范围、门禁实测、环境适配与待确认事项 |
|
||||||
|
| **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/工程规范/`。
|
||||||
@@ -26,7 +32,7 @@
|
|||||||
## 核心链路(五步)
|
## 核心链路(五步)
|
||||||
|
|
||||||
| 步骤 | 动作 | 产出 |
|
| 步骤 | 动作 | 产出 |
|
||||||
| --- | --- | --- |
|
| ---- | ---------------------------------------------- | -------------------- |
|
||||||
| 1 | 建行业 / 客户信息,选行业包并套客户资产包 | 归属根节点就绪 |
|
| 1 | 建行业 / 客户信息,选行业包并套客户资产包 | 归属根节点就绪 |
|
||||||
| 2 | 要素卡 + 卖点库 → 脚本生成 / 校核 / 审核入库 | 已入库脚本(带编号) |
|
| 2 | 要素卡 + 卖点库 → 脚本生成 / 校核 / 审核入库 | 已入库脚本(带编号) |
|
||||||
| 3 | 图片批次(模板 × 商品图 × 规格)/ 视频逐镜生成 | 候选素材 |
|
| 3 | 图片批次(模板 × 商品图 × 规格)/ 视频逐镜生成 | 候选素材 |
|
||||||
@@ -38,11 +44,11 @@
|
|||||||
## 功能模块(PRD V1.6)
|
## 功能模块(PRD V1.6)
|
||||||
|
|
||||||
| 模块 | 定位 |
|
| 模块 | 定位 |
|
||||||
| --- | --- |
|
| ------------ | ------------------------------------------------------------------------ |
|
||||||
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
| **脚本** | 要素卡 / 卖点库 / 模板 / 生成 / 质量校核 / 审核入库 / 版本 |
|
||||||
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
| **素材** | 图片线(PSD 套版、抠图、规格矩阵)、视频线(逐镜生成、变体)、快筛与预览 |
|
||||||
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
| **资产** | 15 类资产的库、版本、引用、复审与复用度量 |
|
||||||
| **平台管理** | 组织账号、客户、角色权限、AI 配置、数据字典、审计、行业包与表单设计器 |
|
| **平台管理** | 组织账号、客户、角色管理、AI 配置、数据字典、审计、行业包与表单设计器 |
|
||||||
|
|
||||||
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
> 早期文档中的「四大模块(脚本生成 / 素材生产 / 编辑 / 管理)」已于 PRD V1.6 重构为「脚本 / 素材 / 资产 / 平台管理」三主线 + 一底座。
|
||||||
|
|
||||||
@@ -57,23 +63,65 @@ 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 / 技术架构 / 开发计划 / 立项建议 / 工程规范
|
||||||
```
|
```
|
||||||
|
|
||||||
当前仓库处于**规范与设计阶段**,尚未建代码;建仓必须遵循 [AGENTS.md §5](AGENTS.md) 的结构与依赖方向。
|
当前已完成 **A0 工程骨架与契约基线**(monorepo、API/Worker/Web 骨架、Prisma 平台表、shared 契约包、UI 包、布局与登录壳、CI 与冒烟测试);业务模块从 A1 起按开发计划推进。目录与依赖方向见 [AGENTS.md §5](AGENTS.md)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 本地开发(建仓后适用)
|
## 本地开发
|
||||||
|
|
||||||
|
前置:Node.js `24.15.0`(`.nvmrc`)、pnpm `10.x`、Docker。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
pnpm compose:up # PostgreSQL 18 / Redis 7.4 / MinIO
|
cp .env.local .env # Prisma CLI 只读取 .env(两者均已 gitignore)
|
||||||
pnpm db:migrate
|
pnpm check:dev # 检查本机 PostgreSQL / Redis 是否可用
|
||||||
pnpm dev
|
pnpm db:init # 首次/迁移后:应用已有迁移 + 幂等种子(不重置数据、不改已有密码)
|
||||||
|
pnpm dev # 并行启动 api(3000) / web(5173) / worker
|
||||||
```
|
```
|
||||||
|
|
||||||
命令与门禁见 [AGENTS.md §6](AGENTS.md) 与 [06-测试与CI规范](docs/工程规范/06-测试与CI规范.md)。
|
> **日常启动不需要执行任何数据库命令**。`pnpm dev` / `./dev.sh` 都不会迁移、播种或重置数据库,已有账号密码保持不变。
|
||||||
|
> **只有需要清空本地库时才用 `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`。
|
||||||
|
- **演示登录**:A0 尚未接鉴权(A1 实现),`VITE_PUBLIC_DEMO_SESSION=true` 时登录页使用本地演示会话进入工作台;A1 接入真实登录后删除。
|
||||||
|
|
||||||
|
常用校验:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint && pnpm typecheck && pnpm test # 本地门禁
|
||||||
|
VITE_PUBLIC_DEMO_SESSION=true pnpm test:e2e # 构建 + 端到端冒烟(demo 会话)
|
||||||
|
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 · 云白蓝(传统企业后台 + 现代浅色质感)**。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run --coverage",
|
||||||
|
"test:int": "vitest run --config vitest.int.config.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cors": "11.3.1",
|
||||||
|
"@fastify/helmet": "13.2.0",
|
||||||
|
"@fastify/static": "10.1.8",
|
||||||
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"@nestjs/common": "11.2.8",
|
||||||
|
"@nestjs/core": "11.2.8",
|
||||||
|
"@nestjs/jwt": "12.0.3",
|
||||||
|
"@nestjs/platform-fastify": "11.2.8",
|
||||||
|
"@nestjs/swagger": "11.4.7",
|
||||||
|
"@prisma/client": "6.19.3",
|
||||||
|
"argon2": "0.45.1",
|
||||||
|
"dotenv": "18.0.9",
|
||||||
|
"fastify": "5.12.7",
|
||||||
|
"ioredis": "5.11.1",
|
||||||
|
"nestjs-pino": "5.3.1",
|
||||||
|
"pino": "10.4.0",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"reflect-metadata": "0.2.2",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"zod": "3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/testing": "11.2.8",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
|
"pino-pretty": "13.2.0",
|
||||||
|
"tsx": "4.23.15",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { SecurityModule } from './common/security/security.module';
|
||||||
|
import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
||||||
|
import type { AppConfig } from './config/env';
|
||||||
|
import { PrismaModule } from './infra/prisma/prisma.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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AppConfigModule,
|
||||||
|
LoggerModule.forRootAsync({
|
||||||
|
imports: [AppConfigModule],
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig) => ({
|
||||||
|
pinoHttp: {
|
||||||
|
level: config.logLevel,
|
||||||
|
genReqId: (request, response) => {
|
||||||
|
const header = request.headers[REQUEST_ID_HEADER];
|
||||||
|
const existing = Array.isArray(header) ? header[0] : header;
|
||||||
|
const traceId = typeof existing === 'string' && existing.length > 0 ? existing : ulid();
|
||||||
|
response.setHeader(REQUEST_ID_HEADER, traceId);
|
||||||
|
return traceId;
|
||||||
|
},
|
||||||
|
customProps: (request) => ({ trace_id: request.id }),
|
||||||
|
redact: {
|
||||||
|
paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'],
|
||||||
|
censor: '[redacted]',
|
||||||
|
},
|
||||||
|
autoLogging: {
|
||||||
|
ignore: (request) => request.url === '/health',
|
||||||
|
},
|
||||||
|
...(config.nodeEnv === 'development' && process.env.VITEST === undefined
|
||||||
|
? {
|
||||||
|
transport: {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: { singleLine: true, translateTime: 'SYS:HH:MM:ss.l' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
PrismaModule,
|
||||||
|
RedisModule,
|
||||||
|
SecurityModule,
|
||||||
|
IdentityModule,
|
||||||
|
ClientModule,
|
||||||
|
PlatformModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import helmet from '@fastify/helmet';
|
||||||
|
import { API_PREFIX } from '@lingxi/shared';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||||
|
import { APP_CONFIG } from './config/config.module';
|
||||||
|
import type { AppConfig } from './config/env';
|
||||||
|
import { buildOpenApiDocument, setupOpenApi } from './openapi/document';
|
||||||
|
|
||||||
|
export interface CreatedApiApp {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
config: AppConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建并配置 API 应用(测试与 OpenAPI 导出复用,不负责监听端口) */
|
||||||
|
export async function createApp(): Promise<CreatedApiApp> {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }),
|
||||||
|
{ bufferLogs: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = app.get<AppConfig>(APP_CONFIG);
|
||||||
|
app.useLogger(app.get(Logger));
|
||||||
|
|
||||||
|
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
app.enableCors({
|
||||||
|
origin: config.webOrigins,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 安全响应头;Swagger UI 需要内联脚本,CSP 在关闭文档页后再开启
|
||||||
|
await app.register(helmet, { contentSecurityPolicy: false });
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
|
||||||
|
return { app, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { buildOpenApiDocument, setupOpenApi };
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ERROR_MESSAGES, type ErrorCode, httpStatusOf } from '@lingxi/shared';
|
||||||
|
|
||||||
|
export interface AppExceptionOptions {
|
||||||
|
code: ErrorCode;
|
||||||
|
message?: string;
|
||||||
|
details?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务异常:由统一异常过滤器序列化为 { code, message, details, trace_id }。
|
||||||
|
* 业务代码禁止直接 throw new Error 表达可预期错误。
|
||||||
|
*/
|
||||||
|
export class AppException extends Error {
|
||||||
|
readonly code: ErrorCode;
|
||||||
|
readonly status: number;
|
||||||
|
readonly details?: unknown;
|
||||||
|
|
||||||
|
constructor(options: AppExceptionOptions) {
|
||||||
|
super(options.message ?? ERROR_MESSAGES[options.code]);
|
||||||
|
this.name = 'AppException';
|
||||||
|
this.code = options.code;
|
||||||
|
this.status = httpStatusOf(options.code);
|
||||||
|
this.details = options.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp } from '../../testing/test-app';
|
||||||
|
|
||||||
|
describe('统一异常过滤器(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp({ includeErrorRoutes: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AppException 输出业务错误码与 trace_id', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/app-error' });
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.STATE_NOT_ALLOWED);
|
||||||
|
expect(body.message).toBe('当前状态不允许该操作');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HttpException 404 映射为 40401', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/http-404' });
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未知异常收敛为 50001,不泄露内部信息', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/boom' });
|
||||||
|
expect(response.statusCode).toBe(500);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.INTERNAL_ERROR);
|
||||||
|
expect(body.message).toBe('服务内部错误');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未注册路由返回统一 404 错误体', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/not-exists' });
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { type ApiErrorBody, ErrorCode, httpStatusOf, isErrorCode } from '@lingxi/shared';
|
||||||
|
import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
|
||||||
|
interface RequestWithTrace {
|
||||||
|
id?: string;
|
||||||
|
url?: string;
|
||||||
|
method?: string;
|
||||||
|
log?: {
|
||||||
|
error: (payload: unknown, message?: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyLike {
|
||||||
|
status: (code: number) => { send: (body: unknown) => void };
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFromHttpStatus(status: number): ErrorCode {
|
||||||
|
switch (status) {
|
||||||
|
case 400:
|
||||||
|
return ErrorCode.PARAM_INVALID;
|
||||||
|
case 401:
|
||||||
|
return ErrorCode.UNAUTHENTICATED;
|
||||||
|
case 403:
|
||||||
|
return ErrorCode.PERMISSION_DENIED;
|
||||||
|
case 404:
|
||||||
|
return ErrorCode.NOT_FOUND;
|
||||||
|
case 409:
|
||||||
|
return ErrorCode.STATE_NOT_ALLOWED;
|
||||||
|
case 422:
|
||||||
|
return ErrorCode.BUSINESS_RULE_VIOLATION;
|
||||||
|
case 429:
|
||||||
|
return ErrorCode.RATE_LIMITED;
|
||||||
|
case 502:
|
||||||
|
case 503:
|
||||||
|
return ErrorCode.DEPENDENCY_UNAVAILABLE;
|
||||||
|
case 504:
|
||||||
|
return ErrorCode.TIMEOUT;
|
||||||
|
default:
|
||||||
|
return status >= 400 && status < 500 ? ErrorCode.PARAM_INVALID : ErrorCode.INTERNAL_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageFromHttpException(exception: HttpException): string {
|
||||||
|
const response = exception.getResponse();
|
||||||
|
if (typeof response === 'string') {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
if ('message' in response) {
|
||||||
|
const message = response.message;
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (Array.isArray(message) && message.length > 0 && typeof message[0] === 'string') {
|
||||||
|
return message.join('; ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exception.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常过滤器:所有错误统一输出 { code, message, details, trace_id }(工程规范 04)。
|
||||||
|
* trace_id 与 Pino 的 request id 同源,响应用户报障时可反查日志。
|
||||||
|
*/
|
||||||
|
@Catch()
|
||||||
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const context = host.switchToHttp();
|
||||||
|
const request = context.getRequest<RequestWithTrace>();
|
||||||
|
const reply = context.getResponse<ReplyLike>();
|
||||||
|
const traceId = request.id ?? '';
|
||||||
|
|
||||||
|
let status = 500;
|
||||||
|
let body: ApiErrorBody;
|
||||||
|
|
||||||
|
if (exception instanceof AppException) {
|
||||||
|
status = exception.status;
|
||||||
|
body = {
|
||||||
|
code: exception.code,
|
||||||
|
message: exception.message,
|
||||||
|
...(exception.details === undefined ? {} : { details: exception.details }),
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
} else if (exception instanceof HttpException) {
|
||||||
|
status = exception.getStatus();
|
||||||
|
const code = codeFromHttpStatus(status);
|
||||||
|
body = {
|
||||||
|
code,
|
||||||
|
message: status >= 500 ? '服务内部错误' : messageFromHttpException(exception),
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
body = {
|
||||||
|
code: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: '服务内部错误',
|
||||||
|
trace_id: traceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500) {
|
||||||
|
request.log?.error(
|
||||||
|
{
|
||||||
|
err: exception,
|
||||||
|
trace_id: traceId,
|
||||||
|
method: request.method,
|
||||||
|
url: request.url,
|
||||||
|
code: body.code,
|
||||||
|
},
|
||||||
|
'request failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.status(status).send(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供测试或未来的 OpenAPI 生成复用:确认错误码与 HTTP 状态一致 */
|
||||||
|
export function httpStatusForCode(code: number): number {
|
||||||
|
return isErrorCode(code) ? httpStatusOf(code) : 500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
/** OpenAPI 错误响应模型(工程规范 04 统一错误体) */
|
||||||
|
export class ErrorResponseDto {
|
||||||
|
@ApiProperty({ type: Number, description: '错误码(见 ErrorCode 常量)', example: 42202 })
|
||||||
|
code!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, description: '可直接展示给用户的提示', example: '内容触发合规拦截' })
|
||||||
|
message!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: Object,
|
||||||
|
description: '补充信息(字段错误、命中规则等),结构由错误码决定',
|
||||||
|
required: false,
|
||||||
|
example: { issues: [{ path: 'name', message: 'Required' }] },
|
||||||
|
})
|
||||||
|
details?: unknown;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
description: '请求追踪 ID,与日志同源',
|
||||||
|
example: '01JABCDEF0123456789ABCDEFG',
|
||||||
|
})
|
||||||
|
trace_id!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from './zod-validation.pipe';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(2),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ZodValidationPipe', () => {
|
||||||
|
it('通过校验并返回转换后的值', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
expect(pipe.transform({ name: '素材', limit: '30' })).toEqual({ name: '素材', limit: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺省值生效', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
expect(pipe.transform({ name: '素材' })).toEqual({ name: '素材', limit: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('校验失败抛 40001,details 带字段错误', () => {
|
||||||
|
const pipe = new ZodValidationPipe(schema);
|
||||||
|
try {
|
||||||
|
pipe.transform({ name: 'x' });
|
||||||
|
throw new Error('应当抛错');
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
const appError = error instanceof AppException ? error : null;
|
||||||
|
expect(appError?.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
expect(appError?.status).toBe(400);
|
||||||
|
expect(appError?.details).toMatchObject({
|
||||||
|
issues: [{ path: 'name' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { type PipeTransform } from '@nestjs/common';
|
||||||
|
import type { ZodType, ZodTypeDef } from 'zod';
|
||||||
|
|
||||||
|
import { AppException } from '../errors/app-exception';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod 校验管道(静态 DTO):body / query / params 共用。
|
||||||
|
* 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。
|
||||||
|
*/
|
||||||
|
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: ZodType<T, ZodTypeDef, unknown>) {}
|
||||||
|
|
||||||
|
transform(value: unknown): T {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '参数校验失败',
|
||||||
|
details: {
|
||||||
|
issues: result.error.issues.map((issue) => ({
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { type AppConfig, loadEnv } from './env';
|
||||||
|
|
||||||
|
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_CONFIG,
|
||||||
|
useFactory: (): AppConfig => loadEnv(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [APP_CONFIG],
|
||||||
|
})
|
||||||
|
export class AppConfigModule {}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { loadEnv } from './env';
|
||||||
|
|
||||||
|
const validEnv: NodeJS.ProcessEnv = {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
|
||||||
|
REDIS_URL: 'redis://127.0.0.1:16379',
|
||||||
|
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', () => {
|
||||||
|
it('解析必填项并给可选项默认值', () => {
|
||||||
|
const config = loadEnv(validEnv);
|
||||||
|
expect(config.nodeEnv).toBe('test');
|
||||||
|
expect(config.isProduction).toBe(false);
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
expect(config.host).toBe('0.0.0.0');
|
||||||
|
expect(config.logLevel).toBe('info');
|
||||||
|
expect(config.maxUploadMb).toBe(500);
|
||||||
|
expect(config.featureVideo).toBe(false);
|
||||||
|
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', () => {
|
||||||
|
const config = loadEnv({ ...validEnv, PORT: '8080', MAX_UPLOAD_MB: '200' });
|
||||||
|
expect(config.port).toBe(8080);
|
||||||
|
expect(config.maxUploadMb).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少 DATABASE_URL 时抛错并带字段名', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, DATABASE_URL: undefined })).toThrow(/DATABASE_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝非 postgres 连接串', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, DATABASE_URL: 'mysql://localhost/db' })).toThrow(
|
||||||
|
/DATABASE_URL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝非法 LOG_LEVEL', () => {
|
||||||
|
expect(() => loadEnv({ ...validEnv, LOG_LEVEL: 'verbose' })).toThrow(/LOG_LEVEL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FEATURE_VIDEO=true 转为布尔值', () => {
|
||||||
|
expect(loadEnv({ ...validEnv, FEATURE_VIDEO: 'true' }).featureVideo).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||||
|
HOST: z.string().min(1).default('0.0.0.0'),
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
DATABASE_URL: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('postgresql://') || value.startsWith('postgres://'),
|
||||||
|
'DATABASE_URL 必须是 postgresql:// 连接串',
|
||||||
|
),
|
||||||
|
REDIS_URL: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(value) => value.startsWith('redis://') || value.startsWith('rediss://'),
|
||||||
|
'REDIS_URL 必须是 redis:// 连接串',
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
FEATURE_VIDEO: z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.default('false')
|
||||||
|
.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 interface AppConfig {
|
||||||
|
nodeEnv: RawEnv['NODE_ENV'];
|
||||||
|
isProduction: boolean;
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
logLevel: RawEnv['LOG_LEVEL'];
|
||||||
|
databaseUrl: string;
|
||||||
|
redisUrl: string;
|
||||||
|
webOrigins: string[];
|
||||||
|
maxUploadMb: number;
|
||||||
|
featureVideo: boolean;
|
||||||
|
jwtAccessSecret: string;
|
||||||
|
jwtRefreshSecret: string;
|
||||||
|
jwtAccessTtlSeconds: number;
|
||||||
|
jwtRefreshTtlSeconds: number;
|
||||||
|
authMaxLoginFailures: number;
|
||||||
|
authLockMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||||
|
const result = envSchema.safeParse(source);
|
||||||
|
if (!result.success) {
|
||||||
|
const details = result.error.issues
|
||||||
|
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(`环境变量校验失败:${details}`);
|
||||||
|
}
|
||||||
|
const env = result.data;
|
||||||
|
return {
|
||||||
|
nodeEnv: env.NODE_ENV,
|
||||||
|
isProduction: env.NODE_ENV === 'production',
|
||||||
|
port: env.PORT,
|
||||||
|
host: env.HOST,
|
||||||
|
logLevel: env.LOG_LEVEL,
|
||||||
|
databaseUrl: env.DATABASE_URL,
|
||||||
|
redisUrl: env.REDIS_URL,
|
||||||
|
webOrigins: env.WEB_ORIGIN.split(',')
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0),
|
||||||
|
maxUploadMb: env.MAX_UPLOAD_MB,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { config as loadDotenvFile } from 'dotenv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
|
||||||
|
* 同时向上查找两级目录,兼容「从 apps/api 或包内跑 vitest」的工作目录差异。
|
||||||
|
* 只做加载,不做校验(校验见 config/env.ts)。
|
||||||
|
*/
|
||||||
|
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 directory of directories) {
|
||||||
|
const path = resolve(directory, file);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
loadDotenvFile({ path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from './bootstrap';
|
||||||
|
import { loadDotenv } from './config/load-dotenv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 集成测试:需要 pnpm compose:up 后真实 PG / Redis。
|
||||||
|
* 单独运行:pnpm test:int
|
||||||
|
*/
|
||||||
|
describe('GET /health(集成)', () => {
|
||||||
|
let created: CreatedApiApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
loadDotenv();
|
||||||
|
created = await createApp();
|
||||||
|
await created.app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await created.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('真实依赖健康时返回 200', async () => {
|
||||||
|
const response = await created.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{
|
||||||
|
status: string;
|
||||||
|
checks: { database: { status: string }; redis: { status: string } };
|
||||||
|
}>();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.checks.database.status).toBe('up');
|
||||||
|
expect(body.checks.redis.status).toBe('up');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp } from './testing/test-app';
|
||||||
|
|
||||||
|
describe('GET /health(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('依赖健康时返回 200 与检查明细', async () => {
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json<{
|
||||||
|
status: string;
|
||||||
|
checks: { database: { status: string }; redis: { status: string } };
|
||||||
|
}>();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
expect(body.checks.database.status).toBe('up');
|
||||||
|
expect(body.checks.redis.status).toBe('up');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /health(依赖异常)', () => {
|
||||||
|
it('数据库不可用时返回 503 与统一错误体', async () => {
|
||||||
|
const testApp = await createTestApp({ databaseUp: false });
|
||||||
|
const response = await testApp.app.inject({ method: 'GET', url: '/health' });
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
const body = response.json<{ code: number; message: string; trace_id: string }>();
|
||||||
|
expect(body.code).toBe(ErrorCode.DEPENDENCY_UNAVAILABLE);
|
||||||
|
expect(body.message).toBe('依赖服务不可用');
|
||||||
|
expect(body.trace_id.length).toBeGreaterThan(0);
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { testConfig } from '../testing/test-app';
|
||||||
|
import { PrismaService } from './prisma/prisma.service';
|
||||||
|
import { RedisService } from './redis/redis.service';
|
||||||
|
|
||||||
|
describe('PrismaService', () => {
|
||||||
|
it('关闭应用时断开 Prisma 连接', async () => {
|
||||||
|
const service = new PrismaService(testConfig);
|
||||||
|
const disconnect = vi.spyOn(service, '$disconnect').mockResolvedValue();
|
||||||
|
await service.onModuleDestroy();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RedisService', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ping 返回 PONG', async () => {
|
||||||
|
const service = new RedisService(testConfig);
|
||||||
|
const client = service.getClient();
|
||||||
|
vi.spyOn(client, 'connect').mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(client, 'ping').mockResolvedValue('PONG');
|
||||||
|
await expect(service.ping()).resolves.toBe('PONG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('关闭应用时断开 Redis 连接', () => {
|
||||||
|
const service = new RedisService(testConfig);
|
||||||
|
const client = service.getClient();
|
||||||
|
const disconnect = vi.spyOn(client, 'disconnect').mockReturnValue(undefined);
|
||||||
|
service.onModuleDestroy();
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
import { PRISMA } from '../tokens';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: PRISMA,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig): PrismaService => new PrismaService(config),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PRISMA],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prisma 封装。
|
||||||
|
* 不主动 $connect:Prisma 首次查询时才建连接,这样 OpenAPI 导出、单元测试不需要真实数据库;
|
||||||
|
* /health 会执行 SELECT 1 做真实检查。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
||||||
|
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||||
|
super({
|
||||||
|
datasources: { db: { url: config.databaseUrl } },
|
||||||
|
log: config.isProduction ? ['error'] : ['warn', 'error'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
import { REDIS } from '../tokens';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: REDIS,
|
||||||
|
inject: [APP_CONFIG],
|
||||||
|
useFactory: (config: AppConfig): RedisService => new RedisService(config),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [REDIS],
|
||||||
|
})
|
||||||
|
export class RedisModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
import { APP_CONFIG } from '../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis 客户端封装。
|
||||||
|
* lazyConnect:不在应用启动时连接,避免 OpenAPI 导出 / 单测依赖真实 Redis;
|
||||||
|
* /health 调 ping 时才会真正连接。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RedisService implements OnModuleDestroy {
|
||||||
|
private readonly client: Redis;
|
||||||
|
|
||||||
|
constructor(@Inject(APP_CONFIG) config: AppConfig) {
|
||||||
|
this.client = new Redis(config.redisUrl, {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
});
|
||||||
|
this.client.on('error', () => {
|
||||||
|
// 连接错误由健康检查与请求日志暴露,这里只避免 ioredis 抛未处理异常
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): Redis {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<'PONG'> {
|
||||||
|
if (this.client.status === 'wait') {
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
await this.client.ping();
|
||||||
|
return 'PONG';
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy(): void {
|
||||||
|
if (this.client.status !== 'end') {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** 依赖注入 token:避免跨模块直接依赖具体实现 */
|
||||||
|
export const PRISMA = Symbol('PRISMA');
|
||||||
|
export const REDIS = Symbol('REDIS');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { Logger } from 'nestjs-pino';
|
||||||
|
|
||||||
|
import { createApp, setupOpenApi } from './bootstrap';
|
||||||
|
import { loadDotenv } from './config/load-dotenv';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
loadDotenv();
|
||||||
|
const { app, config } = await createApp();
|
||||||
|
await app.init();
|
||||||
|
setupOpenApi(app, config);
|
||||||
|
await app.listen(config.port, config.host);
|
||||||
|
|
||||||
|
const logger = app.get(Logger);
|
||||||
|
logger.log(`灵犀 API 已启动:http://${config.host}:${config.port}(文档 /api/docs)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap().catch((error: unknown) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`API 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -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' }];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { Public } from '../../../common/security/decorators';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
import { type HealthReport, HealthResponseDto } from './health.types';
|
||||||
|
|
||||||
|
@ApiTags('platform')
|
||||||
|
@Public()
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: '健康检查',
|
||||||
|
description: '真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: HealthResponseDto, description: '全部依赖健康' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 503,
|
||||||
|
description: '依赖不可用(错误码 50002),details 内附各依赖检查结果',
|
||||||
|
})
|
||||||
|
async check(): Promise<HealthReport> {
|
||||||
|
return this.healthService.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { HealthService } from './health.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
providers: [HealthService],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { ErrorCode, nowIso } from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { APP_CONFIG } from '../../../config/config.module';
|
||||||
|
import type { AppConfig } from '../../../config/env';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import type { RedisService } from '../../../infra/redis/redis.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { REDIS } from '../../../infra/tokens';
|
||||||
|
import type { ComponentHealth, HealthReport } from './health.types';
|
||||||
|
|
||||||
|
const API_VERSION = '0.1.0';
|
||||||
|
const CHECK_TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
function withTimeout<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new Error(`检查超时(${String(timeoutMs)}ms)`));
|
||||||
|
}, timeoutMs);
|
||||||
|
void task.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 健康检查:真实检查数据库(SELECT 1)与 Redis(PING),不只看端口(技术架构 §4.1.1)。
|
||||||
|
* 任一依赖不可用时返回 503 + 统一错误体,details 里带完整检查快照。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HealthService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(REDIS) private readonly redis: RedisService,
|
||||||
|
@Inject(APP_CONFIG) private readonly config: AppConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async check(): Promise<HealthReport> {
|
||||||
|
const [database, redis] = await Promise.all([this.checkDatabase(), this.checkRedis()]);
|
||||||
|
const report: HealthReport = {
|
||||||
|
status: database.status === 'up' && redis.status === 'up' ? 'ok' : 'degraded',
|
||||||
|
env: this.config.nodeEnv,
|
||||||
|
version: API_VERSION,
|
||||||
|
uptimeSeconds: Math.round(process.uptime()),
|
||||||
|
checks: { database, redis },
|
||||||
|
timestamp: nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (report.status === 'degraded') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DEPENDENCY_UNAVAILABLE,
|
||||||
|
message: '依赖服务不可用',
|
||||||
|
details: report.checks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkDatabase(): Promise<ComponentHealth> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await withTimeout(this.prisma.$queryRaw`SELECT 1`, CHECK_TIMEOUT_MS);
|
||||||
|
return { status: 'up', latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRedis(): Promise<ComponentHealth> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
await withTimeout(this.redis.ping(), CHECK_TIMEOUT_MS);
|
||||||
|
return { status: 'up', latencyMs: Date.now() - startedAt };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
status: 'down',
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
error: error instanceof Error ? error.message : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export interface ComponentHealth {
|
||||||
|
status: 'up' | 'down';
|
||||||
|
latencyMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthReport {
|
||||||
|
status: 'ok' | 'degraded';
|
||||||
|
env: string;
|
||||||
|
version: string;
|
||||||
|
uptimeSeconds: number;
|
||||||
|
checks: {
|
||||||
|
database: ComponentHealth;
|
||||||
|
redis: ComponentHealth;
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ComponentHealthDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['up', 'down'], example: 'up' })
|
||||||
|
status!: 'up' | 'down';
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, description: '检查耗时(毫秒)', example: 3 })
|
||||||
|
latencyMs!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, required: false, description: '失败原因(仅 down 时返回)' })
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthChecksDto {
|
||||||
|
@ApiProperty({ type: ComponentHealthDto })
|
||||||
|
database!: ComponentHealthDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: ComponentHealthDto })
|
||||||
|
redis!: ComponentHealthDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthResponseDto {
|
||||||
|
@ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' })
|
||||||
|
status!: 'ok' | 'degraded';
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: 'development' })
|
||||||
|
env!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '0.1.0' })
|
||||||
|
version!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: Number, description: '进程运行秒数', example: 128 })
|
||||||
|
uptimeSeconds!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: HealthChecksDto })
|
||||||
|
checks!: HealthChecksDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, example: '2026-11-02T08:00:00.000Z' })
|
||||||
|
timestamp!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
|
||||||
|
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
|
||||||
|
*/
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [HealthModule],
|
||||||
|
exports: [HealthModule],
|
||||||
|
})
|
||||||
|
export class PlatformModule {}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createTestApp, type TestApp,testConfig } from '../testing/test-app';
|
||||||
|
import { buildOpenApiDocument } from './document';
|
||||||
|
|
||||||
|
describe('OpenAPI 产物(冒烟)', () => {
|
||||||
|
let testApp: TestApp;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testApp = await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testApp.app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('版本为 3.1 且包含健康检查路由', () => {
|
||||||
|
const document = buildOpenApiDocument(testApp.app, testConfig);
|
||||||
|
expect(document.openapi).toBe('3.1.0');
|
||||||
|
expect(Object.keys(document.paths).some((path) => path.endsWith('/health'))).toBe(true);
|
||||||
|
expect(document.info.version).toBe('0.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('包含全部错误码枚举与统一错误体', () => {
|
||||||
|
const document = buildOpenApiDocument(testApp.app, testConfig);
|
||||||
|
const schemas = document.components?.schemas ?? {};
|
||||||
|
expect(schemas.ErrorCode).toBeDefined();
|
||||||
|
expect(schemas.ErrorResponse).toBeDefined();
|
||||||
|
const errorCodeSchema = schemas.ErrorCode;
|
||||||
|
expect(errorCodeSchema && 'enum' in errorCodeSchema ? errorCodeSchema.enum : []).toHaveLength(
|
||||||
|
Object.values(ErrorCode).length,
|
||||||
|
);
|
||||||
|
expect('x-error-codes' in document).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import type { OpenAPIObject } from '@nestjs/swagger';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ErrorResponseDto } from '../common/http/error-response.dto';
|
||||||
|
import type { AppConfig } from '../config/env';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OpenAPI 文档。
|
||||||
|
* - 版本固定 3.1(技术架构:OpenAPI 3.1 + oasdiff breaking 检测)
|
||||||
|
* - 错误码以 ErrorCode schema + x-error-codes 扩展显式写入产物,供前端 codegen 与契约比对
|
||||||
|
*/
|
||||||
|
export function buildOpenApiDocument(app: INestApplication, config: AppConfig): OpenAPIObject {
|
||||||
|
const builder = new DocumentBuilder()
|
||||||
|
.setTitle('灵犀 API')
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
'信息流广告素材生产平台后端 API。',
|
||||||
|
'',
|
||||||
|
'统一约定:',
|
||||||
|
'- 前缀 /api/v1(健康检查 /health 除外)',
|
||||||
|
'- 错误体 { code, message, details, trace_id }',
|
||||||
|
'- 列表响应 { items, next_cursor }',
|
||||||
|
].join('\n'),
|
||||||
|
)
|
||||||
|
.setVersion('0.1.0')
|
||||||
|
.setOpenAPIVersion('3.1.0')
|
||||||
|
.addServer('/api/v1', 'API v1')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(app, builder, {
|
||||||
|
extraModels: [ErrorResponseDto],
|
||||||
|
});
|
||||||
|
|
||||||
|
const components = document.components ?? {};
|
||||||
|
const schemas = components.schemas ?? {};
|
||||||
|
|
||||||
|
schemas.ErrorCode = {
|
||||||
|
type: 'integer',
|
||||||
|
description: '业务错误码(只允许使用 @lingxi/shared 中登记的错误码)',
|
||||||
|
enum: Object.values(ErrorCode),
|
||||||
|
};
|
||||||
|
schemas.ErrorResponse = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['code', 'message', 'trace_id'],
|
||||||
|
properties: {
|
||||||
|
code: { $ref: '#/components/schemas/ErrorCode' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
details: { description: '补充信息,结构由错误码决定' },
|
||||||
|
trace_id: { type: 'string' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.components = { ...components, schemas };
|
||||||
|
Object.assign(document, {
|
||||||
|
'x-error-codes': Object.values(ErrorCode).map((code) => ({ code })),
|
||||||
|
'x-api-config': {
|
||||||
|
env: config.nodeEnv,
|
||||||
|
featureVideo: config.featureVideo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupOpenApi(app: INestApplication, config: AppConfig): OpenAPIObject {
|
||||||
|
const document = buildOpenApiDocument(app, config);
|
||||||
|
SwaggerModule.setup('api/docs', app, document, {
|
||||||
|
jsonDocumentUrl: 'api/openapi.json',
|
||||||
|
});
|
||||||
|
return document;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { createApp } from '../bootstrap';
|
||||||
|
import { loadDotenv } from '../config/load-dotenv';
|
||||||
|
import { buildOpenApiDocument } from '../openapi/document';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
loadDotenv();
|
||||||
|
const { app, config } = await createApp();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const document = buildOpenApiDocument(app, config);
|
||||||
|
const outputDir = resolve(process.cwd(), 'openapi');
|
||||||
|
const outputFile = resolve(outputDir, 'lingxi.openapi.json');
|
||||||
|
await mkdir(outputDir, { recursive: true });
|
||||||
|
await writeFile(outputFile, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
process.stdout.write(`OpenAPI 已导出:${outputFile}\n`);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main().catch((error: unknown) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`OpenAPI 导出失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { API_PREFIX, ErrorCode } from '@lingxi/shared';
|
||||||
|
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||||
|
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { type Mock, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../common/errors/app-exception';
|
||||||
|
import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter';
|
||||||
|
import { APP_CONFIG } from '../config/config.module';
|
||||||
|
import type { AppConfig } from '../config/env';
|
||||||
|
import { PRISMA, REDIS } from '../infra/tokens';
|
||||||
|
import { HealthController } from '../modules/platform/health/health.controller';
|
||||||
|
import { HealthService } from '../modules/platform/health/health.service';
|
||||||
|
|
||||||
|
export const testConfig: AppConfig = {
|
||||||
|
nodeEnv: 'test',
|
||||||
|
isProduction: false,
|
||||||
|
port: 3000,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
logLevel: 'error',
|
||||||
|
databaseUrl: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
|
||||||
|
redisUrl: 'redis://127.0.0.1:16379',
|
||||||
|
webOrigins: ['http://127.0.0.1:5173'],
|
||||||
|
maxUploadMb: 500,
|
||||||
|
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')
|
||||||
|
class ThrowingController {
|
||||||
|
@Get('app-error')
|
||||||
|
appError(): never {
|
||||||
|
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '当前状态不允许该操作' });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('http-404')
|
||||||
|
notFound(): never {
|
||||||
|
throw new NotFoundException('资源不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('boom')
|
||||||
|
boom(): never {
|
||||||
|
throw new Error('unexpected failure');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestAppOptions {
|
||||||
|
databaseUp?: boolean;
|
||||||
|
redisUp?: boolean;
|
||||||
|
includeErrorRoutes?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestApp {
|
||||||
|
app: NestFastifyApplication;
|
||||||
|
prisma: { $queryRaw: Mock };
|
||||||
|
redis: { ping: Mock };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
|
||||||
|
const prisma = {
|
||||||
|
$queryRaw:
|
||||||
|
options.databaseUp === false
|
||||||
|
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:15432'))
|
||||||
|
: vi.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
ping:
|
||||||
|
options.redisUp === false
|
||||||
|
? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379'))
|
||||||
|
: vi.fn().mockResolvedValue('PONG'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
controllers:
|
||||||
|
options.includeErrorRoutes === true
|
||||||
|
? [HealthController, ThrowingController]
|
||||||
|
: [HealthController],
|
||||||
|
providers: [
|
||||||
|
HealthService,
|
||||||
|
{ provide: PRISMA, useValue: prisma },
|
||||||
|
{ provide: REDIS, useValue: redis },
|
||||||
|
{ provide: APP_CONFIG, useValue: testConfig },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
return { app, prisma, redis };
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.int.spec.ts", "src/scripts/**"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"useDefineForClassFields": false
|
||||||
|
},
|
||||||
|
"include": ["src", "vitest.config.ts", "vitest.int.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.spec.ts'],
|
||||||
|
exclude: ['**/*.int.spec.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'lcov', 'json-summary'],
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.int.spec.ts'],
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 30_000,
|
||||||
|
// 集成测试共用本地真实 PG / Redis,且各文件会清理带前缀的夹具(如 it.* 与 it.dep.* 重叠)。
|
||||||
|
// 文件级并行会让跨文件的夹具清理 / 数据断言互相干扰,因此串行执行。
|
||||||
|
fileParallelism: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>灵犀 · 信息流素材生产平台</title>
|
||||||
|
</head>
|
||||||
|
<body theme-mode="light">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@lingxi/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 4173 --strictPort",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"lint": "eslint src --max-warnings=0",
|
||||||
|
"test": "vitest run --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-ui-19": "2.104.0",
|
||||||
|
"@lingxi/shared": "workspace:*",
|
||||||
|
"@lingxi/ui": "workspace:*",
|
||||||
|
"@tanstack/react-query": "5.105.1",
|
||||||
|
"react": "19.3.1",
|
||||||
|
"react-dom": "19.3.1",
|
||||||
|
"react-router": "7.18.5",
|
||||||
|
"zod": "3.25.76",
|
||||||
|
"zustand": "5.0.16"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "16.3.3",
|
||||||
|
"@types/react": "19.3.0",
|
||||||
|
"@types/react-dom": "19.3.0",
|
||||||
|
"@vitejs/plugin-react": "5.2.0",
|
||||||
|
"@vitest/coverage-v8": "3.2.7",
|
||||||
|
"jsdom": "28.1.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "6.4.5",
|
||||||
|
"vitest": "3.2.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
|
import { ApiError, request, setUnauthorizedHandler } from './client';
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, status = 200): Response =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyResponse = (status: number): Response => new Response(null, { status });
|
||||||
|
|
||||||
|
function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void {
|
||||||
|
useAuthStore.getState().setSession({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' },
|
||||||
|
permissions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizationOf(init: RequestInit | undefined): string | null {
|
||||||
|
return init?.headers instanceof Headers ? init.headers.get('Authorization') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
window.localStorage.clear();
|
||||||
|
useAuthStore.getState().clearSession();
|
||||||
|
setUnauthorizedHandler(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('注入 Bearer token 并解析 JSON', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi
|
||||||
|
.spyOn(globalThis, 'fetch')
|
||||||
|
.mockResolvedValue(jsonResponse({ status: 'ok' }));
|
||||||
|
|
||||||
|
await expect(request<{ status: string }>('/health')).resolves.toEqual({ status: 'ok' });
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(authorizationOf(init)).toBe('Bearer token-1');
|
||||||
|
expect(toUrl(fetchMock.mock.calls[0]?.[0])).toBe('http://127.0.0.1:3000/api/v1/health');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auth: false 时不注入 token', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||||
|
await request('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email: 'a', password: 'b' },
|
||||||
|
});
|
||||||
|
expect(authorizationOf(fetchMock.mock.calls[0]?.[1])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('204 返回 undefined', async () => {
|
||||||
|
signIn();
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(emptyResponse(204));
|
||||||
|
await expect(request('/resource')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('统一错误体解析为 ApiError,保留 trace_id', async () => {
|
||||||
|
signIn();
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
jsonResponse(
|
||||||
|
{ code: 42201, message: '业务校验失败', details: { field: 'name' }, trace_id: 'trace-1' },
|
||||||
|
422,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const error = await request('/resource').catch((caught: unknown) => caught);
|
||||||
|
expect(error).toBeInstanceOf(ApiError);
|
||||||
|
const apiError = error instanceof ApiError ? error : null;
|
||||||
|
expect(apiError?.code).toBe(42201);
|
||||||
|
expect(apiError?.status).toBe(422);
|
||||||
|
expect(apiError?.traceId).toBe('trace-1');
|
||||||
|
expect(apiError?.details).toEqual({ field: 'name' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('401 时单飞刷新并重放原请求', async () => {
|
||||||
|
signIn('expired-token', 'refresh-1');
|
||||||
|
let refreshCalls = 0;
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
|
||||||
|
const url = toUrl(input);
|
||||||
|
const authorization = authorizationOf(init);
|
||||||
|
if (url.endsWith('/auth/refresh')) {
|
||||||
|
refreshCalls += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
jsonResponse({ accessToken: 'new-token', refreshToken: 'refresh-2' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (authorization === 'Bearer expired-token') {
|
||||||
|
return Promise.resolve(emptyResponse(401));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([request('/a'), request('/b')]);
|
||||||
|
expect(first).toEqual({ ok: true });
|
||||||
|
expect(second).toEqual({ ok: true });
|
||||||
|
expect(refreshCalls).toBe(1);
|
||||||
|
expect(useAuthStore.getState().accessToken).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('刷新失败时清空会话并触发未授权回调', async () => {
|
||||||
|
signIn('expired-token', 'refresh-1');
|
||||||
|
const onUnauthorized = vi.fn();
|
||||||
|
setUnauthorizedHandler(onUnauthorized);
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||||
|
const url = toUrl(input);
|
||||||
|
return Promise.resolve(
|
||||||
|
url.endsWith('/auth/refresh') ? emptyResponse(401) : emptyResponse(401),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(request('/secure')).rejects.toBeInstanceOf(ApiError);
|
||||||
|
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||||
|
expect(useAuthStore.getState().accessToken).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('把请求体序列化为 JSON', async () => {
|
||||||
|
signIn();
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ok: true }));
|
||||||
|
await request('/resource', { method: 'POST', body: { name: '素材' } });
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1];
|
||||||
|
expect(init?.body).toBe(JSON.stringify({ name: '素材' }));
|
||||||
|
expect(init?.method).toBe('POST');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user