mirror of
https://gitee.com/lakernote/easy-next-admin.git
synced 2026-09-03 05:53:52 +08:00
feat: initialize EasyNextAdmin
Publish the current verified project state without local development history or personal-path artifacts.
This commit is contained in:
159
.codex/skills/easy-next-admin-vibecoding/SKILL.md
Normal file
159
.codex/skills/easy-next-admin-vibecoding/SKILL.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
name: easy-next-admin-vibecoding
|
||||
description: Use when inspecting, modifying, reviewing, or documenting EasyNextAdmin, including Spring Boot backend, Vue admin frontend, sys_menu permissions, dynamic routes, Flyway, workflows, monitoring, audits, reports, deployment, or agent/vibecoding guidance.
|
||||
---
|
||||
|
||||
# EasyNextAdmin Vibe Coding
|
||||
|
||||
## Purpose
|
||||
|
||||
Use this skill to keep EasyNextAdmin changes aligned with its product boundary and implementation contracts. EasyNextAdmin is a Chinese-first enterprise admin scaffold, not a low-code platform, BI platform, BPM platform, or template showcase.
|
||||
|
||||
The main job is contract preservation: backend APIs, frontend feature wrappers, `sys_menu`, permission constants, dynamic routes, Flyway seed data, tests, and docs should move together.
|
||||
|
||||
## Read First
|
||||
|
||||
- `AGENTS.md` for repo-wide agent rules.
|
||||
- `README.md` for project overview and quick start.
|
||||
- `docs/features-and-components.md` before changing user-facing capabilities.
|
||||
- `docs/architecture.md` before changing backend contracts, permissions, audit, data scope, workflow, schedule, report, or monitoring behavior.
|
||||
- `docs/deployment.md` before changing Docker, Nginx, profiles, ports, or build output.
|
||||
|
||||
For skill or agent-entry changes, read:
|
||||
|
||||
- `.codex/skills/easy-next-admin-vibecoding/SKILL.md`
|
||||
- `.codex/skills/easy-next-admin-vibecoding/agents/openai.yaml`
|
||||
- `AGENTS.md`
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Inspect the existing implementation with `rg` / `rg --files`.
|
||||
2. Classify the task before editing:
|
||||
- Backend/API/SQL: controller, service, entity, mapper, DTO, `EasyPermissions`, Flyway SQL, tests.
|
||||
- Frontend/page: `src/features/<domain>`, `src/views`, `PermissionCodes`, dynamic route component paths, tests.
|
||||
- Menu/permission: MySQL seed, H2 test seed, backend constants, frontend constants, role seed blocks.
|
||||
- Docs/product surface: README, `docs/features-and-components.md`, architecture/deployment docs as relevant.
|
||||
- Skill/agent entry: `SKILL.md`, `agents/openai.yaml`, `AGENTS.md`.
|
||||
3. Identify the full contract before changing code. Do not update only one side of a frontend/backend or permission/menu contract.
|
||||
4. Make small scoped edits that follow existing naming and layout.
|
||||
5. Keep Chinese enterprise admin UX: efficient CRUD, dense but readable information, clear permissions, stable internal-network deployment.
|
||||
6. Verify with the narrowest meaningful command, then report exactly what passed.
|
||||
|
||||
## Contract Map
|
||||
|
||||
| Change type | Required checks |
|
||||
| --- | --- |
|
||||
| Backend endpoint | Standard `Response<T>` / `PageResponse<T>`, `@EasyPermission`, audit for important writes, service boundary, DTO instead of persistence entity exposure. |
|
||||
| Frontend page | Feature API wrapper and types, loading/empty/error states, no raw Axios in views, `v-permission` for restricted buttons. |
|
||||
| Menu or permission | `EasyPermissions`, `PermissionCodes`, MySQL `sys_menu`, H2 seed SQL, role permission seed, dynamic `component_path`. |
|
||||
| Database schema | Flyway migration, MySQL/H2 test compatibility, docs or local startup notes when behavior changes. |
|
||||
| Workflow behavior | Runtime service, task policy/dispatcher/navigator, message sync, instance/detail views, workflow tests. |
|
||||
| Docs | Only describe implemented, runnable, verifiable capabilities. Put speculative work in issues or design notes. |
|
||||
| Skill/agent guide | Keep trigger metadata concise, update `agents/openai.yaml`, avoid duplicating long docs, run skill validation. |
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- Do not create or restore a root `scripts/` directory unless the user explicitly asks.
|
||||
- Do not add CDN, online icon, or online font dependencies.
|
||||
- Do not copy a full admin template or keep third-party template branding.
|
||||
- Do not expose abstract extension-center concepts in product UI or public API.
|
||||
- Do not write planned or speculative features as if they already exist.
|
||||
- Do not change frontend/backend response contracts on only one side.
|
||||
- Do not keep adding behavior into very large files when a local component, helper, or service boundary already exists or can be extracted safely.
|
||||
|
||||
## Frontend Rules
|
||||
|
||||
- Use Vue 3, TypeScript, Vite, Pinia, Vue Router, Axios, Element Plus, ECharts, and LogicFlow as already present.
|
||||
- Views live under `easy-next-admin-web/src/views`.
|
||||
- Business API wrappers and types live under `easy-next-admin-web/src/features/<domain>`.
|
||||
- Route, menu, and page permission metadata live in backend `sys_menu`; frontend resolves `component_path` through `src/router/dynamicRoutes.ts`.
|
||||
- Button permissions use `v-permission`; permission constants in `src/permissions/codes.ts` must match backend `EasyPermissions` and `sys_menu`.
|
||||
- Pages should call feature API functions, not raw Axios.
|
||||
- If a Vue SFC is already large, prefer extracting stable subcomponents or feature helpers before adding more unrelated logic.
|
||||
- After frontend changes, run:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Frontend Resource Page Layout
|
||||
|
||||
Before adding or changing an admin list/table page, inspect nearby pages such as `UserView.vue`, `FileCenterView.vue`, `MessageCenterView.vue`, `BehaviorAuditView.vue`, or the closest same-domain page. Preserve the established resource-page shell unless the page has a clearly different interaction model.
|
||||
|
||||
Use this standard structure for main resource list pages:
|
||||
|
||||
- Page shell: `<section class="resource-page ...">` with `.resource-hero` for title and page actions.
|
||||
- Metrics: use `.resource-metrics` / `.resource-metric` for summary numbers; do not create one-off summary strip CSS when the shared metric style works.
|
||||
- Table panel: use `<section ref="tablePanelRef" class="surface resource-panel is-fluid-table">`.
|
||||
- Table controls: use `<div class="table-control-row">` containing an inline `<el-form class="filter-bar ...">` and `<TableToolbar class="table-toolbar-inline" />`.
|
||||
- Table: use `row-key`, `:height="tableHeight"`, and `class="admin-table ..."` for list tables that use fluid height.
|
||||
- Footer: use `.table-footer` or `.table-footer.is-split` with the project pagination layout.
|
||||
|
||||
Avoid these regressions:
|
||||
|
||||
- Do not split the main table filters into a separate `surface` card when the page is a normal resource list.
|
||||
- Do not put `TableToolbar` inside `filter-bar`; keep it as the sibling inside `table-control-row`.
|
||||
- Do not add page-specific flex/padding/background rules that duplicate `.resource-panel`, `.table-control-row`, `.filter-bar`, `.admin-table`, or `.table-footer`.
|
||||
- Keep scoped CSS to page-specific widths, table cell display, responsive exceptions, and domain-specific visual details. Use scoped `:deep(...)` only where Element Plus internals require it.
|
||||
- Use `useFluidTableHeight(tablePanelRef)` for full-height table pages and keep the panel ref on the `resource-panel`.
|
||||
|
||||
## Backend Rules
|
||||
|
||||
- Use Java 17, Spring Boot 3, MyBatis-Plus, Flyway.
|
||||
- Keep modules under `easy-next-admin-server/src/main/java/com/laker/admin/module`.
|
||||
- Shared platform behavior belongs in `common`, `config`, or `infrastructure`.
|
||||
- Return `Response<T>` or `PageResponse<T>`.
|
||||
- Protect real write operations and sensitive reads with `@EasyPermission`.
|
||||
- Add or reuse `EasyPermissions` constants for permission codes.
|
||||
- Use `@EasyAudit` or the audit collector for important business actions.
|
||||
- Keep data-scope behavior aligned with current role and department rules.
|
||||
- If a service is already large, prefer extracting cohesive domain collaborators instead of appending another workflow branch.
|
||||
|
||||
## Feature Checklist
|
||||
|
||||
For a new user-facing capability, check all of these:
|
||||
|
||||
- Backend API exists and returns the standard response shape.
|
||||
- Frontend feature API and types exist.
|
||||
- View has loading, empty, and basic error states.
|
||||
- `sys_menu` registration includes directory/page/button resources and the page `component_path`.
|
||||
- Buttons with restricted actions use explicit permission strings.
|
||||
- Docs mention the capability if it is part of the product surface.
|
||||
- Verification command was run.
|
||||
|
||||
## Local Commands
|
||||
|
||||
Start dependencies:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Backend:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
Frontend:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build checks:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
cd easy-next-admin-web && npm run build
|
||||
```
|
||||
|
||||
Skill validation:
|
||||
|
||||
```bash
|
||||
python3 "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/quick_validate.py" .codex/skills/easy-next-admin-vibecoding
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "EasyNextAdmin Vibe Coding"
|
||||
short_description: "EasyNextAdmin 代码、权限、菜单、文档和 agent 入口约束"
|
||||
default_prompt: "Use $easy-next-admin-vibecoding to inspect or implement this EasyNextAdmin change while preserving backend, frontend, permission, menu, documentation, and verification contracts."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
22
.dockerignore
Normal file
22
.dockerignore
Normal file
@@ -0,0 +1,22 @@
|
||||
.git
|
||||
.codex
|
||||
.idea
|
||||
.playwright-mcp
|
||||
.DS_Store
|
||||
*.md
|
||||
LICENSE
|
||||
docs
|
||||
easy-next-admin-web
|
||||
logs
|
||||
storage
|
||||
target
|
||||
work
|
||||
.env
|
||||
.env.*
|
||||
easy-next-admin-server/logs
|
||||
easy-next-admin-server/storage
|
||||
easy-next-admin-server/work
|
||||
easy-next-admin-server/target/*
|
||||
!easy-next-admin-server/target/easyNextAdmin.jar
|
||||
easy-next-admin-client
|
||||
easy-next-admin-client-vue3
|
||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.java]
|
||||
indent_size = 4
|
||||
|
||||
[*.{md,yml,yaml,json,ts,vue,css,html}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Bug 报告
|
||||
description: 报告可复现的问题
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈。请不要在公开 Issue 中粘贴密码、密钥、真实用户数据或未脱敏日志。
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 简要说明发生了什么。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请提供最小可复现步骤。
|
||||
placeholder: |
|
||||
1. 启动后端...
|
||||
2. 打开页面...
|
||||
3. 点击...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境信息
|
||||
placeholder: |
|
||||
JDK:
|
||||
Maven:
|
||||
Node.js:
|
||||
npm:
|
||||
Docker:
|
||||
浏览器:
|
||||
validations:
|
||||
required: false
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 安全问题
|
||||
url: https://github.com/lakernote/easy-next-admin/security
|
||||
about: 请不要公开提交漏洞细节,先阅读仓库安全策略。
|
||||
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
35
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: 功能建议
|
||||
description: 提出适合企业后台脚手架的改进建议
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 业务场景
|
||||
description: 说明这个建议解决什么企业后台场景。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: 期望方案
|
||||
description: 描述你希望 EasyNextAdmin 如何支持。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 已考虑的替代方案
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: scope
|
||||
attributes:
|
||||
label: 边界确认
|
||||
options:
|
||||
- label: 该建议不把项目变成低代码平台、BI 平台或完整 BPM 平台
|
||||
required: true
|
||||
- label: 该建议可以通过当前 Spring Boot 3 + Vue 3 架构落地
|
||||
required: true
|
||||
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
## 变更说明
|
||||
|
||||
-
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 后端:
|
||||
- 前端:
|
||||
- 数据库 / 配置:
|
||||
- 文档:
|
||||
|
||||
## 验证
|
||||
|
||||
- [ ] `mvn -pl easy-next-admin-server -am verify`
|
||||
- [ ] `cd easy-next-admin-web && npm run test:unit`
|
||||
- [ ] `cd easy-next-admin-web && npm run build`
|
||||
- [ ] 不涉及对应模块
|
||||
|
||||
## 检查清单
|
||||
|
||||
- [ ] 新增接口使用标准 `Response<T>` / `PageResponse<T>`
|
||||
- [ ] 写操作和敏感查询已加 `@EasyPermission`
|
||||
- [ ] 新页面已补齐菜单资源、权限码和前端 API wrapper
|
||||
- [ ] 文档只描述真实存在、可验证的能力
|
||||
54
.github/workflows/ci.yml
vendored
Normal file
54
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
cache: maven
|
||||
|
||||
- name: Run backend verification
|
||||
run: mvn -pl easy-next-admin-server -am verify
|
||||
|
||||
frontend:
|
||||
name: Frontend checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: npm
|
||||
cache-dependency-path: easy-next-admin-web/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: easy-next-admin-web
|
||||
run: npm ci
|
||||
|
||||
- name: Run frontend unit tests
|
||||
working-directory: easy-next-admin-web
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: easy-next-admin-web
|
||||
run: npm run build
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
# Compiled class file
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
*.ear
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
/.idea/
|
||||
/target/
|
||||
/logs/
|
||||
/file/
|
||||
/oss-file/
|
||||
/easy-next-admin-server/logs/
|
||||
/easy-next-admin-server/storage/
|
||||
/easy-next-admin-server/work/
|
||||
/work/
|
||||
|
||||
*.iml
|
||||
.DS_Store
|
||||
.playwright-mcp/
|
||||
.worktrees/
|
||||
easy-next-admin-server/target/
|
||||
easy-next-admin-web/node_modules/
|
||||
easy-next-admin-web/dist/
|
||||
.env
|
||||
.env.*
|
||||
!easy-next-admin-server/.env.local.example
|
||||
easy-next-admin-web/.env
|
||||
easy-next-admin-web/.env.*
|
||||
/storage/
|
||||
108
AGENTS.md
Normal file
108
AGENTS.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# EasyNextAdmin Agent Guide
|
||||
|
||||
本文件是仓库级 Agent 入口,适用于整个 `easy-next-admin` 项目。Codex / Claude / 其他 coding agent 进入本仓库时,先读这里,再按任务读取 `README.md` 和 `docs/`。
|
||||
|
||||
## 项目定位
|
||||
|
||||
EasyNextAdmin 是面向中文企业后台二次开发的开源脚手架,采用 Spring Boot 3 + Vue 3 前后端分离架构。默认能力应围绕企业后台真实工作流展开:权限管理、组织与用户、用户导入导出、运行监控、行为审计、在线 WebLog、动态定时任务、轻量工作流、消息中心和文件中心。
|
||||
|
||||
不要把 EasyNextAdmin 做成低代码平台、BI 平台、完整 BPM 平台或模板展示站。产品 UI 和公开 API 不暴露抽象“扩展中心”概念,只展示真实用户能力。
|
||||
|
||||
## 主要目录
|
||||
|
||||
```text
|
||||
easy-next-admin-server Spring Boot 3 服务端
|
||||
easy-next-admin-web Vue 3 + Vite + Element Plus 前端
|
||||
docs 开源项目文档
|
||||
docker-compose.yml 本地 MySQL、Redis 依赖
|
||||
Dockerfile 服务端镜像构建
|
||||
```
|
||||
|
||||
当前仓库不使用根目录 `scripts/`。不要重新创建 `scripts/` 辅助脚本,除非用户明确要求。
|
||||
|
||||
## 本地命令
|
||||
|
||||
启动本地依赖:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动服务端:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
启动前端:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
后端构建:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
```
|
||||
|
||||
前端构建:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm run build
|
||||
```
|
||||
|
||||
涉及前端文件时,完成前必须运行 `npm run build`。涉及后端 Java、SQL 或接口契约时,优先运行 Maven 测试或至少运行后端打包命令。
|
||||
|
||||
## 后端规则
|
||||
|
||||
- 使用 Java 17、Spring Boot 3、MyBatis-Plus、Flyway。
|
||||
- 控制器返回 `Response<T>` 或 `PageResponse<T>`,不要临时发明响应结构。
|
||||
- 真实写操作和敏感查询必须使用 `@EasyPermission` 保护。
|
||||
- 关键业务动作优先接入 `@EasyAudit` 或审计采集器。
|
||||
- 数据权限相关查询要尊重当前用户、角色和部门范围,不要绕过数据范围上下文,除非是认证、初始化或明确的管理入口。
|
||||
- 新表结构通过 Flyway 迁移维护,保持 MySQL 初始化数据和本地开发说明一致。
|
||||
- 新功能按 `module/<domain>` 分层,保持 controller、service、entity、mapper 边界清楚。
|
||||
|
||||
## 前端规则
|
||||
|
||||
- 使用 Vue 3、TypeScript、Vite、Pinia、Vue Router、Axios、Element Plus。
|
||||
- 默认中文优先,适合国内企业后台:高效 CRUD、密集信息、清晰权限、内网稳定部署。
|
||||
- 运行时资产保持本地,不加 CDN、在线图标或在线字体。
|
||||
- 页面使用可读 Vue SFC,避免为了抽象而抽象。
|
||||
- 页面不直接写 Axios 请求,接口封装放到 `easy-next-admin-web/src/features/<domain>/api.ts`。
|
||||
- 新页面必须在服务端 `sys_menu` 登记目录/页面/按钮资源,页面资源写 `component_path`,前端通过动态路由解析到本地 `src/views` 页面。
|
||||
- 页面权限来自后端菜单资源,按钮权限走 `v-permission`,后端接口权限用 `@EasyPermission` 兜底。
|
||||
- 新系统页应同时包含 API wrapper、`sys_menu` 资源、permission strings 和基础错误处理。
|
||||
|
||||
## 文档规则
|
||||
|
||||
- 开源入口文档只保留真实存在、可启动、可验证的能力。
|
||||
- 新功能需要同步更新 `README.md` 或 `docs/features-and-components.md`。
|
||||
- 启动、部署、架构和参考项目说明分别维护在 `docs/getting-started.md`、`docs/deployment.md`、`docs/architecture.md`、`docs/reference-projects.md`。
|
||||
- 不把规划项写成已完成功能。未实现内容放 issue、roadmap 或单独设计稿。
|
||||
- 不恢复历史迁移文档、内部执行计划、模板品牌页或无关中间件清单。
|
||||
|
||||
## Vibe Coding 工作流
|
||||
|
||||
1. 先用 `rg` / `rg --files` 查当前实现,不凭记忆改代码。
|
||||
2. 找到相关后端接口、前端 API、页面、`sys_menu` 资源和文档,保持契约一致。
|
||||
3. 小步修改,优先复用现有组件、样式、权限码和响应结构。
|
||||
4. 不复制整套后台模板,不引入无关依赖。
|
||||
5. 修改完成后运行能证明结果的命令,并在回复里说明验证结果。
|
||||
|
||||
## Codex Skill
|
||||
|
||||
仓库内置一份项目技能:
|
||||
|
||||
```text
|
||||
.codex/skills/easy-next-admin-vibecoding/SKILL.md
|
||||
```
|
||||
|
||||
这份技能是 code agent 的轻量任务路由入口,应覆盖触发描述、上下文读取顺序、前后端契约、`sys_menu` / 权限码同步、验证矩阵和 agent 入口自检。更新本文件中的仓库规则时,同步检查 skill frontmatter、正文和 `agents/openai.yaml`,避免 AGENTS 与 Codex Skill 口径漂移。
|
||||
|
||||
需要让 Codex 自动发现时,可把该目录复制到当前机器的 `$CODEX_HOME/skills` 或 `~/.codex/skills`。技能内容应与本文件保持一致。
|
||||
19
CODE_OF_CONDUCT.md
Normal file
19
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 行为准则
|
||||
|
||||
EasyNextAdmin 希望保持直接、专业、尊重事实的协作氛围。所有 Issue、PR、评论和文档讨论都适用本准则。
|
||||
|
||||
## 鼓励的行为
|
||||
|
||||
- 围绕具体代码、设计、文档和验证结果讨论问题。
|
||||
- 清楚描述复现步骤、环境、期望行为和实际行为。
|
||||
- 对不同意见给出技术理由,避免人身评价。
|
||||
- 发现安全问题时按 [安全策略](SECURITY.md) 处理,不公开敏感细节。
|
||||
|
||||
## 不接受的行为
|
||||
|
||||
- 人身攻击、歧视、骚扰、威胁或持续挑衅。
|
||||
- 发布他人隐私、凭据、密钥、真实业务数据或未脱敏日志。
|
||||
- 在无关 Issue/PR 中重复刷屏、引战或推广无关内容。
|
||||
- 明知会破坏项目安全或用户数据仍提供利用方式。
|
||||
|
||||
维护者可以编辑、隐藏、关闭或删除违反准则的内容,并在必要时限制参与权限。
|
||||
51
CONTRIBUTING.md
Normal file
51
CONTRIBUTING.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你关注 EasyNextAdmin。这个项目面向中文企业后台二次开发,贡献应优先围绕真实后台能力、清晰权限边界和稳定本地部署展开。
|
||||
|
||||
## 开发准备
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 提交要求
|
||||
|
||||
- 后端使用 Java 17、Spring Boot 3、MyBatis-Plus 和 Flyway。
|
||||
- 前端使用 Vue 3、TypeScript、Vite、Pinia、Vue Router、Axios 和 Element Plus。
|
||||
- 新页面要同时补齐后端菜单资源、权限码、前端 API wrapper 和基础错误处理。
|
||||
- 不把规划项写成已完成功能,不引入低代码、BI 或完整 BPM 平台概念。
|
||||
- 文档只描述当前仓库真实存在、可运行、可验证的能力。
|
||||
|
||||
## 验证命令
|
||||
|
||||
修改后端、SQL 或接口契约时,优先运行:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am verify
|
||||
```
|
||||
|
||||
修改前端时,至少运行:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm run test:unit
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
|
||||
提交 PR 时请说明:
|
||||
|
||||
- 变更目的和影响范围。
|
||||
- 涉及的后端接口、前端页面、菜单权限或数据库迁移。
|
||||
- 已运行的验证命令和结果。
|
||||
|
||||
安全问题不要直接提交公开 Issue 或 PR,请先阅读 [安全策略](SECURITY.md)。
|
||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
207
README.md
Normal file
207
README.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# EasyNextAdmin
|
||||
|
||||
[](https://github.com/lakernote/easy-next-admin/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
[](https://adoptium.net/)
|
||||
[](https://vuejs.org/)
|
||||
[](https://github.com/lakernote/easy-next-admin)
|
||||
[](https://gitee.com/lakernote/easy-next-admin)
|
||||
|
||||
EasyNextAdmin 是一套面向中文企业后台二次开发的 Spring Boot 3 + Vue 3 开源脚手架。它不是只展示页面的空壳模板,而是把企业后台常见的用户组织、角色权限、菜单路由、数据范围、审计、监控、文件、消息、定时任务和轻量流程串成一套可运行的工程基线。
|
||||
|
||||
如果你要做 OA、运营后台、审批流、权限审计、运维管理、内部工具或企业信息化系统,EasyNextAdmin 的目标是让团队拉下代码后少搭基础设施,直接进入业务开发。
|
||||
|
||||
## 为什么选择 EasyNextAdmin
|
||||
|
||||
- **真实后台闭环**:登录、菜单、按钮权限、角色授权、数据范围、用户导入导出、审计、监控、WebLog、文件和流程都能本地跑通。
|
||||
- **前后端契约清晰**:菜单和权限资源以服务端 `sys_menu` 为事实源,前端动态路由和按钮权限都来自后端授权结果。
|
||||
- **技术栈新且克制**:Spring Boot 3、Java 17、Vue 3、TypeScript、Vite、Element Plus,不引入低代码、BI 或完整 BPM 平台的复杂度。
|
||||
- **适合二开学习**:代码按企业后台真实模块拆分,核心位置保留简洁中文注释,便于团队理解和扩展。
|
||||
|
||||
## 项目状态
|
||||
|
||||
当前处于公开 alpha 阶段,核心功能已具备本地启动、测试和构建验证;生产上线前仍应替换数据库、Redis、域名、HTTPS、默认账号密码和会话安全方案。
|
||||
|
||||
| 适合 | 不适合 |
|
||||
| --- | --- |
|
||||
| 中文企业后台二次开发、权限/组织/审计/流程类内网系统、需要前后端分离脚手架的团队 | 低代码平台、BI 平台、完整 BPM 引擎、只展示 UI 模板的项目 |
|
||||
|
||||
## 界面预览
|
||||
|
||||
业务工作台:
|
||||
|
||||

|
||||
|
||||
核心业务流:
|
||||
|
||||
| 提交申请单 | 审批待办列表 | 审批处理 |
|
||||
| --- | --- | --- |
|
||||
|  |  |  |
|
||||
|
||||
<details>
|
||||
<summary>查看更多界面截图</summary>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
| --- | --- |
|
||||
| 后端 | Java 17、Spring Boot 3.5、MyBatis-Plus、Flyway、Spring Actuator、springdoc-openapi、Redisson、Caffeine |
|
||||
| 前端 | Vue 3、TypeScript、Vite、Pinia、Vue Router、Axios、Element Plus、ECharts、LogicFlow |
|
||||
| 本地依赖 | MySQL 8.4 LTS、Redis 7.4 |
|
||||
| 部署 | 后端可打 JAR 或 Docker 镜像,前端输出静态资源并由 Nginx 托管 |
|
||||
|
||||
## 快速启动
|
||||
|
||||
环境要求:
|
||||
|
||||
- JDK 17+
|
||||
- Maven 3.9+
|
||||
- Node.js 22 LTS 或 24 LTS,以及随 Node.js 安装的 npm
|
||||
- Docker 和 Docker Compose
|
||||
|
||||
启动本地依赖:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后端:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
启动前端:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
首次启动时,`local` 默认连接可在数据库不存在且账号具备建库权限时创建 `easy-next-admin`,随后由 Flyway 的单一 `V1` 基线创建表结构并初始化演示数据。生产环境仍应由 DBA 预建数据库并使用最小权限账号。
|
||||
|
||||
访问地址:
|
||||
|
||||
- 前端:http://127.0.0.1:5174
|
||||
- 后端:http://127.0.0.1:8080
|
||||
- OpenAPI:http://127.0.0.1:8080/swagger-ui.html
|
||||
|
||||
默认演示账号。登录页只在 `local` profile 通过 `/api/auth/demo-accounts` 返回这些账号;生产 profile 不返回演示密码,正式环境必须替换初始化密码:
|
||||
|
||||
| 角色 | 账号 | 密码 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| 超级管理员 | `admin` | `admin` | 查看和维护全部内置能力 |
|
||||
| 部门负责人 | `manager` | `easynext` | 组织、流程和部门数据 |
|
||||
| 普通员工 | `staff` | `easynext` | 工作台和个人流程入口 |
|
||||
| 审计人员 | `auditor` | `easynext` | 审计记录和财务复核类待办 |
|
||||
|
||||
更完整的本地开发说明见 [本地开发](docs/getting-started.md)。
|
||||
|
||||
## 内置能力
|
||||
|
||||
- **工作台**:聚合个人待办、常用申请、系统能力和关键统计。入口:`/dashboard`
|
||||
- **系统管理**:用户、直属上级、用户导入导出、角色、菜单权限、部门负责人、组织架构和文件中心。入口:`/system/users`、`/system/roles`、`/system/menus`、`/system/departments`、`/system/files`
|
||||
- **报表中心**:组织人员台账和采购流程复核的 A4 纸质报表预览与打印。入口:`/reports/enterprise`
|
||||
- **运行监控**:应用运行状态、在线用户、缓存指标、缓存键值和在线请求日志。入口:`/monitor/server`、`/monitor/online`、`/monitor/cache`、`/monitor/cache-list`、`/monitor/weblog`
|
||||
- **审计中心**:登录、操作、数据变更、错误和接口访问审计。入口:`/audit/behavior`
|
||||
- **任务调度**:动态任务定义、集群启停同步、实例心跳、单实例/广播执行和执行日志。入口:`/schedule/jobs`
|
||||
- **批处理任务**:查看长任务进度、失败明细、Worker 租约、取消状态和未完成项恢复;固定账期支持多实例动态领取分区。入口:`/batch/tasks`
|
||||
- **流程中心**:统一发起请假、采购、报修流程,处理我的流程;按直属上级、部门负责人、职能角色等规则派单,管理员可监控流程实例和维护流程配置。入口:`/workflow/start`、`/workflow/tasks`、`/workflow/instances`、`/workflow/console`
|
||||
- **消息中心**:个人消息、流程通知、审计提醒和任务消息。入口:`/messages`
|
||||
- **智能助手**:只保留两个入口。普通员工在 `/assistant/chat` 使用真实 SSE 对话,后端每完成一个上下文、模型或 Tool 阶段,页面都会追加一个安全执行步骤,最终只返回精简回复;管理员在 `/assistant/debug` 同步运行同一条 Agent 链,可按 Step 查看真实输入输出、模型实际可见的消息数组/Tool Schema/生成选项、供应商语义输出、Runtime 归一化结果、Token、耗时和完整 Trace,便于学习、调试与 eval。同步调试使用真实 Conversation State,相同 `conversationId` 会读取并写回同一会话,只有新建或重置测试时才生成新会话。Spring AI 负责 OpenAI-compatible 模型与 Tool Calling 协议适配,项目自己的 Java Mini Runtime 负责单批 Tool round、权限、同会话并发保护、写操作确认、幂等、审计和结构化结果合同。普通员工不会收到 Prompt、Tool 参数或 Trace;生产 Trace 默认只保留元数据,local profile 默认输出统一脱敏且单 Payload 最多 100000 字符的调试内容。
|
||||
- **个人中心**:个人资料、改密、登录历史和会话管理。入口:`/profile/security`
|
||||
|
||||
功能说明、组件用法和实现原理见 [功能与组件](docs/features-and-components.md)。
|
||||
|
||||
## 工程结构
|
||||
|
||||
```text
|
||||
easy-next-admin
|
||||
├── easy-next-admin-server # Spring Boot 3 服务端
|
||||
│ └── Dockerfile # 服务端镜像构建
|
||||
├── easy-next-admin-web # Vue 3 + Vite + Element Plus 前端
|
||||
│ ├── Dockerfile # 前端镜像构建
|
||||
│ └── nginx.conf # 前端容器 Nginx 配置
|
||||
├── docs # 开源项目文档
|
||||
├── docker-compose.yml # 本地 MySQL、Redis 依赖
|
||||
└── pom.xml # Maven 聚合工程
|
||||
```
|
||||
|
||||
菜单、页面路由、角色授权资源和页面权限码以服务端 `sys_menu` 为唯一事实源。前端只通过 `/api/auth/me` 接收当前账号可见菜单,并在 `easy-next-admin-web/src/router/dynamicRoutes.ts` 中把 `component_path` 解析到本地 Vue 页面;按钮权限使用 `v-permission`,后端接口继续由 `@EasyPermission` 兜底。
|
||||
|
||||
## 编译打包
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
easy-next-admin-server/target/easyNextAdmin.jar
|
||||
```
|
||||
|
||||
前端:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
easy-next-admin-web/dist
|
||||
```
|
||||
|
||||
部署方式、Nginx 反向代理、Docker 构建和生产配置覆盖见 [编译与部署](docs/deployment.md)。
|
||||
|
||||
## 发布前验证
|
||||
|
||||
发布或提交 PR 前建议至少运行:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am verify
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run test:unit
|
||||
npm run build
|
||||
```
|
||||
|
||||
本仓库已配置 GitHub Actions,在 `main` 分支 push 和 pull request 时会执行后端 `verify`、前端单元测试和前端构建。
|
||||
|
||||
## 文档
|
||||
|
||||
- [文档目录](docs/README.md)
|
||||
- [本地开发](docs/getting-started.md)
|
||||
- [编译与部署](docs/deployment.md)
|
||||
- [功能与组件](docs/features-and-components.md)
|
||||
- [架构与实现原理](docs/architecture.md)
|
||||
- [参考项目与借鉴边界](docs/reference-projects.md)
|
||||
|
||||
## 开源协作
|
||||
|
||||
- [贡献指南](CONTRIBUTING.md)
|
||||
- [安全策略](SECURITY.md)
|
||||
- [行为准则](CODE_OF_CONDUCT.md)
|
||||
|
||||
默认启动不需要额外 `.env` 文件。Docker Compose 依赖端口和前端开发代理都带默认值,确需覆盖时可用命令行环境变量或本机不提交的 `.env` / `easy-next-admin-web/.env.local`。`.editorconfig` 用于统一 IDE/编辑器格式;默认账号和默认密码只用于本地开发,生产环境必须覆盖。
|
||||
|
||||
## 参考项目
|
||||
|
||||
EasyNextAdmin 主要参考 RuoYi/RuoYi-Vue 的中文企业后台习惯,参考 Vben Admin 的前端权限与路由组织思路,参考 Flowable/Camunda 在流程领域的产品边界,同时直接使用 Element Plus、ECharts、LogicFlow 等开源组件。详细说明见 [参考项目与借鉴边界](docs/reference-projects.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目使用 [Apache License 2.0](LICENSE)。
|
||||
25
SECURITY.md
Normal file
25
SECURITY.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 安全策略
|
||||
|
||||
## 支持范围
|
||||
|
||||
当前仓库处于公开 alpha 阶段,安全修复优先覆盖 `main` 分支。生产使用前请替换默认账号密码、数据库密码、Redis 密码、域名、HTTPS 和会话安全配置。
|
||||
|
||||
## 报告安全问题
|
||||
|
||||
请不要在公开 Issue 中粘贴漏洞细节、利用步骤、凭据、日志或真实业务数据。
|
||||
|
||||
优先通过 GitHub Security Advisory 或仓库安全页面提供的私密渠道报告。若私密入口不可用,请先创建一个不包含利用细节的 Issue,说明“存在安全问题需要维护者联系”,等待维护者确认沟通方式。
|
||||
|
||||
报告时建议包含:
|
||||
|
||||
- 受影响模块或接口。
|
||||
- 影响范围和触发条件。
|
||||
- 最小化复现思路。
|
||||
- 建议修复方向。
|
||||
|
||||
## 生产安全提醒
|
||||
|
||||
- 不要使用 README 中的演示账号密码部署生产环境。
|
||||
- 不要提交 `.env`、数据库备份、访问密钥或真实用户数据。
|
||||
- 对公网部署时必须开启 HTTPS,并在反向代理层设置合理的请求体大小、超时和安全响应头。
|
||||
- 涉及真实写操作和敏感查询的接口应使用 `@EasyPermission` 保护,并接入审计。
|
||||
69
docker-compose.yml
Normal file
69
docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
name: easy-next-admin
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: ${MYSQL_IMAGE:-mysql:8.4}
|
||||
container_name: easy-next-admin-mysql
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- --max_connections=300
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
MYSQL_DATABASE: easy-next-admin
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-123456}
|
||||
ports:
|
||||
- "${MYSQL_PORT:-3306}:3306"
|
||||
volumes:
|
||||
- mysqlData:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$${MYSQL_ROOT_PASSWORD} --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- easy_next_admin_net
|
||||
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:-redis:7.4-alpine}
|
||||
container_name: easy-next-admin-redis
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:-111222}"]
|
||||
environment:
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-111222}
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
volumes:
|
||||
- redisData:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "redis-cli -a $${REDIS_PASSWORD:-111222} ping | grep PONG"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
networks:
|
||||
- easy_next_admin_net
|
||||
|
||||
volumes:
|
||||
mysqlData:
|
||||
redisData:
|
||||
|
||||
networks:
|
||||
easy_next_admin_net:
|
||||
driver: bridge
|
||||
52
docs/README.md
Normal file
52
docs/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# EasyNextAdmin 文档目录
|
||||
|
||||
这里保留适合作为开源项目交付的文档。当前文档只描述仓库中真实存在、可运行、可二次开发的能力。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
| 顺序 | 文档 | 适合对象 | 内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | [本地开发](getting-started.md) | 新贡献者、二开团队 | 环境要求、本地依赖、前后端启动、演示账号和常见问题 |
|
||||
| 2 | [功能与组件](features-and-components.md) | 产品、测试、前后端开发 | 当前内置功能、页面入口、权限码、组件用法和扩展方式 |
|
||||
| 3 | [架构与实现原理](architecture.md) | 后端、前端、架构开发 | 模块分层、请求链路、认证授权、数据权限、审计、工作流和调度原理 |
|
||||
| 4 | [编译与部署](deployment.md) | 运维、交付、开发负责人 | Maven/NPM 构建、JAR 部署、Nginx 静态部署、Docker 构建和生产配置 |
|
||||
| 5 | [参考项目与借鉴边界](reference-projects.md) | 选型、贡献者、二开团队 | 说明参考了哪些项目的哪些功能、组件和设计边界 |
|
||||
|
||||
## 开源协作
|
||||
|
||||
| 文档 | 适合对象 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| [贡献指南](../CONTRIBUTING.md) | 贡献者、二开团队 | 本地开发、提交要求、验证命令和 PR 信息 |
|
||||
| [安全策略](../SECURITY.md) | 使用方、安全研究者 | 支持范围、安全问题报告方式和生产安全说明 |
|
||||
| [行为准则](../CODE_OF_CONDUCT.md) | 所有参与者 | 讨论、提交、Issue 和 PR 的基本协作边界 |
|
||||
|
||||
## 技术组件
|
||||
|
||||
| 文档 | 适合对象 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| [技术组件文档](components/README.md) | 后端、前端、架构开发 | 按技术组件说明如何使用、实现原理、tradeoff、常见坑和扩展建议 |
|
||||
|
||||
## 治理基线
|
||||
|
||||
| 文档 | 适合对象 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| [可观测性体系蓝图](development/observability-baseline.md) | 架构、运维、后端、二开负责人 | 从开放标准、RFC 和厂商实践沉淀日志、指标、链路、事件、审计和中小企业落地路线 |
|
||||
| [稳定性体系蓝图](development/stability-baseline.md) | 架构、运维、后端、二开负责人 | 从 SRE、RFC 和云厂商可靠性实践沉淀 SLO、韧性模式、发布恢复和中小企业落地路线 |
|
||||
| [企业级组件能力矩阵](development/enterprise-components-baseline.md) | 架构、后端、运维、二开负责人 | 梳理企业级应用组件、分布式组件、平台支撑组件、交付分层和研发治理,以及 EasyNextAdmin 已具备和缺少的能力 |
|
||||
|
||||
## 设计稿
|
||||
|
||||
| 文档 | 适合对象 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| [Easy Job 设计](development/easy-job-design.md) | 后端、架构、运维 | 从单实例锁到广播批处理的演进、控制面、实例心跳、执行日志和能力边界 |
|
||||
| [Easy Batch 设计](development/easy-batch-design.md) | 后端、架构、二开团队 | 多实例动态分区、准备屏障、Claim/Lease、Reader / Processor、断点续跑和一致性取舍 |
|
||||
| [文档结构整理设计稿](development/documentation-structure.md) | 维护者、二开负责人 | 规划业务模块、技术组件和二开流程文档的目录、模板和整理顺序 |
|
||||
|
||||
## 文档维护规则
|
||||
|
||||
- 功能、组件和 README 只写当前代码已经具备或能够通过公开命令启动验证的能力。
|
||||
- 治理基线可以沉淀标准、厂商实践、路线图和扫描清单,但必须明确这些内容是演进蓝图,不能写成已实现能力。
|
||||
- 新增页面时,同步补充 API 包装、路由、菜单、权限码、后端权限注解和文档说明。
|
||||
- 不把规划项写成已完成能力。未实现内容必须放在 issue、roadmap 或单独设计稿中。
|
||||
- 不保留模板品牌、历史实验前端、内部执行计划和与当前工程无关的中间件清单。
|
||||
- 默认中文表达,面向中文企业后台二开团队。
|
||||
626
docs/architecture.md
Normal file
626
docs/architecture.md
Normal file
@@ -0,0 +1,626 @@
|
||||
# 架构与实现原理
|
||||
|
||||
EasyNextAdmin 是单体优先的企业后台脚手架。后端按模块分层,菜单、路由和权限资源以数据库 `sys_menu` 为唯一事实源,前端根据登录账号返回的授权菜单动态装配页面。
|
||||
|
||||
## 总体结构
|
||||
|
||||
```text
|
||||
easy-next-admin
|
||||
├── easy-next-admin-server
|
||||
│ ├── common # 统一响应、异常、常量、工具
|
||||
│ ├── config # Spring、MyBatis、WebMVC、OpenAPI、线程池等配置
|
||||
│ ├── infrastructure # 认证、权限、数据范围、审计、缓存、锁、幂等、限流、观测性、AI 模型适配
|
||||
│ └── module # 系统、报表、监控、审计、调度、流程、助手、消息等业务模块
|
||||
└── easy-next-admin-web
|
||||
├── src/features # 业务 API、类型和前端领域逻辑
|
||||
├── src/views # 页面
|
||||
├── src/components # 通用组件
|
||||
├── src/router # 路由守卫和动态路由解析
|
||||
├── src/permissions # 按钮权限码常量
|
||||
└── src/stores # Pinia 状态
|
||||
```
|
||||
|
||||
## 请求链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Vue 页面"] --> B["features/*/api.ts"]
|
||||
B --> C["Axios request.ts"]
|
||||
C --> D["/api/**"]
|
||||
D --> E["EasyAuthFilter"]
|
||||
E --> F["EasyPermissionInterceptor"]
|
||||
F --> G["Controller"]
|
||||
G --> H["Service"]
|
||||
H --> I["Mapper / MyBatis-Plus"]
|
||||
I --> J["MySQL"]
|
||||
G --> K["Response / PageResponse"]
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- 前端页面只依赖 feature API,不直接散落 Axios 调用。
|
||||
- `EasyAuthFilter` 从 token 解析当前会话和用户权限。
|
||||
- `EasyPermissionInterceptor` 校验控制器或方法上的 `@EasyPermission`。
|
||||
- MyBatis 数据权限拦截器在查询阶段追加组织范围过滤。
|
||||
- 返回值通过统一响应对象和异常处理器保持前后端契约稳定。
|
||||
|
||||
## 企业智能助手 Mini Agent
|
||||
|
||||
助手使用 Spring AI 1.1.x 作为 Spring Boot 3.5 下的模型协议适配层,不使用 Spring AI Alibaba,也不把企业 Runtime 交给框架。
|
||||
项目自己的 `ToolCallingModelClient` 端口隔离 Spring AI,默认适配器把不可变消息和 `ToolDefinition` 映射到
|
||||
OpenAI-compatible Chat Completions 的原生 Function Calling,并显式关闭 Spring AI 内部 Tool 执行。未来可以增加 Responses API、
|
||||
其他模型 SDK 或替换 Spring AI,而不改变 AgentLoop 和业务 Tool。
|
||||
|
||||
普通请求不先做意图分类。`AgentTurnService` 读取 Request Context 和版本化 Conversation State 后,先用
|
||||
`ToolVisibilityPolicy` 确定性过滤当前用户无权使用的 Tool,再把剩余的真实 `ToolDefinition` Schema 交给同一个模型。
|
||||
模型直接选择 Tool 并生成参数;Runtime 校验、执行后把真实结果作为标准 Tool Message 回填同一个模型,直到模型生成最终回复或达到预算:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Request Context + Conversation State"] --> B["权限过滤后的 Tool Schemas"]
|
||||
B --> C["同一个 LLM:回复或 Tool Call"]
|
||||
C -->|Tool Call| D["参数合同 / 权限 / 风险 / 确认"]
|
||||
D -->|允许| E["执行真实企业 Tool"]
|
||||
D -->|拒绝| F["安全错误 Tool Message"]
|
||||
E --> G["真实结果 Tool Message"]
|
||||
F --> C
|
||||
G --> C
|
||||
C -->|Final Answer| H["用户回复"]
|
||||
```
|
||||
|
||||
主 Agent 的 Prompt 不是一段不断拼接的长字符串,而是三层消息:
|
||||
|
||||
| 层 | 代码名称 | 内容与信任边界 |
|
||||
| --- | --- | --- |
|
||||
| 稳定系统指令 | `systemInstructions` | 身份、目标完成、事实来源、Tool、会话和安全规则;不包含本轮动态数据,便于审阅和 Prompt Cache |
|
||||
| 可信运行上下文 | `trustedRuntimeContext` | 服务端生成的时间、时区、Pending Action 和 Last Result;能提供事实,但不能覆盖系统规则或代替权限校验 |
|
||||
| 会话与用户输入 | `userInput` | 最近对话、被动业务记忆、Pending Action 和当前消息;只作为数据理解,不能注入系统指令 |
|
||||
|
||||
Trace 使用模型调用的职责名称,不再把所有请求统称为 `tool_calling_model`:
|
||||
|
||||
| `operationName` | 用途 | 输出上限 |
|
||||
| --- | --- | --- |
|
||||
| `agent_step` | 模型可直接回复或选择本轮可见 Tool | 使用全局模型配置 |
|
||||
| `final_response` | Tool 面关闭后,根据已有 observation 合成最终回复 | 使用全局模型配置 |
|
||||
|
||||
这些名称描述职责,不绑定 Spring AI 或某个模型供应商;两类调用都经过同一个 `ToolCallingModelClient` 端口和统一预算。
|
||||
|
||||
Request Context 与 Conversation State 必须分开理解:前者固定当前请求的用户、权限、时区、时间点和消息;后者只保存跨轮次需要的
|
||||
Chat Memory、Pending Action 与 Last Result。一次单轮 Trace 的模型耗时、Token、Step 和可见 Tool 数量不进入会话状态。
|
||||
会话快照的简化结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 7,
|
||||
"chatMemory": {
|
||||
"recentTurns": [
|
||||
{
|
||||
"userMessage": "查一下我的待办",
|
||||
"assistantMessage": "你当前有 2 个待办。",
|
||||
"completedAt": "2026-07-14T02:30:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pendingAction": null,
|
||||
"lastResult": {
|
||||
"toolName": "workflow.task.list_my_pending",
|
||||
"artifactType": "workflow.task.list",
|
||||
"summary": "找到 2 个待办任务",
|
||||
"references": [],
|
||||
"updatedAt": "2026-07-14T02:30:00Z"
|
||||
},
|
||||
"updatedAt": "2026-07-14T02:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Chat Memory 按完整 Turn 追加和淘汰,不会把 user/assistant 拆开。存储层保留最近 6 个完整 Turn;进入 Prompt 时再按“最近 4 轮 + 1200 字符总预算”自适应取窗,单条消息也会限长。超出预算时只保留最新的完整 Turn 并标记早期对话已省略,不增加摘要模型调用。Pending Action 和 Last Result 仍以结构化状态独立承接,且各有确定性长度上限,避免业务结果或草稿无界增长。`AssistantConversationStateStore` 以“用户 + conversationId”保存一个
|
||||
完整快照;Redis 实现通过 Lua 在同一原子操作里校验 `expectedVersion`、写 JSON 和续期,内存实现用进程内 CAS 支撑单节点开发测试。
|
||||
因此会话锁负责避免无意义的并发模型调用,版本校验继续阻止旧 Turn 覆盖新状态,两者职责不同。
|
||||
|
||||
主链可简化理解为下面的伪代码;确认/取消不使用独立 `ApprovalParser`,而是由仅在存在 Pending Action 时才暴露的控制 Tool
|
||||
进入同一个 AgentLoop,因此“确认提交,然后继续查我的待办”仍能在一轮内继续执行:
|
||||
|
||||
```java
|
||||
AssistantEntryResponse run(AssistantMessageRequest request) {
|
||||
AssistantRequestContext context = contextBuilder.build(request);
|
||||
|
||||
try (TurnLease ignored = conversationTurnCoordinator.acquire(context)) {
|
||||
AssistantConversationState state = conversationStateStore.load(context);
|
||||
List<AssistantVisibleTool> tools = toolVisibilityPolicy.selectVisibleTools(context).tools();
|
||||
|
||||
AgentResult outcome = agentLoop.run(context, state, tools);
|
||||
|
||||
conversationStateWriter.writeTurn(
|
||||
context,
|
||||
state, // expectedVersion 来自这个不可变快照
|
||||
outcome.pendingActionUpdate(),
|
||||
outcome.toolResult(),
|
||||
outcome.reply()
|
||||
);
|
||||
return AssistantEntryResponse.from(outcome);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`AgentLoop.run` 只保留一次 Tool round 和协议修复所需的小循环,不再为“继续/完成”定义一套迭代枚举:
|
||||
|
||||
```java
|
||||
AgentLoopState state = start(context, conversationState, visibleTools);
|
||||
for (int step = 1; step <= maxSteps; step++) {
|
||||
ToolCallingResponse response = modelClient.call(messagesAndVisibleTools(state));
|
||||
|
||||
if (response.hasToolCalls()) {
|
||||
List<ToolCall> requestedToolCalls = toolCallProcessor.normalizeRequestedToolCalls(response.toolCalls());
|
||||
state.addMessage(ToolCallingMessage.assistant(response.content(), requestedToolCalls));
|
||||
|
||||
Optional<AgentResult> waitingForUser = toolCallProcessor.executeRequestedToolCalls(requestedToolCalls);
|
||||
if (waitingForUser.isPresent()) {
|
||||
return waitingForUser.get();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (response.hasContent()) {
|
||||
return finalAnswer(response.content());
|
||||
}
|
||||
return invalidModelResponse();
|
||||
}
|
||||
return deterministicFallback(state);
|
||||
```
|
||||
|
||||
`response.toolCalls()` 是模型根据可见 Tool Schema 提出的调用请求,不代表业务已经执行。Runtime 先把这次
|
||||
`assistant(tool_calls)` 原样加入消息历史,再经 `ToolCallProcessor -> BusinessToolCallHandler -> AssistantToolRunner -> AssistantTool.execute`
|
||||
完成 Schema、权限、风险、确认、幂等和真实业务调用;随后以相同 `toolCallId` 追加 `tool(result)` observation。这个顺序是
|
||||
Chat Completions / Claude Tool Use 等原生 Tool 协议的会话结构要求,也保证下一次模型调用能知道“哪个请求对应哪个结果”。
|
||||
|
||||
`ToolCallProcessor` 逐个执行 Tool Call,把 observation 放回消息历史,并直接使用业务已有的
|
||||
`ToolExecutionStatus` 决定工具面和暂停点:
|
||||
|
||||
```java
|
||||
for (ToolCall call : response.toolCalls()) {
|
||||
var step = executeThroughDeterministicBoundary(call);
|
||||
messages.add(toolObservation(step));
|
||||
updateToolSurface(step.plan().status());
|
||||
if (step.waitsForUser()) return deterministicFallback(state);
|
||||
}
|
||||
closeToolSurfaceAfterCompletedRound(state);
|
||||
```
|
||||
|
||||
`ToolExecutionStatus` 描述业务计划处于查询、缺字段、待确认、已确认等哪种状态;AgentLoop 不再把同一事实翻译为
|
||||
`CONTINUE / COMPLETED` 和 `NextAction` 两层重复状态。`AssistantPendingActionUpdate` 仍用
|
||||
`UNCHANGED / UPSERT / CLEAR` 独立记录跨轮会话状态转移,因此最后一个只读查询即使覆盖
|
||||
`lastPlan`,也不会把前面已经形成或确认完成的写动作状态覆盖掉。
|
||||
|
||||
“智能”首先来自模型直接面对真实能力和 observation,而不是 Router、Selector、参数抽取、Replan、Response 多个模型调用串行猜测。
|
||||
Tool 合同必须提供完成用户目标所需的最小充分结果:例如流程列表项已经包含流程标识、标题、当前节点和状态,查看“最近一个流程的进度”
|
||||
只需要一次列表查询,不应再扩查流程详情和业务申请单。首次模型响应可以并列提交多个相互独立的只读 Tool;批次执行后 Runtime
|
||||
统一关闭 Tool 面,只根据已有 observation 生成最终回复。参数协议错误仍可以在预算内修复,缺字段和写草稿则等待用户。
|
||||
Agent Step 和无 Tool 最终回复共享一个简单的模型调用计数器;
|
||||
批次后的最终调用不会携带 Tool,
|
||||
不会在 Tool 已经执行后因为预算边界直接退回模板文案。普通直答只调用一次主模型;单个 LOW 风险只读 Tool 通常是
|
||||
“选择 Tool + observation 后合成”两次模型调用。缺字段和写操作草稿直接使用服务端确定性文案停在确认点,不增加润色或审查调用。
|
||||
每轮默认最多 6 次模型调用、6 次 Tool Call;
|
||||
相同 Tool + 参数不会重复执行。模型返回损坏的参数 JSON 时不会降级为空参数执行,而是回填可重试的失败 observation 让模型自行修正;
|
||||
模型不可用或预算耗尽时保留已经得到的真实结果并安全停止。协议适配层还会把供应商的 `stop / tool_calls / length /
|
||||
max_tokens / content_filter / refusal` 等结束原因归一化为 `ModelFinishReason`;截断文本不会返回用户,截断的 Tool Call 绝不执行,
|
||||
过滤、拒绝和未知非空终态采用 fail-closed。若截断前已经获得真实 Tool observation,则回退到该确定性结果,不丢掉已经完成的工作。
|
||||
循环预算在启动期校验,并在 Runtime 再次夹紧,避免错误配置形成无界循环。
|
||||
|
||||
模型选中 Tool 后,Runtime 直接使用标准 `ToolDefinition.name` 记录工具身份,不存在固定 Intent 枚举或 Route,也不再维护一份重复的
|
||||
`capabilities` 能力字符串。模型的选择语义只来自 `description` 和 `inputSchema`;`subjectScope` 则向 Runtime 明确声明
|
||||
`NONE / CURRENT_USER / AUTHORIZED_TARGET`,不让它用“参数是否为空”猜测 Tool 实际查询谁。新增业务能力只需增加 Tool。Pending Action 的补字段由原业务 Tool 继续完成;明确确认和取消由仅在存在待处理动作时动态暴露的内部控制 Tool 完成,
|
||||
确认时复用服务端已保存的动作 ID、参数快照和幂等键。这是写操作安全状态机,不是普通请求的前置意图路由。
|
||||
例如“核对请假单号对应的申请人、时间和原因”由独立 `workflow.leave.detail` 查询真实请假业务数据,
|
||||
不会把请假表查询硬编码进通用 `workflow.process.detail`;后者只负责流程节点和审批进度。这种边界让企业迭代保持为新增 Tool 和查询适配器,而不是修改 Router 分支。
|
||||
|
||||
“企业”边界由模型外的确定性组件保证:
|
||||
|
||||
- `ToolVisibilityPolicy` 只向模型暴露当前用户有权限的真实 Tool;真实 Tool 执行前仍再次检查业务权限和数据范围。
|
||||
- `ToolDefinition` 同时声明输入输出 Schema、主体作用域、执行模式、风险级别和权限;模型只能建议调用,不能授予权限或绕过校验。
|
||||
- `AssistantToolRegistry` 在应用启动时校验标准机器名、Schema 重复字段和执行接口;写 Tool 未实现 `ConfirmableTool` 会直接启动失败。
|
||||
- 原生 Tool Call 参数只接受 Schema 内字段,日期、枚举、业务标识和领域不变量继续确定性校验;普通语义字段允许模型同义归纳,
|
||||
不再要求与用户原话逐字匹配。
|
||||
- 写操作实现 `ConfirmableTool`,第一次调用只能 `prepare` 草稿;确认后才进入 `executeConfirmed`。同一模型响应包含一个写 Tool 和只读 Tool 时,
|
||||
Runtime 按“只读在前、写入在后”串行调度,每项仍独立经过参数、权限和执行策略;多个写操作会整体拒绝并要求拆分。草稿形成后先保存确认 checkpoint;确认成功会显式清除 Pending Action,
|
||||
然后只向模型保留只读 Tool,使“确认提交,然后继续查待办”能够续跑又不能顺手执行第二个写操作。
|
||||
- 写 Tool 的原始 Schema 保留完整领域必填约束;模型协议层使用派生的部分草稿 Schema,允许只提交用户明确给出的字段,
|
||||
再由服务端原始合同返回缺失参数,避免 Function Calling 为凑齐 required 字段而编造默认值。
|
||||
- 草稿准备成功后,`ConfirmableTool.confirmationArguments` 会生成确认阶段原样重放的服务端参数快照;例如先把业务单号解析成
|
||||
稳定 `taskId`,确认时不再根据可能已经变化的流程状态重新选择目标,避免并发下确认对象漂移。
|
||||
- 确认执行继续使用接口权限、数据权限、动作 ID、强制幂等和审计;缺少幂等键会直接拒绝执行。进入真实写边界后出现失败时采用
|
||||
fail-closed,不自动删除幂等键,因为失败可能发生在数据库提交之后或 Tool 输出校验阶段。再次收到相同幂等键时返回
|
||||
`INDETERMINATE`,不伪装成已成功、不清除 Pending Action,要求先查询业务状态或人工核对;`CRITICAL` 动作默认要求外部审批或人工处理。
|
||||
- 相反业务动作不能靠一个自由文本参数区分:待办同意和驳回使用独立 `workflow.task.approve` / `workflow.task.reject` Tool、
|
||||
独立权限和独立审计,共享的只有安全目标解析服务,避免把“驳回”误装成“同意 Tool 的审批意见”。
|
||||
- 多步聚合结果会在写入 Last Result 前展开,并由注册表按引用类型统一生成连续 `index`;各业务
|
||||
`AssistantRunReferenceExtractor` 仍只处理自己的单 Tool 结果。因而下一轮“看第一个详情”使用结构化业务引用,
|
||||
不会因聚合中的每个单项都从 `index=1` 开始而选错,也不依赖聊天文本猜测。
|
||||
- 模型调用继续进入助手 Run Trace,单次调用拆分请求准备、模型网关往返和响应处理,ToolRunner 也独立计时并保留模型 `toolCallId`,便于把模型选择与真实执行对应起来;整轮汇总模型、Tool 与其他运行时耗时,并通过低基数 Micrometer Timer 按 operation、model、decision 聚合 p50/p95/p99。同步 Chat Completions 无法区分供应商排队、TTFT 和纯生成时间,因此只记录真实可测的网关往返,TTFT/生成字段保持空值,不能用估算值冒充。真实工具自身仍必须执行数据权限和业务权限校验,不能把 Agent 工具可见性当作最终鉴权。
|
||||
- Tool observation 已产生后,如果下一次模型汇总因瞬时网关异常失败,AgentLoop 会在统一模型预算内最多恢复一次;恢复仍使用原消息历史和已保存 observation,同一 Tool Call 签名继续去重,不会重放写操作。第二次失败立即采用确定性结果回退,避免持续故障演变成隐式重试风暴。
|
||||
- 成功 Tool 批次后 Runtime 会关闭整个 Tool 面;若供应商仍返回 Tool Call,则按协议失败安全停止,不执行额外业务调用。
|
||||
已有成功业务结果时,后续参数、阶段或预算协议错误不能覆盖已完成结果。
|
||||
- Tool 查询优先复用业务模块已有的查询边界,并传递完整 `AssistantUserContext`;不能只传 userId 或复制一份可见性 SQL,
|
||||
否则超级管理员、部门范围和角色权限容易与 Controller 口径漂移。
|
||||
- Pending Action 补字段时由原生 Tool Call 的实参键声明用户本轮明确提供或修改的字段;参数只有通过该证据边界才能合并,
|
||||
历史回显和默认值不能覆盖用户已经明确给出的值,确认动作直接使用已保存快照,不重新抽取参数。
|
||||
- 上一轮 reference 只保存受控 Tool 生成的稳定标识;主模型负责判断当前消息是否明确承接,Runtime 负责 Schema、权限和 Tool 内数据范围。
|
||||
不再增加第二次模型语义审查,也不使用 Java 中文短语表或正则二次猜测意图。对象不唯一时,业务查询返回无结果或模型主动澄清;写操作仍必须经过服务端草稿与显式确认。
|
||||
- 自由文本最终回复不再经过通用正则 Grounding 或 LLM critic。事实正确性放在 Tool 输入输出 Schema、领域服务状态、Pending Action 和用户界面投影中;
|
||||
缺字段、写草稿、执行失败和不确定结果直接返回确定性 Runtime 文案。若未来某入口需要严格结构化最终输出,应为该入口增加响应 Schema,而不是恢复文本匹配。
|
||||
- Tool observation 是独立的模型合同,不是完整 Run Record 的 JSON 镜像。成功且已有结构化 `data` 时只回填 `status + complete + data`,不再重复 `summary / artifactType / planStatus / null`;失败时回填短 `message`,只有缺字段或待确认才附加 `next`。结构化数据按 `outputSchema` 保留顶层字段,列表、文本、对象层级和总 observation 均有确定性预算;完整业务结果仍保留在 Run State / Trace 的受控边界中。复合请求中,一个 Tool 的成功不能为其他企业制度、业务数据或实时事实背书;这一来源纪律只在稳定系统指令中维护。
|
||||
- 基础算术由主模型一次直答。最小版不注册 Calculator Tool,避免简单算术随机进入两次模型调用;Runtime 也不扫描用户文本、
|
||||
不维护算术前后缀或自然语言正则路由。未来出现真实高精度核算需求时,再以独立 Tool 和版本化 eval 加回。
|
||||
- 普通聊天由聊天模型回答;实时外部信息没有对应 Tool 结果时必须说明无法确认,身份和敏感凭据遵守系统边界,复合问题逐项作答。
|
||||
- 未接入真实数据源的占位能力不注册为 Tool。企业制度库尚未接入时可以解释通用定义和稳定区别,但资格、额度、期限、结转、失效、折算、补偿、薪酬、是否带薪、法律责任和本公司规则没有来源就不得断言;不能把模型记忆当成本企业事实,也不使用无结果 Tool 干扰模型选择。实时天气等外部事实同样必须有对应 Tool 结果。
|
||||
- Tool description 必须使用简短且对称的语义边界:“用于……;不用于……”;唯一标识、时间格式、枚举和必填约束写在字段 Schema。不把 `attributes.instanceId`、历史承接实现、内部 Tool 名跳转或安全策略复制到模型文案中。这是语义合同,不是 Java 关键词/正则路由;质量由 Tool 单测和真实模型 eval 约束。
|
||||
- `/api/assistant/chat/messages/stream` 是普通用户 SSE 入口,使用 `assistant:chat`;上下文构建、会话读取、Tool 面准备、每次模型调用、Tool 选择/参数/结果和状态写回都会映射成有序的安全 Step,最后只返回包含会话 ID、用户可见回复和状态的精简终态。`/api/assistant/messages` 是同步调试与 eval 入口,使用 `assistant:debug`,返回完整 Agent Step、Tool Trace 和真实模型交互;相同 `conversationId` 与用户流一样读取并写回同一 Conversation State,只有不传 ID 时才创建新会话。模型请求包含实际消息数组、实际 Tool Schema 和生成选项;响应同时保留供应商交给 Spring AI 的文本/原始 Tool Call 参数和 AgentLoop 实际消费的归一化结果。`easy.assistant.trace.content-mode` 生产默认 `METADATA_ONLY`,不记录 Prompt、Tool 参数和结果正文;local profile 默认 `SANITIZED_CONTENT`,经 `EasySensitiveDataMasker` 脱敏且单 Payload 上限为 100000 字符。它是语义级真实模型交互,不宣称是模型网关原始 HTTP 抓包。两个入口复用同一个 Runtime,但不复用响应 DTO 或权限。
|
||||
- 用户 SSE 入口使用独立的 `assistantStreamExecutor` 和注释心跳,不占用普通业务线程池。浏览器断开后事件出口停止发送,
|
||||
但传输异常不会反向中断已经开始的 Agent 业务收尾;Tool、会话状态、幂等结果和 Trace 仍按同一轮语义完成写回。
|
||||
内部 `AssistantAgentLifecycleEvent` 先经过 `AssistantUserStreamEventAdapter` 单向映射,Prompt、Tool 参数、Trace Step 和内部 payload 不会进入用户 SSE。
|
||||
- 同一用户、同一会话的状态读取、模型推理和状态写回由 `AssistantConversationTurnCoordinator` 使用项目分布式锁串行化;
|
||||
获取锁采用非阻塞语义,冲突的同步请求返回 409,用户 SSE 请求返回明确的 `error` 事件,不让第二个长模型请求排队占用线程。
|
||||
锁键使用固定长度摘要兼容 Redis 和 MySQL 降级实现,租约获取后立即进入 try-with-resources,并覆盖 State Load 到 State Write;租约自动续期,释放异常只记录日志,不能覆盖已经完成的业务结果。锁后端异常采用 fail-closed,不能降级成无锁执行;该锁只保护 Agent 会话状态,业务 Tool 仍必须保留事务、幂等和自己的并发校验。
|
||||
- 模型调用开始/结束、Tool 选择、参数解析、参数校验和每次 Tool 完成事件由 `AgentLoopListener` 在 AgentLoop 内真实阶段发出,
|
||||
不在 AgentLoop 结束后从最终 Plan 反推伪造中间状态。观察者仅用于诊断,SSE 断开或任意观察者异常都会被隔离。
|
||||
- Pending Action 的确认或取消只作用于服务端保存的原动作;新话题仍可选择其他有权限 Tool,不会被旧动作锁死。
|
||||
|
||||
当前内置 Tool 数量较少,因此把全部有权限的 Tool Schema 交给模型,避免词面召回误删正确能力。企业 Tool 数量超过上下文预算后,
|
||||
在 `ToolVisibilityPolicy` / Tool Provider 边界增加 embedding、企业搜索或按需 Tool Search;不要重新引入“必须先命中固定 Intent”
|
||||
的前置门控。`ToolCallingModelClient`、`ToolVisibilityPolicy`、`ToolExecutionPolicy` 和 Tool 接口都是替换点,业务 Tool 无需改造。
|
||||
|
||||
智能效果使用版本化 eval 而不是主观对话判断。`assistant/evals/enterprise-agent-v1.json` 当前覆盖直答、知识边界、Tool 选择、参数抽取、
|
||||
多轮状态、写操作治理、多目标、Prompt Injection 和失败恢复;普通 CI 只校验数据集合同与确定性 Runtime,显式启用的
|
||||
`AssistantLiveAgentEvalTest` 才通过已启动的本地 HTTP 入口调用真实模型和真实 Tool。
|
||||
真实用户或 live eval 暴露的每个失败对话都按原始多轮消息先加入该回归集,再修 Runtime、Prompt 或 Tool 合同;不使用删减后的近似问题代替真实失败。
|
||||
|
||||
核心代码位于 `module/assistant/runtime`。该包保持显式 Java 接口和不可变消息对象,不实现通用 Graph DSL、动态代码执行或多 Agent 编排,
|
||||
以便二开者直接读懂循环,并按需替换模型协议、Tool Provider、状态存储或整个 Runtime。
|
||||
建议按下面顺序阅读代码,避免把“请求入口”和“推理循环”混为一层:
|
||||
|
||||
1. `AssistantController`:两个 HTTP 入口;用户 SSE 对话返回安全阶段和精简终态,同步调试返回完整流程,两者使用同一执行链。
|
||||
2. `AgentTurnService.run`:单轮总入口,依次构建不可变 Run Context、获取会话 Turn 锁、读取 Conversation State、过滤 Tool、调用 AgentLoop、写回状态和 Trace。Context 只固定身份/授权快照、会话、请求时间点、时区和规范化消息,不重复保存原始消息,也不保存可由时间点与时区推导的日期/本地时间;消息只统一换行和首尾空白,保留列表、缩进与多行结构供模型理解。给模型的可信运行上下文只发送时间、时区、Pending Action 和 Last Result;用户昵称、部门、角色和权限码不进入 Prompt,继续由 Tool 可见性与执行边界确定性判断。Context 不包含存储 Key、HTTP 审计字段或某个业务 Tool 的专属规则。
|
||||
3. `ModelMessageBuilder.buildInitialMessages`:构建真正的消息数组;稳定 System Prompt 在最前,历史对话保持独立 `user/assistant` 角色,本轮可信状态使用独立 system 消息,当前用户输入是最后一条 user 消息。
|
||||
4. `AgentLoop.run`:约 180 行的推理核心,使用可直接阅读的 `if (response.hasToolCalls()) { ... continue; }` 主路径,只保留模型调用、预算、单批 Tool observation、等待用户和退出条件,不直接写业务表。
|
||||
5. `ToolSetBuilder`:把当前可见业务 Tool 与仅在存在草稿时出现的确认/取消 Tool 组成本轮 Tool 面。
|
||||
6. `ToolCallProcessor`:处理 Tool 批次协议、调用预算、重复调用和紧凑 observation。
|
||||
7. `BusinessToolCallHandler` 与 `DefaultToolExecutionPlanFactory`:绑定/校验业务 Tool 参数,并按 `ToolExecutionStatus` 转换为只读执行、缺字段或待确认草稿状态。
|
||||
8. `PendingActionHandler`:只处理服务端已保存草稿的确认和取消,不从用户文本重新提取写参数。
|
||||
9. `AssistantToolRunner`:二次权限、风险、确认阶段、幂等和输出合同边界;具体业务 Tool 才调用领域 Service。
|
||||
10. `AgentTraceSnapshots`:只生成调试快照,不参与 Agent 决策,避免 Trace 代码淹没单轮主链。
|
||||
11. `AssistantConversationStateWriter`:按 Agent 返回的显式 `AssistantPendingActionUpdate` 原子写回版本化 Chat Memory、Pending Action 和 Last Result。
|
||||
|
||||
多 Tool 聚合使用 `PARTIAL_SUCCESS` 表达部分成功,不再用最后一个调用的状态代表整批结果。写操作草稿生成失败时会转为
|
||||
`BLOCKED`,不会留下可确认假草稿;用户已经确认但业务执行失败时保留原草稿和幂等身份。相同确认再次到达时如果无法证明
|
||||
历史执行结果,状态是 `INDETERMINATE`,必须先查询真实业务状态或人工核对,不能把“收到过相同请求”误报成“业务已经成功”。
|
||||
|
||||
## 认证与会话
|
||||
|
||||
认证组件入口:
|
||||
|
||||
```text
|
||||
POST /api/auth/login
|
||||
GET /api/auth/me
|
||||
POST /api/auth/logout
|
||||
GET /api/auth/demo-accounts
|
||||
```
|
||||
|
||||
核心实现:
|
||||
|
||||
```text
|
||||
EasyAuthService # 登录、会话创建、会话恢复、退出
|
||||
EasyAuthFilter # /api/** 请求认证过滤器
|
||||
EasyPermissionInterceptor # @EasyPermission 接口鉴权
|
||||
EasySecurityContext # 单次请求内的认证上下文
|
||||
AuthSessionStore # Redis / Memory 会话存储
|
||||
PermissionVersionService # 权限版本递增和旧会话失效
|
||||
```
|
||||
|
||||
Servlet 过滤器统一由 `EasyServletFilterConfig` 注册,过滤器类本身不再使用 `@WebFilter`、`@Component` 或局部 `@Order`。注册名、顺序和 URL pattern 集中在 `EasyFilterOrders` 枚举:
|
||||
|
||||
| 顺序 | 过滤器 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `TRACE` | `EasyTraceIdFilter` | 最先生成或接收 `X-Trace-Id`,写入响应头和 MDC |
|
||||
| `WAF` | `WafFilter` | 写可配置安全响应头,并按配置处理 XSS / SQL 注入参数过滤 |
|
||||
| `CORS` | `EasyCorsFilter` | 按 `easy.web.cors` 白名单处理跨域和预检请求,避免认证过滤器拦截 OPTIONS |
|
||||
| `AUTH` | `EasyAuthFilter` | 仅对 `/api/*` 恢复认证上下文和用户 MDC |
|
||||
|
||||
新增过滤器必须在 `EasyFilterOrders` 声明注册元数据,并通过 `EasyServletFilterConfig` 注册;不要在过滤器类上直接写注册注解,避免顺序和 URL pattern 分散。
|
||||
审计请求参数从 Controller 方法参数提取并脱敏,不再为所有 JSON 请求提前缓存原始 body;如确需原始 body 日志,应使用受限路径和大小上限的显式缓存策略。
|
||||
|
||||
### Web MVC 扩展
|
||||
|
||||
MVC 扩展按职责拆分在 `config` 包下,不再集中到一个大配置类:
|
||||
|
||||
| 配置类 | 职责 |
|
||||
| --- | --- |
|
||||
| `EasyMvcInterceptorConfig` | 只注册 `/api/**` 的 HTTP 慢请求和权限拦截器;慢请求计时先执行,权限后执行,确保鉴权失败也能进入耗时排查。 |
|
||||
| `EasyMvcArgumentResolverConfig` | 注册 Controller 参数解析器,目前用于安全版 `PageRequest`。 |
|
||||
| `EasyMvcFormatterConfig` | 注册字符串到业务枚举的转换器。 |
|
||||
| `EasyStaticResourceConfig` | 注册本地文件资源映射,并显式创建本地存储目录;前端静态资源由 Vite / Nginx / 前端镜像提供。 |
|
||||
|
||||
`configureHandlerExceptionResolvers` 当前不使用。异常统一交给 `GlobalExceptionHandler`,不要保留空实现,也不要在 MVC 配置里分散异常响应结构。
|
||||
|
||||
`addReturnValueHandlers` 当前不使用。返回值日志、审计和脱敏由审计组件、响应 Advice 和 `EasySensitiveDataMasker` 处理,不在返回值处理器里打印完整响应,避免敏感数据泄露。
|
||||
|
||||
`addArgumentResolvers` 只承载明确的 Controller 参数注入能力。新增解析器必须独立成类、可单测、默认拒绝不安全输入,不能在配置类中写匿名解析器。
|
||||
|
||||
安全分页查询通过 `PageRequestArgumentResolver` 实现。Controller 参数必须加 `@PageQuery(fields = XxxQueryField.class)`,字段枚举实现 `PageQueryField`,前端只能传 `paramName`,数据库列名只能来自服务端枚举的 `columnName`。这样既保留通用筛选和排序的开发效率,又避免客户端直接控制 SQL 列名。
|
||||
|
||||
完整链路:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant UserApi as 用户管理接口
|
||||
participant AuthApi as 认证接口
|
||||
participant Store as 会话存储
|
||||
participant Filter as EasyAuthFilter
|
||||
participant Context as EasySecurityContext
|
||||
participant Permission as EasyPermissionInterceptor
|
||||
|
||||
Admin->>UserApi: 创建账号 / 绑定角色
|
||||
UserApi->>UserApi: BCrypt 保存密码摘要
|
||||
UserApi->>UserApi: 初始化 permission_version
|
||||
Admin->>AuthApi: 用户名、密码、验证码登录
|
||||
AuthApi->>AuthApi: 校验密码、账号状态、角色权限
|
||||
AuthApi->>Store: 保存 token 摘要和会话快照
|
||||
AuthApi-->>Admin: 返回访问令牌或写入会话 Cookie
|
||||
Admin->>Filter: 携带令牌或 Cookie 访问 /api/**
|
||||
Filter->>Store: 校验会话、过期时间、权限版本
|
||||
Filter->>Context: 写入 AuthPrincipal
|
||||
Permission->>Context: 读取当前用户权限码
|
||||
Permission-->>Admin: 放行或拒绝接口
|
||||
Admin->>AuthApi: 退出登录 / 下线会话
|
||||
AuthApi->>Store: 撤销会话
|
||||
```
|
||||
|
||||
设计说明:
|
||||
|
||||
1. 账号创建:用户管理只保存 BCrypt 密码摘要,角色关系写入 `sys_user_role`,不把明文密码持久化。
|
||||
2. 登录认证:登录接口先执行验证码和失败次数策略,再校验账号、密码、启用状态和角色权限。
|
||||
3. 会话创建:登录成功后生成随机访问令牌,服务端只保存 token 摘要和 `AuthSession` 快照,快照包含用户、角色、权限、部门、数据范围和权限版本。
|
||||
4. 会话验证:每次请求由 `EasyAuthFilter` 解析令牌,校验会话状态、空闲过期时间、绝对过期时间和权限版本,并刷新活跃时间。
|
||||
5. 认证上下文:过滤器把 `AuthPrincipal` 放入 `EasySecurityContext`,供权限、数据范围、审计和业务服务读取;请求结束必须清理上下文,避免线程复用串用户。
|
||||
6. 接口鉴权:`EasyPermissionInterceptor` 读取 `@EasyPermission`,按当前用户权限码放行;超级管理员角色编码 `admin` 可跳过权限码校验。
|
||||
7. 退出和治理:退出登录撤销当前会话;在线用户可按权限下线指定会话;修改密码后撤销其他会话。
|
||||
|
||||
权限版本用于解决“权限已经变了,但旧会话还拿着旧授权快照”的问题。`sys_user.permission_version` 在登录时写入会话快照;后续每次恢复会话都会和数据库当前版本比较。只要用户角色、角色权限、数据范围、菜单权限资源、账号状态等影响真实授权的配置发生变化,就递增相关用户的权限版本。旧会话下一次请求会因为版本不一致而失效,用户必须重新登录获取新的授权快照。
|
||||
|
||||
会话超时分为两层:`easy.auth.session.idle-timeout` 控制空闲滑动过期,默认 `30m`;`easy.auth.session.absolute-timeout` 控制绝对登录时长,默认 `8h`。持续操作只会把 access 过期时间延长到“当前时间 + 空闲超时”和“登录时间 + 绝对超时”两者中更早的时间。企业后台通常建议绝对超时设置为 8-12 小时;公网或高权限后台建议 4-8 小时。
|
||||
|
||||
二开接入规则:
|
||||
|
||||
- 只影响单个用户授权时调用 `PermissionVersionService.increaseForUser(userId)`。
|
||||
- 修改角色权限、角色数据范围或角色状态时调用 `increaseForRole(roleId)`。
|
||||
- 修改菜单权限资源、全局权限语义或需要全部用户重新拿权限时调用 `increaseForAllUsers()`。
|
||||
|
||||
### 会话存储策略
|
||||
|
||||
当前脚手架为了本地调试和前后端分离体验,前端把 access token 放在 Pinia 持久化状态和 `localStorage`,请求时由 Axios 拦截器放入 `Authorization: Bearer ...`。这不是公开生产环境的推荐方案,因为浏览器可读存储会被同源脚本读取;一旦出现 XSS、恶意浏览器扩展、第三方脚本污染或调试环境泄露,攻击者可以直接取走 token 并在别处复用。
|
||||
|
||||
企业级生产路线固定为服务端会话 + `HttpOnly; Secure; SameSite` Cookie,并为写接口配置 CSRF 防护。当前 Bearer token 实现只作为本地开发和前后端分离调试路线,不能作为公开生产验收口径:
|
||||
|
||||
- 生产环境由服务端设置会话 Cookie,让前端 JavaScript 不读取会话标识。
|
||||
- 生产环境必须使用 HTTPS,`Secure` Cookie 只在 HTTPS 下发送。
|
||||
- 写接口必须增加 CSRF 防护,常见做法是 SameSite Cookie 配合 CSRF token 请求头,或双提交 Cookie。
|
||||
- CORS 使用明确白名单,不反射任意 Origin,也不把凭证发送给未知来源。
|
||||
- 浏览器可读存储只保存展示状态,例如用户昵称、头像、菜单缓存和 UI 偏好,不保存 refresh token、长期凭证或可直接调用接口的密钥。
|
||||
|
||||
## 权限模型
|
||||
|
||||
EasyNextAdmin 使用“角色拥有权限,用户绑定角色”的模型:
|
||||
|
||||
- 用户:`sys_user`
|
||||
- 角色:`sys_role`
|
||||
- 用户角色关系:`sys_user_role`
|
||||
- 权限资源:`sys_menu`
|
||||
- 角色权限关系:`sys_role_permission`
|
||||
|
||||
权限分三层:
|
||||
|
||||
| 层级 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 页面权限 | 前端 route meta | 控制页面是否可访问 |
|
||||
| 按钮权限 | `v-permission` | 控制操作按钮是否可用 |
|
||||
| 接口权限 | 后端 `@EasyPermission` | 控制真实数据读写边界 |
|
||||
|
||||
超级管理员角色编码为 `admin`,可跳过权限码校验。普通角色必须拥有对应权限码。角色授权时后端会校验资源真实存在;非超级管理员不能给别人授予自己没有的权限、不能分配超过自身范围的数据权限,也不能维护自己的角色或更高等级角色。
|
||||
|
||||
## 数据权限
|
||||
|
||||
数据权限以角色上的 `data_scope` 为基础,数据库存储稳定 code,中文只用于展示:
|
||||
|
||||
- `ALL`:全部数据
|
||||
- `DEPT_AND_CHILDREN`:本部门及以下
|
||||
- `DEPT`:本部门
|
||||
- `SELF`:本人数据
|
||||
- `DEPT_SETS`:自定义部门
|
||||
|
||||
运行时根据当前用户所属部门、角色数据范围和自定义部门授权计算可见组织集合。MyBatis 拦截器在 SQL 执行前追加过滤条件,减少业务查询忘记加条件的风险。
|
||||
|
||||
数据权限不替代接口权限。接口权限先判断“能不能访问这个操作”,数据权限再判断“能看到哪些数据”。
|
||||
|
||||
更完整的接入方式、执行流程和方案取舍见 [数据权限组件](components/security/data-scope.md)。
|
||||
|
||||
## 菜单与权限资源
|
||||
|
||||
服务端 `sys_menu` 统一维护目录、页面和按钮资源,是侧边栏菜单、动态路由、角色授权树和页面权限判断的唯一事实源。关键字段:
|
||||
|
||||
- `type`:`0` 目录、`1` 页面、`2` 按钮。
|
||||
- `href`:页面路由路径。
|
||||
- `permission_code`:页面或按钮权限码,必须与后端 `EasyPermissions` 保持一致。
|
||||
- `component_path`:页面资源对应的本地 Vue SFC,例如 `@/views/system/UserView.vue`。
|
||||
- `visible`、`enable`、`sort`:控制可见性、启停和排序。
|
||||
|
||||
登录后 `/api/auth/me` 返回当前账号可见 `menus` 和 `permissions`。前端 `src/router/dynamicRoutes.ts` 只从 Vite 已知的本地页面集合中解析 `component_path`,不会执行后端传入的任意脚本路径。角色授权页读取 `/api/system/roles/permission-resources`,展示的资源树与真实菜单表一致。按钮使用 `v-permission` 和 `src/permissions/codes.ts` 中的常量,后端接口仍由 `@EasyPermission` 做最终保护。
|
||||
|
||||
## 业务编号
|
||||
|
||||
业务编号是用户可见的申请单号、工单号、采购单号等业务语义编号,不等同于数据库主键或分布式 ID。表主键继续使用 MyBatis-Plus `ASSIGN_ID`,业务模块需要可读编号时只依赖 `BusinessNumberService#nextNumber(ruleCode)`。
|
||||
|
||||
当前实现分两张表:
|
||||
|
||||
- `biz_number_rule`:维护规则编码、名称、前缀、日期周期、分隔符、流水位数、递增步长、初始当前值和启停状态。
|
||||
- `biz_number_sequence`:按 `规则编码:日期段` 保存当前流水值。取号时先保证当前周期计数器存在,再执行 `current_value = current_value + step` 原子递增并读取新值,保证单体多实例部署下不重复,同时少一次锁定查询。
|
||||
|
||||
后台 `/system/business-numbers` 只维护规则和人工生成测试号。请假、采购和报修流程已内置 `LEAVE_REQUEST`、`PURCHASE_REQUEST`、`REPAIR_REQUEST` 三条规则,业务代码不再直接拼接前缀和日期。
|
||||
|
||||
## 审计实现
|
||||
|
||||
审计分为采集和查询两部分:
|
||||
|
||||
- 采集:`infrastructure/audit` 提供 `@EasyAudit`、切面和审计采集器。
|
||||
- 脱敏:`infrastructure/security/masking` 提供 `@EasyMask` 字段注解和 `EasySensitiveDataMasker` 边界组件,统一遮盖密码、token、验证码、手机号、邮箱、姓名、证件号和卡号等敏感内容。
|
||||
- 存储:`module/audit` 下的登录日志、操作日志、数据变更日志、错误日志和 API 访问日志表。
|
||||
- 展示:前端审计中心按审计类型切换,并复用统一表格交互。
|
||||
- 敏感变更:业务模块通过 `SensitiveAuditService` 记录为数据变更日志,审计中心“敏感变更”视图直接查询这些真实数据。
|
||||
|
||||
审计原则:
|
||||
|
||||
- 登录成功和失败都要留痕。
|
||||
- 关键写操作要用 `@EasyAudit` 或显式审计采集器记录模块、动作、对象和结果。
|
||||
- 权限、菜单、用户等敏感变更要进入数据变更审计,不能只停留在普通操作日志。
|
||||
- 异常和慢接口归入可排查视角,避免只在服务器日志里可见。
|
||||
- 请求参数先由 `AuditRequestPayloadFormatter` 过滤框架对象和空值,再交给 `EasySensitiveDataMasker` 入库;GET 查询也要记录为有字段名的 JSON,不记录裸数组。
|
||||
- 审计中心查询必须通过 `AuditVisibilitySupport` 收口数据范围。普通数据范围账号只能看到自己或可见组织内用户产生的审计记录;全局统计只对全部数据范围开放,避免局部运维账号反推出全局访问量和异常分布。
|
||||
|
||||
## 轻量链路与慢入口
|
||||
|
||||
项目只保留自研轻量 `X-Trace-Id` 和本地 Trace Tree,不内置 Micrometer/Brave tracing。HTTP 请求由 `EasyTraceIdFilter` 接收或生成链路号并写入响应头和 MDC;认证成功后 `EasyAuthFilter` 再把 `userId` 放入 MDC。异步线程池、Feign、RestClient 和 Kafka 生产端继续透传同一个链路号,Kafka 消费入口如果没有 traceId 会新建 traceId,日志通过 `logback.xml` 输出 `[user:%X{userId:-} trace:%X{traceId:-}]`。
|
||||
|
||||
慢调用只在入口边界记录,阈值集中放在 `easy.trace`:
|
||||
|
||||
```yaml
|
||||
easy:
|
||||
trace:
|
||||
enabled: true
|
||||
http-slow-threshold-ms: 3000
|
||||
max-depth: 8
|
||||
min-node-cost-ms: 1
|
||||
schedule-slow-threshold-ms: 10000
|
||||
kafka-consumer-slow-threshold-ms: 30000
|
||||
```
|
||||
|
||||
`EasyHttpSlowRequestInterceptor`、`ScheduleJobLogCallback` 和 `EasyKafkaConsumerSlowAspect` 负责创建入口 root span;`@EasyTrace` 负责标记需要进入调用树的 Service / Mapper 等业务节点;`TraceCodeBlock` 用于局部代码块,例如用户详情查询中的部门查询。`EasyMybatisTraceInterceptor` 继承 MyBatis-Plus 插件链,数据权限、乐观锁和分页仍按 `InnerInterceptor` 配置顺序执行;它只在 Executor 查询和更新外层增加 `Mapper` span,tag 只保留 `SqlCommandType`,不记录 SQL 文本、statement id 和参数。span 支持 `tag`,适合区分同一个方法下的不同业务对象、路由、任务编码或消息 topic;连续重复的叶子节点会在渲染时聚合为 `count/total/min/max`,非连续重复节点保持原顺序。慢入口超过阈值时用 WARN 打印 Trace Tree;入口抛出异常时不看慢阈值,直接用 ERROR 打印 Trace Tree 和异常堆栈。`max-depth` 限制树深度,root 深度为 1;`min-node-cost-ms` 会在子节点结束时丢弃过小节点,只保留有排查价值的分支。入口慢阈值小于等于 0 表示关闭对应入口的慢日志和 Trace Tree 采集。
|
||||
`easy.trace.enabled=false` 会关闭轻量 Trace Tree 的采集和打印,但不影响 `X-Trace-Id` 响应头、MDC 和审计表中的链路号。
|
||||
|
||||
## API 访问日志和标准指标
|
||||
|
||||
API 访问日志与指标分开维护:
|
||||
|
||||
- `@EasyApiAccessLog` / `EasyApiAccessLogAspect` 只负责写入 `audit_api_log`,用于按单次请求排查“谁、何时、从哪里、请求了什么、耗时多少、是否失败”。该表属于审计和排障数据,不作为标准指标后端。
|
||||
- `EasyBusinessMetrics` 是业务指标统一入口,基于 Micrometer 记录 `easy.api.requests`、`easy.remote.calls`、`easy.rate_limit.blocked`、`easy.schedule.jobs`、`easy.outbox.messages`。指标只使用 `controller/action/result`、`target/method/result`、`job/result`、`operation/status` 这类低基数标签,不写真实 URL、用户 ID、traceId、IP、文件名或请求参数。
|
||||
- `RemoteCallMetricsAspect` 负责 Feign、client 和 remote 包装类的远程调用指标,同时保留远程调用日志表用于近端排查。
|
||||
- InfluxDB 是当前可选指标导出后端。应用侧仍通过 Micrometer 建模,避免指标代码绑定到某个存储;后续需要 Prometheus 或 OTLP 时,只新增 registry/exporter,不改业务埋点。
|
||||
|
||||
脱敏分层:
|
||||
|
||||
- DTO 字段输出使用 `@EasyMask`,适合响应对象、导出对象和审计快照对象。
|
||||
- 审计、接口日志、异常文本、URL 查询参数和 Map 参数使用 `EasySensitiveDataMasker`,不依赖 DTO 注解。
|
||||
- 日志和审计落库前只保存脱敏内容;前端隐藏字段不能作为安全边界。
|
||||
|
||||
DTO 注解示例:
|
||||
|
||||
```java
|
||||
public record UserProfileView(
|
||||
@EasyMask(type = EasyMaskType.PHONE) String phone,
|
||||
@EasyMask(type = EasyMaskType.EMAIL) String email,
|
||||
@EasyMask String token) {
|
||||
}
|
||||
```
|
||||
|
||||
通用脱敏组件可以被审计之外的业务模块直接注入使用:
|
||||
|
||||
```java
|
||||
private final EasySensitiveDataMasker masker;
|
||||
|
||||
String payload = masker.toSanitizedCompactJson(request);
|
||||
String uri = masker.maskUri(currentUri);
|
||||
```
|
||||
|
||||
新增敏感字段时,先判断它是否属于明确 DTO 输出:是则加 `@EasyMask`;同时会出现在请求参数、Map 或日志文本中时,再修改 `EasySensitiveDataMasker` 的字段集合并补测试,避免各模块各自维护一套规则。
|
||||
|
||||
## 运行监控和实时日志
|
||||
|
||||
运行监控由三部分组成:
|
||||
|
||||
- Spring Actuator 只保留健康检查和服务信息入口,服务监控页面展示应用自身聚合后的资源水位。
|
||||
- `module.monitor` 聚合系统状态、接口统计、在线用户、任务状态和缓存状态。
|
||||
- 实时日志读取 logback 当前文件日志的尾部快照,并支持按关键词、级别和行数过滤。
|
||||
|
||||
实时日志是开发和内网排障工具,不应替代集中日志平台。生产环境需要按权限控制入口;日志级别调整使用独立权限 `monitor:weblog:level` 和操作审计,且只允许白名单 logger 前缀或 Spring Boot logger group。
|
||||
|
||||
日志输出建议分成两条链路:本地文件继续使用人可读文本,服务 WebLog 和现场排障;采集到 OpenSearch / Elasticsearch / SLS / Loki 的日志使用结构化 JSON 或采集器解析后的结构化字段,至少包含 `timestamp`、`level`、`service`、`env`、`trace_id`、`user_id`、`event_type`、`outcome`、`error.type`。本地文本和集中检索不要互相绑死,避免为了平台检索牺牲现场可读性,或为了本地可读性导致集中日志只能全文搜索。
|
||||
|
||||
前端观测事件由 `src/features/observability/events.ts` 维护,当前采集 Vue 全局错误、未处理 Promise、路由错误和 Axios 失败,并只做本地有界缓冲。事件保留最后一个后端 `X-Trace-Id`,URL 只保留 path 和 hash,不保留 query 参数;后续若接入事件上报接口,应继续沿用这个脱敏事件模型。
|
||||
|
||||
## 动态定时任务
|
||||
|
||||
任务调度模块保存任务定义、调度实例和执行日志:
|
||||
|
||||
- `ScheduleJob`:任务编码、执行类、Cron、启停状态、锁租约和集群执行模式。
|
||||
- `ScheduleInstance`:应用实例心跳,记录 instanceId、主机、进程和最近心跳。
|
||||
- `ScheduleJobManager`:注册本地 Cron、每 15 秒同步数据库目标状态,并按 `SINGLETON / BROADCAST` 执行。
|
||||
- `ScheduleJobLog`:记录每个真实执行实例的 runId、instanceId、lockToken、traceId、耗时和异常摘要。
|
||||
|
||||
`SINGLETON` 是默认模式,执行前通过 `IEasyLocker` 抢占 `schedule:job:{jobCode}`,适合整个集群只执行一次的普通任务。`BROADCAST` 不获取 Job 全局锁,每个在线实例都会进入 Handler,适合唤醒多个 Easy Batch Worker 或执行本机动作。广播不能替代业务幂等,也不能直接用于每个实例都处理整批共享数据。
|
||||
|
||||
应用启动后扫描 `@EasyJob`,最终是否执行以数据库 `schedule_job` 为准。执行模式进入本地注册签名,数据库修改模式后各实例会自动重建对应 Cron。错过触发补偿、任务 DAG 和大规模调度中心仍不属于当前轻量组件。
|
||||
|
||||
完整原理和取舍见 [Easy Job 设计](development/easy-job-design.md)。
|
||||
|
||||
## 批处理任务
|
||||
|
||||
批处理是长任务治理和执行模型,不替代定时任务,也不替代业务表状态:
|
||||
|
||||
- Easy Job 负责何时触发,以及单实例还是广播进入 Handler。
|
||||
- `BatchTask` 负责同一 `taskType + businessKey + runNo` 的一次不可变执行和整体进度。
|
||||
- `BatchTaskItem` 负责一条业务数据或一个业务分区的状态、失败原因和执行租约。
|
||||
- 业务表保存最终领域事实,Processor 负责业务幂等。
|
||||
|
||||
`EasyBatchRunner` 提供分页/游标 Reader、Processor、参数快照、失败策略和两种运行方式。普通模式按页读取并在当前实例执行;`distributed=true` 要求稳定 businessKey,先由准备锁让一个实例完整登记分区,再由所有广播实例直接 Claim 持久化 `input_json`,通过 ItemDecoder 恢复业务对象,不再重放 Reader。
|
||||
|
||||
每次 Claim 写入 `worker_id`、`lease_token` 和 `lease_until`。Runner 在 Processor 执行期间按租期三分之一自动续租;实例宕机后租约到期,其他实例可以接管。成功或失败回写必须匹配 Token 和有效租约,失去租约的旧 Worker 无法覆盖新结果。该模型提供 at-least-once,不承诺 exactly-once,业务 Writer 仍要使用唯一键、条件更新或下游幂等键。
|
||||
|
||||
固定账期任务建议把 ID 范围、租户、商户或时间片作为 `BatchTaskItem`,分区数高于 Worker 数量,由快实例继续领取。技术 Claim 字段统一保存在治理明细中,业务表只保留领域事实和幂等约束;分区通过稳定 `item_key` 和 `input_json` 描述业务范围,Processor 再读取具体业务数据。
|
||||
|
||||
滚动消息、待处理收件箱等数据会持续到达,没有“全部分区准备完成”的稳定边界。当前 LocalMessage 在本地事务提交后立即发送,失败或进程崩溃时由广播 Job 直接在源表 Claim/Lease 恢复;高吞吐、跨服务场景交给 Kafka/RabbitMQ。在线订单抢单属于领域状态竞争,应使用业务表原子条件 UPDATE,不能套用临时 Batch 租约。管理页可以查看固定批次的任务进度、失败明细、最后执行 Worker 和活动租约,并按业务日期触发采购状态对账。终态任务不重置,补跑通过新 `runNo` 保留完整历史。
|
||||
|
||||
完整原理、时序和接入模板见 [Easy Batch 设计](development/easy-batch-design.md)。
|
||||
|
||||
## 轻量工作流
|
||||
|
||||
EasyNextAdmin 工作流不是 Flowable/Camunda 替代品,而是内置轻量审批能力。
|
||||
|
||||
核心表意:
|
||||
|
||||
- 流程定义:业务流程的基本信息。
|
||||
- 流程版本:发布时生成不可变版本。
|
||||
- 流程节点和连线:由版本图 JSON 同步生成的结构化投影,便于查询、审计和后续运维分析。
|
||||
- 流程实例:一次发起记录,绑定发起时版本。
|
||||
- 待办任务:当前需要处理的节点。
|
||||
- 历史任务:已处理节点记录。
|
||||
- 抄送:需要知会但不阻塞流程的记录。
|
||||
- 事件:提交、审批、驳回、转办、委派、加签、减签、催办、撤回等动作留痕。
|
||||
|
||||
流程图以 JSON 保存,前端用 LogicFlow 编辑和展示。保存当前版本和发布新版本时,后端会把节点、审批规则和连线条件同步投影到 `wf_process_node`、`wf_process_transition`,运行态仍以版本快照为准,结构化表用于配置检索、审计和后续运维分析。后端在启用、发布和发起前都会校验图结构:必须有唯一开始、唯一结束、至少一个审批节点,连线端点必须存在,条件连线必须有表达式且符合白名单语法,流程图不能有环,多出口条件分支必须配置唯一默认路径,开始节点必须能连通结束节点。发布和启用前还会校验指定成员、职能角色及角色成员是否存在且启用。运行时解析图结构,根据节点类型、审批人规则、审批方式和条件表达式分派任务。审批方式包括任一人审批、全部审批和顺序审批,由 `WorkflowTaskPolicy` 决定当前节点是否继续等待或生成下一位处理人的待办。
|
||||
|
||||
审批人规则不做完整岗位体系,而是围绕中小企业常见组织关系闭环:用户管理维护直属上级,组织架构维护部门负责人,流程节点选择“发起人直属上级 / 发起人部门负责人 / 发起人上级部门负责人 / 职能角色 / 指定成员 / 发起人自选”。运行时由 `WorkflowAssigneeResolver` 解析为具体待办处理人,并把规则类型、规则名称和解析路径写入任务,实例详情展示派单来源。发起人就是本部门负责人时,部门负责人审批会自动上跳到上级部门负责人,避免自审。
|
||||
|
||||
流程实例详情优先展示发起时保存的 `definition_snapshot_json`,历史实例不会被后续定义调整影响。运行中实例和历史实例在列表查询中按范围分开分页,避免跨运行表和历史表做内存合并分页;管理员实例监控和个人“我发起的”列表都按运行中、历史流程分开查询,详情页可以按实例 ID 兼容运行态和归档态。催办动作由工作流运行服务统一处理:先写 `REMIND` 事件,再给当前待办处理人写入 `WORKFLOW` 站内消息,消息带流程实例业务关联和任务中心跳转链接。
|
||||
|
||||
## 批量数据与导入导出
|
||||
|
||||
批量导入导出仍由具体业务模块实现,避免脚手架暴露空泛的通用导入中心。当前用户管理页提供 CSV 模板下载、用户导入、按筛选条件导出;后端在 `module.system` 内完成解析、校验和结果返回,单次导入限制为 CSV、2MB 和 1000 行,导出 CSV 会处理 Excel 公式注入风险。
|
||||
|
||||
长耗时、大数据量或需要失败明细的固定数据集应接入 `module.batch`。当前轻量模型已提供任务中心页面、取消请求、分页 Reader、持久化工作单元、ItemDecoder、固定账期分区、多实例 Claim/Lease、自动续租和失效接管;采购流程状态日终对账是可运行样例。结果文件归档和超长分区的细粒度 checkpoint 仍属于后续增强。
|
||||
|
||||
## 扩展边界
|
||||
|
||||
EasyNextAdmin 默认保持“代码可读、二开直接、内部网络部署稳定”的边界:
|
||||
|
||||
- 不做在线低代码页面搭建器。
|
||||
- 不做完整 BI 数据集和拖拽大屏平台。
|
||||
- 不做完整 BPMN 流程引擎。
|
||||
- 不复制外部后台模板的品牌、演示页和无关模块。
|
||||
- 新能力必须能在代码中明确找到 API、页面、权限和数据表边界。
|
||||
BIN
docs/assets/screenshots/readme-approval-action.png
Normal file
BIN
docs/assets/screenshots/readme-approval-action.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
docs/assets/screenshots/readme-approval-list.png
Normal file
BIN
docs/assets/screenshots/readme-approval-list.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
BIN
docs/assets/screenshots/readme-dashboard.png
Normal file
BIN
docs/assets/screenshots/readme-dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/assets/screenshots/readme-login.png
Normal file
BIN
docs/assets/screenshots/readme-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
BIN
docs/assets/screenshots/readme-submit-form.png
Normal file
BIN
docs/assets/screenshots/readme-submit-form.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/assets/screenshots/readme-users.png
Normal file
BIN
docs/assets/screenshots/readme-users.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
19
docs/components/README.md
Normal file
19
docs/components/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 技术组件文档
|
||||
|
||||
这里按技术组件整理 EasyNextAdmin 的可复用能力。组件文档面向二开开发者,重点回答三个问题:
|
||||
|
||||
- 如何接入和使用。
|
||||
- 内部机制如何工作。
|
||||
- 当前方案和其他方案相比有什么 tradeoff。
|
||||
|
||||
组件文档只描述当前仓库真实存在的能力。尚未整理完成的组件不会在这里提前占位成已完成文档。
|
||||
|
||||
## 已整理组件
|
||||
|
||||
| 组件 | 文档 | 适合场景 |
|
||||
| --- | --- | --- |
|
||||
| 数据权限 | [security/data-scope.md](security/data-scope.md) | 需要按角色、部门和本人范围限制列表查询、任务查询和组织数据可见性 |
|
||||
|
||||
## 写作模板
|
||||
|
||||
新增组件文档时,先复制 [_template.md](_template.md),再按真实代码补齐示例、流程、tradeoff 和验证命令。
|
||||
33
docs/components/_template.md
Normal file
33
docs/components/_template.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 组件名称
|
||||
|
||||
## 适用场景
|
||||
|
||||
说明这个组件解决哪个真实后台问题,什么情况下应该使用,什么情况下不应该使用。
|
||||
|
||||
## 如何使用
|
||||
|
||||
给出最小接入步骤。示例必须来自当前项目已有代码,或是能按当前工程约束直接落地的代码片段。
|
||||
|
||||
## 请求或执行流程
|
||||
|
||||
用步骤描述一次请求或一次任务执行时,哪些类按什么顺序参与。
|
||||
|
||||
## 原理
|
||||
|
||||
解释组件的核心机制、边界和失败策略。优先说明“谁负责决策,谁负责执行”。
|
||||
|
||||
## 关键类、配置和表
|
||||
|
||||
列出入口注解、配置类、核心服务和数据库表。
|
||||
|
||||
## Tradeoff
|
||||
|
||||
比较当前方案和至少两个备选方案。说明各自优点、缺点、适用场景和不适用场景。
|
||||
|
||||
## 常见坑
|
||||
|
||||
列出二开时容易出错的地方,以及如何避免。
|
||||
|
||||
## 扩展建议
|
||||
|
||||
记录后续可以优化的方向。不要把尚未实现的优化写成当前能力。
|
||||
278
docs/components/security/data-scope.md
Normal file
278
docs/components/security/data-scope.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 数据权限组件
|
||||
|
||||
## 适用场景
|
||||
|
||||
企业后台里,同一个列表接口通常不是所有人都能看全量数据。管理员可以看全部账号,部门负责人只能看本部门及以下,普通员工只能看本人相关数据。数据权限组件解决的是“能访问接口之后,还能看到哪些数据”的问题。
|
||||
|
||||
它适合用于用户、部门、流程任务、调度任务等带有组织归属或负责人字段的查询。它不替代接口权限:`@EasyPermission` 决定用户能不能调用接口,`@DataScope` 决定接口查询结果会被限制到什么范围。
|
||||
|
||||
不适合使用数据权限组件的场景:
|
||||
|
||||
- 登录、验证码、初始化、权限恢复这类还没有稳定登录上下文的查询。
|
||||
- 必须跨组织读取的系统内部查询,例如审批参与人补齐、关系校验、任务派发内部查询。
|
||||
- 无法在 SQL 外层结果中暴露部门字段或本人字段的复杂聚合查询。
|
||||
|
||||
## 如何使用
|
||||
|
||||
### 1. 数据库存储稳定编码
|
||||
|
||||
`sys_role.data_scope` 存储稳定 code,不存储中文。中文只作为前端 label、报表展示和文档解释。
|
||||
|
||||
| `sys_role.data_scope` | 中文展示 | 运行时类型 | 含义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `ALL` | 全部数据 | `ALL` | 不追加数据范围条件 |
|
||||
| `DEPT_AND_CHILDREN` | 本部门及以下 | `DEPT_AND_CHILDREN` | 当前部门及子部门 |
|
||||
| `DEPT` | 本部门 | `DEPT` | 当前用户所在部门 |
|
||||
| `SELF` | 本人数据 | `SELF` | 当前用户本人相关数据 |
|
||||
| `DEPT_SETS` | 自定义部门 | `DEPT_SETS` | 启用角色绑定的部门集合 |
|
||||
|
||||
后端通过 `DataScopeType#fromRoleDataScope` 转换角色数据范围。保存和前端提交都必须使用 code,不接受中文展示值作为接口值。
|
||||
|
||||
多个角色会合并成一个可见范围。只要包含 `ALL`,最终就是全量范围;多个部门范围会取并集。
|
||||
|
||||
数据库初始化脚本对 `sys_role.data_scope` 加了 `NOT NULL` 和 `CHECK` 约束,角色授权保存时也会再次校验标准 code。发现中文、旧编码或空值时应通过迁移脚本先修正,不在运行时静默降级。
|
||||
|
||||
角色授权页会从后端读取当前账号可授予的数据范围。非超级管理员不能把角色授权成高于自身可见边界的范围:
|
||||
|
||||
| 当前账号数据范围 | 可授予角色的数据范围 |
|
||||
| --- | --- |
|
||||
| `ALL` | `ALL`、`DEPT_AND_CHILDREN`、`DEPT`、`SELF`、`DEPT_SETS` |
|
||||
| `DEPT_AND_CHILDREN` | `DEPT_AND_CHILDREN`、`DEPT`、`SELF`、`DEPT_SETS` |
|
||||
| `DEPT` | `DEPT`、`SELF`、`DEPT_SETS` |
|
||||
| `DEPT_SETS` | `SELF`、`DEPT_SETS` |
|
||||
| `SELF` | `SELF` |
|
||||
|
||||
选择 `DEPT_SETS` 时,部门 ID 还会校验是否真实存在、是否启用,以及是否落在当前账号可见组织范围内。
|
||||
|
||||
### 2. 在 Mapper 类上声明数据范围
|
||||
|
||||
类级注解适合 MyBatis-Plus 的 `selectList`、`selectPage`,这是标准列表页最常用的接入方式。
|
||||
|
||||
用户列表示例:
|
||||
|
||||
```java
|
||||
@DataScope(
|
||||
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
|
||||
deptColumn = DataScopeColumns.DB_DEPT_ID,
|
||||
selfColumn = DataScopeColumns.USER_ID
|
||||
)
|
||||
public interface SysUserMapper extends BaseMapper<SysUser> {
|
||||
}
|
||||
```
|
||||
|
||||
部门列表示例:
|
||||
|
||||
```java
|
||||
@DataScope(
|
||||
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
|
||||
selfColumn = DataScopeColumns.DEPT_ID
|
||||
)
|
||||
public interface SysDeptMapper extends BaseMapper<SysDept> {
|
||||
}
|
||||
```
|
||||
|
||||
流程任务示例:
|
||||
|
||||
```java
|
||||
@DataScope(
|
||||
methods = {DataScopeMapperMethods.SELECT_LIST, DataScopeMapperMethods.SELECT_PAGE},
|
||||
deptColumn = DataScopeColumns.DB_ASSIGNEE_DEPT_ID,
|
||||
selfColumn = DataScopeColumns.DB_ASSIGNEE_ID
|
||||
)
|
||||
public interface WfTaskMapper extends BaseMapper<WfTask> {
|
||||
}
|
||||
```
|
||||
|
||||
`methods` 必须尽量收窄。不要在 Mapper 类上无差别覆盖所有方法,否则 `selectById`、`selectBatchIds` 这类内部查询也可能被误过滤。
|
||||
|
||||
### 3. 在 Mapper 方法上声明数据范围
|
||||
|
||||
方法级注解适合自定义查询。它比类级注解优先级更高,适合某个方法的过滤字段和默认字段不一致的场景。
|
||||
|
||||
```java
|
||||
public interface CustomMapper {
|
||||
|
||||
@DataScope(deptColumn = "org_id", selfColumn = "owner_id")
|
||||
List<CustomView> search(CustomQuery query);
|
||||
}
|
||||
```
|
||||
|
||||
方法级注解的核心价值是把“这个查询到底按哪个字段做部门过滤、按哪个字段做本人过滤”写在查询入口旁边,避免二开人员去 Service 里猜。
|
||||
|
||||
### 4. 字段名必须匹配外层查询
|
||||
|
||||
`deptColumn` 和 `selfColumn` 不是随便写实体属性名,它们必须是原 SQL 被包成子查询之后,外层查询能看到的列名或别名。
|
||||
|
||||
这就是为什么项目里会同时出现 `dept_id` 和 `deptId`:
|
||||
|
||||
- `dept_id`:原 SQL 外层直接暴露数据库列名,例如用户表的部门字段。
|
||||
- `deptId`:MyBatis-Plus 或自定义查询把列投影成 Java 属性别名,例如 `id AS deptId`。
|
||||
|
||||
数据权限改写发生在 SQL 层,不知道 Java 实体字段语义。字段写错时,轻则查不到数据,重则 SQL 执行失败。
|
||||
|
||||
### 5. 系统内部查询显式绕过
|
||||
|
||||
如果业务逻辑需要查询完整数据用于校验或派单,必须显式写出绕过意图:
|
||||
|
||||
```java
|
||||
SysUser manager = EasyDataScopeContext.ignore(() -> this.lambdaQuery()
|
||||
.eq(SysUser::getId, managerUserId)
|
||||
.one());
|
||||
```
|
||||
|
||||
`ignore(...)` 使用 ThreadLocal 记录嵌套深度,执行结束后会恢复现场。不要把它包在 Controller 大范围入口上,只能包住确实需要全量数据的内部查询。
|
||||
|
||||
## 请求或执行流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["用户请求列表接口"] --> B["EasyAuthFilter 恢复 AuthPrincipal"]
|
||||
B --> C["@EasyPermission 校验接口权限"]
|
||||
C --> D["Service 调用带 @DataScope 的 Mapper"]
|
||||
D --> E["EasyDataScopeInnerInterceptor 拦截 SELECT"]
|
||||
E --> F["CurrentUserDataScopeResolver 计算当前账号可见范围"]
|
||||
F --> G["DataScopeSqlRewriter 包装 SQL 并追加条件"]
|
||||
G --> H["MyBatis 执行改写后的 SQL"]
|
||||
H --> I["Controller 返回 Response 或 PageResponse"]
|
||||
```
|
||||
|
||||
一次查询的关键步骤:
|
||||
|
||||
1. 登录后,会话快照里包含用户 ID、部门 ID、角色和数据范围。
|
||||
2. Controller 先通过 `@EasyPermission` 判断能不能访问接口。
|
||||
3. Service 调用 Mapper。
|
||||
4. MyBatis-Plus 插件链触发 `EasyDataScopeInnerInterceptor#beforeQuery`。
|
||||
5. 拦截器只处理 `SELECT`,并且只处理命中 `@DataScope` 的 Mapper 方法。
|
||||
6. `CurrentUserDataScopeResolver` 从 `EasySecurityContext` 读取当前用户,计算 `DataScopeCondition`。
|
||||
7. `DataScopeSqlRewriter` 把原 SQL 包成子查询,再追加外层条件。
|
||||
8. 分页插件在数据权限插件之后执行,保证分页总数和列表数据使用同一套范围条件。
|
||||
|
||||
## 原理
|
||||
|
||||
数据权限组件分成两层:决策层和执行层。
|
||||
|
||||
决策层是 `CurrentUserDataScopeResolver`。它只回答一个问题:当前登录人对“带组织归属的数据”能看到什么范围。它不关心具体表名,也不拼 SQL。
|
||||
|
||||
执行层是 `EasyDataScopeInnerInterceptor` 和 `DataScopeSqlRewriter`。Mapper 用 `@DataScope` 告诉组件“这个查询结果里哪个字段代表部门,哪个字段代表本人”。拦截器在 SQL 执行前读取注解和权限范围,然后把原 SQL 改写成:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM (
|
||||
原始查询
|
||||
) ea_ds
|
||||
WHERE ea_ds.deptId IN (10, 20)
|
||||
```
|
||||
|
||||
如果当前用户拥有全部数据权限,原 SQL 不改写。如果没有登录上下文、数据范围无法计算、本人范围但没有 `selfColumn`,组件默认追加 `1 = 0`,宁可查不到数据,也不误放开。
|
||||
|
||||
列名会经过白名单校验,只允许普通标识符,例如 `dept_id`、`deptId`、`assignee_id`。不允许 `dept_id;drop table` 这类拼接内容进入 SQL。
|
||||
|
||||
## 关键类、配置和表
|
||||
|
||||
| 类型 | 位置 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| 注解 | `DataScope` | 标记 Mapper 查询需要自动追加数据范围 |
|
||||
| 字段常量 | `DataScopeColumns` | 提供常用外层字段名,如 `deptId`、`userId` |
|
||||
| 方法常量 | `DataScopeMapperMethods` | 提供 MyBatis-Plus 常用 Mapper 方法名 |
|
||||
| 范围对象 | `DataScopeCondition` | 表达当前请求的可见范围 |
|
||||
| 决策接口 | `DataScopeResolver` | 解耦“计算范围”和“改写 SQL” |
|
||||
| 决策实现 | `CurrentUserDataScopeResolver` | 从当前用户、角色和部门树计算范围 |
|
||||
| 元数据端口 | `DataScopeMetadataRepository` | 读取启用部门树和启用角色的自定义部门授权等权限元数据 |
|
||||
| 元数据缓存 | `CachedDataScopeMetadataRepository` | 通过 `@Cacheable` 复用 Spring Cache,避免每次数据范围计算都查部门树和自定义部门表 |
|
||||
| 缓存失效 | `@CacheEvict` | 部门、角色授权和用户角色变化后清理数据权限元数据缓存,提交后生效 |
|
||||
| 拦截器 | `EasyDataScopeInnerInterceptor` | MyBatis 查询前识别注解并触发 SQL 改写 |
|
||||
| SQL 构造 | `DataScopeSqlRewriter` | 包装原 SQL 并追加部门或本人条件 |
|
||||
| 绕过上下文 | `EasyDataScopeContext` | 对系统内部查询显式跳过数据权限 |
|
||||
| 插件配置 | `EasyMybatisConfig` | 将数据权限插件放在分页插件之前 |
|
||||
| 枚举 | `DataScopeType` | 维护数据范围 code 和中文 label |
|
||||
| 表 | `sys_role` | 保存角色数据范围 code |
|
||||
| 表 | `sys_role_dept` | 保存自定义部门范围 |
|
||||
| 表 | `sys_user_role` | 关联用户和角色 |
|
||||
| 表 | `sys_dept` | 提供未删除且启用的部门树 |
|
||||
|
||||
后端分包按职责收敛在 `infrastructure/security/datascope` 下:
|
||||
|
||||
```text
|
||||
annotation # @DataScope 和 DataScopeColumns,给 Mapper 声明字段
|
||||
context # EasyDataScopeContext,显式绕过内部查询
|
||||
model # DataScopeType、DataScopeCondition,稳定 code 和运行时范围
|
||||
resolver # CurrentUserDataScopeResolver,计算当前账号可见范围
|
||||
policy # DataScopeAssignmentPolicy,角色授权可授予范围矩阵
|
||||
repository # DataScopeMetadataRepository,读取并缓存数据权限元数据
|
||||
mybatis # EasyDataScopeInnerInterceptor、DataScopeSqlRewriter,执行 SQL 改写
|
||||
```
|
||||
|
||||
## Tradeoff
|
||||
|
||||
### 方案一:Mapper 注解 + MyBatis 拦截器
|
||||
|
||||
这是当前方案。
|
||||
|
||||
优点:
|
||||
|
||||
- 过滤靠近数据库执行,不会先查出越权数据再在内存过滤。
|
||||
- 业务 Service 不需要在每个列表里重复拼部门条件。
|
||||
- 分页前执行,分页总数和当前页数据一致。
|
||||
- Mapper 必须显式声明字段,能看出哪些查询受数据权限保护。
|
||||
|
||||
缺点:
|
||||
|
||||
- 查询结果外层必须暴露过滤字段或别名。
|
||||
- SQL 被包装成子查询后,复杂 SQL 的执行计划需要关注。
|
||||
- 只适合标准行级过滤,复杂 ABAC 规则仍要在业务服务里补充。
|
||||
|
||||
适合 EasyNextAdmin 当前这种单体后台、MyBatis-Plus、MySQL、强 CRUD 的场景。
|
||||
|
||||
### 方案二:Service 层手写条件
|
||||
|
||||
每个 Service 根据当前用户自己拼 `dept_id in (...)` 或 `create_by = ?`。
|
||||
|
||||
优点:
|
||||
|
||||
- 逻辑显式,调试时容易从 Service 代码读懂。
|
||||
- 对复杂业务规则更灵活。
|
||||
- 不需要 SQL 改写插件。
|
||||
|
||||
缺点:
|
||||
|
||||
- 容易漏加条件,尤其是新增列表、导出、统计接口时。
|
||||
- 同一套数据范围逻辑会散落在多个 Service。
|
||||
- 分页、统计、导出可能各写一遍,长期容易不一致。
|
||||
|
||||
适合小项目或只有一两个受控查询的模块,不适合作为企业后台脚手架默认方案。
|
||||
|
||||
### 方案三:数据库行级安全或安全视图
|
||||
|
||||
把数据范围放进数据库层,例如 PostgreSQL RLS 或安全视图。
|
||||
|
||||
优点:
|
||||
|
||||
- 安全边界更靠近数据源。
|
||||
- 多个应用共享数据库时,可以减少应用侧遗漏。
|
||||
|
||||
缺点:
|
||||
|
||||
- MySQL 没有 PostgreSQL 那种原生 RLS 能力,落地通常要靠视图、存储过程或会话变量,复杂度高。
|
||||
- 应用用户、业务用户、部门树、角色版本之间的上下文传递难维护。
|
||||
- 本项目的权限版本、会话快照和菜单权限都在应用层,强行下沉会让模型分裂。
|
||||
|
||||
适合数据库治理能力很强、跨应用共享同一套权限规则的企业,不适合作为 EasyNextAdmin 默认脚手架方案。
|
||||
|
||||
## 常见坑
|
||||
|
||||
- 只加 `@EasyPermission` 不加 `@DataScope`:接口能鉴权,但列表仍可能查到超范围数据。
|
||||
- `@DataScope` 标了错误字段:SQL 外层看不到字段时会执行失败,或者查不到数据。
|
||||
- 本人范围没有设置 `selfColumn`:组件会追加 `1 = 0`,避免误放开。
|
||||
- 内部校验查询没有 `ignore(...)`:例如校验直属上级、部门负责人、流程参与人时,可能因为当前用户数据范围太小而查不到真实数据。
|
||||
- 在 Controller 大范围使用 `ignore(...)`:这等于绕过整个请求的数据权限边界,应该禁止。
|
||||
- 把数据权限当作接口权限:数据权限只决定“看哪些数据”,不能决定“能不能执行操作”。
|
||||
- 把中文当作数据库枚举值:数据库、接口和审计日志都应使用稳定 code,中文只负责展示。
|
||||
- 只在前端隐藏高风险选项:后端必须用授权矩阵再次校验,前端禁用只是操作体验,不是安全边界。
|
||||
|
||||
## 扩展建议
|
||||
|
||||
整理文档和代码时暴露出的后续优化点:
|
||||
|
||||
- 当前已缓存部门树和用户自定义部门集合;后续如果部门规模很大,可以继续缓存部门闭包或改用 `tree_path` 前缀查询,但必须保持组织变更后失效。
|
||||
- SQL 包装成子查询对复杂报表可能影响执行计划,后续复杂查询可以保留手写 SQL + 方法级 `@DataScope`,并用集成测试验证。
|
||||
- 组件文档稳定后,可以补一篇 `components/security/permission.md`,明确接口权限和数据权限的分工,避免二开时混用。
|
||||
246
docs/deployment.md
Normal file
246
docs/deployment.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# 编译与部署
|
||||
|
||||
EasyNextAdmin 建议维护三套企业内部交付主线:裸机/VM 传统部署、单机 Docker Compose 部署、K8s 云原生部署。Docker Swarm 只作为已有 Swarm 团队的兼容说明,不作为主推交付路线。当前仓库的 `docker-compose.yml` 只用于本地 MySQL、Redis 依赖,不作为生产编排方案。
|
||||
|
||||
## 交付形态选择
|
||||
|
||||
| 形态 | 适合团队 | 推荐方式 |
|
||||
| --- | --- | --- |
|
||||
| 裸机/VM 传统包 | 没有 Docker/K8s 平台,只有 Linux 服务器、Nginx、MySQL、Redis | 后端 JAR + systemd,前端 `dist` + Nginx,配置用环境变量或外部配置文件 |
|
||||
| 单机 Docker Compose 包 | 有 Docker,但没有集群编排;适合小企业、演示和私有化单机交付 | 后端镜像 + 前端镜像 + Compose 文件,生产依赖优先使用外部 MySQL/Redis |
|
||||
| Docker Swarm 兼容说明 | 客户已有 Swarm 集群和运维经验,但暂不上 K8s | 复用 Compose 镜像和配置,补 `docker stack`、Secret、滚动更新和回滚说明 |
|
||||
| K8s 云原生包 | 已有镜像仓库、Kubernetes、Ingress、Secret/ConfigMap 和运维平台 | 后端镜像 + 前端镜像,K8s Deployment/Service/Ingress,配置和密钥由平台注入 |
|
||||
|
||||
所有生产形态都使用 `prod` profile,都要求外部 MySQL 和可选 Redis;区别在于进程托管、发布回滚、配置密钥和流量摘除方式。裸机/VM、Compose、K8s 是三套主线,Swarm 只做兼容路线。
|
||||
|
||||
## 后端构建
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
easy-next-admin-server/target/easyNextAdmin.jar
|
||||
```
|
||||
|
||||
如需运行测试:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am verify
|
||||
```
|
||||
|
||||
## 后端 JAR 部署
|
||||
|
||||
### 环境 Profile
|
||||
|
||||
配置文件按职责拆分:
|
||||
|
||||
- `application.yaml`:公共基线,放所有环境共享的默认行为。
|
||||
- `application-local.yaml`:本地开发,保留 Swagger UI、演示账号、前端开发 CORS、较小线程池和便捷数据库/Redis 默认值。
|
||||
- `application-prod.yaml`:生产部署,内网和公网都从这里起步,数据库、Redis、线程池、Tomcat、CORS 和安全响应头通过环境变量或启动参数覆盖。
|
||||
|
||||
`local` 默认 MySQL URL 带 `createDatabaseIfNotExist=true`,只用于本机 root 演示账号在空环境下自动创建数据库。`prod` 不提供该参数:生产数据库应由 DBA 或基础设施流程预建,应用账号只拥有目标 schema 内运行和迁移所需权限,不应拥有全局 `CREATE DATABASE` 权限。
|
||||
|
||||
生产部署使用 `prod` profile。`prod` 不提供数据库默认值,必须显式传入 `MYSQL_URL`、`MYSQL_USERNAME` 和 `MYSQL_PASSWORD`;Redis 默认指向 `redis://redis:6379`,真实部署建议显式传入 `EASY_REDIS_ADDRESS` 和 `EASY_REDIS_PASSWORD`。`prod` 默认开启 HSTS、收紧 Actuator 暴露范围和关闭 CORS 白名单。前后端同域网关反代时不需要 CORS;前后端分离域名部署时显式配置真实域名:
|
||||
|
||||
```bash
|
||||
MYSQL_URL="jdbc:mysql://mysql:3306/easy-next-admin?serverTimezone=GMT%2B8&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=true" \
|
||||
MYSQL_USERNAME=easy_admin \
|
||||
MYSQL_PASSWORD="替换为生产数据库密码" \
|
||||
java -jar easy-next-admin-server/target/easyNextAdmin.jar \
|
||||
--spring.profiles.active=prod \
|
||||
--easy.spring.redis.address=redis://redis:6379 \
|
||||
--easy.spring.redis.password="替换为生产 Redis 密码" \
|
||||
--easy.web.cors.allowed-origins[0]=https://admin.example.com
|
||||
```
|
||||
|
||||
内网 HTTP 部署仍建议使用 `prod`,再按实际网络环境覆盖差异。例如纯内网未启用 HTTPS 时关闭 HSTS,并把 CORS 域名设为真实内网前端地址:
|
||||
|
||||
```bash
|
||||
java -jar easy-next-admin-server/target/easyNextAdmin.jar \
|
||||
--spring.profiles.active=prod \
|
||||
--spring.datasource.url="jdbc:mysql://mysql:3306/easy-next-admin?serverTimezone=GMT%2B8&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&allowPublicKeyRetrieval=true" \
|
||||
--spring.datasource.username=easy_admin \
|
||||
--spring.datasource.password="替换为内网数据库密码" \
|
||||
--easy.spring.redis.address=redis://redis:6379 \
|
||||
--easy.spring.redis.password="替换为内网 Redis 密码" \
|
||||
--easy.web.security-headers.hsts-enabled=false \
|
||||
--easy.web.cors.allowed-origins[0]=http://admin.intranet.example
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
java \
|
||||
-jar easy-next-admin-server/target/easyNextAdmin.jar \
|
||||
--spring.profiles.active=prod \
|
||||
--spring.datasource.url="jdbc:mysql://127.0.0.1:3306/easy-next-admin?serverTimezone=GMT%2B8&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&allowPublicKeyRetrieval=true" \
|
||||
--spring.datasource.username=root \
|
||||
--spring.datasource.password=123456 \
|
||||
--easy.features.redis=true \
|
||||
--easy.spring.redis.address=redis://127.0.0.1:6379 \
|
||||
--easy.spring.redis.password=111222
|
||||
```
|
||||
|
||||
生产环境建议:
|
||||
|
||||
- 使用非 `local` profile 启动,避免加载本地开发配置;Swagger UI 和 OpenAPI JSON 在通用配置中默认关闭。
|
||||
- 内网和公网部署都使用 `prod` profile,并通过环境变量或启动参数提供数据库、Redis、对象存储、线程池容量和真实前端域名。
|
||||
- 不启用 `local` profile;`/api/auth/demo-accounts` 在生产环境会返回空列表,仍需替换初始化账号密码。
|
||||
- 使用外部 MySQL 和 Redis,避免把生产状态放在应用容器内。
|
||||
- Redis 使用能力级开关 `easy.features.redis=true`。开启后缓存、会话、验证码、重复请求、幂等、限流和分布式锁会自动切到 Redis/Redisson 实现。
|
||||
- Kafka 使用能力级开关 `easy.features.kafka=true`。未开启时不会创建 Kafka Producer、Consumer、Topic Admin 和健康检查,避免单机开发误连 `localhost:9092`。
|
||||
- Feign、调度、监控、WebLog、OSS 和本地消息也都通过 `easy.features.*` 控制。生产只开启实际需要的能力;Influx 指标导出默认关闭,需要接入外部指标库时再显式配置。InfluxDB 作为当前指标后端没有问题,应用侧通过 Micrometer 输出 `easy.api.requests`、`easy.remote.calls` 等低基数指标,后续要换 Prometheus 或 OTLP 时应新增 exporter,而不是改业务埋点。
|
||||
- 通过环境变量、启动参数或配置中心覆盖数据库密码、Redis 密码、对象存储配置。
|
||||
- 按企业安全策略配置会话超时:`EASY_AUTH_SESSION_IDLE_TIMEOUT` 默认 `30m`,`EASY_AUTH_SESSION_ABSOLUTE_TIMEOUT` 默认 `8h`。普通企业后台绝对超时通常为 8-12 小时,公网或高权限后台建议 4-8 小时。
|
||||
- 按真实前端域名覆盖 `easy.web.cors.allowed-origins` 或 `easy.web.cors.allowed-origin-patterns`,不要在生产环境继续使用本地开发 Origin,也不要开放 `*` 且携带凭证。
|
||||
- 按部署方式收紧 `easy.web.security-headers`:HTTPS 环境可开启 `hsts-enabled`,稳定资源路径后再配置 `content-security-policy`,避免本地 HTTP 和历史内联资源被误伤。
|
||||
- 把 `/actuator/health` 暴露给负载均衡健康检查,其他 Actuator 端点按内网权限控制。
|
||||
- 使用反向代理统一接入 HTTPS、访问日志和限流策略。
|
||||
- 对公开生产环境,生产安全路线固定为服务端会话 + `HttpOnly; Secure; SameSite` Cookie,并为写接口配置 CSRF 防护;当前 Bearer token 方案只适合作为本地开发和前后端分离调试路线,不作为生产验收方案。
|
||||
- 用户导入保持 CSV、小文件和行数限制;当前默认单文件不超过 2MB、单次不超过 1000 行,避免后台导入拖垮服务。
|
||||
- 实时日志的日志级别调整是独立权限 `monitor:weblog:level`,只授予可信运维角色。
|
||||
- 本地 logback 文件日志用于 WebLog 和现场排障;采集到 OpenSearch / Elasticsearch / SLS / Loki 的日志应通过采集器解析或 JSON appender 形成结构化字段。不要把 WebLog 当集中日志平台使用,也不要在本地文件里长期打开 DEBUG。
|
||||
- 审计和 API 访问日志要配置保留策略:`audit_api_log` 这类高增长表建议 30-90 天热数据,登录、权限、角色、菜单和敏感变更按企业合规保留更久;清理或归档任务必须记录范围、行数和执行结果。
|
||||
|
||||
## 依赖版本与兼容性
|
||||
|
||||
本仓库的 `docker-compose.yml` 只作为本地开发依赖编排,当前固定的兼容性基线如下:
|
||||
|
||||
| 依赖 | 本地镜像 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| MySQL | `mysql:8.4` | MySQL 8.4 LTS。新数据卷使用默认 `caching_sha2_password` 认证,不启用已废弃的 `mysql_native_password`。 |
|
||||
| Redis | `redis:7.4-alpine` | Redis 7.4 Alpine。开启 AOF,适合本地保留运行态数据;兼容 Redis 7.4 生成的 RDB/AOF 基础文件格式。 |
|
||||
|
||||
企业部署时建议保持同一大版本和小版本通道,不要直接使用 `latest`。如果升级 MySQL 或 Redis 的大版本、小版本,需要先在预发环境完成数据库迁移、登录、权限、文件、流程、调度、缓存和审计链路验证。生产镜像建议进一步固定到补丁版本或 digest,并通过镜像扫描和变更窗口统一升级。
|
||||
|
||||
MySQL 8.4 默认禁用旧的 `mysql_native_password` 服务端插件,MySQL 9 会移除该插件。EasyNextAdmin 作为全新脚手架不为旧插件保留默认兼容配置;如果企业环境里存在历史账号,应在升级窗口内先迁移到 `caching_sha2_password`,再切换到 MySQL 8.4 或更高版本。
|
||||
|
||||
## 前端构建
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
```text
|
||||
easy-next-admin-web/dist
|
||||
```
|
||||
|
||||
默认构建后的前端仍通过相对路径 `/api` 访问后端。生产部署时,应由 Nginx 或网关把 `/api/` 反向代理到后端服务。
|
||||
|
||||
## Nginx 静态部署
|
||||
|
||||
示例配置:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name example.com;
|
||||
|
||||
root /usr/share/nginx/html/easy-next-admin;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_read_timeout 75s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意 `proxy_pass http://127.0.0.1:8080;` 不带末尾 `/`,这样会保留 `/api` 前缀,匹配当前后端控制器路径。`proxy_read_timeout 75s` 用于给普通业务查询、导入导出和工作流详情留出反向代理读取时间,避免代理先于应用正常响应断开。
|
||||
|
||||
## Docker 构建
|
||||
|
||||
企业环境通常不要直接把本地 `docker-compose.yml` 当生产编排。推荐做法是:
|
||||
|
||||
- 应用镜像和依赖镜像分开发布,MySQL、Redis 优先使用托管服务或独立高可用集群。
|
||||
- 镜像使用明确 tag,生产发布清单固定 digest;升级通过预发验证和回滚方案管理。
|
||||
- 密码、Token、对象存储密钥使用 Secret/配置中心,不写入镜像、不提交 `.env`。
|
||||
- 容器以非 root 用户运行,限制运行权限,保留健康检查、资源限制和日志轮转。
|
||||
- 数据目录、上传文件和日志进入持久化卷或外部服务,备份恢复流程要定期演练。
|
||||
- 业务应用实例保持无状态,横向扩容依赖外部 Redis、数据库和对象存储。
|
||||
|
||||
构建后端镜像:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
docker build -f easy-next-admin-server/Dockerfile -t easy-next-admin-server:local .
|
||||
```
|
||||
|
||||
后端 Dockerfile 放在 `easy-next-admin-server/`,但构建上下文仍然要使用仓库根目录 `.`。镜像构建会复制本地 Maven 产物 `easy-next-admin-server/target/easyNextAdmin.jar`,因此必须先在本地或 CI 中完成 Maven 打包。这样 Docker 镜像阶段只做运行环境封装,不在镜像构建里重复下载 Maven 依赖。后端镜像默认使用 `SPRING_PROFILES_ACTIVE=prod` 启动;本地临时调试容器时,可通过 `-e SPRING_PROFILES_ACTIVE=local` 显式覆盖。
|
||||
|
||||
运行后端容器示例。下面命令只用于本机快速验证后端镜像:数据库连宿主机 MySQL,并显式关闭 Redis,避免单容器示例误连 `redis://redis:6379`。接近生产的容器部署应去掉 `EASY_FEATURE_REDIS=false`,并传入真实 Redis 地址和密码。
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8080:8080 \
|
||||
-e JAVA_OPTS="-Xms512m -Xmx1024m" \
|
||||
-e EASY_FEATURE_REDIS=false \
|
||||
easy-next-admin-server:local \
|
||||
--spring.datasource.url="jdbc:mysql://host.docker.internal:3306/easy-next-admin?serverTimezone=GMT%2B8&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&allowPublicKeyRetrieval=true" \
|
||||
--spring.datasource.username=root \
|
||||
--spring.datasource.password=123456
|
||||
```
|
||||
|
||||
构建前端镜像:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run build
|
||||
cd ..
|
||||
docker build -t easy-next-admin-web:local ./easy-next-admin-web
|
||||
```
|
||||
|
||||
前端 Dockerfile 只复制本地构建产物 `easy-next-admin-web/dist` 到 Nginx 镜像,因此必须先完成前端构建。
|
||||
|
||||
运行前端容器示例:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8081:80 easy-next-admin-web:local
|
||||
```
|
||||
|
||||
前端镜像默认把 `/api/` 和 `/storage/` 代理到 `http://host.docker.internal:8080`,适合本地 Docker Desktop 访问宿主机后端。企业部署或前后端同在 Docker 网络时,通过运行时环境变量覆盖代理目标,不需要重新构建镜像:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 8081:80 \
|
||||
-e API_PROXY_PASS=http://server:8080 \
|
||||
easy-next-admin-web:local
|
||||
```
|
||||
|
||||
前端镜像内置的 `nginx.conf` 默认写入 `X-Frame-Options`、`X-Content-Type-Options`、`Referrer-Policy` 和 `Permissions-Policy`,并对 `/assets/` 指纹文件启用长期缓存,对入口页面使用 `no-store`,避免发版后浏览器长期持有旧入口。
|
||||
|
||||
## 发布检查
|
||||
|
||||
发布前建议至少执行:
|
||||
|
||||
```bash
|
||||
mvn -pl easy-next-admin-server -am -DskipTests package
|
||||
cd easy-next-admin-web && npm run build
|
||||
```
|
||||
|
||||
仓库内置 GitHub Actions 工作流会在 push 和 PR 时运行后端 `verify`、前端单元测试和前端构建;本地仍可按需只运行对应命令。
|
||||
|
||||
发布后检查:
|
||||
|
||||
- `GET /actuator/health` 返回 `UP`。
|
||||
- 前端刷新任意二级路由不会 404。
|
||||
- 登录、工作台、用户管理、角色授权、流程待办、实时日志至少各验证一次。
|
||||
- 用户导入模板、1000 行限制、导出 CSV 脱公式注入至少验证一次。
|
||||
- 公开生产环境必须替换初始化账号密码;如需公开体验环境,应单独准备脱敏账号和隔离数据,不复用生产配置。
|
||||
149
docs/development/documentation-structure.md
Normal file
149
docs/development/documentation-structure.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# 文档结构整理设计稿
|
||||
|
||||
本文是 EasyNextAdmin 企业级文档整理的设计稿。它只规划文档目录、写作模板和推进顺序,不把尚未整理完成的组件写成已完成文档。
|
||||
|
||||
## 目标
|
||||
|
||||
让二开团队能按两条线阅读项目:
|
||||
|
||||
- 业务模块:用户能看到什么,入口在哪里,权限和数据边界是什么。
|
||||
- 技术组件:开发者如何接入,内部原理是什么,为什么采用当前方案,其他方案有什么 tradeoff。
|
||||
|
||||
整理过程中允许发现并记录更优雅的代码方案,但默认先以文档暴露问题,不把无关重构混入单篇组件文档。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把 EasyNextAdmin 扩展成低代码、BI 或完整 BPM 平台。
|
||||
- 不新增模板品牌页、演示组件页或无业务落点的中间件清单。
|
||||
- 不把规划项写进 `README.md` 当成已完成功能。
|
||||
- 不为每个 Java 类生成 API 文档,避免把源码注释搬运成文档。
|
||||
|
||||
## 目录规划
|
||||
|
||||
```text
|
||||
docs/
|
||||
├── README.md
|
||||
├── getting-started.md
|
||||
├── deployment.md
|
||||
├── architecture.md
|
||||
├── features-and-components.md
|
||||
├── reference-projects.md
|
||||
├── modules/
|
||||
│ ├── README.md
|
||||
│ ├── system.md
|
||||
│ ├── workflow.md
|
||||
│ ├── monitor.md
|
||||
│ ├── audit.md
|
||||
│ ├── schedule.md
|
||||
│ ├── message.md
|
||||
│ └── report.md
|
||||
├── components/
|
||||
│ ├── README.md
|
||||
│ ├── _template.md
|
||||
│ ├── security/
|
||||
│ │ ├── auth-session.md
|
||||
│ │ ├── permission.md
|
||||
│ │ ├── data-scope.md
|
||||
│ │ ├── masking.md
|
||||
│ │ ├── rate-limit.md
|
||||
│ │ └── waf-cors-headers.md
|
||||
│ ├── persistence/
|
||||
│ │ ├── response-contract.md
|
||||
│ │ ├── page-query.md
|
||||
│ │ ├── mybatis-trace.md
|
||||
│ │ └── flyway.md
|
||||
│ ├── runtime/
|
||||
│ │ ├── cache.md
|
||||
│ │ ├── scheduler.md
|
||||
│ │ ├── local-message.md
|
||||
│ │ ├── idempotency.md
|
||||
│ │ ├── duplicate-request.md
|
||||
│ │ └── distributed-lock.md
|
||||
│ ├── observability/
|
||||
│ │ ├── audit-collector.md
|
||||
│ │ ├── trace-id.md
|
||||
│ │ ├── trace-tree.md
|
||||
│ │ ├── metrics.md
|
||||
│ │ └── weblog.md
|
||||
│ └── frontend/
|
||||
│ ├── dynamic-routes.md
|
||||
│ ├── permission-directive.md
|
||||
│ ├── feature-api.md
|
||||
│ ├── table-toolbar.md
|
||||
│ └── easy-chart.md
|
||||
└── development/
|
||||
├── documentation-structure.md
|
||||
├── adding-backend-module.md
|
||||
├── adding-permission-resource.md
|
||||
└── release-checklist.md
|
||||
```
|
||||
|
||||
`modules/` 写业务能力,`components/` 写技术机制,`development/` 写二开流程和设计稿。`features-and-components.md` 保留总览和索引,不继续承载所有细节。
|
||||
|
||||
## 组件文档模板
|
||||
|
||||
每篇组件文档固定使用以下结构:
|
||||
|
||||
```text
|
||||
1. 适用场景
|
||||
2. 如何使用
|
||||
3. 请求或执行流程
|
||||
4. 原理
|
||||
5. 关键类、配置和表
|
||||
6. Tradeoff:当前方案与备选方案的优劣
|
||||
7. 常见坑
|
||||
8. 扩展建议
|
||||
```
|
||||
|
||||
写作规则:
|
||||
|
||||
- 先从真实后台场景切入,再解释组件名词。
|
||||
- 先写调用者如何使用,再写内部原理。
|
||||
- Tradeoff 控制在 2-3 个方案内,说明优点、缺点和适用边界。
|
||||
- 如果发现代码设计问题,写入“扩展建议”或单独 issue,不在文档中假装已经优化。
|
||||
|
||||
## 优先整理顺序
|
||||
|
||||
第一批先整理和企业后台安全边界强相关的组件:
|
||||
|
||||
1. `components/security/data-scope.md`
|
||||
2. `components/security/permission.md`
|
||||
3. `components/security/auth-session.md`
|
||||
4. `components/security/masking.md`
|
||||
|
||||
第二批整理稳定性和运行治理组件:
|
||||
|
||||
1. `components/runtime/cache.md`
|
||||
2. `components/runtime/scheduler.md`
|
||||
3. `components/runtime/local-message.md`
|
||||
4. `components/observability/audit-collector.md`
|
||||
5. `components/observability/trace-tree.md`
|
||||
|
||||
第三批整理前端二开入口:
|
||||
|
||||
1. `components/frontend/dynamic-routes.md`
|
||||
2. `components/frontend/permission-directive.md`
|
||||
3. `components/frontend/feature-api.md`
|
||||
|
||||
业务模块文档在组件样板稳定后推进,避免先写业务页时反复调整组件文档口径。
|
||||
|
||||
## 代码整理原则
|
||||
|
||||
整理文档时如果发现更好的代码方案,按下面规则处理:
|
||||
|
||||
- 只影响命名、注释、测试补充的小改动,可以随对应组件文档一起做。
|
||||
- 影响运行链路、权限边界、SQL 生成、响应契约或前后端协议的改动,必须单独开任务。
|
||||
- 每个组件整理完成后至少运行窄范围测试;涉及前端文件时运行 `npm run build`。
|
||||
- 不为了文档美观改接口,不为了统一目录移动稳定业务代码。
|
||||
|
||||
## 数据权限组件样板范围
|
||||
|
||||
首篇样板文档为 `docs/components/security/data-scope.md`,覆盖以下真实实现:
|
||||
|
||||
- `@DataScope` 标记 Mapper 查询。
|
||||
- `CurrentUserDataScopeResolver` 根据当前账号、角色数据范围和部门树计算范围。
|
||||
- `DataScopeMetadataRepository` 读取部门树和自定义部门授权元数据,`CachedDataScopeMetadataRepository` 通过 `@Cacheable` 命名缓存降低查询频率。
|
||||
- `EasyDataScopeInnerInterceptor` 在 MyBatis 查询前改写 SQL。
|
||||
- `DataScopeSqlRewriter` 包装原 SQL 并追加条件。
|
||||
- `EasyDataScopeContext.ignore(...)` 显式绕过系统内部查询。
|
||||
- `EasyDataScopeInnerInterceptorTest`、`CurrentUserDataScopeResolverTest`、`DataScopeSqlRewriterTest` 作为验证入口。
|
||||
355
docs/development/easy-batch-design.md
Normal file
355
docs/development/easy-batch-design.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Easy Batch:用两张表完成可恢复的分布式批处理
|
||||
|
||||
每天凌晨需要核对一批已经确定边界的数据。应用有 5 个实例,数据量可能从几万增长到千万。
|
||||
|
||||
Easy Batch 的目标不是复刻 Spring Batch,而是保留最重要的执行语义:**一次执行有独立历史、输入工作单元可持久化、多个 Worker 动态领取、宕机后可以从工作单元边界继续。**
|
||||
|
||||
## 1. 先区分三类问题
|
||||
|
||||
| 问题 | 组件 | 负责内容 |
|
||||
| --- | --- | --- |
|
||||
| 什么时候触发 | Easy Job | Cron、启停、单实例/广播、执行日志 |
|
||||
| 一次批量执行如何拆分和恢复 | Easy Batch | 执行历史、工作单元、进度、Claim/Lease、失败明细 |
|
||||
| 持续到达的数据如何实时消费 | 源队列表或 MQ | Outbox Claim、Kafka Consumer Group、RabbitMQ ACK |
|
||||
|
||||
`BROADCAST` 只让所有实例进入 Handler,并不会自动把 1000 条数据平均分给 5 个实例。数据所有权必须由 Batch 的 Claim/Lease 或源队列表的 Claim 决定。
|
||||
|
||||
## 2. 适用边界
|
||||
|
||||
适合 Easy Batch:
|
||||
|
||||
- 每日对账、周报、月结;
|
||||
- 固定文件导入;
|
||||
- 有截止时间或高水位的数据同步;
|
||||
- 可以拆成稳定 ID 范围、哈希桶、租户或时间片的任务。
|
||||
|
||||
不适合先复制成 Batch item:
|
||||
|
||||
- 本地消息、实时事件、待消费队列等持续流入数据;
|
||||
- 用户在线抢单等所有权本身就是领域状态的操作;
|
||||
- 要求毫秒级分发、强背压或跨服务消费的高吞吐流。
|
||||
|
||||
判断标准只有一个:**本次执行能否定义一个有边界的数据集合,并判断何时全部完成。**
|
||||
|
||||
## 3. 轻量架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
J["Easy Job<br/>BROADCAST"] --> A["Instance A"]
|
||||
J --> B["Instance B"]
|
||||
J --> C["Instance C"]
|
||||
A --> T["batch_task<br/>一次执行"]
|
||||
B --> T
|
||||
C --> T
|
||||
T --> I["batch_task_item<br/>持久化工作单元"]
|
||||
A -->|"Claim + Lease"| I
|
||||
B -->|"Claim + Lease"| I
|
||||
C -->|"Claim + Lease"| I
|
||||
I --> D["ItemDecoder"]
|
||||
D --> P["Processor<br/>分页读取业务表并幂等写入"]
|
||||
```
|
||||
|
||||
职责拆分:
|
||||
|
||||
- Reader 只在准备阶段生成工作单元;
|
||||
- `input_json` 是 Worker 的事实输入,Worker 不重放 Reader;
|
||||
- ItemDecoder 将持久化输入恢复为业务对象;
|
||||
- Processor 完成业务读取、转换和写入;
|
||||
- BatchTaskService 是治理 Writer,负责状态、租约、计数和错误摘要。
|
||||
|
||||
业务 Writer 没有抽象为万能 DSL。数据库更新、远程调用、文件生成的事务边界完全不同,显式放在 Processor 或领域 Service 中更容易判断幂等性。
|
||||
|
||||
## 4. 两张表为什么足够
|
||||
|
||||
### 4.1 `batch_task`:一次执行
|
||||
|
||||
关键字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `task_type` | 稳定任务类型 |
|
||||
| `business_key` | 业务周期,例如 `PURCHASE_STATUS_RECONCILIATION:DAY:20260628` |
|
||||
| `run_no` | 同一周期的第几次执行 |
|
||||
| `status` | 整体执行状态 |
|
||||
| `total/success/failed/skipped_count` | 工作单元计数 |
|
||||
| `trigger_type/ref_id` | JOB、MANUAL、API 等触发来源 |
|
||||
| `params_json` | 本次参数快照 |
|
||||
| `trace_id` | 与日志和 Trace Tree 关联 |
|
||||
|
||||
唯一约束是:
|
||||
|
||||
```text
|
||||
(task_type, business_key, run_no)
|
||||
```
|
||||
|
||||
`business_key` 表示业务周期,`run_no` 表示执行历史。失败补跑必须创建 `run_no + 1`,不能把终态任务改回 `PENDING`,否则会丢失第一次执行的失败证据、耗时和 Worker 轨迹。
|
||||
|
||||
进度百分比不入库,通过工作单元计数实时推导:
|
||||
|
||||
```text
|
||||
progress = (success + failed + skipped) / total
|
||||
```
|
||||
|
||||
### 4.2 `batch_task_item`:持久化工作单元
|
||||
|
||||
关键字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `task_id + item_key` | 一次执行内稳定唯一 |
|
||||
| `work_type` | `RECORD` 或 `PARTITION` |
|
||||
| `input_json` | Worker 可独立恢复的输入快照 |
|
||||
| `status` | `PENDING/RUNNING/SUCCESS/FAILED/SKIPPED` |
|
||||
| `attempt_count` | 实际领取次数,包含租约接管 |
|
||||
| `worker_id` | 最后领取实例 |
|
||||
| `lease_token/until` | 当前限时所有权 |
|
||||
| `error/result_message` | 排障摘要 |
|
||||
|
||||
索引 `(task_id, status, lease_until, id)` 服务于动态领取。治理历史表不提供逻辑删除,也不原地复用终态记录。
|
||||
|
||||
## 5. 准备屏障:Reader 只能成功登记一次
|
||||
|
||||
如果 A 只登记了第一页分区,B 已处理完这一页并宣布成功,而 A 还没登记第二页,主任务会提前结束。
|
||||
|
||||
分布式模式因此分成两个阶段:
|
||||
|
||||
1. 所有实例使用相同 `taskType + businessKey + runNo` 获取同一次执行;
|
||||
2. 一个实例获得 `batch:prepare:{taskId}` 准备锁;
|
||||
3. 准备者分页执行 Reader,把全部工作单元写入 `batch_task_item`;
|
||||
4. `task_id + item_key` 唯一索引保证准备重试幂等;
|
||||
5. 登记完成后,任务从 `PENDING` 进入 `RUNNING`;
|
||||
6. 所有实例直接从表中 Claim 工作单元。
|
||||
|
||||
这里分页的是分区定义,不是复制全部业务数据。千万级订单可以只登记几十或几百个范围:
|
||||
|
||||
```text
|
||||
item_key = id-range-0001
|
||||
work_type = PARTITION
|
||||
input_json = {"accountDate":"2026-06-28","startId":1,"endId":100000}
|
||||
```
|
||||
|
||||
Worker 领取后,再在范围内按 500 到 2000 条分页读取业务表。
|
||||
|
||||
## 6. Claim、Lease 和 Token
|
||||
|
||||
当前实现用短事务执行:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM batch_task_item
|
||||
WHERE task_id = :taskId
|
||||
AND (
|
||||
status = 'PENDING'
|
||||
OR (status = 'RUNNING' AND lease_until < :now)
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED;
|
||||
```
|
||||
|
||||
领取后在同一事务内写入 `RUNNING / worker_id / lease_token / lease_until` 并累加 `attempt_count`。业务处理发生在事务外,避免远程调用期间长期持有行锁。
|
||||
|
||||
Lease 解决“RUNNING 实例已经宕机”的恢复问题:
|
||||
|
||||
- Runner 每隔租期的三分之一自动续租;
|
||||
- 进程停止后租约自然过期;
|
||||
- 其他实例重新领取过期工作单元;
|
||||
- 成功、失败和续租都必须匹配 `lease_token`;
|
||||
- 旧 Worker 恢复后无法覆盖新 Worker 的结果。
|
||||
|
||||
```text
|
||||
A Claim,token=A1
|
||||
│
|
||||
├─ A 停顿,Lease 过期
|
||||
▼
|
||||
B Claim,token=B1
|
||||
│
|
||||
└─ A1 回写影响 0 行,B1 才是当前所有者
|
||||
```
|
||||
|
||||
Token 只能保护治理状态,不能撤销已经发生的外部副作用。因此 Processor 必须使用业务唯一键、条件 UPDATE 或下游幂等键。
|
||||
|
||||
MySQL 官方明确说明 `SKIP LOCKED` 适合 queue-like table 的并发访问,不适合普通一致性查询。[MySQL InnoDB Locking Reads](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html)
|
||||
|
||||
## 7. 完整时序
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant J as BROADCAST Job
|
||||
participant A as Instance A
|
||||
participant B as Instance B
|
||||
participant T as batch_task
|
||||
participant I as batch_task_item
|
||||
participant P as Processor
|
||||
|
||||
par 同时进入 Handler
|
||||
J->>A: execute(businessDate)
|
||||
J->>B: execute(businessDate)
|
||||
end
|
||||
par 获取同一次执行
|
||||
A->>T: submit(type, key, runNo)
|
||||
B->>T: submit(type, key, runNo)
|
||||
end
|
||||
A->>T: acquire prepare lock
|
||||
A->>I: Reader 分页登记全部 input_json
|
||||
A->>T: PENDING -> RUNNING
|
||||
par 动态领取
|
||||
A->>I: claimNext, token=A1
|
||||
B->>I: claimNext, token=B1
|
||||
end
|
||||
I-->>A: persisted item 01
|
||||
I-->>B: persisted item 02
|
||||
A->>P: decode + process item 01
|
||||
B->>P: decode + process item 02
|
||||
loop 租期的 1/3
|
||||
A->>I: renew A1
|
||||
B->>I: renew B1
|
||||
end
|
||||
A->>I: SUCCESS where token=A1
|
||||
B->>I: SUCCESS where token=B1
|
||||
A->>I: claimNext
|
||||
B->>I: claimNext
|
||||
I-->>T: 所有工作单元终态
|
||||
T->>T: 汇总最终状态
|
||||
```
|
||||
|
||||
这不是“每个实例固定 200 条”。应创建多于实例数的工作单元,让处理快的实例多领取,处理慢或宕机的实例少领取。
|
||||
|
||||
## 8. 状态机与断点续跑
|
||||
|
||||
主任务:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PENDING: 创建一次执行
|
||||
PENDING --> RUNNING: 全部工作单元登记完成
|
||||
PENDING --> CANCELED: 准备前取消
|
||||
RUNNING --> SUCCESS: 全部成功
|
||||
RUNNING --> PARTIAL_SUCCESS: 成功与失败/跳过并存
|
||||
RUNNING --> FAILED: 没有成功且存在失败
|
||||
RUNNING --> CANCELING: 请求取消
|
||||
CANCELING --> CANCELED: 未完成项收口为跳过
|
||||
```
|
||||
|
||||
工作单元:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PENDING: 持久化 input_json
|
||||
PENDING --> RUNNING: Claim
|
||||
RUNNING --> RUNNING: Renew Lease
|
||||
RUNNING --> RUNNING: Lease 过期后接管
|
||||
RUNNING --> SUCCESS: Token 匹配
|
||||
RUNNING --> FAILED: Token 匹配
|
||||
PENDING --> SKIPPED: 取消或失败策略停止
|
||||
RUNNING --> SKIPPED: 取消收口
|
||||
```
|
||||
|
||||
当前断点粒度是一个 `batch_task_item`:已成功工作单元不会再执行,过期 `RUNNING` 工作单元可接管。分区内部如果在第 80 页宕机,会重新执行该分区,因此分区不能过大,内部写入必须幂等。
|
||||
|
||||
只有单个分区执行数小时且重放代价明显时,才应增加 token-fenced `checkpoint_json`。默认不增加逐条 checkpoint,避免把轻量模型变成复杂状态机。
|
||||
|
||||
## 9. 真实样例:采购流程状态日终对账
|
||||
|
||||
项目内置 `PurchaseStatusReconciliationJob`:
|
||||
|
||||
- 每天 01:10 以 `BROADCAST` 唤醒全部实例;
|
||||
- 业务日期默认为昨天;
|
||||
- 同一日期首次执行固定 `runNo=1`;
|
||||
- Reader 先读取候选 ID 高低水位,再登记最多 16 个连续 ID 范围;
|
||||
- Worker 从持久化 `input_json` 恢复分区;
|
||||
- Processor 在分区内按 ID 游标分页;
|
||||
- 采购状态与流程状态不一致时使用带旧状态条件的 UPDATE 修复;
|
||||
- 批处理页可以选择日期手动触发,新建下一个 `runNo`。
|
||||
|
||||
```text
|
||||
task_type = PURCHASE_STATUS_RECONCILIATION
|
||||
business_key= PURCHASE_STATUS_RECONCILIATION:DAY:20260628
|
||||
run_no = 1
|
||||
|
||||
item_key = id-range-03
|
||||
work_type = PARTITION
|
||||
input_json = {"businessDate":"2026-06-28","startId":100001,"endId":150000}
|
||||
```
|
||||
|
||||
这展示了 Easy Job 与 Easy Batch 的边界:Job 只唤醒,Batch 分工,采购领域 Service 解释数据并执行幂等修复。
|
||||
|
||||
## 10. 为什么 LocalMessage 不走 Batch
|
||||
|
||||
本地消息在业务运行期间持续增加,没有“全部工作单元已经登记完成”的时刻。若每分钟复制到 Batch:
|
||||
|
||||
- 相邻批次可能包含同一条消息;
|
||||
- Outbox 状态与 Batch 状态会重复并漂移;
|
||||
- 空批次和重复明细造成长期写放大;
|
||||
- Cron 周期会增加首次发送延迟。
|
||||
|
||||
当前实现使用两层路径:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TX["本地事务"] --> M["插入 infra_local_message<br/>PENDING"]
|
||||
M --> C["事务提交"]
|
||||
C --> F["Fast Path<br/>条件 Claim + 立即发送"]
|
||||
F -->|"成功"| S["SENT"]
|
||||
F -->|"失败"| R["PENDING + nextRetryAt"]
|
||||
J["BROADCAST Retry Job"] --> Q["源表 SKIP LOCKED Claim"]
|
||||
Q --> F
|
||||
```
|
||||
|
||||
第一次发送在事务提交后立即进行。Fast Path 与广播 Worker 使用同一套 Claim/Lease 所有权:谁先把 `PENDING` 条件更新为 `PROCESSING`,谁负责发送。若进程在提交后、领取前崩溃,消息仍是 `PENDING`,下一次广播可直接接管;Cron 只负责失败重试和崩溃恢复。所有权字段直接位于 `infra_local_message`,因为它本身就是基础设施队列表,不需要再复制一份 `batch_task_item`。
|
||||
|
||||
同理:
|
||||
|
||||
| 工作负载 | 所有权位置 |
|
||||
| --- | --- |
|
||||
| 固定账期对账、报表、同步 | `batch_task_item` |
|
||||
| Outbox、任务收件箱 | 源队列表 Claim/Lease |
|
||||
| 在线订单抢单 | 业务表状态条件 UPDATE |
|
||||
| 高吞吐跨服务事件 | Kafka/RabbitMQ |
|
||||
|
||||
## 11. 失败策略与补跑
|
||||
|
||||
轻量 Runner 支持:
|
||||
|
||||
- `CONTINUE_ON_ERROR`:单项失败后继续;
|
||||
- `STOP_ON_ERROR`:首次失败后停止并跳过未执行项;
|
||||
- `STOP_ON_FAILURE_LIMIT`:达到失败阈值后停止。
|
||||
|
||||
停止策略只用于当前实例顺序执行的普通模式。分布式 Worker 之间没有共享的失败计数和停止屏障,因此 `distributed=true` 固定使用 `CONTINUE_ON_ERROR`;失败工作单元进入终态后,通过新 `runNo` 选择性补跑。
|
||||
|
||||
终态执行不可变。补跑流程是:
|
||||
|
||||
1. 根据上一执行的失败/跳过明细或业务条件生成新工作单元;
|
||||
2. 使用相同 `taskType + businessKey`;
|
||||
3. 创建 `runNo + 1`;
|
||||
4. 新执行独立 Claim 和汇总;
|
||||
5. 页面可对比每次执行历史。
|
||||
|
||||
框架不提供“重置原任务”接口,也不假设所有业务都能用同一种方式生成补跑输入。
|
||||
|
||||
## 12. 与 Spring Batch 的关系
|
||||
|
||||
| Spring Batch 概念 | Easy Batch 对应 | 取舍 |
|
||||
| --- | --- | --- |
|
||||
| JobExecution | `batch_task` | 保留一次执行历史和状态 |
|
||||
| Step/Partition Execution | `batch_task_item` | 合并为一层工作单元 |
|
||||
| ItemReader | `EasyBatchPageReader` | 支持页码/游标,分布式时仅准备者执行 |
|
||||
| ItemProcessor/Writer | `EasyBatchItemProcessor` + 领域 Service | 不做通用 Writer DSL |
|
||||
| ExecutionContext | `params_json + input_json` | 默认只恢复到 item 边界 |
|
||||
| Partition Handler | `claimNext + lease` | 直接使用 MySQL,不引入远程分区中间件 |
|
||||
|
||||
需要多 Step 依赖、Chunk 事务、跳过/重试策略 DSL、作业仓库生态或远程 Chunking 时,应直接采用 Spring Batch,而不是继续扩张 Easy Batch。[Spring Batch Reference](https://docs.spring.io/spring-batch/reference/)
|
||||
|
||||
## 13. 生产约束
|
||||
|
||||
- 所有实例必须时间同步;时钟偏差不可控时应改用数据库时间判断租约;
|
||||
- 分区边界必须不重叠,并记录截止时间或高水位;
|
||||
- Processor 必须幂等,尤其是 Lease 接管场景;
|
||||
- `input_json` 只保存恢复所需参数,不复制整批业务数据;
|
||||
- 租约应大于正常单页处理耗时,Runner 会自动续租;
|
||||
- 工作单元数量应显著多于 Worker,但不能细到让治理开销超过业务处理;
|
||||
- 历史表需要按企业保留策略归档,不通过逻辑删除篡改执行证据;
|
||||
- API 手动触发只负责创建执行并异步启动,页面通过任务列表观察进度。
|
||||
|
||||
Easy Batch 的核心不是“又一套任务框架”,而是用最少模型把**固定数据集、执行历史、动态分工和故障恢复**连成闭环。
|
||||
188
docs/development/easy-job-design.md
Normal file
188
docs/development/easy-job-design.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Easy Job:从单实例定时任务到广播批处理
|
||||
|
||||
假设一套企业后台部署了 5 个实例,每天凌晨 2 点需要完成一次对账。
|
||||
|
||||
如果 5 个实例都直接执行完整对账,同一批订单会被处理 5 次;如果只允许一个实例执行,正确性容易保证,但另外 4 个实例完全没有参与,数据量上来后单机执行时间又会越来越长。
|
||||
|
||||
Easy Job 解决的是“什么时候执行、哪些实例进入执行”。它不负责决定某条业务数据归哪个实例处理。后一个问题交给 Easy Batch 的 Claim/Lease。
|
||||
|
||||
## 1. 组件边界
|
||||
|
||||
Easy Job 是轻量分布式定时任务组件,提供:
|
||||
|
||||
- `@EasyJob` 代码声明;
|
||||
- `schedule_job` 数据库目标状态;
|
||||
- 每个实例的本地 Cron 注册;
|
||||
- 15 秒一次的集群配置对齐和实例心跳;
|
||||
- `SINGLETON / BROADCAST` 两种集群执行模式;
|
||||
- 执行日志、instanceId、traceId、耗时和异常摘要。
|
||||
|
||||
它不提供完整调度中心、DAG 编排、依赖任务、秒级海量调度或通用数据分片。任务规模超过轻量模型时,应接入 XXL-JOB、PowerJob、ElasticJob 或云调度平台。
|
||||
|
||||
## 2. 设计如何演进
|
||||
|
||||
### 2.1 第一版:所有实例都注册 Cron
|
||||
|
||||
每个实例都能读取相同的 `schedule_job` 配置并注册本地 Cron。这样没有独立调度中心,实例启动后也能自行恢复调度。
|
||||
|
||||
但 Cron 到点时,所有实例都会进入 Handler,普通任务会重复执行。
|
||||
|
||||
### 2.2 第二版:默认使用集群单实例锁
|
||||
|
||||
`SINGLETON` 模式在执行前获取:
|
||||
|
||||
```text
|
||||
schedule:job:{jobCode}
|
||||
```
|
||||
|
||||
只有一个实例获得分布式锁,其余实例跳过。锁自动续租,进程崩溃后由租约释放。
|
||||
|
||||
这适合生成普通报表、清理共享表等“整个集群只执行一次”的任务。源队列表已经具备 Claim/Lease 时,也可以使用广播唤醒多个 Worker。
|
||||
|
||||
### 2.3 第三版:固定账期需要广播唤醒
|
||||
|
||||
每日对账已经拆成 50 个业务分区时,只让一个实例进入 Handler 会浪费其他实例。于是增加 `BROADCAST`:
|
||||
|
||||
- 每个在线实例都执行 Handler;
|
||||
- 不获取 Job 全局锁;
|
||||
- 所有实例使用同一个固定账期 `businessKey + runNo`;
|
||||
- Easy Batch 原子领取不同分区。
|
||||
|
||||
广播只负责唤醒,不能防止重复处理。正确性来自唯一批次、Claim/Lease、Token 和业务幂等。
|
||||
|
||||
## 3. 核心模型
|
||||
|
||||
| 模型 | 作用 |
|
||||
| --- | --- |
|
||||
| `@EasyJob` | 声明任务编码、名称、Cron、锁租约和集群执行模式。 |
|
||||
| `schedule_job` | 保存数据库目标状态、Cron、执行类、`execution_mode` 和租约。 |
|
||||
| `schedule_instance` | 保存应用实例、主机、进程和最近心跳。 |
|
||||
| `ScheduleJobManager` | 扫描声明、注册本地 Cron、同步数据库状态并选择执行策略。 |
|
||||
| `IEasyLocker` | 为 `SINGLETON` 任务提供 MySQL/Redis 分布式锁及自动续租。 |
|
||||
| `schedule_job_log` | 每个真实执行实例写一条日志,记录 runId、instanceId、traceId 和结果。 |
|
||||
|
||||
数据库是控制面的事实来源,本地 Cron 是数据库目标状态在当前 JVM 的执行副本。
|
||||
|
||||
## 4. 两种执行模式
|
||||
|
||||
| 模式 | 谁进入 Handler | 是否使用 Job 全局锁 | 适合场景 |
|
||||
| --- | --- | --- | --- |
|
||||
| `SINGLETON` | 集群内一个实例 | 是 | 共享表清理、普通报表、状态巡检 |
|
||||
| `BROADCAST` | 每个在线实例 | 否 | 分布式批处理启动、本机缓存刷新、本机文件清理 |
|
||||
|
||||
默认必须是 `SINGLETON`。只有 Handler 本身明确使用 Claim/Lease,或者动作本来就要求每台机器执行时,才选择广播。
|
||||
|
||||
```java
|
||||
@EasyJob(
|
||||
jobCode = "recon_daily",
|
||||
jobName = "每日对账",
|
||||
cron = "0 0 2 * * ?",
|
||||
executionMode = JobExecutionMode.BROADCAST
|
||||
)
|
||||
public class DailyReconciliationJob implements EasyJobHandler {
|
||||
// 每个实例都会进入 execute,业务分区由 Easy Batch 动态领取。
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 单实例执行流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant A as Instance A
|
||||
participant B as Instance B
|
||||
participant DB as MySQL
|
||||
participant L as IEasyLocker
|
||||
participant H as EasyJobHandler
|
||||
|
||||
A->>DB: 读取 schedule_job
|
||||
B->>DB: 读取 schedule_job
|
||||
A->>L: tryAcquire(schedule:job:code)
|
||||
B->>L: tryAcquire(schedule:job:code)
|
||||
L-->>A: lockToken
|
||||
L-->>B: 获取失败
|
||||
B-->>B: 跳过本次触发
|
||||
A->>DB: 写实例执行日志
|
||||
A->>H: execute(params)
|
||||
H-->>A: 完成或异常
|
||||
A->>DB: 回填状态、耗时、错误摘要
|
||||
A->>L: release(lockToken)
|
||||
```
|
||||
|
||||
## 6. 广播批处理执行流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant A as Instance A
|
||||
participant B as Instance B
|
||||
participant C as Instance C
|
||||
participant BT as batch_task
|
||||
participant BI as batch_task_item
|
||||
|
||||
par Cron 同时触发
|
||||
A->>BT: startOrGet(RECON:DAY:20260627, runNo=1)
|
||||
B->>BT: startOrGet(RECON:DAY:20260627, runNo=1)
|
||||
C->>BT: startOrGet(RECON:DAY:20260627, runNo=1)
|
||||
end
|
||||
Note over BT: 唯一索引保证只有一个批次
|
||||
A->>BI: Claim partition-01
|
||||
B->>BI: Claim partition-02
|
||||
C->>BI: Claim partition-03
|
||||
loop 处理完成后继续领取
|
||||
A->>BI: Claim next partition
|
||||
B->>BI: Claim next partition
|
||||
C->>BI: Claim next partition
|
||||
end
|
||||
```
|
||||
|
||||
这里没有给实例静态分配“每台 200 条”。处理快的实例会继续领取,实例宕机后,其租约到期分区由其他实例接管。
|
||||
|
||||
## 7. 为什么不再增加 Redis 广播
|
||||
|
||||
固定时间任务已经由每个实例的本地 Cron 唤醒。如果再发布一次 Redis Pub/Sub,只是把同一件事做两遍,并增加消息丢失、订阅恢复和运维依赖。
|
||||
|
||||
因此当前取舍是:
|
||||
|
||||
```text
|
||||
固定周期批处理:BROADCAST Job 直接唤醒
|
||||
API/人工批处理:调用方实例启动 Runner
|
||||
数据分工与恢复:统一由 BatchTaskItem Claim/Lease 完成
|
||||
```
|
||||
|
||||
Redis/MQ 只有在 Worker 与调度服务需要独立部署、跨服务消费或需要队列背压时才值得引入。
|
||||
|
||||
## 8. 控制面一致性
|
||||
|
||||
多实例最容易出现的问题不是 Cron 本身,而是某节点已经停止任务,其他节点仍保留旧 Cron。
|
||||
|
||||
Easy Job 的处理方式:
|
||||
|
||||
1. 页面启动、停止、保存先更新数据库;
|
||||
2. 当前节点立即 reconcile;
|
||||
3. 所有节点每 15 秒写心跳并读取 `schedule_job`;
|
||||
4. Cron、状态、租约或执行模式变化时重建本地任务;
|
||||
5. 真正执行前再次读取数据库,确认仍处于可运行状态。
|
||||
|
||||
`START / STOP` 表示期望状态,不表示当前一定有线程在执行。
|
||||
|
||||
## 9. 失败场景与边界
|
||||
|
||||
| 故障 | 当前行为 |
|
||||
| --- | --- |
|
||||
| 单实例任务执行节点宕机 | Job 锁租约到期后,下一次 Cron 可由其他节点获取。 |
|
||||
| 广播任务某个 Worker 宕机 | Batch Item Lease 到期,仍在线 Worker 接管。 |
|
||||
| 所有实例在触发时停机 | 当前不会自动补齐所有错过时刻;需启动恢复或业务补跑。 |
|
||||
| 广播 Handler 不是幂等批处理 | 可能在每个实例重复产生副作用,禁止这样配置。 |
|
||||
| 新实例在广播后才启动 | 不参加这次 Job 调用;未完成批次可由后续恢复入口继续。 |
|
||||
|
||||
广播不是 exactly-once。业务写入仍要使用业务唯一键、状态条件更新或幂等消费。
|
||||
|
||||
## 10. 参考与取舍
|
||||
|
||||
- [Spring Batch Scaling and Parallel Processing](https://docs.spring.io/spring-batch/reference/scalability.html):Manager/Worker、Partitioning 和从简单方案开始演进。
|
||||
- [Kubernetes Leases](https://kubernetes.io/docs/concepts/architecture/leases/):租约用于限时所有权、心跳和 Leader Election。
|
||||
- [Quartz JDBC JobStore Clustering](https://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/ConfigJDBCJobStoreClustering.html):共享数据库协调集群调度。
|
||||
- [XXL-JOB 官方文档](https://www.xuxueli.com/xxl-job/en/):执行器、路由和分片广播模型。
|
||||
|
||||
Easy Job 选择的是中小企业可直接落地的最小闭环:数据库控制面、本地 Cron、单实例锁、广播入口和可观测执行日志。复杂调度平台能力不在应用脚手架里重复实现。
|
||||
345
docs/development/enterprise-components-baseline.md
Normal file
345
docs/development/enterprise-components-baseline.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# 企业级组件能力矩阵
|
||||
|
||||
本文整理企业级开发常见组件、分布式组件,以及 EasyNextAdmin 当前已具备和仍缺少的能力。它不是当前功能宣传页,而是用于后续架构扫描、路线规划和二开取舍的基线。
|
||||
|
||||
## 判断原则
|
||||
|
||||
EasyNextAdmin 是企业后台脚手架,不是中间件平台。组件取舍遵循:
|
||||
|
||||
- 应用内强相关能力优先内置,例如权限、审计、数据范围、统一响应、幂等、限流、观测埋点。
|
||||
- 可替换基础设施优先抽象接口和配置开关,例如 Redis、Kafka、OSS、指标后端。
|
||||
- 大型分布式平台能力不直接内置,例如 Kubernetes、Service Mesh、APM 平台、统一日志平台、配置中心集群。
|
||||
- 国内中小企业能落地优先,避免为了“看起来企业级”堆中间件。
|
||||
|
||||
状态说明:
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| 已具备 | 代码中已有可运行实现,文档可描述为当前能力 |
|
||||
| 部分具备 | 有基础实现,但生产完整性、治理或平台化不足 |
|
||||
| 可选具备 | 依赖和开关存在,只有启用外部服务后生效 |
|
||||
| 缺口 | 当前没有实现,后续可按优先级补齐 |
|
||||
| 不建议内置 | 应由外部平台或部署环境提供,项目只保持接入边界 |
|
||||
|
||||
## 文档结构与边界
|
||||
|
||||
本文按四层梳理,避免把应用能力、分布式中间件和交付治理混在一起:
|
||||
|
||||
| 层级 | 放什么 | 不放什么 |
|
||||
| --- | --- | --- |
|
||||
| 应用组件 | 写在 EasyNextAdmin 代码或二开业务代码里的能力,例如权限、审计、幂等、限流、业务编号、批处理、业务事件 | MySQL 高可用、K8s、日志平台这类外部平台 |
|
||||
| 分布式组件 | 支撑多实例、多服务、跨进程协作的中间件或运行时能力,例如 Redis、Kafka、对象存储、配置中心、注册发现、网关、分布式 ID | 业务编号、审批流程、消息模板这类应用语义 |
|
||||
| 平台支撑组件 | 交付、观测、安全、备份和运维平台,例如制品库、镜像仓库、集中日志、APM、告警、Secret、DNS、证书 | 业务代码里的组件封装 |
|
||||
| 交付与研发治理 | 发布包形态、部署方式、CI/CD、测试、安全扫描、SLO、复盘、容量治理 | 单个技术组件的 API 设计 |
|
||||
|
||||
业务编号服务的归类要特别明确:它属于应用组件,因为编号格式和业务规则强相关;底层可以使用 MySQL 行锁、号段、Redis 原子递增或分布式 ID 来保证并发安全,但不应把 Snowflake 或数据库主键直接暴露给用户作为业务编号。
|
||||
|
||||
## 企业级缺口全景
|
||||
|
||||
从“能开发业务”升级到“能长期生产运行”,缺口不只在代码组件,还包括交付、治理、安全、数据和运维流程。
|
||||
|
||||
| 大类 | 企业级通常需要 | EasyNextAdmin 当前状态 | 主要缺口 | 建议优先级 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 应用基础能力 | 统一响应、异常、校验、权限、数据权限、审计、文件、消息、任务、工作流 | 大部分已具备 | 组件使用规范还不够体系化,例如限流、幂等、锁、任务、Outbox 的接入文档和反例 | P1 |
|
||||
| 业务支撑能力 | 业务编号、流程单号、导入导出规范、通知模板、业务事件、批处理 | 部分具备 | 业务编号生成器和轻量批处理治理已具备基础版,批处理已有本地消息重试 worker 示例;仍缺消息模板和统一业务事件模型 | P1 |
|
||||
| 分布式基础能力 | 分布式 ID、缓存、锁、限流、幂等、消息队列、远程调用、熔断、最终一致性 | 部分具备 | 主键分布式 ID 够用;Outbox 缺退避和死信;熔断/重试策略偏基础 | P0/P1 |
|
||||
| 数据库治理 | 迁移、索引、慢 SQL、容量、备份恢复、归档、冷热数据 | 部分具备 | 缺审计/API/任务/Outbox 高增长治理、清理归档任务、大表变更 runbook | P0 |
|
||||
| 缓存治理 | TTL、命名缓存、穿透/击穿/雪崩防护、热点 key、缓存一致性 | 部分具备 | 缺缓存模式文档、热点 key 治理、缓存失效一致性规范 | P1 |
|
||||
| 可观测性 | metrics、logs、traces、events、profiles、前端 RUM、告警、面板 | 部分具备 | 缺结构化日志、OpenSearch/SLS/Loki 接入、前端事件上报、面板和告警规则 | P0/P1 |
|
||||
| 稳定性治理 | SLO、错误预算、限流降级、熔断隔离、容量水位、故障复盘 | 部分具备 | 缺项目级 SLO 模板、burn rate 告警、复盘模板和演练制度 | P1 |
|
||||
| 安全治理 | Cookie 会话、CSRF、密钥管理、漏洞扫描、审计合规、最小权限 | 部分具备 | 当前生产会话路线仍需 HttpOnly Cookie + CSRF;缺 Secret 管理、安全扫描和权限审批 | P0/P1 |
|
||||
| 部署交付 | 传统部署包、单机容器包、K8s 清单、发布检查、回滚、灰度 | 部分具备 | 缺三套主交付包:裸机/VM、单机 Docker Compose、K8s;Docker Swarm 只做兼容说明 | P0 |
|
||||
| CI/CD | 构建、测试、制品、环境审批、发布记录、自动回滚 | 部分具备 | 只有 CI,缺制品库、部署流水线、环境审批、发布事件和回滚自动化 | P1 |
|
||||
| 配置治理 | 环境分层、配置中心、动态配置、灰度配置、配置审计 | 部分具备 | 当前靠配置文件和环境变量;缺配置中心接入规范、配置变更审计 | P2 |
|
||||
| 日志和审计留存 | 本地日志、集中日志、索引、保留周期、归档、合规查询 | 部分具备 | 缺日志索引模板、审计归档策略、保留周期落地任务 | P0/P1 |
|
||||
| 灾备恢复 | RTO/RPO、备份、恢复演练、多机房、对象存储版本 | 缺口较多 | 缺恢复 runbook、备份校验、演练计划和数据恢复验证 | P1 |
|
||||
| 多租户和组织隔离 | 租户隔离、租户配置、租户数据边界、租户审计 | 不作为当前默认能力 | 当前是单企业/组织数据权限模型,不是 SaaS 多租户 | P2,只有做 SaaS 时才需要 |
|
||||
| 国际化和本地化 | 多语言、时区、币种、区域格式 | 不作为当前默认能力 | 项目中文企业后台定位明确,不需要默认做 i18n | P3 |
|
||||
| 成本治理 | 日志/指标成本、存储生命周期、资源配额、容量趋势 | 部分具备 | 缺观测成本预算、存储生命周期和资源用量面板 | P2 |
|
||||
|
||||
当前最核心的判断:
|
||||
|
||||
- 不是缺“权限、审计、缓存、任务、消息”这些应用基础组件,这些已经有基线。
|
||||
- 真正缺的是生产化闭环:交付包、发布回滚、增长治理、结构化日志、告警面板、Outbox 死信、安全会话和灾备恢复。
|
||||
- 分布式平台能力不要一次性全内置。没有 K8s 的企业先做好传统部署包和单机容器包;有 K8s 的企业再提供云原生部署包和平台接入模板。
|
||||
|
||||
## 企业级应用组件
|
||||
|
||||
这些组件通常属于业务应用自身,应尽量在脚手架里提供清晰基线。
|
||||
|
||||
| 组件域 | 企业级常见能力 | 当前状态 | EasyNextAdmin 现有落点 | 缺口和建议 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 统一响应和错误码 | 统一响应结构、业务错误码、参数校验错误详情、全局异常处理 | 已具备 | `Response`、`PageResponse`、`ErrorCode`、`GlobalExceptionHandler` | 可继续补错误码分层文档和前端错误码处理规范 |
|
||||
| API 契约文档 | OpenAPI、接口分组、调试入口、生产关闭策略 | 已具备 | `springdoc-openapi`、`OpenApiConfig`、`ApiDocsView` | 可补接口变更兼容策略和契约测试 |
|
||||
| API 版本和兼容 | URL / Header 版本、废弃策略、兼容窗口、契约变更记录 | 缺口 | 当前接口仍按单版本演进 | P2:公开接口或多客户端接入后再补版本策略;内部后台优先保持兼容字段和契约测试 |
|
||||
| 认证会话 | 登录、退出、会话恢复、在线会话、服务端撤销、会话超时 | 已具备 | `EasyAuthService`、`EasyAuthFilter`、`AuthSessionStore`、在线用户页 | 生产路线仍建议迁移 HttpOnly Cookie + CSRF,当前 Bearer token 仅适合开发调试 |
|
||||
| 登录风控 | 验证码、登录失败次数、账号锁定、异常 IP/设备提醒 | 部分具备 | 登录验证码、登录限流、登录审计 | P1:账号锁定策略、异常登录提醒、可信设备和登录风险规则 |
|
||||
| 账号生命周期 | 开户、离职停用、密码策略、重置密码、会话踢下线 | 部分具备 | 用户启停、重置密码、个人改密、在线用户会话撤销 | P1:补密码复杂度/有效期、离职自动停用、账号锁定和账号状态变更审计 |
|
||||
| MFA / 二次确认 | 登录二次认证、高危操作再确认、OTP/短信/企业微信验证 | 缺口 | 文档中已建议高危动作可叠加二次确认,代码未实现 MFA | P2:公网后台、高权限后台或合规场景再接入,不作为默认登录复杂度 |
|
||||
| 组织用户模型 | 用户、部门、角色、直属上级、部门负责人、岗位/职级 | 部分具备 | `module.system`、用户、角色、部门、直属上级、部门负责人 | 缺岗位、职级、职务、岗位授权和组织变更历史;非所有企业都需要默认内置 |
|
||||
| 多租户隔离 | 租户、租户配置、租户数据边界、租户审计 | 不建议默认内置 | 当前是单企业组织权限和数据权限模型 | 只有做 SaaS 或集团多法人隔离时才作为 P2/P3 扩展 |
|
||||
| 权限控制 | 页面权限、按钮权限、接口权限、超级管理员、权限版本 | 已具备 | `sys_menu`、`@EasyPermission`、`EasyPermissions`、`v-permission`、`PermissionVersionService` | 可补更细的权限变更审计和授权审批流程 |
|
||||
| 数据权限 | 部门范围、本人范围、自定义部门、SQL 拦截、查询收口 | 已具备 | `@DataScope`、`EasyDataScopeInnerInterceptor`、`EasyDataScopeContext` | 可继续补跨模块数据权限接入检查清单 |
|
||||
| 审计 | 登录、操作、异常、接口访问、敏感变更、审计可见性 | 已具备 | `@EasyAudit`、`AuditLogCollector`、`SensitiveAuditService`、`module.audit` | P2:保留周期、清理任务、冷归档、审计报表 |
|
||||
| API 访问日志 | 入口请求、traceId、耗时、状态、请求/响应摘要 | 已具备 | `@EasyApiAccessLog`、`EasyApiAccessLogAspect`、`audit_api_log` | P2:高增长治理和默认查询时间范围 |
|
||||
| 数据生命周期 | 热数据保留、冷归档、清理任务、恢复验证 | 缺口 | 当前主要在观测和稳定性文档中提出治理要求 | P0/P1:先覆盖审计、API 日志、任务日志、Outbox,再扩展到业务大表 |
|
||||
| 数据分类分级 | 普通数据、敏感数据、核心数据、外发审批、访问审计 | 部分具备 | 脱敏、数据权限、敏感变更审计已有基础 | P1/P2:补字段分级清单、导出审批、敏感查询审计和外发规则 |
|
||||
| 脱敏 | DTO 字段脱敏、日志/审计文本脱敏、敏感字段集中维护 | 已具备 | `@EasyMask`、`EasySensitiveDataMasker` | 可补字段分级、导出脱敏策略和安全测试样例 |
|
||||
| 敏感数据加密 | 数据库存储加密、密钥轮换、按字段解密、最小可见范围 | 缺口 | 当前具备密码哈希和输出脱敏,不提供业务字段加密组件 | P2:涉及证件号、银行卡、合同密钥等高敏字段时再引入字段加密和 KMS |
|
||||
| 参数校验 | Bean Validation、业务异常、分页参数白名单 | 已具备 | `spring-boot-starter-validation`、`PageRequestArgumentResolver`、`@PageQuery` | 可补文件上传、导入参数的统一错误详情规范 |
|
||||
| JSON 编解码 | ObjectMapper 统一配置、异常收口 | 已具备 | `EasyJsonCodec`、`EasyJsonException` | 已满足当前脚手架需要 |
|
||||
| 系统参数 | 应用内可维护参数、开关、阈值、业务配置 | 缺口 | 当前主要使用配置文件、环境变量和数据库业务表 | P1:只做应用内系统参数,不做配置中心;敏感参数仍走 Secret/环境变量 |
|
||||
| 功能开关 | 能力开关、业务开关、灰度开关、开关审计 | 部分具备 | `easy.features.*` 支持基础设施能力开关 | P2:业务灰度和动态开关可在系统参数基础上扩展,不直接做复杂 feature flag 平台 |
|
||||
| 数据字典 | 状态、枚举、业务选项、前端展示标签 | 缺口 | 当前多由枚举、常量或业务表承担 | P1:补轻量字典组件,注意不要把权限、菜单、流程配置塞进字典 |
|
||||
| 业务编号 | 申请单号、工单号、采购单号、按日流水号、可读短码 | 基础具备 | `BusinessNumberService`、`biz_number_rule`、`biz_number_sequence`,请假/采购/报修已接入;后台有编号规则页 | 后续可补号段预分配、规则变更审批和编号占用/回收审计 |
|
||||
| 文件中心 | 上传、下载、预览、鉴权、MIME/扩展名/文件头校验 | 已具备 | `SysFileController`、`EasyStorageFacade`、本地存储、Aliyun OSS 可选 | P1:病毒扫描、敏感内容检测、对象存储生命周期 |
|
||||
| 导入导出 | CSV 模板、导入校验、导出防公式注入 | 部分具备 | 用户导入导出 | 可抽取轻量导入导出规范,但不建议做通用导入导出中心 |
|
||||
| 批处理治理 | 长任务进度、取消、失败明细、item 级断点续跑、分页/游标、失败策略、周期业务键、动态分区和租约接管 | 基础具备 | `BatchTaskService`、`EasyBatchRunner`、两张治理表和任务中心页面;`businessKey + runNo` 保留不可变执行历史,准备者一次登记持久化 `input_json`,Worker 通过 ItemDecoder、Claim/Lease、自动续租和失效接管处理;采购状态日终对账已真实接入;设计见 `docs/development/easy-batch-design.md` | P1:补结果文件、超长分区 checkpoint 和归档策略 |
|
||||
| 缓存 | 命名缓存、TTL、容量、事务感知、监控 | 已具备 | `EasyCacheConfig`、Caffeine、Redisson Cache、缓存监控页 | P1:缓存击穿/穿透策略文档、热点 key 治理 |
|
||||
| 幂等 | 写接口幂等、重试防重 | 已具备 | `@Idempotent`、`IdempotentAspect` | P1:分布式幂等存储和业务幂等 key 规范 |
|
||||
| 重复提交保护 | 短时间重复点击、表单重复提交 | 已具备 | `@EasyDuplicateRequestLimiter` | 与幂等边界已区分,继续保持轻量 |
|
||||
| 限流 | IP/用户/全局限流、登录/验证码保护、Redis fallback | 已具备 | `@EasyRateLimit`、`EasyRateLimiterAspect`、`InMemoryRateLimiter`、`RedissonRateLimiter` | P1:429 响应标准、`Retry-After`、后台配置化策略 |
|
||||
| 锁封装 | MySQL/Redis 锁、跨实例互斥 | 已具备 | `IEasyLocker`、`MysqlEasyLocker`、`RedisEasyLocker` | P1:锁超时、续期、可观测指标和使用规范 |
|
||||
| 事务和最终一致性 | 本地事务、Outbox、本地消息重试 | 部分具备 | `EasyLocalMessageTemplate`、`LocalMessageRetryJob` | P0/P1:退避策略、人工处理页、积压告警、消息幂等消费 |
|
||||
| 定时任务 | 任务声明、数据库启停、Cron、实例心跳、集群同步、单实例/广播执行、日志和慢任务 Trace Tree | 已具备 | `@EasyJob`、`JobExecutionMode`、`ScheduleJobManager`、`ScheduleInstanceRegistry`、`ScheduleJobLogCallback`;广播用于唤醒 Batch Worker,Job 层不静态切业务数据;设计见 `docs/development/easy-job-design.md` | P1:错过执行补偿、失败告警和启动恢复;复杂分片调度仍外接成熟平台 |
|
||||
| 轻量工作流 | 流程定义、审批任务、抄送、消息联动、实例监控 | 已具备 | `module.workflow`、LogicFlow 前端 | 保持轻量,不演进成完整 BPM 引擎 |
|
||||
| 消息中心 | 站内消息、未读数、已读、业务跳转 | 已具备 | `module.message`、`MessageCenterView` | 可补消息模板、渠道扩展,但不建议内置短信/邮件平台 |
|
||||
| 通知模板 | 站内信、邮件、短信、企微/钉钉、Webhook 的模板和变量 | 缺口 | 当前以站内消息为主 | P1/P2:先抽通知模板和发送端口,具体短信/企微/钉钉实现按企业接入 |
|
||||
| Webhook / 开放集成 | 出站 Webhook、第三方应用凭证、签名、重放保护、调用审计 | 缺口 | 当前有 Feign/Kafka 基础设施,但没有开放集成模型 | P2:企业要对接 OA、ERP、工单、企微/钉钉时再沉淀统一集成端口 |
|
||||
| 业务事件 | 领域事件、业务状态变更、事件发布、事件订阅、事件审计 | 部分具备 | 工作流事件、本地消息、Kafka 基础设施 | P1:沉淀统一业务事件模型,区分审计事件、消息事件和集成事件 |
|
||||
| 报表 | 固定纸质报表、打印 | 已具备 | `module.report` | 不建议内置 BI 或拖拽报表平台 |
|
||||
| 运行监控页 | JVM、CPU、内存、磁盘、缓存、在线用户、WebLog | 已具备 | `module.monitor`、Actuator、WebLog | 边界是应用内排障,不替代 Grafana/APM/日志平台 |
|
||||
| 业务指标 | API、远程调用、限流、调度、Outbox 指标 | 已具备 | `EasyBusinessMetrics`、Micrometer、Influx registry | P1:指标字典、面板模板、SLO 绑定 |
|
||||
| 前端观测 | Vue 错误、Promise rejection、路由错误、API 失败事件 | 部分具备 | `src/features/observability/events.ts` | P1:事件上报接口、RUM 性能指标、采样和隐私策略 |
|
||||
| Trace / MDC | `X-Trace-Id`、日志 MDC、Kafka/Feign/线程池透传、本地 Trace Tree | 部分具备 | `EasyTraceIdFilter`、`TraceContext`、`@EasyTrace` | P1:W3C Trace Context / OpenTelemetry 可选接入;当前按项目决策先不动 |
|
||||
| 结构化日志 | 本地日志、集中检索字段、OpenSearch/SLS/Loki 采集 | 部分具备 | `logback.xml` 文本日志、MDC、WebLog | P1:生产 JSON appender 或采集器解析配置 |
|
||||
| 安全响应头和 CORS | CSP、HSTS、CORS 白名单、WAF 参数过滤 | 部分具备 | `WafFilter`、`EasyCorsFilter`、`easy.web.security-headers` | P1:CSRF、CSP 收紧、生产 Cookie 会话改造 |
|
||||
|
||||
## 企业级分布式组件
|
||||
|
||||
这些组件支撑多实例、多服务和跨进程协作。脚手架应提供接入边界、默认关闭和本地 fallback,而不是把平台本身做进项目。
|
||||
|
||||
| 分布式组件 | 企业级用途 | 当前状态 | EasyNextAdmin 现有落点 | 缺口和建议 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| MySQL | 关系数据、事务、审计、配置、任务、流程 | 已具备 | MyBatis-Plus、Flyway、MySQL 8.4 本地依赖 | 生产高可用、备份恢复、读写分离由部署环境提供 |
|
||||
| Flyway | 数据库版本管理、可重复部署 | 已具备 | `db/migration`、H2 测试迁移 | 可补迁移回滚策略和大表变更规范 |
|
||||
| Redis | 会话、缓存、验证码、限流、幂等、锁 | 可选具备 | `easy.features.redis`、Redisson、Spring Data Redis | 生产 Redis 高可用、持久化、监控和容量治理由外部提供 |
|
||||
| 消息队列 | 异步事件、削峰、跨服务通知、最终一致性 | 可选具备 | `easy.features.kafka`、Kafka Producer/Consumer、Topic、健康检查、trace 透传 | 当前以 Kafka 为样板;RabbitMQ/RocketMQ 不默认内置,按企业现有技术栈扩展 |
|
||||
| OpenFeign | 服务间 HTTP 调用 | 已具备 | `EasyFeignConfig`、`feign-hc5`、`feign-micrometer` | 缺真实业务 Feign 样例和调用降级策略模板 |
|
||||
| Resilience4j | 熔断、隔离、超时、重试治理 | 部分具备 | `spring-cloud-starter-circuitbreaker-resilience4j`、`EasyCircuitBreakerConfig` | 当前配置偏基础,缺按依赖分级的超时/重试/熔断模板 |
|
||||
| 对象存储 | 文件外部化、静态资源、归档 | 可选具备 | 本地存储、Aliyun OSS 可选 | 可增加 S3/MinIO 抽象实现和生命周期文档 |
|
||||
| 配置中心 | 动态配置、灰度配置、统一密钥引用 | 缺口 | 当前使用 Spring 配置文件、环境变量、启动参数 | 中小企业可先用环境变量;多环境多服务后再接 Nacos/Apollo/Spring Cloud Config |
|
||||
| 服务注册发现 | 服务定位、健康摘除 | 缺口 | 当前单体优先,无注册中心 | 单体后台不急需;微服务拆分后再接 Nacos/Consul/Eureka 或 K8s Service |
|
||||
| API 网关 | 统一入口、认证前置、限流、路由、灰度 | 缺口 | 当前由 Nginx/前端代理承担基础反代 | 生产建议用 Nginx/Ingress/API Gateway;不建议项目内置网关 |
|
||||
| 分布式任务调度平台 | 多实例任务协调、调度实例心跳、集群启停同步、广播、分片、补偿和任务治理 | 部分具备 | 自研轻量 `ScheduleJobManager` 已支持心跳、DB 目标状态同步、单实例锁和广播执行;Easy Batch 负责共享数据分区 Claim/Lease。misfire、DAG 和调度中心未内置 | 如任务规模变大,可外接 XXL-JOB、PowerJob 或云调度;脚手架保留轻量任务 |
|
||||
| 分布式事务 | TCC/Saga/事务消息/补偿 | 部分具备 | 本地事务 + Outbox | 不建议引入 Seata 作为默认;按业务选择 Outbox/Saga/TCC |
|
||||
| 搜索引擎 | 全文检索、复杂查询、日志检索 | 缺口 | 无业务搜索引擎 | 后台 CRUD 先用 MySQL;日志检索交给 OpenSearch/SLS |
|
||||
| 分布式 ID | 表主键全局唯一、跨实例写入不冲突 | 部分具备 | MyBatis-Plus `ASSIGN_ID` 已用于实体主键;`EasyIdGenerator` 提供非业务主键的 UUID 工具 | 主键层够用;不要把 Snowflake 或主键直接暴露为业务编号,业务编号由应用组件负责 |
|
||||
| CDC / 数据同步 | 数据库变更订阅、异构同步、搜索索引增量更新 | 缺口 | 当前无 CDC 组件 | 只有需要数据湖、搜索索引或跨系统同步时再接 Debezium、Canal 或云 CDC |
|
||||
| Service Mesh | mTLS、流量治理、透明重试、链路 | 不建议内置 | 无 | 服务数量较少时不建议引入 |
|
||||
|
||||
## 企业级平台支撑组件
|
||||
|
||||
这些能力通常由企业平台、云服务或运维体系提供。EasyNextAdmin 不应内置平台本身,但要保持清晰接入要求。
|
||||
|
||||
| 平台组件 | 企业级用途 | 当前状态 | EasyNextAdmin 现有落点 | 缺口和建议 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 负载均衡 / 反向代理 | HTTPS 入口、反向代理、健康摘除、基础限流 | 部分具备 | Nginx 示例、前端 Nginx 配置、Actuator 健康检查 | 裸机/VM 走 Nginx;K8s 走 Ingress;应用不内置网关 |
|
||||
| DNS / 域名治理 | 内外网域名、环境域名、灰度域名、TTL 管理 | 缺口 | 当前只在部署文档中说明前端域名和 CORS | 由企业 DNS 或云解析提供,项目只要求配置真实 Origin 和回调地址 |
|
||||
| TLS 证书 / PKI | HTTPS 证书、证书轮换、内网 CA、mTLS 基础 | 缺口 | 应用提供安全响应头和 HTTPS 场景配置提示 | 裸机用 Nginx 证书;K8s 用 Ingress/cert-manager;应用不管理证书生命周期 |
|
||||
| 容器编排 | 扩缩容、滚动发布、健康探针 | 不建议内置 | Dockerfile、健康检查、部署文档 | Kubernetes、Helm、Argo CD 由部署层提供 |
|
||||
| 制品库 / 镜像仓库 | JAR、前端包、容器镜像、版本保留、镜像扫描 | 缺口 | 当前只有本地构建产物和 Dockerfile | 企业交付需要 Nexus/Artifactory/Harbor/云镜像仓库,发布记录固定版本和 digest |
|
||||
| Secret 管理 | 密钥托管、轮换、审计 | 缺口 | 当前通过环境变量/启动参数 | 生产使用 K8s Secret、Vault、云 KMS 或配置中心 Secret |
|
||||
| 指标后端 | 趋势、面板、容量、告警 | 可选具备 | Micrometer + `micrometer-registry-influx` | 缺 Grafana 面板、告警规则、Prometheus/OTLP 可选出口 |
|
||||
| 告警和事件平台 | 告警规则、通知路由、值班、升级、静默、告警审计 | 缺口 | 当前只有应用指标和基线文档,没有告警平台模板 | 中小企业可先 Grafana Alerting/云监控;成熟团队接 Alertmanager、PagerDuty 或企业 IM |
|
||||
| 集中日志 | OpenSearch/Elasticsearch/SLS/Loki 检索和告警 | 缺口 | 当前只有本地 logback 文件和 WebLog | P1:JSON 日志或采集器解析字段、索引模板、保留策略 |
|
||||
| APM / Trace 后端 | 跨服务 trace、服务拓扑、采样分析 | 缺口 | 当前是自研 `X-Trace-Id` + 本地 Trace Tree | P1/P2:OpenTelemetry SDK/Agent + Collector 可选,不强制内置 |
|
||||
| 备份恢复平台 | 数据库备份、对象存储备份、日志归档、恢复演练 | 缺口 | 文档已有备份恢复要求,代码不负责平台能力 | 由 DBA、云数据库、对象存储和备份平台承载,项目提供表增长和恢复 runbook |
|
||||
| 成本治理平台 | 资源用量、日志成本、指标基数、对象存储生命周期 | 缺口 | 稳定性和观测基线已有治理原则 | P2:先通过容量巡检和保留周期控制成本,成熟后接云成本或 FinOps 工具 |
|
||||
|
||||
## 企业内部交付分层
|
||||
|
||||
企业内部部署建议按运行平台分成三套主线,再给 Docker Swarm 一个兼容说明。不要把交付包、发布流程、监控平台和运维制度混在一个“部署文档”里。
|
||||
|
||||
| 形态 | 建议维护级别 | 适合场景 | 交付物 | 发布和回滚 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 裸机/VM 传统包 | 主线一 | 没有 Docker/K8s 平台,只有 Linux、Nginx、MySQL、Redis | 后端 JAR、前端 `dist`、Nginx 配置、systemd 服务、配置样例、发布/回滚 runbook | 版本目录 + `current` 软链切换;systemd 重启;数据库迁移先备份 |
|
||||
| 单机 Docker Compose 包 | 主线二 | 有 Docker,但没有集群编排;适合小企业、演示、私有化单机交付 | 后端镜像、前端镜像、`compose.yaml`、`.env.example`、volume 和日志说明 | 镜像 tag/digest 固定;`docker compose pull/up -d`;回滚到上一镜像 tag |
|
||||
| Docker Swarm 包 | 兼容路线 | 客户已有 Swarm 集群和运维经验,但不准备上 K8s | `docker stack` 示例、secret/config、service update/rollback 说明 | 作为 Compose 包的派生说明维护,不和 K8s 平级投入 |
|
||||
| K8s 云原生包 | 主线三 | 已有 Kubernetes、Ingress、Secret/ConfigMap、镜像仓库和运维平台 | 后端镜像、前端镜像、Helm 或 Kustomize、Deployment、Service、Ingress、探针、资源配额 | 使用镜像 tag/digest 回滚;readiness 摘流;数据库变更走 expand/migrate/contract |
|
||||
|
||||
四种形态的共同原则:
|
||||
|
||||
- 都使用 `prod` profile,不把 `local` profile 带到生产。
|
||||
- 都不把 MySQL、Redis、对象存储密钥写进代码、镜像或前端产物。
|
||||
- 都要有发布前检查、发布后检查、回滚步骤和数据备份策略。
|
||||
- 都要把上传文件、审计日志、应用日志、数据库备份纳入恢复计划。
|
||||
- 都应把内置监控页视为应用内排障入口,集中日志、指标和告警由外部平台承载。
|
||||
|
||||
交付、部署、监控和运维迭代应分成四条线维护:
|
||||
|
||||
| 维度 | 关注点 | 建议沉淀物 |
|
||||
| --- | --- | --- |
|
||||
| 交付发布 | 构建、制品、版本号、变更记录、数据库迁移、发布审批、回滚 | 构建流水线、制品清单、发布检查表、回滚 runbook、迁移策略 |
|
||||
| 部署运行 | 进程托管、容器编排、配置注入、密钥注入、健康检查、资源限制 | 裸机/VM、Compose、K8s 三套部署模板,Swarm 兼容说明 |
|
||||
| 监控观测 | 指标、日志、Trace、事件、告警、值班通知、SLO 面板 | 应用内监控页、Influx/Grafana 或 Prometheus 面板、日志索引模板、告警规则 |
|
||||
| 运维迭代 | 备份恢复、容量、证书、补丁、漏洞、故障复盘、演练 | 备份恢复 runbook、容量巡检表、证书轮换计划、安全升级计划、复盘模板 |
|
||||
|
||||
### 传统内网部署包
|
||||
|
||||
建议沉淀这些文件和模板:
|
||||
|
||||
```text
|
||||
release/
|
||||
├── backend/easyNextAdmin.jar
|
||||
├── frontend/dist/
|
||||
├── nginx/easy-next-admin.conf
|
||||
├── systemd/easy-next-admin.service
|
||||
├── config/application-prod.example.yaml
|
||||
├── runbook/deploy-traditional.md
|
||||
└── runbook/rollback-traditional.md
|
||||
```
|
||||
|
||||
关键要求:
|
||||
|
||||
- 后端 JAR 和前端 `dist` 都带版本目录,例如 `/opt/easy-next-admin/releases/2026-06-25-001`。
|
||||
- `/opt/easy-next-admin/current` 软链指向当前版本,回滚就是切回上一版软链。
|
||||
- systemd 只读取环境文件或外部配置,不在服务文件里写密码。
|
||||
- Nginx 只负责 HTTPS、静态资源、`/api` 反向代理、访问日志和基础限流。
|
||||
- 日志用 logback 文件 + logrotate,集中日志由采集器读取文件。
|
||||
|
||||
### 单机 Docker Compose 包
|
||||
|
||||
建议沉淀这些文件和模板:
|
||||
|
||||
```text
|
||||
deploy/compose/
|
||||
├── compose.yaml
|
||||
├── compose.prod.yaml
|
||||
├── .env.example
|
||||
├── nginx/
|
||||
├── runbook/deploy-compose.md
|
||||
└── runbook/rollback-compose.md
|
||||
```
|
||||
|
||||
关键要求:
|
||||
|
||||
- Compose 包只面向单机或小规模私有化交付,不承诺跨节点高可用。
|
||||
- MySQL、Redis 可以在演示环境跟随 Compose 启动,生产建议仍使用外部数据库和 Redis。
|
||||
- 后端、前端镜像使用明确 tag 或 digest,不能使用 `latest` 作为发布记录。
|
||||
- volume、上传文件、日志目录必须有备份说明。
|
||||
- 回滚优先切回上一镜像 tag;数据库迁移仍按兼容迁移处理。
|
||||
|
||||
### Docker Swarm 兼容说明
|
||||
|
||||
Swarm 不作为 EasyNextAdmin 的主交付路线,但可以给已有 Swarm 团队提供兼容说明:
|
||||
|
||||
- 复用 Compose 包的镜像、环境变量和 Secret 命名。
|
||||
- 用 `docker stack deploy` 管理服务,用 `docker service update` 和 `docker service rollback` 做更新和回滚。
|
||||
- 明确 manager/worker 节点、资源约束、持久化卷、滚动更新和健康检查要求。
|
||||
- 不为 Swarm 单独维护完整平台能力文档;新增治理能力优先落到 Compose 和 K8s。
|
||||
|
||||
### K8s 云原生部署包
|
||||
|
||||
建议沉淀这些文件和模板:
|
||||
|
||||
```text
|
||||
deploy/k8s/
|
||||
├── base/
|
||||
│ ├── backend-deployment.yaml
|
||||
│ ├── frontend-deployment.yaml
|
||||
│ ├── service.yaml
|
||||
│ ├── ingress.yaml
|
||||
│ ├── configmap.yaml
|
||||
│ └── secret.example.yaml
|
||||
├── overlays/prod/
|
||||
└── runbook/
|
||||
```
|
||||
|
||||
关键要求:
|
||||
|
||||
- 后端和前端镜像分开构建、分开发布,镜像使用明确 tag,生产发布记录 digest。
|
||||
- 后端配置走 ConfigMap,密码、Token、对象存储密钥走 Secret 或外部 Secret 管理。
|
||||
- `/actuator/health/liveness` 用于 liveness,`/actuator/health/readiness` 用于 readiness。
|
||||
- 设置 CPU/内存 requests 和 limits,避免单个后台服务拖垮节点。
|
||||
- 使用 rolling update,readiness 未通过前不接流量。
|
||||
- 上传文件优先外置对象存储;如果使用本地存储,必须明确 PVC 和备份策略。
|
||||
- 日志输出到 stdout 或文件采集二选一,平台侧统一进入 OpenSearch/SLS/Loki。
|
||||
|
||||
## 研发治理组件
|
||||
|
||||
这些不是运行时中间件,但企业级开发长期离不开。
|
||||
|
||||
| 能力 | 当前状态 | 现有落点 | 建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| 自动化测试 | 部分具备 | 后端 JUnit、前端 Vitest、CI | 增加接口契约测试、关键业务集成测试和迁移测试 |
|
||||
| 代码质量 | 部分具备 | 架构测试、TypeScript、Maven 编译 | 可补 Checkstyle/Spotless/ESLint 统一格式和静态扫描 |
|
||||
| 安全扫描 | 缺口 | `SECURITY.md` | 可接 Dependabot、CodeQL、SCA、镜像扫描 |
|
||||
| 依赖/SBOM/License 治理 | 缺口 | Maven、npm 依赖清单 | 可补 SBOM 生成、开源许可证检查、依赖准入和高危漏洞阻断 |
|
||||
| 文档治理 | 已具备 | `docs/`、开发基线文档 | 后续新增组件必须同步“当前能力”和“路线图”边界 |
|
||||
| 发布治理 | 部分具备 | `docs/deployment.md`、Dockerfile、CI | 补发布检查表、回滚步骤、变更窗口、发布事件 |
|
||||
| SLO / 复盘 | 部分具备 | `stability-baseline.md` | 需要落到核心指标、告警规则和故障复盘模板 |
|
||||
| 容量治理 | 部分具备 | 缓存监控、系统监控、稳定性基线 | 补数据库增长、日志增长、连接池和线程池容量面板 |
|
||||
|
||||
## 当前最重要缺口
|
||||
|
||||
按 EasyNextAdmin 的定位,优先补这些,不建议先堆大型平台。
|
||||
|
||||
| 优先级 | 缺口 | 原因 | 建议落点 |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 | 审计/API/任务/Outbox 高增长治理 | 直接影响数据库容量、查询性能、备份恢复 | 默认时间范围、索引复核、清理任务、冷归档文档 |
|
||||
| P0 | Outbox 退避、死信和人工处理 | 当前最终一致性闭环还不完整 | 本地消息状态页、最大重试后的人工处理、积压指标告警 |
|
||||
| P0 | 三套交付包和 Swarm 兼容说明 | 内网企业交付差异主要来自运行平台,不应混成一个部署说明 | 裸机/VM、单机 Compose、K8s 三套模板;Swarm 只做兼容说明 |
|
||||
| P0 | 指标字典和面板模板 | 已有 Micrometer 指标,但缺统一面板沉淀 | `docs/components/observability/metrics.md`、Influx/Grafana 样例 |
|
||||
| P1 | 数据分类分级和导出治理 | 脱敏只解决展示问题,导出、查询和外发还需要数据级别规则 | 字段分级清单、导出审批、敏感查询审计、外发规则 |
|
||||
| P1 | 生产结构化日志 | OpenSearch/SLS/Loki 接入需要结构化字段 | JSON appender 或采集器解析方案、索引字段规范 |
|
||||
| P1 | 前端事件上报 | 当前前端事件仅本地缓冲,不能集中分析 | 前端事件上报 API、采样、隐私字段过滤 |
|
||||
| P1 | 限流/幂等/锁治理文档 | 这些组件有了,但误用会造成业务问题 | 组件文档、接入示例、反例和测试模板 |
|
||||
| P1 | 生产会话安全方案 | Bearer token + localStorage 不应作为公开生产方案 | HttpOnly Cookie、CSRF、防重放、会话轮换 |
|
||||
| P1 | 发布和回滚模板 | 企业交付需要可重复流程 | 发布检查表、回滚 runbook、数据库变更策略 |
|
||||
| P1 | 批处理治理增强 | 已有任务/明细/取消、不可变补跑、分页/游标、持久化分区、采购对账样例、多实例 Claim/Lease、自动续租和接管,仍缺结果文件和超长分区 checkpoint | 用户导入或报表生成继续接入批处理,补结果文件和历史归档策略 |
|
||||
| P2 | 敏感数据加密 | 脱敏解决展示和日志问题,不等于数据库高敏字段加密 | 字段加密组件、密钥版本、KMS 接入、解密审计 |
|
||||
| P2 | Webhook / 开放集成 | 企业对接 OA、ERP、工单和企微/钉钉时需要统一凭证、签名和审计 | 出站 Webhook、API Key、签名验签、重放保护、调用审计 |
|
||||
| P2 | CDC / 数据同步 | 搜索索引、数据湖或异构同步需要变更订阅,不应靠业务表轮询 | Debezium、Canal、云 CDC 接入说明,默认不内置 |
|
||||
| P2 | OpenTelemetry 可选接入 | 服务增多后需要标准 trace 和 collector | 保持可选,不影响现有 Trace Tree |
|
||||
| P2 | 配置中心接入 | 多环境多实例后才需要 | 先定义配置分层和 Secret 规则,再选 Nacos/Apollo/云配置 |
|
||||
| P2 | 搜索/日志平台接入样例 | 业务搜索和日志检索是不同问题 | 业务搜索按需,日志检索走 OpenSearch/SLS/Loki |
|
||||
|
||||
## 不建议默认内置的组件
|
||||
|
||||
这些组件企业里常见,但不适合作为 EasyNextAdmin 默认能力:
|
||||
|
||||
- API 网关:由 Nginx、Ingress、Spring Cloud Gateway 或云网关提供。
|
||||
- Kubernetes / Helm / Argo CD:由部署平台提供,项目保留 Dockerfile 和健康检查即可。
|
||||
- Service Mesh:服务数量少时收益低、复杂度高。
|
||||
- BI 平台:与本项目“企业后台脚手架”定位冲突。
|
||||
- 完整 BPM 引擎:当前轻量流程足够二开,不把项目做成 Flowable/Camunda 替代品。
|
||||
- 统一日志平台:项目提供结构化输出和 traceId,OpenSearch/SLS/Loki 由企业部署。
|
||||
- APM SaaS:项目保持 OpenTelemetry/Micrometer 接入边界,不绑定厂商。
|
||||
- 通用低代码扩展中心:容易稀释真实后台能力,不作为产品 UI 暴露。
|
||||
|
||||
## 推荐演进路线
|
||||
|
||||
### 第一阶段:应用内组件闭环
|
||||
|
||||
- 审计/API 日志增长治理。
|
||||
- Outbox 死信和人工处理。
|
||||
- 批处理治理增强。
|
||||
- 指标字典和 Influx/Grafana 面板样例。
|
||||
- 限流、幂等、锁、任务的组件文档。
|
||||
|
||||
### 第二阶段:生产观测和发布治理
|
||||
|
||||
- 结构化日志进入 OpenSearch/SLS/Loki。
|
||||
- 前端事件上报。
|
||||
- 发布检查表、回滚 runbook、数据库变更策略。
|
||||
- 数据分类分级、导出审批和敏感查询审计。
|
||||
- 核心 SLO 和告警规则。
|
||||
|
||||
### 第三阶段:按规模接入分布式平台
|
||||
|
||||
- 服务拆分后接配置中心、注册发现、网关。
|
||||
- 多服务链路排障需要时接 OpenTelemetry Collector。
|
||||
- 高异步规模后增强 Kafka 业务事件模型。
|
||||
- 对接搜索、数据湖或异构系统时再引入 CDC。
|
||||
- 大量文件和归档后完善对象存储生命周期。
|
||||
383
docs/development/observability-baseline.md
Normal file
383
docs/development/observability-baseline.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# 可观测性体系蓝图
|
||||
|
||||
本文不是当前实现盘点,而是把可观测性领域的 RFC、开放标准、SRE 方法论和主流厂商实践整理成一套适合中小型企业后台落地的体系。EasyNextAdmin 后续按本文做能力沉淀和缺口扫描。
|
||||
|
||||
## 一句话目标
|
||||
|
||||
可观测性不是“多打日志”或“接一个监控平台”,而是让团队在故障、变慢、越权、数据异常和用户投诉发生时,能用统一证据回答:
|
||||
|
||||
- 发生了什么。
|
||||
- 影响了哪些用户和业务。
|
||||
- 是入口、应用、依赖、数据、网络还是发布变更导致。
|
||||
- 当前是否还在恶化。
|
||||
- 谁需要处理,处理优先级是什么。
|
||||
|
||||
## 设计约束
|
||||
|
||||
面向中小型企业时,体系必须克制:
|
||||
|
||||
- 不默认建设大而全 APM 平台。
|
||||
- 不让团队维护过多中间件。
|
||||
- 不把所有日志、trace 和指标无限期保存。
|
||||
- 不采集高基数、敏感和低价值数据。
|
||||
- 不让告警直接淹没研发和运维。
|
||||
- 优先用开放标准和可替换组件,避免被单一厂商锁死。
|
||||
|
||||
## 标准和方法论
|
||||
|
||||
| 领域 | 标准或资料 | 核心启发 |
|
||||
| --- | --- | --- |
|
||||
| 可观测性定义 | [OpenTelemetry: What is observability](https://opentelemetry.io/docs/what-is-opentelemetry/) | 系统要主动产生 traces、metrics、logs 等遥测数据,再送到观测后端分析。 |
|
||||
| 语义标准 | [OpenTelemetry Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/) | HTTP、DB、Messaging、异常、资源、日志、指标字段应使用统一语义,避免各服务各叫各的。 |
|
||||
| Trace 传播 | [W3C Trace Context](https://www.w3.org/TR/trace-context/) | 跨服务链路传播使用 `traceparent` / `tracestate`,避免私有 header 导致断链。 |
|
||||
| 指标暴露 | [Prometheus Metric and Label Naming](https://prometheus.io/docs/practices/naming/)、[OpenMetrics](https://www.cncf.io/projects/openmetrics/) | 指标名称、单位、标签和低基数设计决定后续能不能聚合、告警和长期存储。 |
|
||||
| 事件格式 | [CloudEvents](https://cloudevents.io/) | 事件数据应有统一元数据,便于跨服务、跨平台传递和路由。 |
|
||||
| 日志传输 | [RFC 5424 Syslog](https://datatracker.ietf.org/doc/html/rfc5424) | 日志进入外部系统时应考虑标准格式、结构化字段和可解析性。 |
|
||||
| 日志安全 | [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) | 明确哪些事件必须记录、哪些数据必须排除,避免安全风险和告警噪声。 |
|
||||
| 日志管理 | [NIST SP 800-92](https://csrc.nist.gov/pubs/sp/800/92/final) | 企业日志管理要覆盖采集、存储、分析、保护、归档、处置和流程。 |
|
||||
| 服务目标 | [Google SRE: Implementing SLOs](https://sre.google/workbook/implementing-slos/) | 观测数据最终应服务于 SLI/SLO、错误预算和稳定性决策。 |
|
||||
|
||||
## 厂商打法
|
||||
|
||||
主流厂商的方向高度一致,但产品形态不同。
|
||||
|
||||
| 厂商或生态 | 典型打法 | 对中小企业的借鉴 |
|
||||
| --- | --- | --- |
|
||||
| AWS Well-Architected | 用 Well-Architected Reliability / Operational Excellence 指导监控、自动化、恢复和持续改进。 | 先定义工作负载关键路径和恢复目标,再补指标、告警和演练,不从工具开始。 |
|
||||
| Azure Well-Architected | Reliability 强调可用性、恢复能力、冗余、容量和设计检查清单。 | 用 checklist 固化设计评审,避免依赖个人经验。 |
|
||||
| Google Cloud Architecture Framework | 把可靠性、运营卓越、自动化、监控、容量和变更治理合在架构框架里。 | 把观测性做成工程流程的一部分,而不是上线后补。 |
|
||||
| OpenTelemetry 生态 | 统一 instrumentation、collector、exporter 和语义约定,后端可接多厂商。 | 应用侧尽量标准化,后端可先开源自建,后续迁移托管平台。 |
|
||||
| Grafana LGTM | Loki 日志、Grafana 可视化、Tempo trace、Mimir/Prometheus 指标组合成开放栈。 | 适合希望自建、成本可控、团队有运维能力的企业。 |
|
||||
| Datadog / New Relic / Honeycomb | SaaS 平台统一 APM、日志、指标、trace、SLO、错误预算和事件关联。 | 借鉴其“服务目录 + SLO + 关联排障”模型,不一定直接购买全套。 |
|
||||
| Elastic Observability | 搜索和日志分析强,逐步扩展到 APM、指标、trace 和安全分析。 | 日志检索和安全审计强需求场景可参考。 |
|
||||
| 阿里云 SLS / ARMS | SLS 统一 logs、metrics、traces、events;ARMS 强调应用监控、链路和业务指标。 | 国内企业常见选择:云上低运维成本,适合先托管后自研沉淀。 |
|
||||
| 腾讯云 TCOP | 指标、链路、日志、事件和告警统一入口,覆盖云资源和自定义监控。 | 适合已在腾讯云上部署的企业,重点借鉴云资源 + 应用统一视图。 |
|
||||
| 华为云 AOM | 一站式指标、trace、日志、事件观测和告警。 | 适合政企或华为云环境,重点借鉴应用、容器、基础设施联动。 |
|
||||
|
||||
厂商共性可以抽象为五层:
|
||||
|
||||
```text
|
||||
采集标准化 -> 传输管道化 -> 存储分层化 -> 分析关联化 -> 告警行动化
|
||||
```
|
||||
|
||||
中小企业不需要一次做满,但不能跳过“标准化”和“行动化”。
|
||||
|
||||
## 信号体系
|
||||
|
||||
### Metrics
|
||||
|
||||
回答“是否异常、趋势如何、是否要告警”。
|
||||
|
||||
优先采集:
|
||||
|
||||
- RED:Rate、Errors、Duration,用于 HTTP/API/远程调用。
|
||||
- USE:Utilization、Saturation、Errors,用于 CPU、内存、磁盘、线程池、连接池。
|
||||
- 业务计数:登录失败、限流命中、任务失败、Outbox 堆积、文件上传失败、审批超时。
|
||||
- SLO 指标:好事件、坏事件、总事件、错误预算消耗。
|
||||
|
||||
设计原则:
|
||||
|
||||
- 只用低基数标签。
|
||||
- 路径用 route template,不用真实 URL。
|
||||
- 时间单位用 seconds,大小用 bytes,计数器用 total 语义。
|
||||
- 不把用户 ID、traceId、手机号、IP 原文、订单号、文件名作为标签。
|
||||
|
||||
EasyNextAdmin 当前指标出口建议:
|
||||
|
||||
- 应用侧统一使用 Micrometer 建模,业务代码只依赖 `MeterRegistry` 和内部指标门面。
|
||||
- InfluxDB 作为当前可选指标后端没有问题,适合中小型团队快速做趋势、容量和面板。
|
||||
- 指标命名、单位和标签必须先标准化;不要因为使用 InfluxDB 就把高基数字段写成 tag。
|
||||
- 后续如果需要 Prometheus 或 OTLP,只新增 registry/exporter,不改业务指标代码。
|
||||
|
||||
### Logs
|
||||
|
||||
回答“具体发生了什么、上下文是什么”。
|
||||
|
||||
日志要分层:
|
||||
|
||||
- 应用运行日志:用于排障。
|
||||
- 安全日志:认证、授权、输入异常、敏感操作。
|
||||
- 审计日志:谁在什么时间改了什么。
|
||||
- 访问日志:请求入口、状态、耗时、客户端信息。
|
||||
|
||||
生产日志建议结构化,最小字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `timestamp` | 统一时间格式和时区 |
|
||||
| `level` | INFO / WARN / ERROR |
|
||||
| `service` | 服务名 |
|
||||
| `env` | 环境 |
|
||||
| `trace_id` | 链路 ID |
|
||||
| `span_id` | 可选 span ID |
|
||||
| `user_id` | 登录后用户 ID,必要时脱敏或哈希 |
|
||||
| `event_type` | 标准事件类型 |
|
||||
| `outcome` | success / failure / denied / timeout |
|
||||
| `message` | 脱敏后的描述 |
|
||||
| `error.type` | 异常类型 |
|
||||
| `error.message` | 脱敏异常摘要 |
|
||||
|
||||
本地日志和集中日志建议分开:
|
||||
|
||||
- 本地文件保留人可读文本,服务 WebLog、现场排障和快速 tail。
|
||||
- 进入 OpenSearch / Elasticsearch / SLS / Loki 的日志使用 JSON appender,或由采集器解析成结构化字段。
|
||||
- 两条链路共享 traceId、userId、eventType、outcome 等字段,但不要强制本地文件也变成难读 JSON。
|
||||
- DEBUG 日志只允许临时开启,必须有权限、审计和自动恢复时间。
|
||||
|
||||
### Traces
|
||||
|
||||
回答“一次请求慢在哪里、跨服务哪里断了”。
|
||||
|
||||
最小要求:
|
||||
|
||||
- HTTP 入口创建或继承 trace。
|
||||
- 出站 HTTP / Feign / RestClient 透传 trace。
|
||||
- 消息生产和消费透传 trace。
|
||||
- 慢请求和错误请求能定位数据库、远程调用、缓存、业务分支。
|
||||
- 支持 W3C `traceparent`,保留业务友好的 `X-Trace-Id`。
|
||||
|
||||
中小企业不必全量采样所有 trace:
|
||||
|
||||
- 错误 100% 保留。
|
||||
- 慢请求 100% 保留。
|
||||
- 普通请求按比例采样。
|
||||
- 高价值业务链路可提高采样率。
|
||||
|
||||
### Events
|
||||
|
||||
回答“发生了哪个业务或系统状态变化”。
|
||||
|
||||
适合用事件表达:
|
||||
|
||||
- 发布开始、发布成功、发布回滚。
|
||||
- 配置变更。
|
||||
- 权限变更。
|
||||
- 任务失败进入人工处理。
|
||||
- Outbox 达到最大重试。
|
||||
- SLO 状态变化。
|
||||
|
||||
内部事件字段可参考 CloudEvents 思路:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 事件 ID |
|
||||
| `source` | 事件来源 |
|
||||
| `type` | 事件类型 |
|
||||
| `subject` | 业务对象 |
|
||||
| `time` | 发生时间 |
|
||||
| `trace_id` | 关联链路 |
|
||||
| `data` | 脱敏业务载荷 |
|
||||
|
||||
Events 的存储不要一刀切:
|
||||
|
||||
- 前端错误、路由失败、资源加载失败、发布事件、SLO 状态变化,优先进入日志/事件平台,例如 OpenSearch、SLS、Loki 或云厂商事件中心。
|
||||
- 强审计事件,例如权限变更、用户状态变更、敏感配置变更,进入审计表,满足查询和合规留存。
|
||||
- 跨服务业务事件,例如订单状态变化、工作流推进、Outbox 最大重试,进入 Outbox / Kafka / 消息系统,保证传递和补偿。
|
||||
- 事件平台里的 event 适合关联和告警;审计表里的 event 适合追责;消息系统里的 event 适合驱动业务。
|
||||
|
||||
### Profiles
|
||||
|
||||
回答“CPU、内存或锁竞争花在哪里”。
|
||||
|
||||
中小企业可以后置:
|
||||
|
||||
- 先做好 metrics、logs、traces。
|
||||
- 只有遇到 CPU 高、内存泄漏、锁竞争、GC 抖动时再引入 profiling。
|
||||
- 如果用 Grafana Pyroscope、Async Profiler 或云厂商 profiler,应默认低开销、按需开启。
|
||||
|
||||
### RUM 和前端观测
|
||||
|
||||
企业后台也需要前端观测,因为很多问题只发生在用户浏览器。
|
||||
|
||||
最小采集:
|
||||
|
||||
- 白屏和 Vue 全局错误。
|
||||
- 未处理 Promise rejection。
|
||||
- Axios 请求失败和最后一个 traceId。
|
||||
- 路由加载失败。
|
||||
- 页面加载耗时、首屏耗时、资源加载失败。
|
||||
|
||||
注意:
|
||||
|
||||
- 不采集表单明文、token、Cookie、完整 URL 查询参数。
|
||||
- traceId 要能和后端审计、日志、API 访问日志关联。
|
||||
|
||||
## 中小企业落地架构
|
||||
|
||||
### L0:单体内网排障型
|
||||
|
||||
适合:小团队、单体应用、内网部署、预算有限。
|
||||
|
||||
能力:
|
||||
|
||||
- 标准文本日志 + traceId。
|
||||
- Actuator health / metrics。
|
||||
- 审计表。
|
||||
- 慢请求日志。
|
||||
- WebLog 只给可信运维角色。
|
||||
|
||||
边界:
|
||||
|
||||
- 只能本地排障。
|
||||
- 不能做长期趋势、统一告警和跨实例分析。
|
||||
|
||||
### L1:低成本开源统一观测型
|
||||
|
||||
适合:1-5 个服务,开始有生产告警和容量问题。
|
||||
|
||||
推荐组合:
|
||||
|
||||
- Prometheus:指标采集。
|
||||
- Grafana:面板。
|
||||
- Loki 或 Elasticsearch:日志。
|
||||
- Jaeger 或 Tempo:trace。
|
||||
- Alertmanager:告警通知。
|
||||
|
||||
关键要求:
|
||||
|
||||
- 指标标签低基数。
|
||||
- 日志结构化。
|
||||
- trace 采样。
|
||||
- 告警只围绕 SLO、错误率、容量和关键依赖。
|
||||
|
||||
### L2:OpenTelemetry 标准化管道型
|
||||
|
||||
适合:服务增多,可能混合云、自建和托管平台。
|
||||
|
||||
推荐:
|
||||
|
||||
- 应用侧使用 OpenTelemetry SDK / Agent。
|
||||
- 中间层使用 OpenTelemetry Collector。
|
||||
- 后端可以是 Grafana、Elastic、Datadog、New Relic、阿里云 SLS / ARMS 等。
|
||||
|
||||
价值:
|
||||
|
||||
- 应用埋点和后端平台解耦。
|
||||
- 统一 resource attributes。
|
||||
- 支持多后端迁移和并行验证。
|
||||
|
||||
### L3:SLO 驱动运营型
|
||||
|
||||
适合:业务已经要求稳定性承诺。
|
||||
|
||||
能力:
|
||||
|
||||
- 服务目录。
|
||||
- SLO / 错误预算。
|
||||
- 多窗口 burn rate 告警。
|
||||
- 发布事件和故障事件关联。
|
||||
- 自动生成复盘数据。
|
||||
- 观测成本治理。
|
||||
|
||||
## 推荐数据模型
|
||||
|
||||
统一资源标签:
|
||||
|
||||
| 字段 | 示例 |
|
||||
| --- | --- |
|
||||
| `service.name` | `easy-next-admin-server` |
|
||||
| `service.version` | `1.0.0` |
|
||||
| `deployment.environment` | `prod` |
|
||||
| `host.name` | `admin-01` |
|
||||
| `cloud.provider` | `aliyun`、`aws`、`tencent` |
|
||||
| `region` | `cn-hangzhou` |
|
||||
| `team` | `platform` |
|
||||
|
||||
HTTP 标签:
|
||||
|
||||
| 字段 | 示例 |
|
||||
| --- | --- |
|
||||
| `http.request.method` | `GET` |
|
||||
| `http.route` | `/api/system/users/{id}` |
|
||||
| `http.response.status_code` | `200` |
|
||||
| `error.type` | `TimeoutException` |
|
||||
|
||||
业务标签只保留低基数:
|
||||
|
||||
| 字段 | 示例 |
|
||||
| --- | --- |
|
||||
| `module` | `system`、`workflow`、`audit` |
|
||||
| `operation` | `create_user`、`approve_task` |
|
||||
| `outcome` | `success`、`failure`、`denied` |
|
||||
|
||||
## 告警原则
|
||||
|
||||
告警只分三类:
|
||||
|
||||
- Page:现在必须处理,否则用户继续受影响。
|
||||
- Ticket:几天内处理,防止演变成故障。
|
||||
- Log:只记录,不打扰人。
|
||||
|
||||
优先级顺序:
|
||||
|
||||
1. SLO 错误预算快速燃烧。
|
||||
2. 登录不可用、核心 API 错误率升高。
|
||||
3. 数据库、Redis、文件存储等关键依赖不可用。
|
||||
4. Outbox、任务、队列出现不可自动恢复堆积。
|
||||
5. 容量长期接近阈值。
|
||||
|
||||
不建议告警:
|
||||
|
||||
- 单次 500。
|
||||
- 单次慢请求。
|
||||
- 短暂 CPU 尖刺。
|
||||
- 已自动恢复且没有用户影响的重试。
|
||||
|
||||
## 可观测性成本治理
|
||||
|
||||
中小企业尤其要控制观测成本:
|
||||
|
||||
- 指标控制标签基数。
|
||||
- trace 控制采样率。
|
||||
- 日志按级别和环境过滤。
|
||||
- 审计日志与排障日志保留周期分开。
|
||||
- 热存储只留近期,冷归档留合规周期。
|
||||
- 调试级日志默认关闭,临时开启必须有权限和自动恢复时间。
|
||||
|
||||
审计和 API 日志增长治理建议:
|
||||
|
||||
- `audit_api_log` 属于高增长访问日志,默认只保留 30-90 天热数据;超过周期后按月归档到对象存储或低成本库。
|
||||
- 登录、权限、角色、菜单、敏感数据变更属于安全审计,保留周期通常高于 API 访问日志,可按企业合规要求保留 180-365 天或更久。
|
||||
- 错误日志和异常明细优先保留 30-90 天热数据,长期趋势交给指标,长期原文交给集中日志归档。
|
||||
- 查询页面默认带时间范围,禁止无条件扫全表;常用查询字段要有索引,历史归档不参与默认在线查询。
|
||||
- 清理任务必须记录执行审计,包括清理范围、清理行数、执行人或任务名、开始结束时间。
|
||||
- 大表治理优先级:时间字段索引 -> 默认时间范围 -> 定时清理 -> 冷归档 -> 必要时按月分区或拆冷热表。
|
||||
|
||||
## EasyNextAdmin 应沉淀的能力
|
||||
|
||||
这不是现状描述,而是后续扫描清单:
|
||||
|
||||
| 优先级 | 能力 | 落地方向 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 指标命名和标签规范 | 新增 `docs/components/observability/metrics.md` |
|
||||
| P0 | Micrometer + InfluxDB 指标出口 | `EasyBusinessMetrics` 覆盖 API、远程调用、限流、调度任务、Outbox,`micrometer-registry-influx` 负责导出 |
|
||||
| P0 | API 访问日志与指标职责分离 | `@EasyApiAccessLog` 只写访问日志,指标统一走 `EasyBusinessMetrics` |
|
||||
| P1 | W3C Trace Context 兼容 | `EasyTraceIdFilter`、Feign、Kafka、前端 trace |
|
||||
| P1 | 生产集中日志结构化 | 本地文本日志 + OpenSearch/SLS/Loki 结构化采集链路 |
|
||||
| P1 | 前端错误和性能观测 | Vue 全局错误、Promise rejection、Axios 失败事件、本地事件模型 |
|
||||
| P1 | 审计归档和保留策略 | 审计组件文档、清理任务、后续 Flyway 方案 |
|
||||
| P2 | 观测性接入清单 | 新增模块时检查 metrics、logs、traces、audit |
|
||||
| P2 | Grafana / 云厂商面板样例 | 只作为部署参考,不内置平台 |
|
||||
|
||||
## 90 天落地路线
|
||||
|
||||
### 0-30 天:标准先行
|
||||
|
||||
- 固定指标、日志、trace、事件字段规范。
|
||||
- 明确哪些数据不能采集。
|
||||
- 明确 WebLog、审计、集中日志的边界。
|
||||
- 先补核心 API、登录、任务、Outbox、远程调用指标;当前指标出口优先 InfluxDB。
|
||||
|
||||
### 31-60 天:最小闭环
|
||||
|
||||
- 建立 InfluxDB 指标库和核心面板;如服务数量增加,再补 Prometheus 或 OTLP。
|
||||
- 建立核心 Grafana 面板。
|
||||
- 结构化日志进入统一检索。
|
||||
- traceId 能在前端、审计、日志、API 访问日志之间串起来。
|
||||
|
||||
### 61-90 天:SLO 联动
|
||||
|
||||
- 为稳定性文档中的 SLO 提供指标。
|
||||
- 建立错误预算和 burn rate 面板。
|
||||
- 告警接入 Page / Ticket / Log 分级。
|
||||
- 形成新增模块观测性接入检查。
|
||||
361
docs/development/stability-baseline.md
Normal file
361
docs/development/stability-baseline.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# 稳定性体系蓝图
|
||||
|
||||
本文不是当前实现盘点,而是把 SRE 方法论、RFC、云厂商 Well-Architected 框架和主流可观测平台的稳定性实践,整理成一套适合中小型企业后台落地的稳定性体系。EasyNextAdmin 后续按本文做能力沉淀和缺口扫描。
|
||||
|
||||
## 一句话目标
|
||||
|
||||
稳定性不是“永不出故障”,而是让系统在故障、流量波动、依赖异常、发布变更和人为误操作下:
|
||||
|
||||
- 少出问题。
|
||||
- 出问题能快速发现。
|
||||
- 影响范围可控。
|
||||
- 能快速恢复。
|
||||
- 能通过复盘降低重复发生概率。
|
||||
|
||||
## 设计约束
|
||||
|
||||
中小型企业的稳定性方案要务实:
|
||||
|
||||
- 不追求超出业务价值的 99.999%。
|
||||
- 不用复杂架构掩盖基础工程缺失。
|
||||
- 不把所有问题都交给 Kubernetes 或云平台。
|
||||
- 不依赖英雄式救火。
|
||||
- 不让重试、队列和缓存把故障放大。
|
||||
- 优先保护登录、权限、核心 CRUD、文件、任务、审批和审计链路。
|
||||
|
||||
## 标准和方法论
|
||||
|
||||
| 领域 | 标准或资料 | 核心启发 |
|
||||
| --- | --- | --- |
|
||||
| SLO / 错误预算 | [Google SRE: Implementing SLOs](https://sre.google/workbook/implementing-slos/)、[Alerting on SLOs](https://sre.google/workbook/alerting-on-slos/) | 用用户可感知 SLI/SLO 和错误预算管理稳定性,而不是只看资源阈值。 |
|
||||
| 错误预算策略 | [Google SRE: Error Budget Policy](https://sre.google/workbook/error-budget-policy/) | 错误预算耗尽时要有明确发布冻结、优先级调整和复盘策略。 |
|
||||
| AWS Reliability | [AWS Well-Architected Reliability Pillar](https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/welcome.html) | 可靠性覆盖设计、交付、运行、恢复和生命周期测试。 |
|
||||
| Azure Reliability | [Azure Well-Architected Reliability principles](https://learn.microsoft.com/en-us/azure/well-architected/reliability/principles) | 可靠性要贯穿开发生命周期,并用 checklist 驱动行动。 |
|
||||
| Google Cloud Reliability | [Google Cloud Architecture Framework: Reliability](https://docs.cloud.google.com/architecture/framework/reliability) | 可靠性设计要覆盖部署、运行、恢复、容量和运营流程。 |
|
||||
| 探针 | [Kubernetes Liveness / Readiness / Startup Probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/) | liveness 决定是否重启,readiness 决定是否接流量,startup 保护冷启动。 |
|
||||
| HTTP 幂等 | [RFC 9110](https://datatracker.ietf.org/doc/html/rfc9110) | 自动重试必须尊重 HTTP 方法和业务幂等语义,非幂等请求不能盲目重试。 |
|
||||
| 限流 | [RFC 6585](https://datatracker.ietf.org/doc/html/rfc6585) | 限流应返回 429,并尽量给 `Retry-After`。 |
|
||||
| 错误详情 | [RFC 9457](https://datatracker.ietf.org/doc/html/rfc9457) | 跨系统 API 可用机器可读错误详情增强恢复和定位。 |
|
||||
|
||||
## 厂商打法
|
||||
|
||||
| 厂商或生态 | 稳定性打法 | 对中小企业的借鉴 |
|
||||
| --- | --- | --- |
|
||||
| Google SRE | SLO、错误预算、burn rate 告警、复盘和减少 toil。 | 先用少量关键 SLO 管理稳定性,不要直接堆监控项。 |
|
||||
| AWS Well-Architected | 多 AZ、自动恢复、容量规划、变更管理、备份恢复、演练。 | 把“恢复能力”和“演练”纳入稳定性,而不只看可用性。 |
|
||||
| Azure Well-Architected | checklist、冗余、恢复目标、故障模式分析、运营流程。 | 用 checklist 固化架构评审,避免漏项。 |
|
||||
| Google Cloud Architecture Framework | 可靠性、运营卓越、自动化、发布和监控联动。 | 把稳定性融入研发流程,发布前就定义观测和恢复方案。 |
|
||||
| Datadog SLO | SLO 管理、标签检索、服务视图、错误预算告警。 | SLO 要能按服务、环境、团队检索和归属。 |
|
||||
| New Relic Service Levels | 用 SLIs/SLOs 和 burn rate 连接用户体验和告警。 | 以“坏事件”消耗错误预算,而不是只看单点异常。 |
|
||||
| Grafana SLO | SLI、目标、时间窗口、错误预算和告警规则集成。 | 开源栈也可以建立 SLO,不必依赖商业平台。 |
|
||||
| Honeycomb SLO | burn alerts 从 SLO 直接跳到事件级调试。 | 告警必须能带人进入可排查上下文。 |
|
||||
| 阿里云 / 腾讯云 / 华为云 | 云资源、应用、日志、链路、事件和告警统一平台化。 | 已上云团队优先复用云厂商能力,应用侧保持标准化,降低迁移成本。 |
|
||||
|
||||
厂商共性可以抽象为六层:
|
||||
|
||||
```text
|
||||
目标定义 -> 故障隔离 -> 自动恢复 -> 变更控制 -> 数据保护 -> 复盘改进
|
||||
```
|
||||
|
||||
## 稳定性能力模型
|
||||
|
||||
| 层 | 解决的问题 | 典型能力 |
|
||||
| --- | --- | --- |
|
||||
| 目标层 | 什么叫稳定,容忍多少失败 | SLI、SLO、错误预算、SLA 边界 |
|
||||
| 入口层 | 如何保护入口不被打垮 | 限流、鉴权、请求大小限制、超时、排队上限 |
|
||||
| 依赖层 | 下游慢或挂了怎么办 | 超时、重试、熔断、隔离、降级、缓存 |
|
||||
| 并发层 | 资源耗尽怎么办 | 线程池、连接池、队列、背压、拒绝策略 |
|
||||
| 数据层 | 重复、丢失、不一致怎么办 | 幂等、事务、Outbox、锁、补偿、审计 |
|
||||
| 任务层 | 异步和定时任务是否可靠 | 调度日志、分布式锁、重试、死信、人工处理 |
|
||||
| 变更层 | 发布如何不制造故障 | CI、迁移、灰度、回滚、配置审计 |
|
||||
| 恢复层 | 出故障如何恢复 | 备份、恢复演练、RTO/RPO、runbook |
|
||||
| 改进层 | 如何避免重复故障 | 复盘、行动项、故障注入、容量复盘 |
|
||||
|
||||
## SLO 体系
|
||||
|
||||
SLO 要少而准。中小企业后台初期只建议定义 4-6 个。
|
||||
|
||||
### 推荐 SLI
|
||||
|
||||
| 用户旅程 | SLI | 坏事件示例 |
|
||||
| --- | --- | --- |
|
||||
| 登录 | 登录请求成功率、p95 延迟 | 5xx、业务系统错误、超时 |
|
||||
| 核心 API | `/api/**` 成功率、p95 延迟 | 5xx、系统异常、超时 |
|
||||
| 权限和菜单 | 登录后获取菜单和权限成功率 | 权限快照失败、菜单加载失败 |
|
||||
| 文件 | 上传、下载、预览成功率 | 上传失败、下载 5xx、存储不可用 |
|
||||
| 任务调度 | 按计划执行成功率、调度延迟 | 任务失败、超时、连续失败 |
|
||||
| Outbox | 消息最终成功率、最老失败年龄 | ERROR、超过重试窗口 |
|
||||
|
||||
### 推荐目标
|
||||
|
||||
初始目标不要过高:
|
||||
|
||||
- 内网后台核心 API:月度 99.5%。
|
||||
- 登录链路:月度 99.9%。
|
||||
- 核心 API p95:800ms 或根据实际压测调整。
|
||||
- 任务执行:月度 99%,失败 10 分钟内可见。
|
||||
- Outbox:ERROR 为 0,FAILED 10 分钟内重试或告警。
|
||||
|
||||
目标设置原则:
|
||||
|
||||
- 先用历史数据校准,没有历史数据就从保守目标开始。
|
||||
- SLO 低于用户感知底线没有意义。
|
||||
- SLO 高到长期没有错误预算也没有意义。
|
||||
- 每个 SLO 必须有 owner、窗口、计算口径、排除项和响应策略。
|
||||
|
||||
## 错误预算和告警
|
||||
|
||||
错误预算 = 100% - SLO 目标。
|
||||
|
||||
使用方式:
|
||||
|
||||
- 预算健康:正常发布。
|
||||
- 预算快速燃烧:暂停高风险变更,优先处理稳定性问题。
|
||||
- 预算耗尽:冻结普通发布,只允许 P0/P1 故障修复和安全修复。
|
||||
- 单次事故消耗大量预算:必须复盘。
|
||||
|
||||
告警分级:
|
||||
|
||||
| 等级 | 含义 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| Page | 现在必须处理 | 登录不可用、错误预算快速燃烧、数据库不可用、Outbox ERROR 堆积 |
|
||||
| Ticket | 几天内处理 | 容量趋势逼近阈值、任务偶发失败、缓存命中率持续下降 |
|
||||
| Log | 只记录上下文 | 单次重试成功、短暂抖动、无用户影响的自动恢复 |
|
||||
|
||||
推荐采用多窗口 burn rate:
|
||||
|
||||
- 短窗口发现快速故障。
|
||||
- 长窗口避免噪声。
|
||||
- 告警内容必须带 runbook、面板链接、最近发布、trace/log 查询入口。
|
||||
|
||||
## 韧性模式
|
||||
|
||||
### Timeout
|
||||
|
||||
- 所有外部调用必须有连接超时和读取超时。
|
||||
- 超时要小于用户请求总预算。
|
||||
- 数据库、Redis、HTTP、对象存储、消息中间件都要单独配置。
|
||||
|
||||
### Retry
|
||||
|
||||
- 默认只重试幂等读。
|
||||
- 写请求只有在有幂等键、业务去重或确认原请求未生效时才重试。
|
||||
- 使用指数退避和抖动。
|
||||
- 遵守 `Retry-After`。
|
||||
- 设置最大次数和总耗时上限。
|
||||
|
||||
### Circuit Breaker
|
||||
|
||||
- 按下游服务和操作拆分。
|
||||
- 失败率、慢调用率、半开探测要配置。
|
||||
- 熔断时必须有降级响应或明确失败。
|
||||
|
||||
### Bulkhead
|
||||
|
||||
- 关键业务和非关键业务隔离线程池或连接池。
|
||||
- 慢下游不能占满全局业务线程池。
|
||||
- 文件、报表、导入导出等重操作单独限制。
|
||||
|
||||
### Rate Limit
|
||||
|
||||
- 登录、验证码、导入导出、文件上传、批量操作优先限流。
|
||||
- 返回 429 和可计算的 `Retry-After`。
|
||||
- 区分用户、IP、租户、全局维度。
|
||||
|
||||
### Backpressure
|
||||
|
||||
- 队列必须有上限。
|
||||
- 满了就快速失败或降级,不无限堆积。
|
||||
- 客户端要能看到明确错误和重试建议。
|
||||
|
||||
### Idempotency
|
||||
|
||||
- POST 写操作需要业务幂等键或服务端去重。
|
||||
- 幂等结果要定义:重复请求返回原结果、拒绝还是提示处理中。
|
||||
- 幂等记录要有过期时间和清理策略。
|
||||
|
||||
### Outbox
|
||||
|
||||
- 本地事务和消息发送解耦。
|
||||
- 失败重试使用退避。
|
||||
- 达到最大重试进入人工处理。
|
||||
- 监控 FAILED、ERROR、最老消息年龄和重试成功率。
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
- 停止接新流量。
|
||||
- 等待进行中请求和任务完成。
|
||||
- 超时后强制退出。
|
||||
- 关闭前刷新日志和指标。
|
||||
|
||||
## 健康检查和探针
|
||||
|
||||
三类探针必须分开:
|
||||
|
||||
- liveness:进程是否需要重启。不要依赖数据库、Redis、下游服务。
|
||||
- readiness:实例是否能接流量。要检查关键依赖和应用初始化状态。
|
||||
- startup:启动慢时保护实例,避免冷启动被 liveness 误杀。
|
||||
|
||||
建议:
|
||||
|
||||
- 容器健康检查用 liveness。
|
||||
- 负载均衡摘流用 readiness。
|
||||
- 迁移、缓存预热、首次启动慢时加 startup。
|
||||
- 可选依赖按功能开关决定是否进入 readiness。
|
||||
|
||||
## 发布稳定性
|
||||
|
||||
发布前:
|
||||
|
||||
- 自动化测试。
|
||||
- 数据库迁移预演。
|
||||
- 回滚方案。
|
||||
- 指标和告警确认。
|
||||
- 默认账号、CORS、Actuator、OpenAPI、安全头检查。
|
||||
|
||||
发布中:
|
||||
|
||||
- 记录发布事件。
|
||||
- 观察登录成功率、API 错误率、p95、数据库连接池、Outbox、任务失败。
|
||||
- 高风险变更灰度或低峰发布。
|
||||
|
||||
发布后:
|
||||
|
||||
- 看 SLO 是否异常燃烧。
|
||||
- 看错误日志是否出现新异常类型。
|
||||
- 看用户侧前端错误是否升高。
|
||||
- 确认无异常后关闭变更窗口。
|
||||
|
||||
数据库迁移原则:
|
||||
|
||||
- 优先 expand / migrate / contract。
|
||||
- 先兼容旧代码和新代码。
|
||||
- 大表变更避免长锁。
|
||||
- 不可逆迁移必须有备份和人工确认。
|
||||
|
||||
## 数据保护
|
||||
|
||||
中小企业也必须定义:
|
||||
|
||||
| 项 | 要求 |
|
||||
| --- | --- |
|
||||
| RTO | 多久恢复服务 |
|
||||
| RPO | 最多丢多少数据 |
|
||||
| 备份范围 | MySQL、Redis 持久化、上传文件、审计日志、配置 |
|
||||
| 备份频率 | 全量、增量、binlog 或对象存储版本 |
|
||||
| 恢复演练 | 至少季度演练核心数据恢复 |
|
||||
| 校验 | 备份可用性校验,不只检查文件存在 |
|
||||
|
||||
## 容量和增长治理
|
||||
|
||||
稳定性不仅是服务不宕机,也包括数据增长不会把数据库、备份和查询拖垮。
|
||||
|
||||
- 高增长表必须有默认时间范围和清理策略,典型包括 API 访问日志、登录日志、错误日志、任务执行日志、消息重试表。
|
||||
- 在线查询默认查热数据,历史数据走归档库、对象存储或离线检索,不要让后台页面无条件扫全表。
|
||||
- 清理任务要限批、限时、可恢复,并记录清理范围、行数、耗时和结果。
|
||||
- 备份策略要考虑日志表膨胀后的恢复窗口;如果审计合规要求长期保留,应把热库保留和冷归档保留拆开。
|
||||
- 容量告警优先看趋势和剩余天数,不只看单点磁盘百分比。
|
||||
|
||||
## 故障复盘
|
||||
|
||||
复盘不是追责,而是改系统。
|
||||
|
||||
模板:
|
||||
|
||||
- 影响范围:用户、功能、时间窗口、数据影响。
|
||||
- 时间线:发现、确认、止血、恢复、关闭。
|
||||
- 根因:直接原因、触发条件、系统性原因。
|
||||
- 检测:为什么被发现,为什么没有更早发现。
|
||||
- 响应:哪些动作有效,哪些动作浪费时间。
|
||||
- 预防:代码、配置、告警、流程、演练改什么。
|
||||
- 验证:行动项如何证明完成。
|
||||
|
||||
复盘行动项必须可验证:
|
||||
|
||||
- “优化监控”不是行动项。
|
||||
- “为 Outbox ERROR > 0 持续 5 分钟增加 Page 告警,并在预发验证告警触发”是行动项。
|
||||
|
||||
## 中小企业落地路线
|
||||
|
||||
### L0:基础可恢复
|
||||
|
||||
- 健康检查。
|
||||
- 日志和审计。
|
||||
- 数据库备份。
|
||||
- 关键配置不进代码。
|
||||
- 手工 runbook。
|
||||
|
||||
### L1:入口可保护
|
||||
|
||||
- 限流。
|
||||
- 超时。
|
||||
- 幂等。
|
||||
- 重复提交保护。
|
||||
- 线程池和连接池上限。
|
||||
- 核心 API 指标。
|
||||
|
||||
### L2:故障可隔离
|
||||
|
||||
- 熔断。
|
||||
- 隔离池。
|
||||
- Outbox。
|
||||
- 任务重试和人工处理。
|
||||
- readiness 摘流。
|
||||
- SLO 告警。
|
||||
|
||||
### L3:变更可控
|
||||
|
||||
- 灰度发布。
|
||||
- 数据库兼容迁移。
|
||||
- 自动回滚条件。
|
||||
- 发布事件关联观测数据。
|
||||
- 错误预算驱动发布节奏。
|
||||
|
||||
### L4:持续改进
|
||||
|
||||
- 定期恢复演练。
|
||||
- 故障注入。
|
||||
- 容量复盘。
|
||||
- 复盘行动项闭环。
|
||||
|
||||
## EasyNextAdmin 应沉淀的能力
|
||||
|
||||
这不是现状描述,而是后续扫描清单:
|
||||
|
||||
| 优先级 | 能力 | 落地方向 |
|
||||
| --- | --- | --- |
|
||||
| P0 | 项目级 SLO 模板 | `docs/development` 或部署文档 |
|
||||
| P0 | liveness/readiness/startup 明确分层 | Actuator health group、Dockerfile、部署文档 |
|
||||
| P0 | Feign 重试语义收敛 | `EasyFeignConfig`、远程调用组件文档 |
|
||||
| P0 | Outbox 积压、退避和人工处理 | `LocalMessageRetryJob`、监控统计、页面入口 |
|
||||
| P1 | 线程池、Hikari、Tomcat 容量指标 | Micrometer binder、监控页面、Grafana 样例 |
|
||||
| P1 | 限流响应标准化 | 429、`Retry-After`、统一错误详情 |
|
||||
| P1 | 发布检查和回滚模板 | `docs/deployment.md`、发布清单 |
|
||||
| P1 | 前端稳定性观测 | Vue 全局错误、路由错误、Axios 失败事件 |
|
||||
| P2 | 审计和 API 日志增长治理 | 默认时间范围、清理任务、冷归档、容量告警 |
|
||||
| P2 | 备份恢复 runbook | 部署文档、运维手册 |
|
||||
| P2 | 降级分级策略 | 业务模块接入清单 |
|
||||
|
||||
## 90 天落地路线
|
||||
|
||||
### 0-30 天:把故障看见
|
||||
|
||||
- 定义 4-6 个核心 SLO。
|
||||
- 固定 Page / Ticket / Log 告警分级。
|
||||
- 补核心 API、登录、任务、Outbox 指标。
|
||||
- 明确 liveness/readiness/startup。
|
||||
|
||||
### 31-60 天:把故障收住
|
||||
|
||||
- 收敛重试语义。
|
||||
- 限流返回 429 / `Retry-After`。
|
||||
- Outbox 增加退避、最大重试和人工处理。
|
||||
- 线程池、连接池、Tomcat 容量进入面板。
|
||||
|
||||
### 61-90 天:把变更管住
|
||||
|
||||
- 发布检查清单。
|
||||
- 数据库迁移兼容策略。
|
||||
- 回滚 runbook。
|
||||
- 故障复盘模板。
|
||||
- 至少做一次数据库不可用、Redis 不可用或下游超时演练。
|
||||
714
docs/features-and-components.md
Normal file
714
docs/features-and-components.md
Normal file
@@ -0,0 +1,714 @@
|
||||
# 功能与组件
|
||||
|
||||
本文按“用户能看到什么、开发者如何接入、底层如何实现”整理 EasyNextAdmin 当前已经具备的功能和组件。
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 功能域 | 页面入口 | 前端目录 | 后端模块 | 主要权限 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 工作台 | `/dashboard` | `src/views/dashboard`、`src/features/dashboard` | `module.system`、`module.workflow` | `dashboard:view` |
|
||||
| 认证授权 | `/login`、`/profile/security`、`/monitor/online` | `src/views/login/LoginView.vue`、`src/stores/auth.ts`、`src/api/request.ts` | `infrastructure.security`、`module.system` | 登录入口公开,业务接口按 `@EasyPermission` |
|
||||
| 用户管理 | `/system/users` | `src/views/system/UserView.vue`、`src/features/system/userApi.ts` | `module.system` | `sys:user:list/add/edit/delete/import/export` |
|
||||
| 角色权限 | `/system/roles` | `src/views/system/RoleView.vue` | `module.system` | `sys:role:list/edit` |
|
||||
| 菜单配置 | `/system/menus` | `src/views/system/MenuView.vue` | `module.system` | `sys:menu:list/edit` |
|
||||
| 组织架构 | `/system/departments` | `src/views/system/DepartmentView.vue` | `module.system` | `sys:dept:list/edit` |
|
||||
| 文件中心 | `/system/files` | `src/views/system/FileCenterView.vue` | `module.system` | `sys:file:list/upload/delete` |
|
||||
| 编号规则 | `/system/business-numbers` | `src/views/system/BusinessNumberRuleView.vue`、`src/features/business-number` | `module.business.number` | `business:number:list/edit/generate` |
|
||||
| 报表中心 | `/reports/enterprise` | `src/views/report/EnterpriseReportView.vue`、`src/features/report` | `module.report` | `report:view` |
|
||||
| 运行监控 | `/monitor/server` | `src/views/monitor/MonitorView.vue` | `module.monitor` | `monitor:server:view` |
|
||||
| 在线用户 | `/monitor/online` | `src/views/monitor/OnlineUserView.vue` | `infrastructure.security` | `monitor:online:view`、`auth:session:revoke` |
|
||||
| 缓存监控 | `/monitor/cache` | `src/views/monitor/CacheMonitorView.vue` | `module.monitor` | `monitor:cache:view/clear` |
|
||||
| 缓存列表 | `/monitor/cache-list` | `src/views/monitor/CacheListView.vue` | `module.monitor` | `monitor:cache:view/clear` |
|
||||
| 实时日志 | `/monitor/weblog` | `src/views/monitor/WebLogView.vue` | `module.system`、`infrastructure.observability` | `monitor:weblog:view/level` |
|
||||
| 审计中心 | `/audit/behavior` | `src/views/audit/BehaviorAuditView.vue` | `module.audit`、`infrastructure.audit` | `audit:behavior:view` |
|
||||
| 任务调度 | `/schedule/jobs` | `src/views/schedule/JobView.vue` | `module.schedule` | `schedule:job:list/edit` |
|
||||
| 批处理任务 | `/batch/tasks` | `src/views/batch/BatchTaskView.vue`、`src/features/batch` | `module.batch` | `batch:task:list/manage` |
|
||||
| 流程中心 | `/workflow/*` | `src/views/workflow`、`src/features/workflow` | `module.workflow` | `workflow:*` |
|
||||
| 消息中心 | `/messages` | `src/views/message/MessageCenterView.vue` | `module.message` | `message:view/read` |
|
||||
| 智能助手 | `/assistant/chat`、`/assistant/debug` | `src/views/assistant`、`src/features/assistant` | `module.assistant` | 用户流式对话 `assistant:chat`;同步调试与 eval `assistant:debug` |
|
||||
| 接口文档 | `/developer/api-docs` | `src/views/developer/ApiDocsView.vue` | `config.api`、springdoc-openapi | `developer:api-docs:view` |
|
||||
| 个人中心 | `/profile/security` | `src/views/profile/ProfileSecurityView.vue` | `module.system` | 登录用户可访问 |
|
||||
|
||||
## 功能组件使用与设计
|
||||
|
||||
| 功能组件 | 使用方式 | 设计要点 |
|
||||
| --- | --- | --- |
|
||||
| 工作台 | 登录后进入 `/dashboard`,查看待办、消息、系统状态和快捷入口。 | 只做运营入口,不承载复杂配置;待办、消息和监控指标均来自真实模块数据。 |
|
||||
| 认证授权 | 登录页调用 `/api/auth/login`,请求拦截器携带当前会话凭证,后端过滤器恢复当前用户,接口注解完成授权。 | 当前代码使用 Bearer token 和服务端会话快照,适合本地开发和前后端分离调试;权限版本让角色、菜单和用户授权变化能立即让旧会话失效。`/api/auth/demo-accounts` 只在 `local` profile 返回演示账号。生产安全路线固定为 HttpOnly Cookie 会话 + CSRF 防护,不把浏览器可读 token 作为生产验收方案。 |
|
||||
| 系统管理 | 通过用户、角色、菜单、部门页面维护组织和授权;用户管理页支持下载 CSV 模板、导入用户和按筛选条件导出用户。 | 菜单权限、按钮权限和后端权限码同源;用户详情、编辑、启停、删除、重置密码和部门维护都走数据权限边界。用户页维护直属上级并预览审批关系,部门页维护部门负责人,供工作流运行时派单。用户批量导入由 `SysUserImportExportService` 校验 CSV 类型、2MB 大小、1000 行上限、部门名称、角色编码和重复用户名;导出 CSV 会防 Excel 公式注入。 |
|
||||
| 文件中心 | 在 `/system/files` 上传、预览、下载和清理文件;图片、PDF 和文本类文件可直接预览。 | 业务模块通过文件 API 保存文件元数据,预览和下载复用鉴权下载接口,不在页面拼接裸地址。上传会校验大小、扩展名、MIME 和常见文件头签名,拒绝把可执行文件伪装成图片、PDF 或压缩包;病毒扫描和敏感内容检测应在企业网关或对象存储侧继续补齐。 |
|
||||
| 编号规则 | 在 `/system/business-numbers` 维护申请单号、工单号、采购单号等业务可读编号规则;业务代码通过 `BusinessNumberService#nextNumber(ruleCode)` 取号。 | 业务编号是应用内组件,不替代表主键或分布式 ID。规则表维护前缀、日期周期、分隔符、流水位数、递增步长和启停状态;计数器表按规则和日期段独立计数,通过数据库原子递增保证多实例并发取号不重复。 |
|
||||
| 报表中心 | 在 `/reports/enterprise` 查看组织人员台账和采购流程复核两张 A4 纸质报表,并可使用浏览器打印。 | 报表接口只读,走 `report:view` 权限和当前账号数据范围;页面用固定版式表格、签核栏和报表编号呈现,不做 BI 配置器或大屏图表。 |
|
||||
| 运行监控 | 在监控菜单查看服务器、缓存、缓存列表、在线用户和实时日志。 | 面向内网运维排障,默认展示实时状态和 logback 当前文件日志;缓存列表只展示脱敏后的 key/value 预览并支持精确清理单个 key;实时日志级别调整使用独立权限和审计。监控页是应用内排障入口,不替代 InfluxDB、OpenSearch、Grafana 或云厂商观测平台。 |
|
||||
| 审计中心 | 在 `/audit/behavior` 查询登录、操作、异常、接口访问和敏感数据变更记录。 | 关键接口用 `@EasyAudit` 或审计采集器记录;审计查询通过 `AuditVisibilitySupport` 按当前账号数据范围过滤操作者或登录用户,只有全部数据范围可看全局统计;敏感字段在入库前统一交给 `EasySensitiveDataMasker` 脱敏。 |
|
||||
| 任务调度 | 在 `/schedule/jobs` 查看和维护动态任务。 | 任务通过 `@EasyJob` 声明,数据库维护 Cron、锁租约、`SINGLETON / BROADCAST` 执行模式、启停状态和执行日志;多实例节点定期心跳并对齐数据库目标状态。单实例模式使用分布式锁互斥,广播模式让每个在线实例进入 Handler。 |
|
||||
| 批处理任务 | 在 `/batch/tasks` 查看长任务进度、失败明细、执行 Worker 和租约,对运行中任务请求取消,并可按日期触发采购流程状态对账。 | `EasyBatchRunner` 提供 PageReader / ItemDecoder / Processor、参数、失败策略和固定账期分布式模式;准备者只执行一次 Reader,Worker 直接消费持久化 `input_json`,通过 Claim/Lease 动态分工并接管失效实例。一次执行由 `businessKey + runNo` 标识,终态历史不原地重置。 |
|
||||
| 工作流 | 在 `/workflow/start` 直接填写业务申请,在 `/workflow/tasks` 处理待办和查看我发起的流程,具备流程实例管理权限的管理员在 `/workflow/instances` 监控全部流程实例,在定义页维护轻量审批图。 | 流程定义保存图 JSON,并同步生成节点和连线结构化投影;启用、发布和发起前都会校验图结构;实例详情优先使用发起时快照,避免定义变更影响历史实例。流程实例页用纸张式申请单展示业务详情、申请人、单号和审批记录,并保留流程图和处理动态。审批节点支持任一人、全部、顺序三种审批方式,处理人规则支持指定成员、职能角色、发起人直属上级、发起人部门负责人、发起人上级部门负责人和发起人自选;节点配置里的转办、委派、加签、减签、退回开关会在运行时校验。抄送已读只能由接收人本人或超级管理员操作。参与人统一读取 `/api/system/users/assignees`,历史任务和历史抄送也参与可见性判断。 |
|
||||
| 消息中心 | 顶部铃铛显示未读数,`/messages` 处理流程、审计和任务消息。 | 消息接口集中在 `src/features/message/api.ts`,顶部铃铛使用 `headerMessages.ts`,避免布局组件直接写请求细节;流程催办消息带业务关联和任务中心跳转链接。消息中心只保留查看和已读处理。 |
|
||||
| 智能助手 | 普通用户在 `/assistant/chat` 进行多轮流式对话,实时看到后端上下文、模型和 Tool 阶段映射出的安全 Step;管理员在 `/assistant/debug` 同步运行并按执行链查看每一步真实输入输出、模型消息数组、Tool Schema、生成选项、供应商输出、Runtime 归一化结果、Token、耗时和原始 JSON。同步调试与真实用户流遵守同一 Conversation State 语义,相同 `conversationId` 读取并写回同一会话,新建或重置测试才生成新会话。内置流程查询、待办查询、请假申请与请假单核对,以及待办同意/驳回 Tool。 | Spring AI 只负责模型与 Tool Calling 协议适配;项目自己的 Java Mini Runtime 负责单模型循环、权限、确认、同会话并发保护、异常隔离、预算、幂等和结果合同。普通请求由同一个模型直接选择真实 Tool 并消费 observation,不经过 Router、参数抽取模型、输入审查模型、输出 critic,也不使用 Java 中文短语表或正则猜意图和审查自由文本;基础算术由主模型一次直答。历史对话以独立 `user/assistant` 消息发送,当前用户消息不与历史和状态拼成字符串。普通用户 Step 只含顺序、阶段和安全文案,不包含 Prompt、Tool 参数或内部 Payload;完整流程只由同步调试接口返回。版本化 eval 数据集同时约束质量、模型调用、Token 与总耗时。 |
|
||||
| 接口文档 | 在 `/developer/api-docs` 内嵌查看 Swagger UI,也可新窗口打开 `/swagger-ui.html`。 | OpenAPI 入口默认只在 `local` profile 开启;前端开发代理同时转发 `/swagger-ui.html`、`/swagger-ui/*` 和 `/v3/api-docs`。 |
|
||||
| 个人中心 | 在 `/profile/security` 修改资料、头像和密码。 | 头像使用文件上传和裁剪,不让用户手填图片地址;无头像时使用姓名首字作为占位。 |
|
||||
|
||||
## 项目内示例索引
|
||||
|
||||
二开时先找当前项目中的真实落点,再复制同类结构。不要为了展示组件单独写空 demo。
|
||||
|
||||
### 功能组件示例
|
||||
|
||||
| 功能组件 | 页面或接口示例 | 代码位置 | 适合复用的点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 工作台 | `/dashboard` | `EnterpriseWorkbenchController`、`EnterpriseWorkbenchService`、`src/views/dashboard` | 聚合统计、快捷入口、待办和消息摘要。 |
|
||||
| 认证授权 | `/api/auth/login`、`/api/auth/logout` | `AuthController`、`EasyAuthService`、`EasyAuthFilter` | 登录、会话快照、退出、会话校验和权限版本。 |
|
||||
| 用户管理 | `/api/system/users` | `SysUserController`、`SysUserServiceImpl`、`UserView.vue` | 标准 CRUD、角色绑定、状态切换、数据权限校验、导入导出。 |
|
||||
| 角色权限 | `/api/system/roles` | `SysRoleController`、`SysRoleServiceImpl`、`RoleView.vue` | 授权配置、数据范围、敏感变更审计和权限版本刷新。 |
|
||||
| 菜单配置 | `/api/system/menus` | `SysMenuController`、`SysMenuServiceImpl`、`MenuView.vue` | 菜单树、按钮权限、前后端权限码同源维护。 |
|
||||
| 组织架构 | `/api/system/departments` | `SysDeptController`、`SysDeptServiceImpl`、`DepartmentView.vue` | 树形组织、数据权限部门边界。 |
|
||||
| 文件中心 | `/api/system/files` | `SysFileController`、`EasyStorageFacade`、`FileCenterView.vue` | 上传、鉴权下载、图片/PDF/文本预览和下载审计。 |
|
||||
| 编号规则 | `/api/business-numbers/rules` | `BusinessNumberRuleController`、`BusinessNumberService`、`BusinessNumberRuleView.vue` | 业务可读编号规则维护、按规则编码取号、按日期段独立流水和并发安全计数器。 |
|
||||
| 报表中心 | `/api/reports/enterprise-paper` | `EnterpriseReportController`、`EnterpriseReportService`、`EnterpriseReportView.vue` | 组织人员台账和采购流程复核的纸质报表数据,按当前账号数据范围生成。 |
|
||||
| 运行监控 | `/api/monitor/system`、`/api/monitor/statistics` | `SystemStatusController`、`MonitorStatisticsController`、`MonitorView.vue` | JVM、CPU、内存、磁盘、健康状态、接口、在线用户、远程调用和任务统计展示。 |
|
||||
| 缓存监控 | `/api/monitor/cache` | `CacheMonitorController`、`CacheMonitorService`、`CacheMonitorView.vue`、`CacheListView.vue` | 查看缓存 provider、命中率、大小、key/value 预览和精确清理缓存项。 |
|
||||
| 在线用户 | `/api/monitor/statistics/online-users` | `MonitorStatisticsController`、`AuthSessionStore`、`OnlineUserView.vue` | 在线会话查询、当前会话标记和踢人下线。 |
|
||||
| 审计中心 | `/api/audit/*` | `module.audit`、`AuditLogCollector`、`BehaviorAuditView.vue` | 登录、操作、异常、接口访问和敏感变更查询。 |
|
||||
| 定时任务 | `/api/schedule/jobs` | `ScheduleJobController`、`ScheduleJobManager`、`ScheduleInstanceRegistry`、`JobView.vue` | 动态 Cron、单实例/广播执行、锁租约、集群状态同步、启停任务、实例心跳和执行日志。 |
|
||||
| 批处理任务 | `/api/batch/tasks`、`/api/workflow/purchase/reconciliation` | `BatchTaskController`、`BatchTaskService`、`EasyBatchRunner`、`PurchaseStatusReconciliationService`、`BatchTaskView.vue` | 任务进度、失败明细、取消、分页/游标、不可变执行历史、持久化分区、多实例 Claim/Lease、自动续租、失效接管和采购状态对账。 |
|
||||
| 工作流 | `/api/workflow/*` | `module.workflow`、`WorkflowTaskCenterView.vue`、`WorkflowInstanceMonitorView.vue` | 流程定义、流程实例监控、待办、审批、转办、加签、催办和消息联动。 |
|
||||
| 消息中心 | `/api/messages` | `UserMessageController`、`UserMessageService`、`MessageCenterView.vue` | 未读数、流程消息、审计提醒、任务消息、前往业务详情。 |
|
||||
| 智能助手 | `/api/assistant/chat/messages/stream`、`/api/assistant/messages` | `AssistantController`、`AgentTurnService`、`AgentLoop`、`ModelMessageBuilder`、`ToolCallingModelClient`、`ToolDefinition`、`src/views/assistant` | 用户安全流式对话、同步完整诊断与 eval、结构化消息数组、权限过滤后的原生 Tool Calling、写操作确认和结构化运行时边界。 |
|
||||
| 接口文档 | `/swagger-ui.html`、`/v3/api-docs` | `OpenApiConfig`、`ApiDocsView.vue` | 按模块查看 OpenAPI 分组,开发调试时复制 Bearer token 后可在 Swagger UI 调用接口。 |
|
||||
| 个人中心 | `/api/profile/*` | `ProfileSecurityController`、`ProfileSecurityService`、`ProfileSecurityView.vue` | 资料维护、头像上传裁剪、改密、登录历史和本人会话治理。 |
|
||||
|
||||
### 技术组件示例
|
||||
|
||||
| 技术组件 | 项目内示例 | 关键文件 | 接入要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 统一响应和异常码 | 所有 JSON Controller | `Response`、`PageResponse`、`GlobalExceptionHandler` | Controller 返回标准响应,业务异常使用 `ErrorCode`,不要裸数字错误码。 |
|
||||
| 接口权限 | 用户、角色、菜单、工作流接口 | `@EasyPermission`、`EasyPermissionInterceptor`、`EasyPermissions` | 页面按钮隐藏只是体验,后端注解才是真实边界。 |
|
||||
| 业务编号 | 请假、采购、报修申请单号 | `BusinessNumberService`、`BusinessNumberRule`、`BusinessNumberSequence` | 业务代码只按规则编码取号;用户可见编号与数据库主键、Snowflake ID 分离。 |
|
||||
| 认证会话 | 登录、退出、在线用户 | `EasyAuthService`、`EasyAuthFilter`、`AuthSessionStore` | token 只保存摘要,服务端会话保存权限快照和权限版本。 |
|
||||
| 数据权限 | 用户、部门、任务查询 | `@DataScope`、`EasyDataScopeInnerInterceptor`、`EasyDataScopeContext` | Mapper 声明范围列,特殊系统查询用 `ignore` 明确绕过。 |
|
||||
| 缓存 | `/api/monitor/cache`、业务命名缓存 | `EasyCacheConfig`、`CacheMonitorService` | 命名缓存统一 TTL,监控页展示命中率、容量和脱敏 value 预览;敏感或强数据权限详情不做共享缓存。 |
|
||||
| 审计 | 退出登录、清理缓存、角色授权 | `@EasyAudit`、`AuditLogCollector`、`SensitiveAuditService` | 操作审计走注解,敏感数据变更走显式服务。 |
|
||||
| 脱敏 | 审计参数、接口访问日志、实时日志 | `EasySensitiveDataMasker`、`@EasyMask` | DTO 输出用注解,Map/请求参数/日志文本用组件。 |
|
||||
| Trace / MDC | HTTP、定时任务、Kafka、异步线程池 | `EasyTraceIdFilter`、`EasyTraceIdContext`、`EasyMdcContext`、`EasyNextAdminMdcThreadPoolExecutor`、`logback.xml` | 入口没有 `X-Trace-Id` 时统一创建,跨线程、Kafka 生产消费和远程调用透传,日志打印 `traceId` 和认证后的 `userId`。 |
|
||||
| API 访问日志 | 用户管理、审计、监控 Controller | `@EasyApiAccessLog`、`EasyApiAccessLogAspect` | 写入 `audit_api_log`,保存 traceId、请求摘要、响应摘要、状态和耗时;它是访问日志,不承担标准指标命名职责。 |
|
||||
| 标准业务指标 | API 访问、远程调用、限流、调度任务、Outbox | `EasyBusinessMetrics`、`RemoteCallMetricsAspect`、Micrometer、`micrometer-registry-influx` | 使用低基数标签输出 `easy.api.requests`、`easy.remote.calls`、`easy.rate_limit.blocked`、`easy.schedule.jobs`、`easy.outbox.messages`;当前指标后端可接 InfluxDB,应用侧保持 Micrometer 标准门面,后续可扩展 OTLP 或 Prometheus。 |
|
||||
| 前端观测事件 | Vue 全局错误、路由错误、Axios 失败 | `src/features/observability/events.ts`、`src/api/request.ts`、`src/main.ts` | 记录白屏类错误、未处理 Promise、路由加载失败和 API 失败;本地有界缓冲,保留 traceId,不保留完整 query 参数,后续再接事件上报通道。 |
|
||||
| 轻量 Trace Tree | HTTP、定时任务、Kafka Consumer、MyBatis 查询/更新 | `TraceContext`、`@EasyTrace`、`TraceCodeBlock`、`EasyHttpSlowRequestInterceptor`、`EasyMybatisTraceInterceptor` | 不依赖外部 tracing;入口超出阈值或异常时打印本地调用树,MyBatis 层 tag 只保留 `SqlCommandType`,连续重复叶子节点聚合为 `count/total/min/max`。 |
|
||||
| 定时任务 | 本地消息恢复、固定账期批处理 | `@EasyJob`、`JobExecutionMode`、`ScheduleJobManager`、`schedule_instance` | 默认 `SINGLETON` 通过分布式锁互斥;`BROADCAST` 可唤醒 Batch Worker,也可让每个实例直接从源队列表 Claim。 |
|
||||
| 批处理治理 | 用户导入、批量同步、报表生成、每日对账等固定数据集 | `BatchTaskService`、`EasyBatchRunner`、`EasyBatchBusinessKeys`、`batch_task`、`batch_task_item` | 普通模式支持分页/游标;分布式模式只由准备者执行 Reader,Worker 通过持久化 `input_json`、ItemDecoder、Claim/Lease 实现 item 级断点续跑。补跑创建新 `runNo`,持续流入数据不复制为 Batch 分区。 |
|
||||
| 本地消息 | 事务后即时发送、失败重试和崩溃恢复 | `EasyLocalMessageTemplate`、`LocalMessageDeliveryService`、`LocalMessageRetryJob` | 本地事务内只落 `PENDING`,提交后线程池立即 Claim 并发送;失败回到 `PENDING` 并退避,广播任务直接 Claim 源表兜底。 |
|
||||
| 幂等 | 重复提交保护 | `@Idempotent`、`IdempotentAspect` | 用业务 key 防止重试或重复提交造成重复写入。 |
|
||||
| 重复请求限制 | 用户保存 `POST /api/system/users` | `@EasyDuplicateRequestLimiter`、`ConcurrentHashMapDuplicateRequestLimiter` | 适合表单短时间重复点击,不替代长期幂等。 |
|
||||
| 限流 | 登录和验证码接口 | `@EasyRateLimit`、`EasyRateLimiterAspect`、`InMemoryRateLimiter` | 适合登录、验证码、导出等高风险入口按 IP、用户或全局限流。 |
|
||||
| 分布式锁 | 锁基础设施 | `IEasyLocker`、`MysqlEasyLocker`、`RedisEasyLocker` | 适合跨实例互斥任务,优先放在任务或关键业务服务边界。 |
|
||||
| 安全分页查询 | 参数解析组件和单元测试 | `PageRequestArgumentResolver`、`@PageQuery`、`PageRequestArgumentResolverTest` | 新列表接口可直接接入字段白名单,避免前端控制 SQL 列名。 |
|
||||
|
||||
### 系统管理模型
|
||||
|
||||
用户、角色、菜单权限和组织是脚手架的基础域,不建议在业务模块里直接散写 SQL。当前实现把常用关系查询收口到专门的 Mapper / Service:
|
||||
|
||||
| 场景 | 入口 | 设计要点 |
|
||||
| --- | --- | --- |
|
||||
| 用户列表 | `SysUserServiceImpl#pageUsers`、`SysUserRelationService` | 用户分页只查主表;部门名称和角色绑定用批量投影补齐,避免逐行查部门、逐行查角色。 |
|
||||
| 审批关系 | `SysUserServiceImpl#saveUser`、`SysDeptServiceImpl#saveDepartment`、`SysUserRelationService` | 用户表维护 `manager_user_id` 作为直属上级,部门表维护 `leader_user_id` 作为部门负责人;用户列表批量补齐直属上级、部门负责人和上级部门负责人,流程配置页直接使用这些企业组织关系。 |
|
||||
| 用户角色绑定 | `ISysUserRoleService`、`SysUserRoleMapper` | `sys_user_role` 是纯关系表,保存前按用户硬删旧绑定,再批量插入新绑定;初始化 SQL 对 `(user_id, role_id)` 加唯一约束,防止重复授权。 |
|
||||
| 角色列表用户数 | `SysRoleServiceImpl#pageRoles`、`SysUserRoleMapper#countUsersByRoleIds` | 用户数由数据库按角色聚合,不把整张关系表拉回应用层再分组。 |
|
||||
| 角色权限保存 | `SysRoleServiceImpl#saveRolePermissions`、`ISysRolePermissionService` | 保存授权时先按角色硬删旧权限,再批量写入选中权限及其父级导航;保存后递增权限版本并写敏感变更审计。 |
|
||||
| 当前用户菜单 | `SysMenuMapper#findEnabledByUserId` | 普通用户菜单通过 `sys_user_role -> sys_role_permission -> sys_menu` 一次 JOIN 查询;超级管理员直接读取启用资源。 |
|
||||
| 组织层级 | `SysDeptServiceImpl#saveDepartment` | 保存部门时统一计算 `full_name` 和 `tree_path`,校验父级存在、禁止把父级改成自己或下级;删除部门前校验下级部门和部门用户。 |
|
||||
|
||||
二开建议:
|
||||
|
||||
- 关系表如果只表达绑定关系,优先使用“硬删除旧关系 + 批量插入新关系 + 唯一约束”的模式,避免逻辑删除关系表反复改动后出现重复数据或唯一键冲突。
|
||||
- 列表展示的派生字段优先做批量补齐或数据库聚合,不要在循环里调用 Service / Mapper。
|
||||
- 组织树字段由服务层统一维护,页面只提交 `deptName`、`pid`、`address`、`leaderUserId`、`status`、`sort` 等业务输入,不要让前端直接拼 `treePath`。
|
||||
|
||||
## 菜单与权限资源
|
||||
|
||||
EasyNextAdmin 不再维护前端硬编码菜单清单。目录、页面、按钮和角色授权资源统一来自服务端 `sys_menu`,角色授权页、侧边栏、页面路由和页面权限判断都读同一份数据,避免“授权配置”和“真实导航”对不上的问题。
|
||||
|
||||
角色授权页通过 `/api/system/roles/permission-resources` 实时读取 `sys_menu`,保存授权时后端会校验每个权限码是否真实存在,不再静默丢弃无效权限。用户创建和编辑时也会校验绑定角色必须存在且启用,避免导入、接口调用和页面选择出现不一致授权。
|
||||
|
||||
页面资源最少需要维护这些字段:
|
||||
|
||||
- `type = 1`:页面节点;`type = 0` 表示目录分组,`type = 2` 表示按钮。
|
||||
- `href`:页面路由,例如 `/messages`。
|
||||
- `permission_code`:页面权限码,例如 `message:view`。
|
||||
- `component_path`:本地页面路径,例如 `@/views/message/MessageCenterView.vue`。
|
||||
- `visible`、`enable`、`sort`:控制菜单可见性、资源启停和排序。
|
||||
|
||||
按钮资源使用 `type = 2` 挂在页面节点下,只维护按钮名称、权限码和用途说明。后端控制器使用同名 `EasyPermissions` 常量兜底,前端按钮用 `v-permission` 隐藏或禁用。
|
||||
|
||||
新增页面的最小 SQL 示例:
|
||||
|
||||
```sql
|
||||
INSERT INTO sys_menu
|
||||
(id, title, type, href, icon, permission_code, component_path, pid, sort, visible, enable)
|
||||
VALUES
|
||||
(9000, '消息中心', 1, '/messages', 'Bell', 'message:view',
|
||||
'@/views/message/MessageCenterView.vue', 0, 120, 1, 1);
|
||||
|
||||
INSERT INTO sys_menu
|
||||
(id, title, type, permission_code, pid, sort, visible, enable)
|
||||
VALUES
|
||||
(9001, '全部已读', 2, 'message:read-all', 9000, 10, 0, 1);
|
||||
```
|
||||
|
||||
前端动态路由由 `easy-next-admin-web/src/router/dynamicRoutes.ts` 负责。它只在 Vite 已知的 `src/views/**/*.vue` 中解析 `component_path`,不会执行数据库传入的任意前端代码。
|
||||
|
||||
## 权限使用
|
||||
|
||||
页面可见性由 `/api/auth/me` 返回的授权菜单决定。页面路由生成后会把响应字段 `permissionCode` 写入 route meta,路由守卫统一校验。
|
||||
|
||||
按钮权限:
|
||||
|
||||
```vue
|
||||
<el-button v-permission="'sys:user:add'" type="primary">
|
||||
新增用户
|
||||
</el-button>
|
||||
```
|
||||
|
||||
后端接口权限:
|
||||
|
||||
```java
|
||||
@EasyPermission(EasyPermissions.System.USER_ADD)
|
||||
@PostMapping
|
||||
public Response<Void> save(@RequestBody UserRequest request) {
|
||||
userService.saveUser(request);
|
||||
return Response.ok();
|
||||
}
|
||||
```
|
||||
|
||||
实现原则:
|
||||
|
||||
- 前端负责隐藏不可见菜单和按钮,提升使用体验。
|
||||
- 后端 `@EasyPermission` 是真正的权限边界,所有写操作和敏感查询都必须配置。
|
||||
- 权限码在 `sys_menu`、后端 `EasyPermissions` 和前端 `PermissionCodes` 中保持同名;`sys_menu` 决定资源树,后端注解决定接口边界,前端常量只用于按钮指令。
|
||||
|
||||
## 通用前端组件
|
||||
|
||||
### EasyChart
|
||||
|
||||
位置:`easy-next-admin-web/src/components/charts/EasyChart.vue`
|
||||
|
||||
用于封装 ECharts 初始化、响应式尺寸和销毁逻辑。适合监控和工作台图表。
|
||||
|
||||
```vue
|
||||
<EasyChart :option="chartOption" height="320px" />
|
||||
```
|
||||
|
||||
### TableToolbar
|
||||
|
||||
位置:`easy-next-admin-web/src/components/table/TableToolbar.vue`
|
||||
|
||||
用于标准 CRUD 页面的查询区、刷新、新增、导出等工具栏承载。页面应保持“筛选区 + 工具栏 + 表格 + 分页”的企业后台结构。
|
||||
|
||||
### v-permission
|
||||
|
||||
位置:`easy-next-admin-web/src/directives/permission.ts`
|
||||
|
||||
用于按钮级权限控制。默认会禁用无权限按钮并显示原因,避免布局跳动。
|
||||
|
||||
```vue
|
||||
<el-button v-permission="{ permissions: 'sys:user:export', mode: 'disable' }">
|
||||
导出
|
||||
</el-button>
|
||||
```
|
||||
|
||||
### API 请求封装
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
easy-next-admin-web/src/api/request.ts
|
||||
easy-next-admin-web/src/features/*/api.ts
|
||||
```
|
||||
|
||||
页面不直接写 Axios 请求。新增接口时先放到对应 `features/*/api.ts`,页面只调用业务函数。
|
||||
|
||||
当前前端把登录响应中的 access token 写入 Pinia 并持久化到 `localStorage`,然后由 `request.ts` 放入 `Authorization: Bearer ...` 请求头。这种方式便于本地开发和前后端分离调试,但不应作为公开生产环境的最终会话方案:一旦页面出现 XSS、浏览器扩展注入、第三方脚本污染或调试台泄露,脚本可以直接读取 `localStorage` 中的 token 并转移到攻击者环境。
|
||||
|
||||
企业级生产路线固定为服务端会话 + `HttpOnly; Secure; SameSite` Cookie,并为写接口配置 CSRF 防护。当前 Bearer token 路线只保留为开发调试路线:
|
||||
|
||||
- 生产环境把会话标识放入 `HttpOnly; Secure; SameSite=Lax/Strict` Cookie,前端 JavaScript 不读取会话标识。
|
||||
- 写接口增加 CSRF 防护,例如 SameSite Cookie + CSRF token 请求头,或双提交 Cookie。
|
||||
- 会话通过服务端续期和撤销控制,并支持空闲超时、绝对超时、权限版本和服务端主动撤销。
|
||||
- CORS 使用 `easy.web.cors` 明确白名单,不反射任意 Origin;安全响应头通过 `easy.web.security-headers` 配置,生产 HTTPS 可开启 HSTS 并逐步收紧 CSP。
|
||||
- 前端只保存用户展示信息、菜单和权限展示状态;不要把可直接调用接口的密钥、refresh token 或长期凭证放入浏览器可读存储。
|
||||
|
||||
### 侧边栏与标签页
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
easy-next-admin-web/src/layout/AppLayout.vue
|
||||
easy-next-admin-web/src/layout/SidebarMenuNode.vue
|
||||
easy-next-admin-web/src/layout/TagsView.vue
|
||||
```
|
||||
|
||||
侧边栏菜单来自 `/api/auth/me` 的 `menus`,前端按 `visible`、`enable` 和父子层级渲染。标签页状态由 Pinia store `src/stores/tagsView.ts` 管理。
|
||||
|
||||
## 后端基础组件
|
||||
|
||||
### 统一响应
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
common/model/Response.java
|
||||
common/model/PageResponse.java
|
||||
common/exception/ErrorCode.java
|
||||
infrastructure/web/handler/EasyResponseBodyAdvice.java
|
||||
infrastructure/web/handler/GlobalExceptionHandler.java
|
||||
```
|
||||
|
||||
普通接口返回 `Response<T>`,分页接口返回 `PageResponse<T>`。响应体固定使用 `code`、`message` 和业务 `data`。参数校验失败、字段错误等错误明细放 `details`,但 `details` 只在非空时返回;不要返回 `details: null`,也不要把错误数组混入业务数据。成功与否只看 `code == 0`,不再返回派生字段 `success`,避免同一响应里出现 `code` 和 `success` 不一致。
|
||||
|
||||
`PageResponse<T>` 的泛型 `T` 表示单条记录类型,不是列表类型。正确写法是 `PageResponse<SysUser>`,响应里的 `data` 固定是 `PageData<SysUser>`;不要写 `PageResponse<List<SysUser>>`,否则接口语义会变成“分页响应里再套一层列表类型”,生成的 OpenAPI 和前端类型都会变得不直观。分页数据只包含 `list` 和 `total`:`list` 是当前页记录,`total` 是匹配条件的总数。当前页码和每页数量属于请求条件,前端状态已经持有,不在响应体里重复派生。
|
||||
|
||||
错误码由 `ErrorCode` 统一维护,HTTP 状态表达协议层结果,响应体 `code` 表达稳定业务错误。成功固定为 `0`;错误码采用“HTTP 状态码 + 三位业务序号”,例如 `400004` 表示参数校验失败,`400100` 表示普通业务失败。前端可按 `Math.trunc(code / 1000)` 识别 401、403 等大类,但不要把业务码当成 HTTP 状态码。
|
||||
|
||||
常用错误码:
|
||||
|
||||
| 错误码 | HTTP 状态 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| `0` | 200 | 成功 |
|
||||
| `400000` | 400 | 请求参数错误 |
|
||||
| `400004` | 400 | 参数校验失败,字段明细在 `details` |
|
||||
| `400100` | 400 | 业务处理失败 |
|
||||
| `401000` | 401 | 未登录或登录已过期 |
|
||||
| `401001` | 401 | 用户名或密码不正确 |
|
||||
| `401002` | 401 | 验证码错误、过期或缺失 |
|
||||
| `401003` | 401 | 会话已过期 |
|
||||
| `401004` | 401 | 权限版本已变化,需要重新登录 |
|
||||
| `403000` | 403 | 无访问权限 |
|
||||
| `403001` | 403 | 账号已被禁用 |
|
||||
| `404000` | 404 | 资源不存在 |
|
||||
| `409000` | 409 | 资源冲突或重复 |
|
||||
| `413000` | 413 | 上传文件过大 |
|
||||
| `415000` | 415 | 不支持当前媒体类型 |
|
||||
| `429000` | 429 | 请求过于频繁 |
|
||||
| `500000` | 500 | 服务端未知异常 |
|
||||
|
||||
本项目的公开响应保留数字型 `code`。数字码适合前端分组判断、日志检索、监控聚合和文档表格;字符串语义由 `ErrorCode` 枚举名承载,代码里不要再额外发明一套字符串码。只有在项目后续要开放给外部第三方长期集成时,才建议新增 `errorKey` 字段,并让它直接来自 `ErrorCode.name()`,避免“双码表”不一致。
|
||||
|
||||
业务代码抛 `BusinessException` 时,默认归类为 `400100`;确实需要表达不存在、冲突、文件过大、媒体类型不支持等稳定语义时传入具体 `ErrorCode`。不要写 `new BusinessException("xxx", 400)` 这类裸数字构造;错误语义必须收敛到 `ErrorCode`,否则后续检索、监控和前端处理都会分叉。
|
||||
|
||||
认证和授权单独分层:认证失败抛 `EasyAuthException`,只能使用 401 系列错误码;已登录但无权访问抛 `EasyForbiddenException`,只能使用 403 系列错误码。登录密码错误、验证码错误、会话过期和权限版本变化要传入对应认证错误码;账号禁用、按钮权限不足、数据不可见等属于 403。异常统一由 `GlobalExceptionHandler` 转换为一致错误结构。链路追踪号只使用 `X-Trace-Id` 请求头和响应头,不再维护额外请求编号;服务端会把 `traceId` 和认证后的 `userId` 写入 MDC,日志排查时可直接按这两个字段检索。
|
||||
|
||||
### 安全分页查询
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/web/mvc/PageRequest.java
|
||||
infrastructure/web/mvc/PageQuery.java
|
||||
infrastructure/web/mvc/PageQueryField.java
|
||||
infrastructure/web/mvc/PageRequestArgumentResolver.java
|
||||
```
|
||||
|
||||
用于需要“分页 + 通用筛选 + 通用排序”的列表接口。Controller 不能裸用 `PageRequest`,必须用 `@PageQuery` 声明字段白名单:
|
||||
|
||||
```java
|
||||
enum UserQueryField implements PageQueryField {
|
||||
USERNAME("username", "username"),
|
||||
STATUS("status", "status"),
|
||||
CREATED_AT("createdAt", "created_at");
|
||||
|
||||
private final String paramName;
|
||||
private final String columnName;
|
||||
|
||||
UserQueryField(String paramName, String columnName) {
|
||||
this.paramName = paramName;
|
||||
this.columnName = columnName;
|
||||
}
|
||||
|
||||
public String paramName() {
|
||||
return paramName;
|
||||
}
|
||||
|
||||
public String columnName() {
|
||||
return columnName;
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PageResponse<SysUser> page(@PageQuery(fields = UserQueryField.class, maxSize = 100) PageRequest pageRequest) {
|
||||
Page<?> page = pageRequest.toPage();
|
||||
QueryWrapper<?> wrapper = pageRequest.getQueryWrapper();
|
||||
// 继续叠加数据权限、业务固定条件,再交给 Mapper / Service 查询。
|
||||
}
|
||||
```
|
||||
|
||||
前端请求示例:
|
||||
|
||||
```text
|
||||
GET /api/system/users?page=1&size=10&filter=username|like|admin,status|eq|1&sort=createdAt|desc
|
||||
```
|
||||
|
||||
设计规则:
|
||||
|
||||
- 前端只传 `paramName`,例如 `createdAt`;真实列名 `created_at` 只来自服务端枚举。
|
||||
- 未在枚举中声明的筛选字段和排序字段会返回 `400004 参数校验失败`。
|
||||
- `size` 必须大于等于 1,且不能超过 `@PageQuery.maxSize`。
|
||||
- 字段枚举可通过 `filterable()`、`sortable()` 和 `allowedOperators()` 限制某个字段是否允许筛选、排序或使用指定操作符。
|
||||
- 该组件只解决通用列表条件解析;业务强约束、数据权限和固定过滤条件仍应在 Service 层显式追加。
|
||||
|
||||
典型响应示例:
|
||||
|
||||
用户分页查询成功,HTTP 状态为 200:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"userId": 202604280101000001,
|
||||
"userName": "admin",
|
||||
"nickName": "超级管理员"
|
||||
}
|
||||
],
|
||||
"total": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
登录请求缺少用户名,属于入参校验失败,HTTP 状态为 400:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400004,
|
||||
"message": "参数校验失败",
|
||||
"data": null,
|
||||
"details": [
|
||||
{
|
||||
"field": "username",
|
||||
"message": "请输入用户名"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
登录用户名或密码错误,属于认证失败,HTTP 状态为 401:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 401001,
|
||||
"message": "用户名或密码不正确"
|
||||
}
|
||||
```
|
||||
|
||||
创建用户时未选择部门,属于可预期业务规则失败,HTTP 状态为 400:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 400100,
|
||||
"message": "请选择部门"
|
||||
}
|
||||
```
|
||||
|
||||
创建用户触发唯一约束或重复资源,属于资源冲突,HTTP 状态为 409:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 409000,
|
||||
"message": "数据库中已存在该记录"
|
||||
}
|
||||
```
|
||||
|
||||
### 缓存组件
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
config/EasyCacheConfig.java
|
||||
module/system/service/impl/SysUserServiceImpl.java
|
||||
module/monitor/service/CacheMonitorService.java
|
||||
```
|
||||
|
||||
缓存组件按“命名缓存 + 明确 TTL”配置。`easy.features.redis=true` 时缓存管理器使用 Redis / Redisson,并通过 Micrometer 读取 Redisson Spring Cache 的命中、未命中和清理统计;关闭 Redis 时自动回退到 Caffeine,本地 fallback 也保持 `CACHE_NAME_1H`、`CACHE_NAME_12H`、`CACHE_NAME_24H` 各自对应的过期语义。
|
||||
|
||||
Redis 是能力级开关,不需要分别维护“缓存是否用 Redis、会话是否用 Redis、锁是否用 Redis”等细碎配置。相关开关和连接参数都放在 `easy` 命名空间下:`easy.features.redis` 控制是否启用 Redis 能力,`easy.spring.redis.*` 控制连接地址、数据库、密码和客户端名称。启用后,会话、验证码、重复请求、幂等、限流和分布式锁都会通过 Redis/Redisson 实现;未启用时分别回退到内存、Caffeine 或 MySQL。
|
||||
|
||||
Kafka 也是能力级开关。`easy.features.kafka=true` 只负责启用 Kafka 基础设施 Bean,包括 Producer、Consumer、Topic Admin、消费慢链路追踪和健康检查;未启用时这些 Bean 不会创建。业务消息目前仍以数据库本地消息表作为默认最终一致性方案,后续如果要把具体业务事件切到 Kafka,需要先抽象明确的事件发布端口,再按业务场景逐步接入。
|
||||
|
||||
Feign、调度、监控、WebLog、OSS、本地消息和 Influx 指标导出也保持“显式开关 + 默认不误连外部依赖”的原则。`easy.features.feign` 只启用远程调用基础配置,不代表默认存在外部服务调用;Influx 指标导出默认关闭,需要企业已有指标平台时再通过 `MANAGEMENT_INFLUX_METRICS_EXPORT_ENABLED=true` 接入。
|
||||
|
||||
当前项目的缓存以命名缓存和监控治理为主:
|
||||
|
||||
```text
|
||||
GET /api/monitor/cache/overview
|
||||
GET /api/monitor/cache/{cacheName}/entries
|
||||
DELETE /api/monitor/cache/{cacheName}
|
||||
DELETE /api/monitor/cache/{cacheName}/entries?key=...
|
||||
```
|
||||
|
||||
`CacheMonitorService` 会读取 Spring Cache / Redisson / Caffeine 的运行状态,展示 provider、TTL、估算大小、命中率、淘汰次数和 key/value 预览。value 预览在返回前会经过 `EasySensitiveDataMasker` 脱敏,并限制最大长度,避免缓存监控页面变成敏感数据泄露入口。
|
||||
|
||||
数据权限组件使用独立命名缓存:`DATA_SCOPE_DEPT_TREE` 缓存有效部门树,部门维护通过 `@CacheEvict` 失效;`DATA_SCOPE_CUSTOM_DEPT_IDS` 按用户缓存自定义部门范围,角色授权或用户角色变化后失效。`CacheManager` 统一使用事务感知代理,缓存写入和失效在事务提交后生效。
|
||||
|
||||
用户详情、用户编辑、删除、重置密码和部门维护这类强数据权限接口不做共享详情缓存,避免“缓存命中绕过当前用户数据范围”的问题。需要缓存的新业务必须先判断数据是否和当前用户权限强相关;如果强相关,优先不缓存或把权限范围纳入 key。
|
||||
|
||||
接入规则:
|
||||
|
||||
- 优先缓存读多写少、可容忍短 TTL、且不依赖当前用户数据范围的查询结果,例如字典、菜单树和聚合统计。
|
||||
- 缓存 key 必须带业务前缀,例如 `system:menu:tree:{scope}` 或 `dict:item:{dictCode}`,避免同一个 TTL 缓存里不同业务 key 冲突。
|
||||
- 写操作必须精确驱逐相关 key;只有无法计算影响范围时才考虑清理整个命名缓存。
|
||||
- 不要缓存包含明文密码、token、验证码、临时授权码或大文件内容的对象。
|
||||
- 缓存运行状态在 `/monitor/cache` 查看;缓存 key/value 在 `/monitor/cache-list` 查看。清理缓存和清理 key 动作都会进入审计。
|
||||
|
||||
### 限流与防重复提交
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/ratelimit
|
||||
infrastructure/idempotency/duplicate
|
||||
module/system/controller/AuthController.java
|
||||
module/system/controller/SysUserController.java
|
||||
```
|
||||
|
||||
限流用于保护高风险入口,例如登录和验证码。当前项目在认证接口上按客户端 IP 限制访问频率:
|
||||
|
||||
```java
|
||||
@EasyRateLimit(
|
||||
key = "auth:login",
|
||||
limit = 10,
|
||||
timeWindow = 1,
|
||||
timeUnit = TimeUnit.MINUTES,
|
||||
type = EasyRateLimitType.CLIENT_IP,
|
||||
message = "登录请求过于频繁,请稍后再试")
|
||||
@PostMapping("/login")
|
||||
public Response<AuthTokenResponse> login(@Valid @RequestBody AuthLoginRequest loginRequest,
|
||||
HttpServletRequest servletRequest) {
|
||||
return Response.ok(authService.login(loginRequest, servletRequest));
|
||||
}
|
||||
```
|
||||
|
||||
防重复提交用于处理用户短时间重复点击。当前项目在用户保存接口上按“当前用户 + 用户 ID / 用户名”生成短期重复提交 key:
|
||||
|
||||
```java
|
||||
@EasyDuplicateRequestLimiter(
|
||||
businessKey = "system:user:save",
|
||||
businessParam = "#userRequest.userId == null ? #userRequest.userName : #userRequest.userId",
|
||||
timeout = 2)
|
||||
@PostMapping
|
||||
public Response<Boolean> saveOrUpdate(@RequestBody UserRequest userRequest) {
|
||||
return Response.ok(sysUserService.saveUser(userRequest));
|
||||
}
|
||||
```
|
||||
|
||||
使用边界:
|
||||
|
||||
- `@EasyRateLimit` 是访问频率保护,适合登录、验证码、导出、上传等入口。
|
||||
- `@EasyDuplicateRequestLimiter` 是短窗口重复提交保护,适合表单保存按钮,不保证长期幂等。
|
||||
- 长期幂等需要业务幂等 key 或 `@Idempotent`,例如本地消息重试场景。
|
||||
|
||||
### 认证会话
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/security/filter/EasyAuthFilter.java
|
||||
infrastructure/security/service/EasyAuthService.java
|
||||
infrastructure/security/store
|
||||
```
|
||||
|
||||
登录成功后服务端创建访问令牌和会话快照。启用 Redis 时会话、验证码和登录失败标记存入 Redis;未启用 Redis 时回退内存。
|
||||
|
||||
完整链路:
|
||||
|
||||
1. 账号创建:`SysUserController` 调用 `SysUserServiceImpl`,新密码通过 `EasyPasswordHasher` 写成 BCrypt 摘要,用户角色关系写入 `sys_user_role`。
|
||||
2. 登录:`AuthController.login` 调用 `EasyAuthService.login`,先判断验证码风险标记,再按用户名查用户、校验 BCrypt、检查账号启用状态。
|
||||
3. 会话创建:登录成功后生成随机 access token,只把 token 摘要保存到 `AuthSessionStore`,会话快照保存用户、角色、权限、部门、数据范围和权限版本。空闲超时默认 `30m`,绝对超时默认 `8h`,可通过 `easy.auth.session.*` 或对应环境变量覆盖。生产路线应把会话标识写入 HttpOnly Cookie,不暴露给前端脚本。
|
||||
4. 会话验证:`EasyAuthFilter` 在当前实现中从 `Authorization` 头读取 Bearer token,查会话、检查状态、空闲过期时间和绝对过期时间,必要时滑动续期,但不会超过绝对超时。生产 Cookie 会话落地时应从 Cookie 恢复会话,并对写接口执行 CSRF 校验。
|
||||
5. 认证上下文:认证成功后把 `AuthPrincipal` 和原始 token 写入 `EasySecurityContext`。请求结束必须清理上下文,避免 Servlet 线程复用导致串用户。
|
||||
6. 鉴权:`EasyPermissionInterceptor` 读取 Controller 或方法上的 `@EasyPermission`,按当前用户权限码判断是否放行;超级管理员角色编码 `admin` 可以跳过权限码校验。
|
||||
7. 退出和会话治理:`/api/auth/logout` 撤销当前会话;在线用户和个人中心会话管理通过 `AuthSessionStore.revoke` 下线指定会话;修改密码后撤销其他会话。
|
||||
|
||||
权限版本:
|
||||
|
||||
`sys_user.permission_version` 是当前用户授权快照的版本号。登录时会把版本号放入会话快照;每次请求恢复会话时,后端会读取用户当前版本并和快照比较。如果用户角色绑定、角色权限、角色数据范围、菜单资源或账号状态发生变化,相关服务会递增权限版本;旧会话下一次请求发现版本不一致后立即失效并要求重新登录。
|
||||
|
||||
这样做的目的不是防止密码攻击,而是解决“权限已经收回但旧 token 还带着旧权限”的问题。二开时只要改动会影响用户实际权限,就要调用 `PermissionVersionService`:
|
||||
|
||||
- 修改某个用户角色、账号启停或重置关键授权状态:`increaseForUser(userId)`。
|
||||
- 修改某个角色的权限、数据范围或状态:`increaseForRole(roleId)`。
|
||||
- 修改菜单权限资源或全局授权语义:`increaseForAllUsers()`。
|
||||
|
||||
生产强化建议:
|
||||
|
||||
- 演示账号接口只在本地或体验环境开放,生产关闭或不返回密码。
|
||||
- 默认密码改为一次性临时密码或邀请链接,首次登录强制修改密码。
|
||||
- 登录失败按账号、IP 和设备维度限速,达到阈值后冷却或锁定。
|
||||
- 高危动作如角色授权、踢人下线、重置密码可叠加二次确认或 MFA。
|
||||
- 对 `/api/**` 建议采用默认拒绝策略:除 `@EasyIgnoreAuth`、`@EasyPermission` 或明确登录态接口外,其余接口不放行。
|
||||
|
||||
### 数据权限
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/security/datascope
|
||||
infrastructure/security/datascope/model/DataScopeType.java
|
||||
```
|
||||
|
||||
角色上保存数据范围。查询执行时,MyBatis 拦截器根据当前登录用户、角色数据范围和部门树自动追加过滤条件。
|
||||
|
||||
完整使用方式、原理和方案取舍见 [数据权限组件](components/security/data-scope.md)。
|
||||
|
||||
### 通用脱敏组件
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/security/masking/EasyMask.java
|
||||
infrastructure/security/masking/EasyMaskType.java
|
||||
infrastructure/security/masking/EasySensitiveDataMasker.java
|
||||
```
|
||||
|
||||
成熟企业后台通常不会只靠一种脱敏方式。EasyNextAdmin 采用“字段注解 + 边界组件”的分层方案:
|
||||
|
||||
- 字段注解:响应 DTO、导出 DTO、审计快照 DTO 上使用 `@EasyMask`,由 Jackson 序列化时自动输出脱敏值。
|
||||
- 边界组件:请求参数、Map、URI、异常文本、接口日志和审计日志使用 `EasySensitiveDataMasker`,因为这些场景没有稳定 DTO 字段注解可依赖。
|
||||
- 业务存储:数据库仍按业务需要保存原值;进入日志、审计、导出文件或对外响应前必须转换成脱敏结果。
|
||||
- 前端展示:优先展示服务端已经脱敏的字段,不把浏览器端遮盖当成安全边界。
|
||||
|
||||
`@EasyMask` 适合明确的字段级输出:
|
||||
|
||||
```java
|
||||
public record UserProfileView(
|
||||
@EasyMask(type = EasyMaskType.NAME) String realName,
|
||||
@EasyMask(type = EasyMaskType.PHONE) String phone,
|
||||
@EasyMask(type = EasyMaskType.EMAIL) String email,
|
||||
@EasyMask(type = EasyMaskType.BANK_CARD) String bankCard,
|
||||
@EasyMask String token) {
|
||||
}
|
||||
```
|
||||
|
||||
`EasySensitiveDataMasker` 是后端统一脱敏组件,审计、接口日志、异常日志和后续业务模块都应复用它,不要在各模块重复写正则或 JSON 处理逻辑。它提供四类能力:
|
||||
|
||||
- `toSanitizedJson(value)`:把对象或 Map 转成 JSON,并按字段名脱敏。
|
||||
- `toSanitizedCompactJson(value)`:脱敏后移除空字符串和 `null`,适合审计请求参数。
|
||||
- `sanitizeJsonText(text)`:处理已有 JSON 字符串,保留结构并脱敏。
|
||||
- `maskText(text)` / `maskUri(uri)`:处理普通文本和 URL 查询参数。
|
||||
|
||||
默认脱敏规则:
|
||||
|
||||
- `password`、`token`、`authorization`、`captcha`、`secret`、`apikey` 等字段全量替换为 `******`。
|
||||
- `phone`、`mobile`、`tel` 等手机号字段保留前三后四。
|
||||
- `email`、`mail` 等邮箱字段保留首字母和域名。
|
||||
- `realName` 保留首字。
|
||||
- `idCard`、`identityNo` 等证件号保留前六后四。
|
||||
- `bankCard`、`cardNo` 等卡号保留前四后四。
|
||||
- `ServletRequest`、`ServletResponse`、`MultipartFile`、`BindingResult`、输入输出流和 `Principal` 这类框架对象不会作为业务参数落审计。
|
||||
|
||||
使用示例:
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class BusinessAuditService {
|
||||
private final EasySensitiveDataMasker masker;
|
||||
|
||||
public BusinessAuditService(EasySensitiveDataMasker masker) {
|
||||
this.masker = masker;
|
||||
}
|
||||
|
||||
public String safePayload(Object payload) {
|
||||
return masker.toSanitizedCompactJson(payload);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不同场景的使用方式:
|
||||
|
||||
| 场景 | 推荐方式 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| API 响应 DTO | `@EasyMask` | 字段语义清晰,直接在序列化阶段输出脱敏值。 |
|
||||
| 导出文件 DTO | `@EasyMask` 或导出前调用组件 | 导出属于数据外发边界,不能直接复用数据库实体原值。 |
|
||||
| 审计请求参数 | `AuditRequestPayloadFormatter` + `EasySensitiveDataMasker` | 过滤框架对象、空值和 `null`,再按字段名脱敏。 |
|
||||
| 接口访问日志 / 实时日志 | `EasySensitiveDataMasker` | Map、数组、原始请求体和响应片段没有稳定注解。 |
|
||||
| URL 查询参数 | `maskUri(uri)` | 防止 token、验证码、授权参数出现在日志和审计详情里。 |
|
||||
| 异常消息 / 普通文本 | `maskText(text)` | 用作兜底,优先级低于结构化 JSON 脱敏。 |
|
||||
| 数据库持久化 | 原值按业务保存,另建脱敏视图或 DTO | 脱敏不是加密;需要保护存储时应使用加密或哈希。 |
|
||||
|
||||
设计原则:
|
||||
|
||||
- 脱敏发生在入库、写日志或对外展示前,原始请求对象只在当前请求内使用。
|
||||
- 优先使用结构化 JSON 脱敏,不把对象先拼成字符串再处理。
|
||||
- 新增字段级输出时优先加 `@EasyMask`;新增通用字段名时修改组件的字段集合,并补充单元测试。
|
||||
- 组件只负责“遮盖和压缩可记录内容”,不承担权限判断、加密或数据访问控制。
|
||||
|
||||
### 审计
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
infrastructure/audit
|
||||
module/audit
|
||||
infrastructure/security/masking
|
||||
```
|
||||
|
||||
`@EasyAudit` 和审计采集器记录登录、关键操作、数据变更、错误和 API 访问日志。系统管理、文件中心、消息已读、定时任务和工作流处理等关键写入口使用语义化 `@EasyAudit` 标注模块、动作、业务类型和变更类型。审计请求参数由 `AuditRequestPayloadFormatter` 组装成有字段名的 JSON,并复用 `EasySensitiveDataMasker` 过滤框架对象、脱敏敏感字段和移除空值,避免出现 `HttpServletRequest` 包装对象、数组式参数或 `null` 噪声。审计中心按类型聚合展示。涉及菜单发布、权限授权等敏感变更时,业务服务调用 `SensitiveAuditService` 写入数据变更日志,页面的“敏感变更”视图展示这些真实记录。
|
||||
|
||||
### 运行监控
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
module/monitor
|
||||
infrastructure/observability
|
||||
```
|
||||
|
||||
服务监控读取 JVM、CPU、内存、线程、磁盘和 GC 水位,并用图表展示资源压力、内存结构、磁盘容量和 GC 状态;实时日志读取 logback 当前文件日志的尾部快照,支持关键词、级别和行数过滤。服务监控页面不展示 Actuator 明细清单。
|
||||
|
||||
### 动态定时任务
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
module/schedule
|
||||
```
|
||||
|
||||
任务定义存入数据库,调度管理器按任务编码、Cron、锁租约、执行模式和启停状态注册运行任务。多实例下每个节点写入 `schedule_instance` 心跳并对齐数据库目标状态;`SINGLETON` 执行前通过分布式锁互斥,`BROADCAST` 让每个在线实例执行。执行日志记录 runId、instanceId、traceId、耗时和异常摘要。完整设计见 [Easy Job 设计](development/easy-job-design.md)。
|
||||
|
||||
### 轻量工作流
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
module/workflow
|
||||
easy-next-admin-web/src/views/workflow
|
||||
easy-next-admin-web/src/features/workflow
|
||||
```
|
||||
|
||||
流程定义使用图结构 JSON 保存。保存当前版本和发布新版本时,后端会把节点、审批规则和连线条件同步到 `wf_process_node`、`wf_process_transition`,让图设计、接口契约和表结构形成可查询闭环。发布后生成版本,实例绑定发起时版本。启用、发布和发起前会校验图结构,拒绝环路、多出口无默认路径、不支持的条件表达式、无效审批人和无可用成员的角色。运行时拆分为流程实例、待办任务、历史任务、抄送和事件记录;实例监控页和“我发起的”按运行中流程和历史流程分开查询,单范围查询走数据库分页。前端使用 LogicFlow 展示和维护流程图,审批节点使用人员图标表达“需要人处理”。审批方式由 `approveType` 控制:`ANY_ONE` 任一人处理即流转,`ALL` 所有人处理后流转,`SEQUENTIAL` 按处理人顺序逐个生成待办;节点动作开关控制转办、委派、加签、减签和退回是否允许,避免配置字段只展示不生效。
|
||||
|
||||
审批人解析遵循“组织关系优先、职能角色补充”的企业后台模型:直属上级来自用户管理的 `manager_user_id`,部门负责人来自组织架构的 `leader_user_id`,跨层级审批使用上级部门负责人,财务、审计、运维等横向职能用角色解析。发起人就是本部门负责人时,部门负责人规则会自动上跳到上级部门负责人,避免自审。每个待办都会记录 `assignment_rule_type`、`assignment_rule_name` 和 `assignment_resolve_path`,用于审计和问题排查;实例详情只展示处理节点、处理人、时间、耗时和意见,避免把内部派单规则直接暴露到业务流水里。
|
||||
|
||||
催办属于流程事件,同时会在消息中心给当前待办处理人生成未读流程消息,消息链接可打开对应流程详情。
|
||||
|
||||
### 用户导入导出
|
||||
|
||||
位置:
|
||||
|
||||
```text
|
||||
module/system
|
||||
easy-next-admin-web/src/views/system/UserView.vue
|
||||
easy-next-admin-web/src/features/system/userApi.ts
|
||||
```
|
||||
|
||||
用户管理页提供“导入用户”和“导出用户”按钮。导入流程先下载 CSV 模板,模板使用“部门名称”和“角色编码”完成组织和角色映射;上传后端会逐行校验必填项、重复用户名、部门状态和角色状态,并返回成功数、失败数和行级错误。导出按当前筛选条件直接返回用户 CSV,适合二开时复制到其他业务模块各自实现,不保留独立中心页。
|
||||
|
||||
## 新增功能建议流程
|
||||
|
||||
新增后台页面时,重点保持这些契约同步:
|
||||
|
||||
1. 后端 `module/<domain>` 控制器、服务、实体、Mapper、DTO 和标准响应结构。
|
||||
2. `@EasyPermission`、`EasyPermissions`、前端 `PermissionCodes`、MySQL `sys_menu` 和 H2 seed。
|
||||
3. 前端 `src/features/<domain>` API/types、`src/views/<domain>` 页面和 `component_path`。
|
||||
4. 本文档中的功能表、权限说明和示例索引。
|
||||
5. 后端 `verify` 或打包、前端 `npm run build`,按影响范围补充单元测试。
|
||||
221
docs/getting-started.md
Normal file
221
docs/getting-started.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 本地开发
|
||||
|
||||
本文说明如何在本地启动 EasyNextAdmin,并确认前后端、数据库和接口文档可用。
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 工具 | 建议版本 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| JDK | 17+ | 运行 Spring Boot 3 服务端 |
|
||||
| Maven | 3.9+ | 后端依赖管理和构建 |
|
||||
| Node.js | 22 LTS 或 24 LTS | 前端开发和构建;不再建议使用已 EOL 的 Node.js 20 |
|
||||
| npm | 随 Node.js 安装 | 前端依赖管理 |
|
||||
| Docker | 24+ | 本地启动 MySQL、Redis |
|
||||
| Docker Compose | v2 | 编排本地依赖 |
|
||||
|
||||
## 根目录配置文件
|
||||
|
||||
`.editorconfig` 用于统一不同 IDE 和编辑器的基础格式,不需要手动执行。IntelliJ IDEA、WebStorm 通常会自动识别;VS Code 需要安装 EditorConfig 插件。当前规则要求 UTF-8、LF 换行、文件末尾保留换行、去除行尾空格,默认 2 空格缩进,Java 文件使用 4 空格。
|
||||
|
||||
默认启动不需要额外 `.env` 文件。`docker-compose.yml` 已经在 `${变量名:-默认值}` 中写了本地默认值;如果没有 `.env`,Docker Compose 会直接使用这些默认值。比如 `${MYSQL_PORT:-3306}:3306` 表示宿主机默认使用 `3306` 端口访问容器内 MySQL。
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `MYSQL_IMAGE` | `mysql:8.4` | 本地 MySQL 镜像,固定在 8.4 LTS 通道 |
|
||||
| `REDIS_IMAGE` | `redis:7.4-alpine` | 本地 Redis 镜像,固定在 7.4 Alpine 通道 |
|
||||
| `MYSQL_PORT` | `3306` | MySQL 映射到宿主机的端口 |
|
||||
| `MYSQL_ROOT_PASSWORD` | `123456` | 本地 root 密码,仅用于开发 |
|
||||
| `REDIS_PORT` | `6379` | Redis 映射到宿主机的端口 |
|
||||
| `REDIS_PASSWORD` | `111222` | 本地 Redis 密码,仅用于开发 |
|
||||
|
||||
临时覆盖端口时,可以直接在命令前加环境变量:
|
||||
|
||||
```bash
|
||||
MYSQL_PORT=13306 REDIS_PORT=16379 docker compose up -d
|
||||
```
|
||||
|
||||
如果经常需要覆盖,也可以自己创建根目录 `.env`,该文件不会提交到仓库:
|
||||
|
||||
```dotenv
|
||||
MYSQL_PORT=13306
|
||||
REDIS_PORT=16379
|
||||
MYSQL_ROOT_PASSWORD=123456
|
||||
REDIS_PASSWORD=111222
|
||||
```
|
||||
|
||||
生产环境应使用部署平台的密钥管理、环境变量或配置中心,不复用本地演示密码。
|
||||
|
||||
## 启动依赖
|
||||
|
||||
`docker-compose.yml` 只负责本地开发依赖,不启动业务应用:
|
||||
|
||||
- MySQL 8.4 LTS,Docker 镜像 `mysql:8.4`,默认端口 `3306`,库名 `easy-next-admin`,root 密码 `123456`,新数据卷默认使用 `caching_sha2_password`
|
||||
- Redis 7.4,Docker 镜像 `redis:7.4-alpine`,默认端口 `6379`,密码 `111222`
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
停止依赖:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## 启动服务端
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run
|
||||
```
|
||||
|
||||
默认配置:
|
||||
|
||||
- 服务端口:`8080`
|
||||
- 数据库:`jdbc:mysql://localhost:3306/easy-next-admin`;本地默认 URL 带 `createDatabaseIfNotExist=true`
|
||||
- 默认 profile:`local`
|
||||
- Flyway:启动时自动执行 `db/migration/V1__init.sql`
|
||||
- OpenAPI:`http://127.0.0.1:8080/swagger-ui.html`
|
||||
|
||||
本地默认使用 root 演示账号,因此数据库不存在时可以自动创建,再由 Flyway 初始化。若通过 `MYSQL_URL` 覆盖默认连接,需要自行保留 `createDatabaseIfNotExist=true`,或者提前创建数据库。这个便利能力只属于 `local` profile;生产环境应由 DBA 预建库并给应用账号授予目标 schema 内的最小权限,不授予全局建库权限。
|
||||
|
||||
Redis 在当前脚手架中默认启用。`local` profile 会使用 `application-local.yaml` 中的本地连接默认值:`redis://localhost:6379`,密码 `111222`。因此前面执行过 `docker compose up -d` 后,直接 `mvn spring-boot:run` 即可连接本地 Redis。
|
||||
|
||||
Redis 连接和能力开关统一放在 `easy` 命名空间下。需要排查连接参数或显式覆盖时,可以这样启动:
|
||||
|
||||
```bash
|
||||
mvn spring-boot:run \
|
||||
-Dspring-boot.run.arguments="--easy.features.redis=true --easy.spring.redis.password=111222"
|
||||
```
|
||||
|
||||
启用 Redis 后,缓存、会话、验证码、重复请求、幂等、限流和分布式锁会自动切到 Redis/Redisson 实现。临时不想启动 Redis 时,可用启动参数覆盖 `--easy.features.redis=false`,这些运行态能力会回退到本地内存或 MySQL。
|
||||
|
||||
Kafka 默认不启用。如果本地需要调试 Kafka 基础设施,启动时增加 `--easy.features.kafka=true --easy.spring.kafka.bootstrap-servers=127.0.0.1:9092`。
|
||||
|
||||
## 启动前端
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认前端启动也不需要额外 `.env` 文件。前端代码默认请求 `/api`,Vite 开发服务器默认把 `/api/**` 和 `/storage/**` 代理到 `http://localhost:8080`。
|
||||
|
||||
如果需要长期覆盖前端配置,可以自己创建 `easy-next-admin-web/.env.local`。这套变量只给 Vite 前端使用,和根目录 `.env` 不是一回事:
|
||||
|
||||
- 根目录 `.env`:给 Docker Compose 用,控制 MySQL、Redis 镜像、端口和密码。
|
||||
- `easy-next-admin-web/.env.local`:给前端开发和构建用,控制接口基础路径和本地代理目标。
|
||||
|
||||
本地个性化配置建议写入 `easy-next-admin-web/.env.local`,该文件不会提交到仓库:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-web
|
||||
touch .env.local
|
||||
```
|
||||
|
||||
如果后端不是 `8080` 端口,改 `.env.local`:
|
||||
|
||||
```dotenv
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
Vite 默认地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:5174
|
||||
```
|
||||
|
||||
前端开发服务器会把 `/api/**` 代理到 `VITE_API_PROXY_TARGET`。也可以临时通过命令行覆盖后端地址:
|
||||
|
||||
```bash
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:8081 npm run dev
|
||||
```
|
||||
|
||||
## 演示账号
|
||||
|
||||
登录页会从 `GET /api/auth/demo-accounts` 读取演示账号。该接口只在 `local` profile 返回账号清单,非本地环境返回空列表,避免生产登录页暴露初始化密码。
|
||||
|
||||
| 角色 | 账号 | 密码 |
|
||||
| --- | --- | --- |
|
||||
| 超级管理员 | `admin` | `admin` |
|
||||
| 部门负责人 | `manager` | `easynext` |
|
||||
| 普通员工 | `staff` | `easynext` |
|
||||
| 审计人员 | `auditor` | `easynext` |
|
||||
|
||||
这些账号用于本地开发,方便第一次启动后直接验证权限、流程、审计和监控页面。正式生产环境必须替换初始化密码,且不要启用 `local` profile。
|
||||
|
||||
首次登录只校验账号密码。同一用户名和客户端 IP 登录失败后,后续登录会要求验证码。
|
||||
|
||||
## 本地检查路径
|
||||
|
||||
启动完成后按下面顺序检查:
|
||||
|
||||
| 检查项 | 地址 |
|
||||
| --- | --- |
|
||||
| 前端登录页 | `http://127.0.0.1:5174/login` |
|
||||
| 工作台 | `http://127.0.0.1:5174/dashboard` |
|
||||
| 后端健康检查 | `http://127.0.0.1:8080/actuator/health` |
|
||||
| OpenAPI UI | `http://127.0.0.1:8080/swagger-ui.html` |
|
||||
| OpenAPI JSON | `http://127.0.0.1:8080/v3/api-docs` |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### MySQL 端口被占用
|
||||
|
||||
调整环境变量后再启动依赖:
|
||||
|
||||
```bash
|
||||
MYSQL_PORT=13306 docker compose up -d
|
||||
```
|
||||
|
||||
然后启动服务端时覆盖数据源地址:
|
||||
|
||||
```bash
|
||||
cd easy-next-admin-server
|
||||
mvn spring-boot:run \
|
||||
-Dspring-boot.run.arguments="--spring.datasource.url=jdbc:mysql://localhost:13306/easy-next-admin?serverTimezone=GMT%2B8&characterEncoding=UTF-8&connectionCollation=utf8mb4_unicode_ci&useSSL=false&allowPublicKeyRetrieval=true"
|
||||
```
|
||||
|
||||
### MySQL 提示 mysql_native_password is not loaded
|
||||
|
||||
这通常说明当前机器复用了旧 MySQL 数据卷,里面的 root 账号仍绑定 `mysql_native_password`。MySQL 8.4 默认禁用这个旧插件;全新数据卷会使用默认 `caching_sha2_password`,正常不会出现这个错误。
|
||||
|
||||
如果这是全新脚手架,本地数据不需要保留,直接删除旧数据卷重新初始化。这个操作会清空本地数据库:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker volume rm easy-next-admin_mysqlData
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
如果本地数据需要保留,先临时启用旧插件完成账号迁移,迁移后再移除临时配置。可以在本机临时给 MySQL 启动参数追加 `--mysql-native-password=ON`,不要把它作为脚手架默认配置提交。容器启动后先检查账号认证插件:
|
||||
|
||||
```bash
|
||||
docker compose exec mysql mysql -uroot -p123456 -e "SELECT user, host, plugin FROM mysql.user;"
|
||||
```
|
||||
|
||||
确认能登录后,把 root 账号迁移到 MySQL 8.4 默认认证方式:
|
||||
|
||||
```bash
|
||||
docker compose exec mysql mysql -uroot -p123456 -e "ALTER USER 'root'@'%' IDENTIFIED WITH caching_sha2_password BY '123456'; ALTER USER 'root'@'localhost' IDENTIFIED WITH caching_sha2_password BY '123456'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
### Flyway 校验失败
|
||||
|
||||
本地开发库可以直接备份并删除,再让 `local` 默认连接自动建库并由 Flyway 执行单一 V1 基线:
|
||||
|
||||
```bash
|
||||
mkdir -p work/db-backups
|
||||
docker exec easy-next-admin-mysql mysqldump --default-character-set=utf8mb4 -uroot -p123456 --single-transaction --set-gtid-purged=OFF easy-next-admin > "work/db-backups/easy-next-admin-$(date +%Y%m%d%H%M%S).sql"
|
||||
docker exec easy-next-admin-mysql mysql -uroot -p123456 -e "DROP DATABASE IF EXISTS \`easy-next-admin\`;"
|
||||
cd easy-next-admin-server
|
||||
mvn clean spring-boot:run
|
||||
```
|
||||
|
||||
这里使用 `clean` 是为了同时删除 `target/classes` 中可能残留的旧迁移文件。重建完成后,`flyway_schema_history` 应只有 `V1__init.sql` 一条成功记录。不要再手工导入 V1 后同时启动 Flyway,避免初始化来源分叉。
|
||||
|
||||
### 前端接口 404 或连接失败
|
||||
|
||||
确认后端在 `8080` 端口运行,或通过 `VITE_API_PROXY_TARGET` 指向正确地址。前端 Axios 的业务前缀是 `/api`,不要在页面里直接请求完整后端域名。
|
||||
46
docs/reference-projects.md
Normal file
46
docs/reference-projects.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 参考项目与借鉴边界
|
||||
|
||||
EasyNextAdmin 参考成熟开源项目的后台习惯、组件能力和领域模型,但不复制完整模板、不保留第三方品牌、不把不属于企业脚手架的能力塞进默认 UI。
|
||||
|
||||
## 参考清单
|
||||
|
||||
| 项目或组件 | 参考内容 | EasyNextAdmin 的做法 | 不做什么 |
|
||||
| --- | --- | --- | --- |
|
||||
| [RuoYi / RuoYi-Vue](https://doc.ruoyi.vip/ruoyi-vue/) | 中文企业后台的信息架构、用户/角色/菜单/按钮权限、数据权限、日志、监控、定时任务、代码组织习惯 | 系统管理、权限授权、监控、日志、任务调度等基础后台能力优先贴近国内企业用户习惯 | 不复制 RuoYi 模板页、品牌、代码生成主线、字典参数等低频默认模块 |
|
||||
| [Vben Admin](https://doc.vben.pro/en/guide/in-depth/access.html) | Vue 管理后台的路由权限、菜单权限、按钮权限和工程化组织 | 后端 `sys_menu` 统一维护菜单、页面和按钮资源,前端按授权菜单动态装配路由,按钮权限走指令 | 不引入 Vben 的完整框架、主题系统和应用结构,也不保留前端硬编码菜单清单 |
|
||||
| [Element Plus](https://element-plus.org/en-US/component/overview.html) | Vue 3 企业后台基础组件,包括表格、表单、弹窗、抽屉、菜单、分页和图标 | 作为默认 UI 组件库,保持本地依赖和中文后台交互习惯 | 不依赖 CDN、在线字体或在线图标 |
|
||||
| [Apache ECharts](https://echarts.apache.org/en/index.html) | 图表类型、交互、响应式渲染和仪表盘展示 | 封装 `EasyChart`,用于工作台和监控页面 | 不做完整 BI 数据建模平台 |
|
||||
| [LogicFlow](https://07.logic-flow.cn/guide/basic/logic-flow.html) | 流程图画布、节点、连线、交互和扩展机制 | 用于流程配置和流程实例图展示,流程运行仍由 EasyNextAdmin 后端轻量引擎处理 | 不把 LogicFlow 当作完整流程引擎 |
|
||||
| [Flowable](https://www.flowable.com/open-source/docs/bpmn/ch07b-BPMN-Constructs) / [Camunda](https://docs.camunda.io/docs/components/concepts/processes/) | BPMN、流程定义、流程实例、任务、历史和可视化运维概念 | 借鉴流程领域术语,内置轻量审批引擎支持发起、待办、审批、转办、委派、加签、退回、撤回和抄送 | 不引入完整 BPMN 运行时、DMN、CMMN、复杂编排和独立流程平台 |
|
||||
| [Spring Boot Actuator](https://docs.spring.io/spring-boot/3.5/reference/actuator/endpoints.html) | 健康检查和应用信息端点 | 保留健康检查与服务信息入口,服务监控页面不展示 Actuator 明细清单 | 不默认引入 Spring Boot Admin 服务端 |
|
||||
| [springdoc-openapi](https://springdoc.org/) | OpenAPI JSON 和 Swagger UI | `local` 开发环境暴露 `/v3/api-docs` 和 `/swagger-ui.html`,方便调试接口 | 通用配置默认关闭文档入口,生产不加载 `local` profile |
|
||||
| [Redisson](https://redisson.pro/docs/integration-with-spring/) | Redis 客户端、锁、Spring 集成 | `easy.features.redis=true` 后统一承载缓存、会话、验证码、重复请求、幂等、限流和分布式锁 | 本地可用 `easy.features.redis=false` 回退到内存或 MySQL |
|
||||
| [Spring Kafka](https://spring.io/projects/spring-kafka) | Kafka Producer、Consumer、Topic Admin 和监听容器 | `easy.features.kafka=true` 后才启用 Kafka 基础设施,默认不误连本机 Kafka | 不把 Kafka 当作所有业务消息的隐式默认通道 |
|
||||
|
||||
## 产品边界
|
||||
|
||||
EasyNextAdmin 的默认 UI 只展示真实企业后台能力:
|
||||
|
||||
- 权限管理
|
||||
- 用户、角色、菜单、组织和文件
|
||||
- 应用监控、在线用户、缓存、实时日志
|
||||
- 行为审计
|
||||
- 动态定时任务
|
||||
- 轻量工作流
|
||||
- 消息中心
|
||||
- 用户导入导出
|
||||
- 个人中心
|
||||
|
||||
以下内容不作为默认主线:
|
||||
|
||||
- 低代码页面搭建
|
||||
- 完整 BI 平台
|
||||
- 完整 BPMN 流程平台
|
||||
- 与当前代码无关的中间件展示页
|
||||
- 模板自带品牌页和演示页
|
||||
|
||||
## 为什么这样取舍
|
||||
|
||||
中文企业后台的核心诉求是稳定、可维护和二开效率。RuoYi 证明了用户、角色、部门、菜单、日志、监控和任务调度这类基础后台能力的长期价值;Vben Admin 证明了前端工程化和权限路由的组织价值;ECharts、LogicFlow、Element Plus 解决了图表、流程图和后台组件的成熟度问题。
|
||||
|
||||
EasyNextAdmin 把这些经验收敛到“Spring Boot 3 + Vue 3 企业脚手架”里:基础后台要完整,增强能力要真实可用,默认产品不能膨胀成低代码、BI 或 BPM 平台。
|
||||
28
easy-next-admin-server/Dockerfile
Normal file
28
easy-next-admin-server/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
# 服务端镜像默认面向生产部署;本地容器运行时显式覆盖为 local。
|
||||
ENV TZ=Asia/Shanghai \
|
||||
SPRING_PROFILES_ACTIVE=prod \
|
||||
JAVA_OPTS=""
|
||||
|
||||
WORKDIR /app
|
||||
RUN groupadd --system app \
|
||||
&& useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p logs storage work \
|
||||
&& chown -R app:app /app
|
||||
|
||||
COPY --chown=app:app easy-next-admin-server/target/easyNextAdmin.jar /app/app.jar
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:8080/actuator/health >/dev/null || exit 1
|
||||
|
||||
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar /app/app.jar \"$@\"", "--"]
|
||||
518
easy-next-admin-server/pom.xml
Normal file
518
easy-next-admin-server/pom.xml
Normal file
@@ -0,0 +1,518 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.laker.admin</groupId>
|
||||
<artifactId>easy-next-admin</artifactId>
|
||||
<version>0.1.0-alpha.0</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<artifactId>easy-next-admin-server</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<!-- 只使用 Spring AI 的模型与 Tool Calling 标准抽象;企业 Agent 循环由本项目控制。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-openai</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<!-- MyBatis-Plus 数据访问 -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-jsqlparser</artifactId>
|
||||
<version>${mybatis-plus.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- MySQL 驱动 -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<version>${mysql-connector-j.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- AOP 支撑审计、权限等横切能力 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-aop</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI 3 接口文档 -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 参数校验 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- BCrypt 密码哈希,仅引入 crypto 包,不启用 Spring Security 过滤链 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- WebSocket 支撑在线日志等实时通道 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 配置管理 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 单元测试 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Actuator 运行监控 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Kafka 可选消息通道 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
<artifactId>spring-kafka</artifactId>
|
||||
</dependency>
|
||||
<!-- 缓存 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
<!-- 用于数据库版本管理 -->
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-mysql</artifactId>
|
||||
</dependency>
|
||||
<!-- Redis 客户端 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<!-- Redisson 客户端:分布式锁、缓存和队列能力。 -->
|
||||
<!-- https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter -->
|
||||
<dependency>
|
||||
<groupId>org.redisson</groupId>
|
||||
<artifactId>redisson-spring-boot-starter</artifactId>
|
||||
<version>3.28.0</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Guava 工具集合 -->
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>${guava.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenFeign 远程调用 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-hc5</artifactId>
|
||||
<version>13.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- https://spring.io/projects/spring-cloud-circuitbreaker -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- feign-micrometer trace and metrics-->
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-micrometer</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.kafka</groupId>
|
||||
<artifactId>spring-kafka-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-registry-influx</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 使用 lombok 简化代码 -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
<version>${lombok.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 使用 mapstruct 简化对象映射 -->
|
||||
<!-- 安装mapstruct插件 https://mapstruct.org/documentation/stable/reference/html/#introduction -->
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||
<version>4.5.18</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.aliyun.oss</groupId>
|
||||
<artifactId>aliyun-sdk-oss</artifactId>
|
||||
<version>3.18.1</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>aliyun-java-sdk-core</artifactId>
|
||||
<groupId>com.aliyun</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<!-- 打包后的启动jar名称 -->
|
||||
<finalName>easyNextAdmin</finalName>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<executions>
|
||||
<execution>
|
||||
<goals>
|
||||
<!-- actuator/info 中 开启 构建信息,mvn 构建后才会生成META-INF/build-info.properties 文件 -->
|
||||
<goal>build-info</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<excludeInfoProperties>
|
||||
<!-- 排除 artifact 信息 -->
|
||||
<infoProperty>group</infoProperty>
|
||||
</excludeInfoProperties>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>${java.version}</source>
|
||||
<target>${java.version}</target>
|
||||
<annotationProcessorPaths>
|
||||
<!-- mapstruct 代码生成器 -->
|
||||
<path>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
<artifactId>mapstruct-processor</artifactId>
|
||||
<version>${org.mapstruct.version}</version>
|
||||
</path>
|
||||
<!-- lombok 代码生成器 -->
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>${lombok.version}</version>
|
||||
</path>
|
||||
<!-- additional annotation processor required as of Lombok 1.18.16 -->
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||
<version>0.2.0</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- actuator/info 中 开启 构建期间 Git 提交的信息,mvn 构建后才会生成git.properties 文件 -->
|
||||
<plugin>
|
||||
<groupId>io.github.git-commit-id</groupId>
|
||||
<artifactId>git-commit-id-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludeProperties>
|
||||
<!-- 排除 time 属性 -->
|
||||
<excludeProperty>time</excludeProperty>
|
||||
</excludeProperties>
|
||||
<!-- <includeOnlyProperties>-->
|
||||
<!-- <!– 只包含 git.commit.id 属性 –>-->
|
||||
<!-- <property>git.commit.id</property>-->
|
||||
<!-- </includeOnlyProperties>-->
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- [单元测试] 测试单个类、方法或功能的正确性。-->
|
||||
<!-- mvn test -Dmaven.test.skip=true 跳过测试 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- 并行测试配置 -->
|
||||
<threadCount>4</threadCount><!-- 最大线程数 -->
|
||||
<parallel>methods</parallel><!-- 并行执行测试方法 -->
|
||||
|
||||
<!-- 测试超时设置 -->
|
||||
<testFailureIgnore>false</testFailureIgnore> <!-- 确保测试失败时构建失败 -->
|
||||
<runOrder>alphabetical</runOrder> <!-- 按字母顺序运行测试,确保一致性 -->
|
||||
|
||||
<!-- 测试结果输出目录 -->
|
||||
<!-- <reportsDirectory>${project.build.directory}/surefire-reports</reportsDirectory>-->
|
||||
|
||||
|
||||
<reportFormat>plain</reportFormat>
|
||||
<!-- 控制台输出详细度 -->
|
||||
<printSummary>true</printSummary>
|
||||
<!-- Spring 上下文测试较重,固定单 JVM,避免按 CPU 核数复制上下文导致本地和 CI 资源耗尽。 -->
|
||||
<forkCount>1</forkCount>
|
||||
<reuseForks>true</reuseForks>
|
||||
|
||||
<!-- 包含测试类的目录 -->
|
||||
<includes>
|
||||
<include>**/*Test.java</include>
|
||||
<include>**/*Tests.java</include>
|
||||
<include>**/*TestCase.java</include>
|
||||
</includes>
|
||||
<!-- 排除集成测试 -->
|
||||
<excludes>
|
||||
<exclude>**/IT.java</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- [单元测试] 代码覆盖率报告 帮助发现未覆盖的代码部分 -->
|
||||
<!-- mvn clean verify 生成的覆盖率报告可以在 target/site/jacoco/index.html 文件中查看-->
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<!-- 在测试运行之前准备 JaCoCo 代理,用于收集覆盖率数据。 -->
|
||||
<execution>
|
||||
<id>prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<!-- 在 verify 阶段生成覆盖率报告。 -->
|
||||
<execution>
|
||||
<id>report</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<!-- 合并多模块覆盖率报告 -->
|
||||
<execution>
|
||||
<id>report-aggregate</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report-aggregate</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
|
||||
<!-- 在 verify 阶段检查覆盖率是否达到预期值。 -->
|
||||
<execution>
|
||||
<id>check</id>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<!-- 检查覆盖率的范围 BUNDLE 表示整个项目(或模块)。
|
||||
其他可能的值包括 PACKAGE、CLASS 和 METHOD,分别表示包、类和方法级别的覆盖率检查-->
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<!-- INSTRUCTION 表示指令覆盖率,即代码中每个指令的覆盖情况。其他可能的值包括 LINE(行覆盖率)、BRANCH(分支覆盖率)、COMPLEXITY(复杂度覆盖率)等-->
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<!-- 覆盖率的最小值 指令覆盖率的最低阈值是 80%。如果实际覆盖率低于这个值,构建将失败。-->
|
||||
<minimum>0.00010</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.00010</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
<configuration>
|
||||
<!-- 指定报告文件输出路径 -->
|
||||
<!-- <outputDirectory>${project.build.directory}/jacoco-reports</outputDirectory>-->
|
||||
|
||||
<!-- 排除不需要计算覆盖率的包或类,如生成代码、测试类等 -->
|
||||
<excludes>
|
||||
<exclude>**/generated/**</exclude> <!-- 生成的代码 -->
|
||||
<exclude>net/sf/jsqlparser/**</exclude> <!-- 第三方 SQL 解析器,部分类方法过大,不做覆盖率插桩 -->
|
||||
<exclude>**/Test*.*</exclude> <!-- 排除测试类 -->
|
||||
<exclude>**/Test.java</exclude> <!-- 排除测试类 -->
|
||||
<exclude>**/SomeSpecificClassToExclude.*</exclude> <!-- 具体排除某些类 -->
|
||||
<exclude>**/*MapperGenerated.*</exclude> <!-- MyBatis 自动生成的 Mapper 接口 -->
|
||||
<exclude>**/*Mapper.*</exclude> <!-- MyBatis Mapper 接口 -->
|
||||
<exclude>**/*Example.*</exclude> <!-- MyBatis Example 类 -->
|
||||
<exclude>**/*VO.*</exclude> <!-- VO 类 -->
|
||||
<exclude>**/*DTO.*</exclude> <!-- DTO 类 -->
|
||||
<exclude>**/*BO.*</exclude> <!-- BO 类 -->
|
||||
<exclude>**/*PO.*</exclude> <!-- PO 类 -->
|
||||
<exclude>**/*Entity.*</exclude> <!-- Entity 类 -->
|
||||
<exclude>**/*Repository.*</exclude> <!-- Repository 类 -->
|
||||
<exclude>**/*RepositoryImpl.*</exclude> <!-- RepositoryImpl 类 -->
|
||||
</excludes>
|
||||
|
||||
<!-- 报告格式,生成 HTML 和 XML 格式报告 -->
|
||||
<formats>
|
||||
<format>HTML</format>
|
||||
<format>XML</format>
|
||||
</formats>
|
||||
|
||||
<!-- 覆盖率数据文件路径 -->
|
||||
<dataFile>${project.build.directory}/jacoco.exec</dataFile>
|
||||
<outputDirectory>${project.build.directory}/jacoco-reports</outputDirectory>
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- [集成测试] 测试不同模块或服务之间的交互是否正确.使用JUnit或TestNG编写集成测试,通常命名为*IT.java -->
|
||||
<!-- mvn clean verify 生成的集成测试报告可以在 target/site/failsafe-report.html 文件中查看-->
|
||||
<!-- 用于运行那些需要启动完整应用或依赖外部服务(如数据库)的测试。-->
|
||||
<!-- maven-failsafe-plugin 是 maven-surefire-plugin 的补充插件,专门用于集成测试(Integration Tests)。
|
||||
与 Surefire 主要用于单元测试不同,Failsafe 用于确保集成测试能够在构建流程的后期正确运行,
|
||||
并且可以在集成测试失败时,控制构建的后续步骤。-->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-failsafe-plugin</artifactId>
|
||||
<!-- <version>3.0.0-M9</version>-->
|
||||
<executions>
|
||||
<!-- 在 integration-test 阶段运行集成测试 -->
|
||||
<execution>
|
||||
<id>integration-test</id>
|
||||
<goals>
|
||||
<goal>integration-test</goal>
|
||||
</goals>
|
||||
<phase>integration-test</phase> <!-- 在这个阶段运行测试 -->
|
||||
</execution>
|
||||
|
||||
<!-- 在 verify 阶段运行测试结果验证,报告生成等 -->
|
||||
<execution>
|
||||
<id>verify</id>
|
||||
<goals>
|
||||
<goal>verify</goal>
|
||||
</goals>
|
||||
<phase>verify</phase> <!-- 在这个阶段检查和报告 -->
|
||||
</execution>
|
||||
</executions>
|
||||
|
||||
<configuration>
|
||||
<!-- 执行集成测试时的测试类命名规则 -->
|
||||
<includes>
|
||||
<include>**/IT*.java</include> <!-- 命名以 IT 开头的类 -->
|
||||
<include>**/*IT.java</include> <!-- 命名以 IT 结尾的类 -->
|
||||
</includes>
|
||||
|
||||
<!-- 指定线程并发测试数,使用与 CPU 核心数相同的线程数 -->
|
||||
<parallel>methods</parallel>
|
||||
<threadCount>4</threadCount> <!-- 根据项目规模调整线程数 -->
|
||||
|
||||
<!-- 失败重试机制:避免偶然失败 -->
|
||||
<systemPropertyVariables>
|
||||
<surefire.junit.retryCount>3</surefire.junit.retryCount> <!-- 测试失败后重试 3 次 -->
|
||||
</systemPropertyVariables>
|
||||
|
||||
<!-- JVM 参数:控制内存分配 -->
|
||||
<argLine>-Xms512m -Xmx2048m</argLine>
|
||||
|
||||
<!-- 测试报告输出目录 -->
|
||||
<!-- <reportsDirectory>${project.build.directory}/failsafe-reports</reportsDirectory>-->
|
||||
|
||||
<!-- 配置测试失败后的处理方式 -->
|
||||
<testFailureIgnore>false</testFailureIgnore> <!-- 测试失败时构建失败 -->
|
||||
|
||||
<!-- 测试过滤和选择 -->
|
||||
<skip>false</skip> <!-- 不跳过集成测试 -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
|
||||
<!-- 静态代码分析(Static Code Analysis)PMD, Checkstyle, FindBugs -->
|
||||
<!--原 FindBugs 分析字节码,找出潜在的 Bug 和安全漏洞。 -->
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>4.10.2.0</version>
|
||||
<executions>
|
||||
<!-- 在 verify 阶段执行 SpotBugs 检查 -->
|
||||
<execution>
|
||||
<goals>
|
||||
<goal>spotbugs</goal>
|
||||
</goals>
|
||||
<phase>verify</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<!-- 设置告警级别 -->
|
||||
<effort>Max</effort> <!-- Max 表示最大努力,执行更详细的检查 -->
|
||||
<threshold>Low</threshold> <!-- Low 表示所有级别的告警都报告,包括低级别 -->
|
||||
|
||||
<!-- 启用 XML 和 HTML 格式的报告 -->
|
||||
<xmlOutput>true</xmlOutput>
|
||||
<!-- <outputDirectory>${project.build.directory}/spotbugs-reports</outputDirectory>-->
|
||||
|
||||
<!-- 构建失败策略 -->
|
||||
<failOnError>true</failOnError> <!-- 如果有错误报告,构建失败 -->
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.laker.admin;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 启动类
|
||||
*
|
||||
* @author laker
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class EasyNextAdminApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(EasyNextAdminApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.laker.admin.common.constant;
|
||||
|
||||
public interface EasyNextAdminConstants {
|
||||
String TRACE_ID = "traceId";
|
||||
String USER_ID = "userId";
|
||||
String TRACE_ID_HEADER = "X-Trace-Id";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
|
||||
package com.laker.admin.common.exception;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 业务异常。
|
||||
*
|
||||
* <p>默认归类为 {@link ErrorCode#BUSINESS_ERROR}。需要表达资源不存在、文件过大等稳定错误语义时,
|
||||
* 直接传入对应 {@link ErrorCode}。认证和授权失败分别使用安全模块的专用异常。</p>
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Getter
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final String msg;
|
||||
private final ErrorCode errorCode;
|
||||
|
||||
public BusinessException(String msg) {
|
||||
this(ErrorCode.BUSINESS_ERROR, msg);
|
||||
}
|
||||
|
||||
public BusinessException(ErrorCode errorCode) {
|
||||
this(errorCode, errorCode.getDefaultMessage());
|
||||
}
|
||||
|
||||
public BusinessException(ErrorCode errorCode, String msg) {
|
||||
super(msg);
|
||||
this.msg = msg;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public BusinessException(String msg, Throwable e) {
|
||||
this(ErrorCode.BUSINESS_ERROR, msg, e);
|
||||
}
|
||||
|
||||
public BusinessException(ErrorCode errorCode, String msg, Throwable e) {
|
||||
super(msg, e);
|
||||
this.msg = msg;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return errorCode.getCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.laker.admin.common.exception;
|
||||
|
||||
import lombok.Getter;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 企业级 API 错误码定义。
|
||||
*
|
||||
* <p>HTTP 状态表达协议结果,业务错误码表达可检索、可告警、可文档化的失败原因。
|
||||
* 数字编码采用 {@code HTTP状态码 + 三位业务序号},例如 400004 表示 400 类参数校验失败。</p>
|
||||
*/
|
||||
@Getter
|
||||
public enum ErrorCode {
|
||||
SUCCESS(0, HttpStatus.OK, "操作成功"),
|
||||
|
||||
BAD_REQUEST(400000, HttpStatus.BAD_REQUEST, "请求参数错误"),
|
||||
PARAM_TYPE_MISMATCH(400001, HttpStatus.BAD_REQUEST, "方法参数类型不匹配"),
|
||||
PARAM_MISSING(400002, HttpStatus.BAD_REQUEST, "缺少请求参数"),
|
||||
REQUEST_BODY_INVALID(400003, HttpStatus.BAD_REQUEST, "参数解析失败"),
|
||||
VALIDATION_FAILED(400004, HttpStatus.BAD_REQUEST, "参数校验失败"),
|
||||
BUSINESS_ERROR(400100, HttpStatus.BAD_REQUEST, "业务处理失败"),
|
||||
|
||||
UNAUTHORIZED(401000, HttpStatus.UNAUTHORIZED, "未登录或登录已过期"),
|
||||
AUTH_INVALID_CREDENTIALS(401001, HttpStatus.UNAUTHORIZED, "用户名或密码不正确"),
|
||||
AUTH_CAPTCHA_INVALID(401002, HttpStatus.UNAUTHORIZED, "验证码不正确"),
|
||||
AUTH_SESSION_EXPIRED(401003, HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"),
|
||||
AUTH_PERMISSION_CHANGED(401004, HttpStatus.UNAUTHORIZED, "权限已变更,请重新登录"),
|
||||
FORBIDDEN(403000, HttpStatus.FORBIDDEN, "无访问权限"),
|
||||
AUTH_ACCOUNT_DISABLED(403001, HttpStatus.FORBIDDEN, "账号已被禁用"),
|
||||
RESOURCE_NOT_FOUND(404000, HttpStatus.NOT_FOUND, "资源不存在"),
|
||||
METHOD_NOT_SUPPORTED(405000, HttpStatus.METHOD_NOT_ALLOWED, "不支持当前请求方法"),
|
||||
DUPLICATE_RESOURCE(409000, HttpStatus.CONFLICT, "资源已存在"),
|
||||
CONCURRENT_OPERATION(409001, HttpStatus.CONFLICT, "同一资源正在处理中"),
|
||||
PAYLOAD_TOO_LARGE(413000, HttpStatus.PAYLOAD_TOO_LARGE, "上传文件过大"),
|
||||
MEDIA_TYPE_NOT_SUPPORTED(415000, HttpStatus.UNSUPPORTED_MEDIA_TYPE, "不支持当前媒体类型"),
|
||||
TOO_MANY_REQUESTS(429000, HttpStatus.TOO_MANY_REQUESTS, "请求过于频繁"),
|
||||
|
||||
INTERNAL_ERROR(500000, HttpStatus.INTERNAL_SERVER_ERROR, "服务器内部发生未知异常");
|
||||
|
||||
private final int code;
|
||||
private final HttpStatus httpStatus;
|
||||
private final String defaultMessage;
|
||||
|
||||
ErrorCode(int code, HttpStatus httpStatus, String defaultMessage) {
|
||||
this.code = code;
|
||||
this.httpStatus = httpStatus;
|
||||
this.defaultMessage = defaultMessage;
|
||||
}
|
||||
|
||||
public static ErrorCode fromHttpStatus(int status) {
|
||||
return switch (status) {
|
||||
case 400 -> BAD_REQUEST;
|
||||
case 401 -> UNAUTHORIZED;
|
||||
case 403 -> FORBIDDEN;
|
||||
case 404 -> RESOURCE_NOT_FOUND;
|
||||
case 405 -> METHOD_NOT_SUPPORTED;
|
||||
case 409 -> DUPLICATE_RESOURCE;
|
||||
case 413 -> PAYLOAD_TOO_LARGE;
|
||||
case 415 -> MEDIA_TYPE_NOT_SUPPORTED;
|
||||
case 429 -> TOO_MANY_REQUESTS;
|
||||
default -> status >= 500 ? INTERNAL_ERROR : BUSINESS_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.laker.admin.common.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(name = "ApiErrorDetail", description = "API 错误明细,常用于参数校验失败")
|
||||
public record ApiErrorDetail(
|
||||
@Schema(description = "错误字段或参数名", example = "userName")
|
||||
String field,
|
||||
@Schema(description = "错误说明", example = "用户名不能为空")
|
||||
String message
|
||||
) {
|
||||
public static ApiErrorDetail of(String field, String message) {
|
||||
return new ApiErrorDetail(field, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.laker.admin.common.model;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
*/
|
||||
@Schema(name = "PageData", description = "分页业务数据。list 为当前页记录,total 为匹配条件下的总记录数。")
|
||||
public record PageData<T>(
|
||||
@Schema(description = "当前页记录")
|
||||
List<T> list,
|
||||
@Schema(description = "总记录数")
|
||||
long total
|
||||
) {
|
||||
public PageData {
|
||||
list = list == null ? List.of() : list;
|
||||
total = Math.max(total, 0);
|
||||
}
|
||||
|
||||
public static <T> PageData<T> of(List<T> list, long total) {
|
||||
return new PageData<>(list, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.laker.admin.common.model;
|
||||
|
||||
import com.laker.admin.common.exception.ErrorCode;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
*/
|
||||
@Getter
|
||||
@Schema(name = "PageResponse", description = "统一分页响应体。泛型 T 表示单条记录类型,响应 data 固定为分页数据。")
|
||||
public class PageResponse<T> extends Response<PageData<T>> {
|
||||
|
||||
private PageResponse(List<T> list, long total) {
|
||||
super(ErrorCode.SUCCESS, ErrorCode.SUCCESS.getDefaultMessage(), PageData.of(list, total), null);
|
||||
}
|
||||
|
||||
public static <T> PageResponse<T> ok(List<T> list, long total) {
|
||||
return new PageResponse<>(list, total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.laker.admin.common.model;
|
||||
|
||||
import com.laker.admin.common.exception.ErrorCode;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
*/
|
||||
@Getter
|
||||
@Schema(name = "ApiResponse", description = "统一业务响应体。链路追踪号通过 X-Trace-Id 响应头返回,不进入业务数据。")
|
||||
public class Response<T> {
|
||||
@Schema(description = "业务响应码。0 表示成功;非 0 为稳定错误码,不等同 HTTP 状态码。", example = "0")
|
||||
private final int code;
|
||||
@Schema(description = "响应消息", example = "操作成功")
|
||||
private final String message;
|
||||
@Schema(description = "响应数据")
|
||||
private final T data;
|
||||
@Schema(description = "错误明细。业务数据始终放 data,校验失败等错误明细放 details。")
|
||||
private final List<ApiErrorDetail> details;
|
||||
|
||||
protected Response(ErrorCode errorCode, String message, T data, List<ApiErrorDetail> details) {
|
||||
ErrorCode resolvedErrorCode = Objects.requireNonNull(errorCode, "errorCode must not be null");
|
||||
this.code = resolvedErrorCode.getCode();
|
||||
this.message = hasText(message) ? message.trim() : resolvedErrorCode.getDefaultMessage();
|
||||
this.data = data;
|
||||
this.details = details == null || details.isEmpty() ? null : List.copyOf(details);
|
||||
}
|
||||
|
||||
public static <T> Response<T> ok(T data) {
|
||||
return new Response<>(ErrorCode.SUCCESS, ErrorCode.SUCCESS.getDefaultMessage(), data, null);
|
||||
}
|
||||
|
||||
public static Response<Void> ok() {
|
||||
return new Response<>(ErrorCode.SUCCESS, ErrorCode.SUCCESS.getDefaultMessage(), null, null);
|
||||
}
|
||||
|
||||
public static <T> Response<T> error(ErrorCode errorCode) {
|
||||
return error(errorCode, errorCode.getDefaultMessage());
|
||||
}
|
||||
|
||||
public static <T> Response<T> error(ErrorCode errorCode, String message) {
|
||||
return new Response<>(errorCode, message, null, null);
|
||||
}
|
||||
|
||||
public static <T> Response<T> error(ErrorCode errorCode, String message, T data) {
|
||||
return new Response<>(errorCode, message, data, null);
|
||||
}
|
||||
|
||||
public static <T> Response<T> error(ErrorCode errorCode, String message, List<ApiErrorDetail> details) {
|
||||
return new Response<>(errorCode, message, null, details);
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 跨模块通用层。
|
||||
*
|
||||
* <p>只放稳定、无业务归属的基础对象,例如统一响应、通用异常、工具类和常量。
|
||||
* 这里不能反向依赖 {@code module} 或 {@code infrastructure},避免公共层变成业务杂物间。</p>
|
||||
*/
|
||||
package com.laker.admin.common;
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.laker.admin.common.util;
|
||||
|
||||
import com.laker.admin.infrastructure.security.context.EasySecurityContext;
|
||||
import com.laker.admin.infrastructure.security.model.AuthPrincipal;
|
||||
import com.laker.admin.infrastructure.persistence.mybatis.UserInfoAndPermissions;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* @author easynext
|
||||
*/
|
||||
@Slf4j
|
||||
public class EasyNextAdminSecurityUtils {
|
||||
private EasyNextAdminSecurityUtils() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
public static UserInfoAndPermissions getCurrentUserInfo() {
|
||||
AuthPrincipal principal = EasySecurityContext.getPrincipal();
|
||||
if (principal == null) {
|
||||
return null;
|
||||
}
|
||||
return UserInfoAndPermissions.builder()
|
||||
.userId(principal.getUserId())
|
||||
.userName(principal.getUserName())
|
||||
.nickName(principal.getNickName())
|
||||
.deptId(principal.getDeptId())
|
||||
.deptIds(principal.getDeptIds())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.laker.admin.common.util;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class EasyTreeUtil {
|
||||
|
||||
private EasyTreeUtil() {
|
||||
// Prevent instantiation
|
||||
}
|
||||
|
||||
public interface TreeNode<T extends TreeNode<T>> {
|
||||
Long getId();
|
||||
|
||||
Long getPid();
|
||||
|
||||
List<T> getChildren();
|
||||
|
||||
void setChildren(List<T> children);
|
||||
}
|
||||
|
||||
public static <T extends TreeNode<T>> List<T> toTree(List<T> treeList, Long pid) {
|
||||
List<T> retList = new ArrayList<>();
|
||||
for (T parent : treeList) {
|
||||
if (Objects.equals(pid, parent.getPid())) {
|
||||
retList.add(findChildren(parent, treeList));
|
||||
}
|
||||
}
|
||||
return retList;
|
||||
}
|
||||
|
||||
private static <T extends TreeNode<T>> T findChildren(T parent, List<T> treeList) {
|
||||
for (T child : treeList) {
|
||||
if (parent.getId() != null && Objects.equals(parent.getId(), child.getPid())) {
|
||||
if (parent.getChildren() == null) {
|
||||
parent.setChildren(new ArrayList<>());
|
||||
}
|
||||
parent.getChildren().add(findChildren(child, treeList));
|
||||
}
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.laker.admin.config.api;
|
||||
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.PathItem;
|
||||
import io.swagger.v3.oas.models.Paths;
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* OpenAPI 3 接口文档配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class OpenApiConfig {
|
||||
private static final String BEARER_AUTH = "BearerAuth";
|
||||
|
||||
/**
|
||||
* @return the global open api customizer
|
||||
*/
|
||||
@Bean
|
||||
public GlobalOpenApiCustomizer orderGlobalOpenApiCustomizer() {
|
||||
return openApi -> {
|
||||
// 获取所有 Paths 并按自定义规则排序
|
||||
Paths sortedPaths = sortPathsByCustomRule(openApi.getPaths());
|
||||
openApi.setPaths(sortedPaths);
|
||||
};
|
||||
}
|
||||
|
||||
private Paths sortPathsByCustomRule(Paths originalPaths) {
|
||||
if (originalPaths == null || originalPaths.isEmpty()) {
|
||||
return new Paths();
|
||||
}
|
||||
// 使用 LinkedHashMap 保持顺序
|
||||
Map<String, PathItem> sortedPathItems = originalPaths.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey()) // 自定义排序规则
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey, // 显式指定泛型
|
||||
Map.Entry::getValue,
|
||||
(existing, replacement) -> existing,
|
||||
Paths::new
|
||||
));
|
||||
|
||||
// 创建新的 Paths 对象
|
||||
Paths sortedPaths = new Paths();
|
||||
sortedPathItems.forEach(sortedPaths::addPathItem);
|
||||
return sortedPaths;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OpenAPI openAPI() {
|
||||
return new OpenAPI()
|
||||
.components(new Components()
|
||||
.addSecuritySchemes(BEARER_AUTH, new SecurityScheme()
|
||||
.type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer")
|
||||
.bearerFormat("opaque")))
|
||||
.addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH))
|
||||
.info(new Info()
|
||||
.title("EasyNextAdmin Enterprise API")
|
||||
.contact(new Contact()
|
||||
.name("laker")
|
||||
.email("935009066@qq.com"))
|
||||
.version("1.0")
|
||||
.description("EasyNextAdmin 企业级开发脚手架 OpenAPI 3 接口文档")
|
||||
.license(new License()
|
||||
.name("Apache 2.0")
|
||||
.url("https://www.apache.org/licenses/LICENSE-2.0")));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi systemApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("1.system")
|
||||
.displayName("1.系统管理")
|
||||
.pathsToMatch("/api/auth/**", "/api/system/**")
|
||||
.packagesToScan("com.laker.admin.module.system")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi monitorApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("2.monitor")
|
||||
.displayName("2.运行监控")
|
||||
.pathsToMatch("/api/monitor/**")
|
||||
.packagesToScan("com.laker.admin.module.monitor")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi auditApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("3.audit")
|
||||
.displayName("3.审计中心")
|
||||
.pathsToMatch("/api/audit/**")
|
||||
.packagesToScan("com.laker.admin.module.audit")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi reportApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("4.report")
|
||||
.displayName("4.企业报表")
|
||||
.pathsToMatch("/api/reports/**")
|
||||
.packagesToScan("com.laker.admin.module.report")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi scheduleApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("5.schedule")
|
||||
.displayName("5.任务调度")
|
||||
.pathsToMatch("/api/schedule/**")
|
||||
.packagesToScan("com.laker.admin.module.schedule")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi batchApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("6.batch")
|
||||
.displayName("6.批处理任务")
|
||||
.pathsToMatch("/api/batch/**")
|
||||
.packagesToScan("com.laker.admin.module.batch")
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GroupedOpenApi workflowApi() {
|
||||
return GroupedOpenApi.builder()
|
||||
.group("7.workflow")
|
||||
.displayName("7.工作流")
|
||||
.pathsToMatch("/api/workflow/**")
|
||||
.packagesToScan("com.laker.admin.module.workflow")
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* API 文档装配。
|
||||
*/
|
||||
package com.laker.admin.config.api;
|
||||
125
easy-next-admin-server/src/main/java/com/laker/admin/config/cache/EasyCacheConfig.java
vendored
Normal file
125
easy-next-admin-server/src/main/java/com/laker/admin/config/cache/EasyCacheConfig.java
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
package com.laker.admin.config.cache;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import com.laker.admin.infrastructure.cache.redis.EasyRedisProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.codec.JsonJacksonCodec;
|
||||
import org.redisson.spring.cache.CacheConfig;
|
||||
import org.redisson.spring.cache.RedissonCacheMeterBinderProvider;
|
||||
import org.redisson.spring.cache.RedissonSpringCacheManager;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCache;
|
||||
import org.springframework.cache.support.SimpleCacheManager;
|
||||
import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
* 企业缓存配置。业务侧通过稳定的 cacheName 表达过期语义,底层可切换 Redis 或本地 Caffeine fallback。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableCaching // 启用缓存
|
||||
@Slf4j
|
||||
public class EasyCacheConfig {
|
||||
|
||||
public static final String CACHE_NAME_1H = "CACHE_NAME_1H";
|
||||
public static final String CACHE_NAME_12H = "CACHE_NAME_12H";
|
||||
public static final String CACHE_NAME_24H = "CACHE_NAME_24H";
|
||||
public static final String CACHE_DATA_SCOPE_DEPT_TREE = "DATA_SCOPE_DEPT_TREE";
|
||||
public static final String CACHE_DATA_SCOPE_CUSTOM_DEPT_IDS = "DATA_SCOPE_CUSTOM_DEPT_IDS";
|
||||
public static final int CACHE_MAX_SIZE = 100_000;
|
||||
private static final List<CacheSpec> CACHE_SPECS = List.of(
|
||||
new CacheSpec(CACHE_NAME_1H, 1, CACHE_MAX_SIZE),
|
||||
new CacheSpec(CACHE_NAME_12H, 12, CACHE_MAX_SIZE),
|
||||
new CacheSpec(CACHE_NAME_24H, 24, CACHE_MAX_SIZE),
|
||||
new CacheSpec(CACHE_DATA_SCOPE_DEPT_TREE, 12, 10_000),
|
||||
new CacheSpec(CACHE_DATA_SCOPE_CUSTOM_DEPT_IDS, 1, CACHE_MAX_SIZE)
|
||||
);
|
||||
|
||||
public static List<CacheSpec> cacheSpecs() {
|
||||
return CACHE_SPECS;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager(@Autowired(required = false) RedissonClient redissonClient) {
|
||||
CacheManager targetCacheManager;
|
||||
if (redissonClient == null) {
|
||||
log.info("Using Caffeine cache manager");
|
||||
targetCacheManager = getCaffeineCacheManager();
|
||||
} else {
|
||||
log.info("Using Redisson cache manager");
|
||||
targetCacheManager = getRedissonSpringCacheManager(redissonClient);
|
||||
}
|
||||
// 统一把缓存写入和失效延迟到事务提交后,避免业务事务回滚但缓存已被更新。
|
||||
return new TransactionAwareCacheManagerProxy(targetCacheManager);
|
||||
}
|
||||
|
||||
private CacheManager getCaffeineCacheManager() {
|
||||
SimpleCacheManager cacheManager = new SimpleCacheManager();
|
||||
cacheManager.setCaches(CACHE_SPECS.stream()
|
||||
.map(this::caffeineCache)
|
||||
.toList());
|
||||
cacheManager.initializeCaches();
|
||||
return cacheManager;
|
||||
}
|
||||
|
||||
private CaffeineCache caffeineCache(CacheSpec cacheSpec) {
|
||||
return new CaffeineCache(cacheSpec.cacheName(), Caffeine.newBuilder()
|
||||
.recordStats()
|
||||
.expireAfterWrite(cacheSpec.ttlHours(), TimeUnit.HOURS)
|
||||
.maximumSize(cacheSpec.maximumSize())
|
||||
.build(), false);
|
||||
}
|
||||
|
||||
// ----- redisson-spring-cache -----
|
||||
private RedissonSpringCacheManager getRedissonSpringCacheManager(RedissonClient redissonClient) {
|
||||
Map<String, CacheConfig> cacheConfigMap = CACHE_SPECS.stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
CacheSpec::cacheName,
|
||||
this::redissonCacheConfig
|
||||
));
|
||||
return getRedissonSpringCacheManager(redissonClient, cacheConfigMap);
|
||||
}
|
||||
|
||||
private CacheConfig redissonCacheConfig(CacheSpec cacheSpec) {
|
||||
long ttlMillis = TimeUnit.HOURS.toMillis(cacheSpec.ttlHours());
|
||||
CacheConfig cacheConfig = new CacheConfig(ttlMillis, ttlMillis);
|
||||
cacheConfig.setMaxSize(Math.toIntExact(cacheSpec.maximumSize()));
|
||||
return cacheConfig;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "easy.features", name = "redis", havingValue = "true")
|
||||
public RedissonCacheMeterBinderProvider redissonCacheMeterBinderProvider() {
|
||||
return new RedissonCacheMeterBinderProvider();
|
||||
}
|
||||
|
||||
private RedissonSpringCacheManager getRedissonSpringCacheManager(RedissonClient redissonClient, Map<String, CacheConfig> cacheConfigMap) {
|
||||
RedissonSpringCacheManager redissonSpringCacheManager = new RedissonSpringCacheManager(redissonClient);
|
||||
// 设置是否允许缓存的值为null。默认值为false,即缓存的值不允许为null。
|
||||
redissonSpringCacheManager.setAllowNullValues(false);
|
||||
// 事务感知统一由 TransactionAwareCacheManagerProxy 处理,避免不同缓存实现语义不一致。
|
||||
redissonSpringCacheManager.setTransactionAware(false);
|
||||
// 设置缓存的配置信息
|
||||
redissonSpringCacheManager.setConfig(cacheConfigMap);
|
||||
// 设置缓存的名称 定义“固定”缓存名称。对于未定义的名称,不会动态创建新的缓存实例。会返回null
|
||||
redissonSpringCacheManager.setCacheNames(cacheConfigMap.keySet());
|
||||
// 设置缓存的编码方式
|
||||
redissonSpringCacheManager.setCodec(new JsonJacksonCodec());
|
||||
return redissonSpringCacheManager;
|
||||
}
|
||||
|
||||
public record CacheSpec(String cacheName, long ttlHours, long maximumSize) {
|
||||
}
|
||||
|
||||
}
|
||||
4
easy-next-admin-server/src/main/java/com/laker/admin/config/cache/package-info.java
vendored
Normal file
4
easy-next-admin-server/src/main/java/com/laker/admin/config/cache/package-info.java
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 缓存装配,统一声明缓存名称和底层缓存管理器。
|
||||
*/
|
||||
package com.laker.admin.config.cache;
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.laker.admin.config.database;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.laker.admin.infrastructure.persistence.mybatis.EasyMybatisTraceInterceptor;
|
||||
import com.laker.admin.infrastructure.security.datascope.resolver.DataScopeResolver;
|
||||
import com.laker.admin.infrastructure.security.datascope.mybatis.EasyDataScopeInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
*/
|
||||
@Configuration
|
||||
@MapperScan("com.laker.admin.**.mapper")
|
||||
public class EasyMybatisConfig {
|
||||
|
||||
/**
|
||||
* mybatis-plus插件
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor(DataScopeResolver dataScopeResolver) {
|
||||
MybatisPlusInterceptor interceptor = new EasyMybatisTraceInterceptor();
|
||||
// 数据权限插件必须在分页前执行,保证分页总数和列表数据使用同一套范围条件。
|
||||
interceptor.addInnerInterceptor(new EasyDataScopeInnerInterceptor(dataScopeResolver));
|
||||
// 乐观锁插件:实体携带 version 时才参与更新校验,默认不增加前端理解成本。
|
||||
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
|
||||
// 分页插件
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 数据库访问装配,包括 MyBatis-Plus 插件和事务管理器。
|
||||
*/
|
||||
package com.laker.admin.config.database;
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.laker.admin.config.jackson;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 自定义扩展jackson序列化和反序列化
|
||||
*
|
||||
* @author laker
|
||||
*/
|
||||
@Component
|
||||
public class EasyJacksonCustomizer implements Jackson2ObjectMapperBuilderCustomizer {
|
||||
|
||||
private static final String STANDARD_PATTERN = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
private static final String DATE_PATTERN = "yyyy-MM-dd";
|
||||
|
||||
private static final String TIME_PATTERN = "HH:mm:ss";
|
||||
|
||||
@Override
|
||||
public void customize(Jackson2ObjectMapperBuilder builder) {
|
||||
// 初始化JavaTimeModule
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
|
||||
//处理LocalDateTime
|
||||
DateTimeFormatter dateTimeFormatter = DateTimeFormatter
|
||||
.ofPattern(STANDARD_PATTERN);
|
||||
javaTimeModule.addSerializer(LocalDateTime.class,
|
||||
new LocalDateTimeSerializer(dateTimeFormatter));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class,
|
||||
new LocalDateTimeDeserializer(dateTimeFormatter));
|
||||
|
||||
//处理LocalDate
|
||||
DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern(DATE_PATTERN);
|
||||
javaTimeModule.addSerializer(LocalDate.class,
|
||||
new LocalDateSerializer(dateFormatter));
|
||||
javaTimeModule.addDeserializer(LocalDate.class,
|
||||
new LocalDateDeserializer(dateFormatter));
|
||||
|
||||
//处理LocalTime
|
||||
DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern(TIME_PATTERN);
|
||||
javaTimeModule.addSerializer(LocalTime.class,
|
||||
new LocalTimeSerializer(timeFormatter));
|
||||
javaTimeModule.addDeserializer(LocalTime.class,
|
||||
new LocalTimeDeserializer(timeFormatter));
|
||||
|
||||
// 创建自定义模块并添加 Long 的序列化器
|
||||
SimpleModule customModule = new SimpleModule();
|
||||
// 防止Long类型返回前端精度丢失
|
||||
customModule.addSerializer(Long.class, new EasyLongToStringSerializer());
|
||||
customModule.addSerializer(Collection.class, new EasyLongArrToStringArrSerializer());
|
||||
|
||||
/*
|
||||
* 1. java.util.Date yyyy-MM-dd HH:mm:ss
|
||||
* 2. 支持JDK8 LocalDateTime、LocalDate、 LocalTime
|
||||
* 3. Jdk8Module模块支持如Stream、Optional等类
|
||||
* 4. 序列化时包含所有字段 ALWAYS,NON_NULL不序列化null字段
|
||||
* 5. 在序列化一个空对象时时不抛出异常
|
||||
* 6. 忽略反序列化时在json字符串中存在, 但在java对象中不存在的属性
|
||||
* 7. 序列化:日期/时间是否序列化为时间戳,这里禁止
|
||||
* 8. 允许忽略未知枚举值和通过@JsonEnumDefaultValue注释指定的预定义值的功能。如果禁用,未知的枚举值将引发异常。如果启用,但未指定预定义的默认 Enum 值,也会引发异常。
|
||||
*/
|
||||
builder.simpleDateFormat(STANDARD_PATTERN)
|
||||
.modules(javaTimeModule, customModule, new Jdk8Module())
|
||||
.serializationInclusion(JsonInclude.Include.NON_NULL) // 序列化时包含所有字段 ALWAYS,NON_NULL不序列化null字段
|
||||
.failOnEmptyBeans(false) // 在序列化一个空对象时时不抛出异常
|
||||
.failOnUnknownProperties(false) // 忽略反序列化时在json字符串中存在, 但在java对象中不存在的属性
|
||||
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.featuresToEnable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.laker.admin.config.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* Long集合转String序列化 防止Long类型精度丢失
|
||||
* 好像没用,被EasyLongToStringSerializer处理了
|
||||
*/
|
||||
public class EasyLongArrToStringArrSerializer extends JsonSerializer<Collection> {
|
||||
|
||||
@Override
|
||||
public void serialize(Collection values, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
gen.writeStartArray();
|
||||
for (Object value : values) {
|
||||
if (value instanceof Long) {
|
||||
// 将 Long 值转换为字符串,防止精度丢失
|
||||
gen.writeString(value.toString());
|
||||
} else {
|
||||
// 非 Long 类型直接写入
|
||||
gen.writeObject(value);
|
||||
}
|
||||
}
|
||||
gen.writeEndArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.laker.admin.config.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Long转String序列化
|
||||
*/
|
||||
public class EasyLongToStringSerializer extends JsonSerializer<Long> {
|
||||
|
||||
@Override
|
||||
public void serialize(Long value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
if (value == null) {
|
||||
gen.writeNull();
|
||||
return;
|
||||
}
|
||||
gen.writeString(value.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Spring Boot 装配层入口。
|
||||
*
|
||||
* <p>顶层包只保留入口说明,具体配置按职责放入子包:</p>
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code properties}: 项目自定义配置属性。</li>
|
||||
* <li>{@code web}: Servlet Filter、Web MVC、静态资源和 WebSocket。</li>
|
||||
* <li>{@code database}: MyBatis-Plus 和事务管理。</li>
|
||||
* <li>{@code cache}: 缓存管理器。</li>
|
||||
* <li>{@code thread}: 线程池和调度线程池。</li>
|
||||
* <li>{@code remote}: Feign、熔断和远程调用基础配置。</li>
|
||||
* <li>{@code observability}: 追踪和可观测性配置。</li>
|
||||
* <li>{@code api}: OpenAPI 文档配置。</li>
|
||||
* <li>{@code jackson}: JSON 序列化定制。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>业务规则应放在 {@code module},技术实现应放在 {@code infrastructure}。</p>
|
||||
*/
|
||||
package com.laker.admin.config;
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.laker.admin.config.properties;
|
||||
|
||||
import com.laker.admin.common.constant.EasyNextAdminConstants;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 自定义配置
|
||||
*
|
||||
* @author laker
|
||||
*/
|
||||
@Configuration
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "easy")
|
||||
public class EasyNextAdminConfig {
|
||||
/**
|
||||
* log配置
|
||||
*/
|
||||
private String logFilePath = "logs/easy-next-admin.log";
|
||||
|
||||
/**
|
||||
* 用户初始密码
|
||||
*/
|
||||
private String defaultPwd = "easynext";
|
||||
|
||||
/**
|
||||
* 防火墙
|
||||
*/
|
||||
private Waf waf = new Waf();
|
||||
|
||||
/**
|
||||
* Web 安全边界配置。
|
||||
*/
|
||||
private Web web = new Web();
|
||||
|
||||
/**
|
||||
* 文件存储
|
||||
*/
|
||||
private Storage storage = new Storage();
|
||||
|
||||
private Auth auth = new Auth();
|
||||
|
||||
private Trace trace = new Trace();
|
||||
|
||||
@Data
|
||||
public static class Waf {
|
||||
private boolean xssEnabled = true;
|
||||
private boolean sqlEnabled = true;
|
||||
private String excludes = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Web {
|
||||
private Cors cors = new Cors();
|
||||
private SecurityHeaders securityHeaders = new SecurityHeaders();
|
||||
|
||||
@Data
|
||||
public static class Cors {
|
||||
/**
|
||||
* 明确允许的前端 Origin。生产环境应替换为真实域名。
|
||||
*/
|
||||
private List<String> allowedOrigins = List.of(
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://localhost:5175",
|
||||
"http://127.0.0.1:5175",
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173"
|
||||
);
|
||||
|
||||
/**
|
||||
* 支持 Spring simple pattern,例如 https://*.example.com。
|
||||
*/
|
||||
private List<String> allowedOriginPatterns = List.of();
|
||||
|
||||
private List<String> allowedMethods = List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS");
|
||||
private List<String> allowedHeaders = List.of("*");
|
||||
private List<String> exposedHeaders = List.of(EasyNextAdminConstants.TRACE_ID_HEADER);
|
||||
private boolean allowCredentials = true;
|
||||
private long maxAgeSeconds = 3600L;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SecurityHeaders {
|
||||
private String frameOptions = "SAMEORIGIN";
|
||||
private String contentSecurityPolicy = "";
|
||||
private boolean hstsEnabled = false;
|
||||
private String hsts = "max-age=31536000; includeSubDomains";
|
||||
private String referrerPolicy = "strict-origin-when-cross-origin";
|
||||
private String permissionsPolicy = "geolocation=(), microphone=(), camera=()";
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Storage {
|
||||
private Local local = new Local();
|
||||
private Aliyun aliyun = new Aliyun();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Auth {
|
||||
private Session session = new Session();
|
||||
|
||||
@Data
|
||||
public static class Session {
|
||||
/**
|
||||
* 会话空闲超时时间;用户持续操作时按该时间滑动续约。
|
||||
*/
|
||||
private Duration idleTimeout = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* 会话绝对超时时间;达到后即使持续操作也必须重新登录。
|
||||
*/
|
||||
private Duration absoluteTimeout = Duration.ofHours(8);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Local {
|
||||
private boolean enable = true;
|
||||
private String address = "http://localhost:8080";
|
||||
private String storagePath = "storage";
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Aliyun {
|
||||
private boolean enable;
|
||||
private String endpoint;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
private String bucketName;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Trace {
|
||||
/**
|
||||
* 是否开启轻量 Trace Tree。关闭后仍保留 X-Trace-Id 和 MDC。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* HTTP 入口慢请求阈值。小于等于 0 表示关闭。
|
||||
*/
|
||||
private long httpSlowThresholdMs = 3000L;
|
||||
|
||||
/**
|
||||
* Trace Tree 最大深度,root 节点深度为 1。
|
||||
*/
|
||||
private int maxDepth = 64;
|
||||
|
||||
/**
|
||||
* 小于该耗时的子节点不进入最终 Trace Tree。0 表示保留所有子节点。
|
||||
*/
|
||||
private long minNodeCostMs = 1L;
|
||||
|
||||
/**
|
||||
* 定时任务入口慢执行阈值。小于等于 0 表示关闭。
|
||||
*/
|
||||
private long scheduleSlowThresholdMs = 10_000L;
|
||||
|
||||
/**
|
||||
* Kafka Consumer 入口慢消费阈值。小于等于 0 表示关闭。
|
||||
*/
|
||||
private long kafkaConsumerSlowThresholdMs = 30_000L;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 项目级配置属性绑定。
|
||||
*/
|
||||
package com.laker.admin.config.properties;
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.laker.admin.config.remote;
|
||||
|
||||
import com.laker.admin.infrastructure.thread.EasyNextAdminMdcThreadPoolExecutor;
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
|
||||
import io.github.resilience4j.timelimiter.TimeLimiterConfig;
|
||||
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory;
|
||||
import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JConfigBuilder;
|
||||
import org.springframework.cloud.client.circuitbreaker.Customizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 为远程调用断路器提供统一默认配置。
|
||||
*/
|
||||
@Configuration
|
||||
public class EasyCircuitBreakerConfig {
|
||||
@Bean
|
||||
public Customizer<Resilience4JCircuitBreakerFactory> defaultCustomizer() {
|
||||
return factory -> {
|
||||
factory.configureDefault(id -> new Resilience4JConfigBuilder(id)
|
||||
.timeLimiterConfig(TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(10)).build())
|
||||
.circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
|
||||
.build());
|
||||
|
||||
factory.configureGroupExecutorService(group -> new EasyNextAdminMdcThreadPoolExecutor(3, 3, group));
|
||||
factory.configureExecutorService(new EasyNextAdminMdcThreadPoolExecutor(3, 3, "easy-feign"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.laker.admin.config.remote;
|
||||
|
||||
import com.laker.admin.common.constant.EasyNextAdminConstants;
|
||||
import com.laker.admin.infrastructure.observability.trace.EasyTraceIdContext;
|
||||
import feign.*;
|
||||
import feign.codec.ErrorDecoder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.cloud.openfeign.CircuitBreakerNameResolver;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Feign 全局配置。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableFeignClients(basePackages = "com.laker.admin.module")
|
||||
@ConditionalOnProperty(prefix = "easy.features", name = "feign", havingValue = "true", matchIfMissing = true)
|
||||
@Slf4j
|
||||
public class EasyFeignConfig {
|
||||
|
||||
/**
|
||||
* 拦截器可以有多个
|
||||
*/
|
||||
@Order(1)
|
||||
@Bean
|
||||
RequestInterceptor traceIdRequestInterceptor() {
|
||||
return requestTemplate ->
|
||||
requestTemplate.header(EasyNextAdminConstants.TRACE_ID_HEADER, EasyTraceIdContext.getOrCreateTraceId());
|
||||
}
|
||||
|
||||
@Order(2)
|
||||
@Bean
|
||||
RequestInterceptor viaRequestInterceptor() {
|
||||
return requestTemplate -> requestTemplate.header("via", "easy-feign");
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置超时时间
|
||||
*/
|
||||
@Bean
|
||||
Request.Options feignOptions() {
|
||||
// 默认连接超时时间为10秒,读取超时时间为60秒
|
||||
return new Request.Options(5, TimeUnit.SECONDS, 10, TimeUnit.SECONDS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置日志级别
|
||||
*/
|
||||
@Bean
|
||||
Logger.Level feignLogger() {
|
||||
return Logger.Level.BASIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置重试
|
||||
* <p>
|
||||
* 在Feign重试行为中,它将自动重试IOException,将它们视为与网络临时相关的异常,
|
||||
* 以及从 ErrorDecoder 抛出的任何 RetryableException。
|
||||
*/
|
||||
@Bean
|
||||
Retryer feignRetryer() {
|
||||
//最大请求次数为3,初始间隔时间为100ms,下次间隔时间1.5倍递增,重试间最大间隔时间为1s,
|
||||
return new Retryer.Default(100, 1000, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置错误解码器
|
||||
*/
|
||||
@Bean
|
||||
public ErrorDecoder feignError() {
|
||||
// ErrorDecoder的默认实现
|
||||
// 响应包含“Retry-After”标头时创建RetryableException实例。
|
||||
// 最常见的是,我们可以在 503 服务不可用响应中找到这个标头。
|
||||
final ErrorDecoder errorDecoder = new ErrorDecoder.Default();
|
||||
return (methodKey, response) -> {
|
||||
|
||||
if (response.status() == 400) {
|
||||
log.error("远程服务 400 参数错误, 返回:{}", response.body());
|
||||
}
|
||||
|
||||
if (response.status() == 404) {
|
||||
log.error("远程服务 404 异常, 返回:{}", response.body());
|
||||
}
|
||||
|
||||
Exception defaultException = errorDecoder.decode(methodKey, response);
|
||||
if (defaultException instanceof RetryableException) {
|
||||
// Requirement 3: retry when Retry-After header is set
|
||||
// 默认的重试逻辑
|
||||
return defaultException;
|
||||
}
|
||||
|
||||
// 扩展的重试逻辑
|
||||
// 5xx 服务器错误 重试
|
||||
if (HttpStatus.valueOf(response.status()).is5xxServerError()) {
|
||||
// 重试
|
||||
return new RetryableException(
|
||||
response.status(),
|
||||
defaultException.getMessage(),
|
||||
response.request().httpMethod(),
|
||||
defaultException,
|
||||
(Long) null,
|
||||
response.request());
|
||||
}
|
||||
|
||||
// 默认处理
|
||||
return defaultException;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置熔断器名称解析器
|
||||
*/
|
||||
@Bean
|
||||
public CircuitBreakerNameResolver circuitBreakerNameResolver() {
|
||||
return (String feignClientName, Target<?> target, Method method) -> feignClientName + "_" + method.getName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 远程调用装配,包括 Feign、熔断和超时等基础能力。
|
||||
*/
|
||||
package com.laker.admin.config.remote;
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.laker.admin.config.thread;
|
||||
|
||||
import com.laker.admin.infrastructure.observability.trace.EasyMdcContext;
|
||||
import com.laker.admin.infrastructure.observability.trace.EasyTraceIdContext;
|
||||
import com.laker.admin.infrastructure.thread.EasyNextAdminMdcThreadPoolExecutor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.RejectedExecutionHandler;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(EasyThreadPoolProperties.class)
|
||||
@Slf4j
|
||||
public class EasyThreadPoolConfig {
|
||||
|
||||
private final EasyThreadPoolProperties properties;
|
||||
|
||||
@Autowired
|
||||
public EasyThreadPoolConfig(EasyThreadPoolProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
EasyThreadPoolConfig() {
|
||||
this(new EasyThreadPoolProperties());
|
||||
}
|
||||
|
||||
static {
|
||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> log.error("Thread {} got exception", thread, throwable));
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时任务线程池
|
||||
*/
|
||||
@Bean
|
||||
public ThreadPoolTaskScheduler easyTaskThreadPool() {
|
||||
EasyThreadPoolProperties.Scheduler scheduler = properties.getScheduler();
|
||||
ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
|
||||
threadPoolTaskScheduler.setPoolSize(scheduler.getPoolSize());
|
||||
threadPoolTaskScheduler.setThreadNamePrefix(scheduler.getThreadNamePrefix());
|
||||
threadPoolTaskScheduler.setRemoveOnCancelPolicy(scheduler.isRemoveOnCancelPolicy());
|
||||
threadPoolTaskScheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(scheduler.isExecuteExistingDelayedTasksAfterShutdownPolicy());
|
||||
threadPoolTaskScheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(scheduler.isContinueExistingPeriodicTasksAfterShutdownPolicy());
|
||||
threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(scheduler.isWaitForTasksToCompleteOnShutdown());
|
||||
threadPoolTaskScheduler.setAwaitTerminationSeconds(scheduler.getAwaitTerminationSeconds());
|
||||
threadPoolTaskScheduler.setRejectedExecutionHandler(new CustomRejectedExecutionHandler());
|
||||
threadPoolTaskScheduler.setTaskDecorator(runnable -> wrapWithTrace(runnable, EasyMdcContext.copy()));
|
||||
return threadPoolTaskScheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务线程池
|
||||
*/
|
||||
@Bean
|
||||
public ThreadPoolTaskExecutor easyThreadPool() {
|
||||
EasyThreadPoolProperties.Business business = properties.getBusiness();
|
||||
ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
|
||||
threadPoolTaskExecutor.setCorePoolSize(business.getCoreSize());
|
||||
threadPoolTaskExecutor.setMaxPoolSize(business.getMaxSize());
|
||||
threadPoolTaskExecutor.setQueueCapacity(business.getQueueCapacity());
|
||||
threadPoolTaskExecutor.setThreadNamePrefix(business.getThreadNamePrefix());
|
||||
threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(business.isWaitForTasksToCompleteOnShutdown());
|
||||
threadPoolTaskExecutor.setAwaitTerminationSeconds(business.getAwaitTerminationSeconds());
|
||||
threadPoolTaskExecutor.setRejectedExecutionHandler(new CustomRejectedExecutionHandler());
|
||||
threadPoolTaskExecutor.setTaskDecorator(runnable -> wrapWithTrace(runnable, EasyMdcContext.copy()));
|
||||
return threadPoolTaskExecutor;
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务线程池
|
||||
*/
|
||||
@Bean
|
||||
public EasyNextAdminMdcThreadPoolExecutor businessMdcThreadPool() {
|
||||
EasyThreadPoolProperties.BusinessMdc businessMdc = properties.getBusinessMdc();
|
||||
return new EasyNextAdminMdcThreadPoolExecutor(businessMdc.getPoolSize(), businessMdc.getQueueSize(), businessMdc.getThreadNamePrefix());
|
||||
}
|
||||
|
||||
// 自定义的拒绝执行处理器,以更好地处理任务被拒绝的情况
|
||||
public static class CustomRejectedExecutionHandler implements RejectedExecutionHandler {
|
||||
@Override
|
||||
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
|
||||
log.warn("Task {} rejected from {}", r, executor);
|
||||
throw new RejectedExecutionException("Task " + r + " rejected from " + executor);
|
||||
}
|
||||
}
|
||||
|
||||
private static Runnable wrapWithTrace(Runnable runnable, Map<String, String> contextMap) {
|
||||
return EasyMdcContext.wrap(() -> {
|
||||
EasyTraceIdContext.getOrCreateTraceId();
|
||||
runnable.run();
|
||||
}, contextMap);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.laker.admin.config.thread;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "easy.thread-pool")
|
||||
public class EasyThreadPoolProperties {
|
||||
private Scheduler scheduler = new Scheduler();
|
||||
private Business business = new Business();
|
||||
private BusinessMdc businessMdc = new BusinessMdc();
|
||||
private Lock lock = new Lock();
|
||||
|
||||
@Data
|
||||
public static class Scheduler {
|
||||
private int poolSize = 20;
|
||||
private String threadNamePrefix = "easy-job-";
|
||||
private boolean removeOnCancelPolicy = true;
|
||||
private boolean executeExistingDelayedTasksAfterShutdownPolicy = false;
|
||||
private boolean continueExistingPeriodicTasksAfterShutdownPolicy = false;
|
||||
private boolean waitForTasksToCompleteOnShutdown = false;
|
||||
private int awaitTerminationSeconds = 10;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Business {
|
||||
private int coreSize = 20;
|
||||
private int maxSize = 100;
|
||||
private int queueCapacity = 100;
|
||||
private String threadNamePrefix = "easy-executor-";
|
||||
private boolean waitForTasksToCompleteOnShutdown = true;
|
||||
private int awaitTerminationSeconds = 60;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class BusinessMdc {
|
||||
private int poolSize = 20;
|
||||
private int queueSize = 100;
|
||||
private String threadNamePrefix = "business";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Lock {
|
||||
private int poolSize = 5;
|
||||
private String threadNamePrefix = "easy-lock-";
|
||||
private boolean waitForTasksToCompleteOnShutdown = true;
|
||||
private int awaitTerminationSeconds = 60;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 线程池装配,统一维护业务线程池和调度线程池。
|
||||
*/
|
||||
package com.laker.admin.config.thread;
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.infrastructure.web.mvc.PageRequestArgumentResolver;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EasyMvcArgumentResolverConfig implements WebMvcConfigurer {
|
||||
|
||||
private final PageRequestArgumentResolver pageRequestArgumentResolver;
|
||||
|
||||
@Override
|
||||
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||
resolvers.add(pageRequestArgumentResolver);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.infrastructure.web.mvc.StringToEnumConvertFactory;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.format.FormatterRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class EasyMvcFormatterConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addFormatters(FormatterRegistry registry) {
|
||||
registry.addConverter(new StringToEnumConvertFactory());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.config.properties.EasyNextAdminConfig;
|
||||
import com.laker.admin.infrastructure.security.interceptor.EasyPermissionInterceptor;
|
||||
import com.laker.admin.infrastructure.web.interceptor.EasyHttpSlowRequestInterceptor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class EasyMvcInterceptorConfig implements WebMvcConfigurer {
|
||||
|
||||
private static final String API_PATH_PATTERN = "/api/**";
|
||||
private static final int HTTP_SLOW_INTERCEPTOR_ORDER = 0;
|
||||
private static final int PERMISSION_INTERCEPTOR_ORDER = 1;
|
||||
|
||||
private final EasyNextAdminConfig easyNextAdminConfig;
|
||||
private final EasyPermissionInterceptor easyPermissionInterceptor;
|
||||
|
||||
@Bean
|
||||
public EasyHttpSlowRequestInterceptor easyHttpSlowRequestInterceptor() {
|
||||
return new EasyHttpSlowRequestInterceptor(
|
||||
easyNextAdminConfig.getTrace().isEnabled(),
|
||||
easyNextAdminConfig.getTrace().getHttpSlowThresholdMs(),
|
||||
easyNextAdminConfig.getTrace().getMaxDepth(),
|
||||
easyNextAdminConfig.getTrace().getMinNodeCostMs());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(easyHttpSlowRequestInterceptor())
|
||||
.addPathPatterns(API_PATH_PATTERN)
|
||||
.order(HTTP_SLOW_INTERCEPTOR_ORDER);
|
||||
registry.addInterceptor(easyPermissionInterceptor)
|
||||
.addPathPatterns(API_PATH_PATTERN)
|
||||
.order(PERMISSION_INTERCEPTOR_ORDER);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.config.properties.EasyNextAdminConfig;
|
||||
import com.laker.admin.infrastructure.security.filter.EasyAuthFilter;
|
||||
import com.laker.admin.infrastructure.security.service.EasyAuthService;
|
||||
import com.laker.admin.infrastructure.web.filter.EasyCorsFilter;
|
||||
import com.laker.admin.infrastructure.web.filter.EasyFilterOrders;
|
||||
import com.laker.admin.infrastructure.web.filter.EasyTraceIdFilter;
|
||||
import com.laker.admin.infrastructure.web.waf.WafFilter;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import jakarta.servlet.Filter;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Servlet 过滤器统一注册入口。
|
||||
*
|
||||
* @author laker
|
||||
*/
|
||||
@Configuration
|
||||
public class EasyServletFilterConfig {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<EasyTraceIdFilter> easyTraceFilterRegistration() {
|
||||
return register(EasyFilterOrders.TRACE, new EasyTraceIdFilter());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<WafFilter> easyWafFilterRegistration(EasyNextAdminConfig easyNextAdminConfig) {
|
||||
EasyNextAdminConfig.Waf waf = easyNextAdminConfig.getWaf();
|
||||
return register(EasyFilterOrders.WAF,
|
||||
new WafFilter(waf.getExcludes(), waf.isXssEnabled(), waf.isSqlEnabled(),
|
||||
easyNextAdminConfig.getWeb().getSecurityHeaders()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<EasyCorsFilter> easyCorsFilterRegistration(EasyNextAdminConfig easyNextAdminConfig) {
|
||||
return register(EasyFilterOrders.CORS, new EasyCorsFilter(easyNextAdminConfig.getWeb().getCors()));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<EasyAuthFilter> easyAuthFilterRegistration(EasyAuthService authService) {
|
||||
return register(EasyFilterOrders.AUTH, new EasyAuthFilter(authService));
|
||||
}
|
||||
|
||||
private static <T extends Filter> FilterRegistrationBean<T> register(EasyFilterOrders filterOrder, T filter) {
|
||||
FilterRegistrationBean<T> registration = new FilterRegistrationBean<>();
|
||||
registration.setDispatcherTypes(DispatcherType.REQUEST);
|
||||
registration.setName(filterOrder.getRegistrationName());
|
||||
registration.setFilter(filter);
|
||||
registration.setOrder(filterOrder.getOrder());
|
||||
registration.addUrlPatterns(filterOrder.getUrlPatterns());
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.config.properties.EasyNextAdminConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class EasyStaticResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
private final EasyNextAdminConfig easyNextAdminConfig;
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
String storagePath = easyNextAdminConfig.getStorage().getLocal().getStoragePath();
|
||||
Path storageRoot = Path.of(storagePath).toAbsolutePath().normalize();
|
||||
createStorageRoot(storageRoot);
|
||||
|
||||
registry.addResourceHandler("/" + resourceUrlPrefix(storagePath) + "/**")
|
||||
.addResourceLocations(resourceLocation(storageRoot));
|
||||
log.info("Static storage resources registered: storage={}", storageRoot);
|
||||
}
|
||||
|
||||
private void createStorageRoot(Path storageRoot) {
|
||||
try {
|
||||
Files.createDirectories(storageRoot);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("无法创建本地文件存储目录:" + storageRoot, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String resourceUrlPrefix(String storagePath) {
|
||||
if (!StringUtils.hasText(storagePath)) {
|
||||
throw new IllegalStateException("easy.storage.local.storage-path 不能为空");
|
||||
}
|
||||
String normalized = storagePath.trim().replace('\\', '/');
|
||||
while (normalized.startsWith("/")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
while (normalized.endsWith("/")) {
|
||||
normalized = normalized.substring(0, normalized.length() - 1);
|
||||
}
|
||||
if (!StringUtils.hasText(normalized) || normalized.contains("..")) {
|
||||
throw new IllegalStateException("easy.storage.local.storage-path 不能包含上级目录:" + storagePath);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String resourceLocation(Path path) {
|
||||
String location = path.toUri().toString();
|
||||
return location.endsWith("/") ? location : location + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.laker.admin.config.web;
|
||||
|
||||
import com.laker.admin.infrastructure.web.websocket.EasyNextChatHandler;
|
||||
import com.laker.admin.infrastructure.web.websocket.EasyNextSessionHandshakeInterceptor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;
|
||||
|
||||
/**
|
||||
* @author laker
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocket // 启动Websocket
|
||||
public class EasyWebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final EasyNextChatHandler webSocketHandler;
|
||||
|
||||
public EasyWebSocketConfig(EasyNextChatHandler webSocketHandler) {
|
||||
this.webSocketHandler = webSocketHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(webSocketHandler, "/websocket/**")
|
||||
// 添加拦截器,可以获取连接的param和 header 用作认证鉴权
|
||||
.addInterceptors(new EasyNextSessionHandshakeInterceptor())
|
||||
// 设置运行跨域
|
||||
.setAllowedOrigins("*");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ServletServerContainerFactoryBean createWebSocketContainer() {
|
||||
ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();
|
||||
// 设置默认会话空闲超时 以毫秒为单位 非正值意味着无限超时,默认值 0 ,默认没10s检查一次空闲就关闭
|
||||
container.setMaxSessionIdleTimeout(10 * 1000L);
|
||||
// 设置异步发送消息的默认超时时间 以毫秒为单位 非正值意味着无限超时 ,默认值-1,还没看到作用
|
||||
// container.setAsyncSendTimeout(10 * 1000L);
|
||||
// 设置文本消息的默认最大缓冲区大小 以字符为单位,默认值 8 * 1024
|
||||
container.setMaxTextMessageBufferSize(8 * 1024);
|
||||
// 设置二进制消息的默认最大缓冲区大小 以字节为单位,默认值 8 * 1024
|
||||
container.setMaxBinaryMessageBufferSize(8 * 1024);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Web 入口装配,包括 Servlet Filter、MVC 扩展、静态资源和 WebSocket。
|
||||
*/
|
||||
package com.laker.admin.config.web;
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.laker.admin.infrastructure.application.listener;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationFailedEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApplicationFailedEventListener implements ApplicationListener<ApplicationFailedEvent> {
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationFailedEvent event) {
|
||||
if (event.getException() != null) {
|
||||
log.error("[启动异常,退出]", event.getException());
|
||||
event.getApplicationContext().close();
|
||||
System.exit(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.laker.admin.infrastructure.application.listener;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApplicationReadyEventListener implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
public ApplicationReadyEventListener(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
String port = environment.getProperty("server.port");
|
||||
log.info("Tomcat启动了,端口为: {}", port);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.laker.admin.infrastructure.application.listener;
|
||||
|
||||
import jakarta.servlet.ServletContextEvent;
|
||||
import jakarta.servlet.ServletContextListener;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApplicationServletContextListener implements ServletContextListener {
|
||||
|
||||
|
||||
@Override
|
||||
public void contextInitialized(ServletContextEvent servletContextEvent) {
|
||||
log.info("ApplicationServletContextListener ServletContextEvent Initialized");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void contextDestroyed(ServletContextEvent servletContextEvent) {
|
||||
log.info("ApplicationServletContextListener ServletContextEvent Destroyed");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.laker.admin.infrastructure.audit;
|
||||
|
||||
import com.laker.admin.infrastructure.observability.trace.EasyTraceIdContext;
|
||||
import com.laker.admin.infrastructure.security.context.EasySecurityContext;
|
||||
import com.laker.admin.infrastructure.security.masking.EasySensitiveDataMasker;
|
||||
import com.laker.admin.infrastructure.security.model.AuthPrincipal;
|
||||
import com.laker.admin.infrastructure.web.context.EasyRequestContext;
|
||||
import com.laker.admin.module.audit.entity.AuditDataChangeLog;
|
||||
import com.laker.admin.module.audit.entity.AuditErrorLog;
|
||||
import com.laker.admin.module.audit.entity.AuditLoginLog;
|
||||
import com.laker.admin.module.audit.entity.AuditOperationLog;
|
||||
import com.laker.admin.module.audit.mapper.AuditDataChangeLogMapper;
|
||||
import com.laker.admin.module.audit.mapper.AuditErrorLogMapper;
|
||||
import com.laker.admin.module.audit.mapper.AuditLoginLogMapper;
|
||||
import com.laker.admin.module.audit.mapper.AuditOperationLogMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
@Slf4j
|
||||
public class AuditLogCollector {
|
||||
private static final int MAX_SHORT_TEXT = 255;
|
||||
private static final int MAX_MESSAGE = 1000;
|
||||
private static final int MAX_STACK_TRACE = 12000;
|
||||
private static final int MAX_USER_AGENT = 500;
|
||||
|
||||
private final AuditLoginLogMapper loginLogMapper;
|
||||
private final AuditOperationLogMapper operationLogMapper;
|
||||
private final AuditDataChangeLogMapper dataChangeLogMapper;
|
||||
private final AuditErrorLogMapper errorLogMapper;
|
||||
private final EasySensitiveDataMasker masker;
|
||||
|
||||
public AuditLogCollector(AuditLoginLogMapper loginLogMapper,
|
||||
AuditOperationLogMapper operationLogMapper,
|
||||
AuditDataChangeLogMapper dataChangeLogMapper,
|
||||
AuditErrorLogMapper errorLogMapper,
|
||||
EasySensitiveDataMasker masker) {
|
||||
this.loginLogMapper = loginLogMapper;
|
||||
this.operationLogMapper = operationLogMapper;
|
||||
this.dataChangeLogMapper = dataChangeLogMapper;
|
||||
this.errorLogMapper = errorLogMapper;
|
||||
this.masker = masker;
|
||||
}
|
||||
|
||||
public void recordLogin(Long userId,
|
||||
String userName,
|
||||
boolean success,
|
||||
String failReason,
|
||||
HttpServletRequest request) {
|
||||
AuditLoginLog loginLog = new AuditLoginLog();
|
||||
loginLog.setUserId(userId);
|
||||
loginLog.setUserName(masker.truncate(userName, 80));
|
||||
loginLog.setLoginResult(success ? "SUCCESS" : "FAIL");
|
||||
loginLog.setFailReason(masker.truncate(masker.maskText(failReason), MAX_SHORT_TEXT));
|
||||
loginLog.setIp(request == null ? EasyRequestContext.currentRemoteIp() : EasyRequestContext.remoteIp(request));
|
||||
loginLog.setUserAgent(masker.truncate(request == null ? null : request.getHeader("User-Agent"), MAX_USER_AGENT));
|
||||
loginLog.setClientType("web");
|
||||
loginLog.setTraceId(EasyTraceIdContext.getOrCreateTraceId());
|
||||
loginLog.setLoginTime(LocalDateTime.now());
|
||||
insertSafely("login", () -> loginLogMapper.insert(loginLog));
|
||||
}
|
||||
|
||||
public void recordOperation(AuditOperationLog operationLog) {
|
||||
if (operationLog == null) {
|
||||
return;
|
||||
}
|
||||
fillOperator(operationLog);
|
||||
operationLog.setTraceId(defaultText(operationLog.getTraceId(), EasyTraceIdContext.getOrCreateTraceId()));
|
||||
operationLog.setRequestMethod(defaultText(operationLog.getRequestMethod(), EasyRequestContext.currentRequestMethod()));
|
||||
operationLog.setRequestUri(masker.truncate(masker.maskUri(defaultText(operationLog.getRequestUri(), EasyRequestContext.currentRequestUri())), MAX_SHORT_TEXT));
|
||||
operationLog.setRequestParams(masker.truncate(masker.sanitizeJsonText(operationLog.getRequestParams()), EasySensitiveDataMasker.DEFAULT_TEXT_LIMIT));
|
||||
operationLog.setErrorMessage(masker.truncate(masker.maskText(operationLog.getErrorMessage()), MAX_MESSAGE));
|
||||
operationLog.setIp(defaultText(operationLog.getIp(), EasyRequestContext.currentRemoteIp()));
|
||||
operationLog.setUserAgent(masker.truncate(defaultText(operationLog.getUserAgent(), currentUserAgent()), MAX_USER_AGENT));
|
||||
operationLog.setModule(masker.truncate(operationLog.getModule(), 80));
|
||||
operationLog.setAction(masker.truncate(operationLog.getAction(), 80));
|
||||
operationLog.setResponseStatus(masker.truncate(operationLog.getResponseStatus(), 20));
|
||||
if (operationLog.getCreatedAt() == null) {
|
||||
operationLog.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
insertSafely("operation", () -> operationLogMapper.insert(operationLog));
|
||||
}
|
||||
|
||||
public void recordDataChange(AuditDataChangeLog dataChangeLog) {
|
||||
if (dataChangeLog == null) {
|
||||
return;
|
||||
}
|
||||
dataChangeLog.setTraceId(defaultText(dataChangeLog.getTraceId(), EasyTraceIdContext.getOrCreateTraceId()));
|
||||
if (dataChangeLog.getOperatorId() == null) {
|
||||
dataChangeLog.setOperatorId(EasySecurityContext.getUserId());
|
||||
}
|
||||
dataChangeLog.setBizType(masker.truncate(dataChangeLog.getBizType(), 80));
|
||||
dataChangeLog.setBizId(masker.truncate(dataChangeLog.getBizId(), 80));
|
||||
dataChangeLog.setTableName(masker.truncate(dataChangeLog.getTableName(), 80));
|
||||
dataChangeLog.setChangeType(masker.truncate(defaultText(dataChangeLog.getChangeType(), "UPDATE"), 20));
|
||||
dataChangeLog.setBeforeJson(masker.sanitizeJsonText(dataChangeLog.getBeforeJson()));
|
||||
dataChangeLog.setAfterJson(masker.sanitizeJsonText(dataChangeLog.getAfterJson()));
|
||||
if (!StringUtils.hasText(dataChangeLog.getChangedFields())) {
|
||||
dataChangeLog.setChangedFields(masker.changedFields(dataChangeLog.getBeforeJson(), dataChangeLog.getAfterJson()));
|
||||
}
|
||||
dataChangeLog.setChangedFields(masker.truncate(dataChangeLog.getChangedFields(), MAX_MESSAGE));
|
||||
if (dataChangeLog.getCreatedAt() == null) {
|
||||
dataChangeLog.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
insertSafely("data-change", () -> dataChangeLogMapper.insert(dataChangeLog));
|
||||
}
|
||||
|
||||
public void recordDataChange(String bizType,
|
||||
String bizId,
|
||||
String tableName,
|
||||
String changeType,
|
||||
Object before,
|
||||
Object after) {
|
||||
AuditDataChangeLog dataChangeLog = new AuditDataChangeLog();
|
||||
dataChangeLog.setBizType(bizType);
|
||||
dataChangeLog.setBizId(bizId);
|
||||
dataChangeLog.setTableName(tableName);
|
||||
dataChangeLog.setChangeType(changeType);
|
||||
dataChangeLog.setBeforeJson(before == null ? null : masker.toSanitizedJson(before));
|
||||
dataChangeLog.setAfterJson(after == null ? null : masker.toSanitizedJson(after));
|
||||
recordDataChange(dataChangeLog);
|
||||
}
|
||||
|
||||
public void recordError(Throwable throwable) {
|
||||
AuditErrorLog errorLog = new AuditErrorLog();
|
||||
errorLog.setErrorType(throwable == null ? null : throwable.getClass().getName());
|
||||
errorLog.setErrorMessage(throwable == null ? null : throwable.getMessage());
|
||||
errorLog.setStackTrace(stackTrace(throwable));
|
||||
recordError(errorLog);
|
||||
}
|
||||
|
||||
public void recordError(AuditErrorLog errorLog) {
|
||||
if (errorLog == null) {
|
||||
return;
|
||||
}
|
||||
errorLog.setTraceId(defaultText(errorLog.getTraceId(), EasyTraceIdContext.getOrCreateTraceId()));
|
||||
errorLog.setRequestUri(masker.truncate(masker.maskUri(defaultText(errorLog.getRequestUri(), EasyRequestContext.currentRequestUri())), MAX_SHORT_TEXT));
|
||||
errorLog.setRequestMethod(defaultText(errorLog.getRequestMethod(), EasyRequestContext.currentRequestMethod()));
|
||||
errorLog.setErrorType(masker.truncate(errorLog.getErrorType(), MAX_SHORT_TEXT));
|
||||
errorLog.setErrorMessage(masker.truncate(masker.maskText(errorLog.getErrorMessage()), MAX_MESSAGE));
|
||||
errorLog.setStackTrace(masker.truncate(masker.maskText(errorLog.getStackTrace()), MAX_STACK_TRACE));
|
||||
if (errorLog.getOperatorId() == null) {
|
||||
errorLog.setOperatorId(EasySecurityContext.getUserId());
|
||||
}
|
||||
if (errorLog.getCreatedAt() == null) {
|
||||
errorLog.setCreatedAt(LocalDateTime.now());
|
||||
}
|
||||
insertSafely("error", () -> errorLogMapper.insert(errorLog));
|
||||
}
|
||||
|
||||
private void fillOperator(AuditOperationLog operationLog) {
|
||||
AuthPrincipal principal = EasySecurityContext.getPrincipal();
|
||||
if (operationLog.getOperatorId() == null) {
|
||||
operationLog.setOperatorId(principal == null ? null : principal.getUserId());
|
||||
}
|
||||
if (!StringUtils.hasText(operationLog.getOperatorName())) {
|
||||
operationLog.setOperatorName(masker.truncate(principal == null ? null : principal.getUserName(), 80));
|
||||
}
|
||||
}
|
||||
|
||||
private String stackTrace(Throwable throwable) {
|
||||
if (throwable == null) {
|
||||
return null;
|
||||
}
|
||||
StringWriter writer = new StringWriter();
|
||||
throwable.printStackTrace(new PrintWriter(writer));
|
||||
return masker.truncate(writer.toString(), MAX_STACK_TRACE);
|
||||
}
|
||||
|
||||
private String currentUserAgent() {
|
||||
return EasyRequestContext.currentRequest()
|
||||
.map(request -> request.getHeader("User-Agent"))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String defaultText(String value, String fallback) {
|
||||
return StringUtils.hasText(value) ? value : fallback;
|
||||
}
|
||||
|
||||
private void insertSafely(String type, Runnable insertAction) {
|
||||
try {
|
||||
insertAction.run();
|
||||
} catch (Exception e) {
|
||||
log.warn("save {} audit log failed: {}", type, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.laker.admin.infrastructure.audit;
|
||||
|
||||
import com.laker.admin.infrastructure.security.masking.EasySensitiveDataMasker;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class AuditRequestPayloadFormatter {
|
||||
private final EasySensitiveDataMasker masker;
|
||||
private final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
|
||||
|
||||
public AuditRequestPayloadFormatter(EasySensitiveDataMasker masker) {
|
||||
this.masker = masker;
|
||||
}
|
||||
|
||||
public String format(Method method, Object[] args) {
|
||||
Object payload = payload(method, args);
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
return masker.toSanitizedCompactJson(payload);
|
||||
}
|
||||
|
||||
private Object payload(Method method, Object[] args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return null;
|
||||
}
|
||||
Map<String, Object> arguments = new LinkedHashMap<>();
|
||||
String[] parameterNames = parameterNameDiscoverer.getParameterNames(method);
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
Object arg = args[i];
|
||||
if (shouldSkipArgument(arg)) {
|
||||
continue;
|
||||
}
|
||||
String name = parameterNames != null && i < parameterNames.length ? parameterNames[i] : "arg" + i;
|
||||
arguments.put(name, arg);
|
||||
}
|
||||
if (arguments.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (arguments.size() == 1) {
|
||||
Map.Entry<String, Object> entry = arguments.entrySet().iterator().next();
|
||||
Object value = entry.getValue();
|
||||
if (!isSimpleValue(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private boolean shouldSkipArgument(Object arg) {
|
||||
if (arg == null) {
|
||||
return true;
|
||||
}
|
||||
if (arg instanceof CharSequence text) {
|
||||
return !StringUtils.hasText(text);
|
||||
}
|
||||
return masker.isRequestInfrastructureValue(arg);
|
||||
}
|
||||
|
||||
private boolean isSimpleValue(Object value) {
|
||||
return value instanceof CharSequence
|
||||
|| value instanceof Number
|
||||
|| value instanceof Boolean
|
||||
|| value instanceof Enum<?>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.laker.admin.infrastructure.audit;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface EasyAudit {
|
||||
String module() default "";
|
||||
|
||||
String action() default "";
|
||||
|
||||
boolean operation() default true;
|
||||
|
||||
boolean dataChange() default false;
|
||||
|
||||
String bizType() default "";
|
||||
|
||||
String bizId() default "";
|
||||
|
||||
String tableName() default "";
|
||||
|
||||
String changeType() default "";
|
||||
|
||||
String before() default "";
|
||||
|
||||
String after() default "";
|
||||
|
||||
String changedFields() default "";
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.laker.admin.infrastructure.audit;
|
||||
|
||||
import com.laker.admin.common.exception.BusinessException;
|
||||
import com.laker.admin.common.model.Response;
|
||||
import com.laker.admin.infrastructure.security.exception.EasyAuthException;
|
||||
import com.laker.admin.infrastructure.security.exception.EasyForbiddenException;
|
||||
import com.laker.admin.infrastructure.security.masking.EasySensitiveDataMasker;
|
||||
import com.laker.admin.module.system.dto.auth.AuthLoginRequest;
|
||||
import com.laker.admin.module.audit.entity.AuditDataChangeLog;
|
||||
import com.laker.admin.module.audit.entity.AuditOperationLog;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Pointcut;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.context.expression.MethodBasedEvaluationContext;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.expression.EvaluationContext;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
@Order(Ordered.LOWEST_PRECEDENCE - 20)
|
||||
public class EasyAuditAspect {
|
||||
private static final Set<String> MUTATION_METHODS = Set.of("POST", "PUT", "PATCH", "DELETE");
|
||||
|
||||
private final AuditLogCollector auditLogCollector;
|
||||
private final EasySensitiveDataMasker masker;
|
||||
private final AuditRequestPayloadFormatter requestPayloadFormatter;
|
||||
private final DefaultParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
|
||||
private final ExpressionParser expressionParser = new SpelExpressionParser();
|
||||
|
||||
public EasyAuditAspect(AuditLogCollector auditLogCollector,
|
||||
EasySensitiveDataMasker masker,
|
||||
AuditRequestPayloadFormatter requestPayloadFormatter) {
|
||||
this.auditLogCollector = auditLogCollector;
|
||||
this.masker = masker;
|
||||
this.requestPayloadFormatter = requestPayloadFormatter;
|
||||
}
|
||||
|
||||
@Pointcut("execution(public * com.laker.admin..controller..*(..))")
|
||||
public void controllerMethod() {
|
||||
}
|
||||
|
||||
@Pointcut("@annotation(com.laker.admin.infrastructure.audit.EasyAudit) " +
|
||||
"|| @within(com.laker.admin.infrastructure.audit.EasyAudit)")
|
||||
public void auditAnnotated() {
|
||||
}
|
||||
|
||||
@Around("controllerMethod() || auditAnnotated()")
|
||||
public Object audit(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodContext methodContext = methodContext(joinPoint);
|
||||
EasyAudit audit = auditAnnotation(methodContext);
|
||||
boolean controller = isRestController(methodContext.targetClass());
|
||||
boolean shouldRecordOperation = shouldRecordOperation(audit);
|
||||
Instant start = Instant.now();
|
||||
try {
|
||||
Object result = joinPoint.proceed();
|
||||
long durationMs = Duration.between(start, Instant.now()).toMillis();
|
||||
if (shouldRecordOperation) {
|
||||
auditLogCollector.recordOperation(operationLog(methodContext, audit, result, null, durationMs));
|
||||
}
|
||||
if (audit != null && audit.dataChange()) {
|
||||
auditLogCollector.recordDataChange(dataChangeLog(methodContext, audit, result, null));
|
||||
}
|
||||
return result;
|
||||
} catch (Throwable throwable) {
|
||||
long durationMs = Duration.between(start, Instant.now()).toMillis();
|
||||
if (shouldRecordOperation) {
|
||||
auditLogCollector.recordOperation(operationLog(methodContext, audit, null, throwable, durationMs));
|
||||
}
|
||||
if ((controller || audit != null) && shouldRecordError(throwable)) {
|
||||
auditLogCollector.recordError(throwable);
|
||||
}
|
||||
throw throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private AuditOperationLog operationLog(MethodContext methodContext,
|
||||
EasyAudit audit,
|
||||
Object result,
|
||||
Throwable throwable,
|
||||
long durationMs) {
|
||||
AuditOperationLog operationLog = new AuditOperationLog();
|
||||
operationLog.setModule(resolveModule(methodContext, audit));
|
||||
operationLog.setAction(resolveAction(methodContext, audit));
|
||||
operationLog.setRequestParams(requestPayloadFormatter.format(methodContext.method(), methodContext.args()));
|
||||
operationLog.setResponseStatus(responseStatus(result, throwable));
|
||||
operationLog.setErrorMessage(throwable == null ? null : throwable.getMessage());
|
||||
operationLog.setDurationMs(safeDuration(durationMs));
|
||||
operationLog.setOperatorName(resolveOperatorName(methodContext));
|
||||
return operationLog;
|
||||
}
|
||||
|
||||
private AuditDataChangeLog dataChangeLog(MethodContext methodContext,
|
||||
EasyAudit audit,
|
||||
Object result,
|
||||
Throwable throwable) {
|
||||
AuditDataChangeLog dataChangeLog = new AuditDataChangeLog();
|
||||
dataChangeLog.setBizType(resolveExpressionAsString(audit.bizType(), methodContext, result, throwable));
|
||||
dataChangeLog.setBizId(resolveExpressionAsString(audit.bizId(), methodContext, result, throwable));
|
||||
dataChangeLog.setTableName(resolveExpressionAsString(audit.tableName(), methodContext, result, throwable));
|
||||
dataChangeLog.setChangeType(resolveExpressionAsString(audit.changeType(), methodContext, result, throwable));
|
||||
dataChangeLog.setBeforeJson(resolveExpressionAsJson(audit.before(), methodContext, result, throwable));
|
||||
dataChangeLog.setAfterJson(resolveExpressionAsJson(audit.after(), methodContext, result, throwable));
|
||||
dataChangeLog.setChangedFields(resolveExpressionAsString(audit.changedFields(), methodContext, result, throwable));
|
||||
return dataChangeLog;
|
||||
}
|
||||
|
||||
private MethodContext methodContext(ProceedingJoinPoint joinPoint) {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Class<?> targetClass = joinPoint.getTarget() == null
|
||||
? signature.getDeclaringType()
|
||||
: AopUtils.getTargetClass(joinPoint.getTarget());
|
||||
Method specificMethod = AopUtils.getMostSpecificMethod(signature.getMethod(), targetClass);
|
||||
return new MethodContext(targetClass, specificMethod, joinPoint.getTarget(), joinPoint.getArgs());
|
||||
}
|
||||
|
||||
private EasyAudit auditAnnotation(MethodContext methodContext) {
|
||||
EasyAudit methodAudit = AnnotatedElementUtils.findMergedAnnotation(methodContext.method(), EasyAudit.class);
|
||||
if (methodAudit != null) {
|
||||
return methodAudit;
|
||||
}
|
||||
return AnnotatedElementUtils.findMergedAnnotation(methodContext.targetClass(), EasyAudit.class);
|
||||
}
|
||||
|
||||
private boolean shouldRecordOperation(EasyAudit audit) {
|
||||
if (audit != null) {
|
||||
return audit.operation();
|
||||
}
|
||||
String method = com.laker.admin.infrastructure.web.context.EasyRequestContext.currentRequestMethod();
|
||||
return MUTATION_METHODS.contains(method);
|
||||
}
|
||||
|
||||
private boolean isRestController(Class<?> targetClass) {
|
||||
return AnnotatedElementUtils.hasAnnotation(targetClass, RestController.class);
|
||||
}
|
||||
|
||||
private String resolveModule(MethodContext methodContext, EasyAudit audit) {
|
||||
if (audit != null && StringUtils.hasText(audit.module())) {
|
||||
return audit.module();
|
||||
}
|
||||
Tag tag = AnnotatedElementUtils.findMergedAnnotation(methodContext.targetClass(), Tag.class);
|
||||
if (tag != null && StringUtils.hasText(tag.name())) {
|
||||
return tag.name();
|
||||
}
|
||||
return methodContext.targetClass().getSimpleName().replace("Controller", "");
|
||||
}
|
||||
|
||||
private String resolveAction(MethodContext methodContext, EasyAudit audit) {
|
||||
if (audit != null && StringUtils.hasText(audit.action())) {
|
||||
return audit.action();
|
||||
}
|
||||
Operation operation = AnnotatedElementUtils.findMergedAnnotation(methodContext.method(), Operation.class);
|
||||
if (operation != null && StringUtils.hasText(operation.summary())) {
|
||||
return operation.summary();
|
||||
}
|
||||
return methodContext.method().getName();
|
||||
}
|
||||
|
||||
private String responseStatus(Object result, Throwable throwable) {
|
||||
if (throwable != null) {
|
||||
return isExpectedFailure(throwable) ? "FAIL" : "ERROR";
|
||||
}
|
||||
if (result instanceof Response<?> response) {
|
||||
return response.getCode() == 0 ? "SUCCESS" : "FAIL";
|
||||
}
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
private boolean shouldRecordError(Throwable throwable) {
|
||||
return !isExpectedFailure(throwable);
|
||||
}
|
||||
|
||||
private boolean isExpectedFailure(Throwable throwable) {
|
||||
return throwable instanceof BusinessException
|
||||
|| throwable instanceof EasyAuthException
|
||||
|| throwable instanceof EasyForbiddenException;
|
||||
}
|
||||
|
||||
private String resolveOperatorName(MethodContext methodContext) {
|
||||
for (Object arg : methodContext.args()) {
|
||||
if (arg instanceof AuthLoginRequest loginRequest && StringUtils.hasText(loginRequest.getUsername())) {
|
||||
return loginRequest.getUsername();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String resolveExpressionAsString(String expression,
|
||||
MethodContext methodContext,
|
||||
Object result,
|
||||
Throwable throwable) {
|
||||
if (!StringUtils.hasText(expression)) {
|
||||
return null;
|
||||
}
|
||||
Object value = isExpression(expression)
|
||||
? evaluate(expression, methodContext, result, throwable)
|
||||
: expression;
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String resolveExpressionAsJson(String expression,
|
||||
MethodContext methodContext,
|
||||
Object result,
|
||||
Throwable throwable) {
|
||||
if (!StringUtils.hasText(expression)) {
|
||||
return null;
|
||||
}
|
||||
Object value = isExpression(expression)
|
||||
? evaluate(expression, methodContext, result, throwable)
|
||||
: expression;
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof String text) {
|
||||
return masker.sanitizeJsonText(text);
|
||||
}
|
||||
return masker.toSanitizedJson(value);
|
||||
}
|
||||
|
||||
private Object evaluate(String expression,
|
||||
MethodContext methodContext,
|
||||
Object result,
|
||||
Throwable throwable) {
|
||||
try {
|
||||
EvaluationContext context = new MethodBasedEvaluationContext(
|
||||
methodContext.target(),
|
||||
methodContext.method(),
|
||||
methodContext.args(),
|
||||
parameterNameDiscoverer);
|
||||
context.setVariable("result", result);
|
||||
context.setVariable("exception", throwable);
|
||||
return expressionParser.parseExpression(expression).getValue(context);
|
||||
} catch (Exception e) {
|
||||
log.warn("evaluate audit expression failed, expression: {}, error: {}", expression, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExpression(String value) {
|
||||
return value.contains("#") || value.startsWith("'") || value.startsWith("T(");
|
||||
}
|
||||
|
||||
private int safeDuration(long durationMs) {
|
||||
return durationMs > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) durationMs;
|
||||
}
|
||||
|
||||
private record MethodContext(Class<?> targetClass, Method method, Object target, Object[] args) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.laker.admin.infrastructure.cache.redis;
|
||||
|
||||
import org.redisson.Redisson;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.redisson.config.Config;
|
||||
import org.redisson.config.SingleServerConfig;
|
||||
import org.redisson.spring.data.connection.RedissonConnectionFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "easy.features", name = "redis", havingValue = "true")
|
||||
public class EasyRedisConfig {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(name = "redisTemplate")
|
||||
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
|
||||
template.setConnectionFactory(redisConnectionFactory);
|
||||
return template;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(StringRedisTemplate.class)
|
||||
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
StringRedisTemplate template = new StringRedisTemplate();
|
||||
template.setConnectionFactory(redisConnectionFactory);
|
||||
return template;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(RedisConnectionFactory.class)
|
||||
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisson) {
|
||||
return new RedissonConnectionFactory(redisson);
|
||||
}
|
||||
|
||||
|
||||
@Bean(destroyMethod = "shutdown")
|
||||
@ConditionalOnMissingBean(RedissonClient.class)
|
||||
public RedissonClient redisson(EasyRedisProperties easyRedisProperties) {
|
||||
Config config = new Config();
|
||||
singleServerConfig(config, easyRedisProperties);
|
||||
return Redisson.create(config);
|
||||
}
|
||||
|
||||
SingleServerConfig singleServerConfig(Config config, EasyRedisProperties easyRedisProperties) {
|
||||
SingleServerConfig serverConfig = config.useSingleServer()
|
||||
.setAddress(easyRedisProperties.getAddress())
|
||||
.setDatabase(easyRedisProperties.getDatabase())
|
||||
.setTimeout(easyRedisProperties.getTimeout())
|
||||
.setConnectionMinimumIdleSize(easyRedisProperties.getConnectionMinimumIdleSize())
|
||||
.setConnectionPoolSize(easyRedisProperties.getConnectionPoolSize())
|
||||
.setDnsMonitoringInterval(easyRedisProperties.getDnsMonitoringInterval())
|
||||
.setSubscriptionConnectionMinimumIdleSize(easyRedisProperties.getSubscriptionConnectionMinimumIdleSize())
|
||||
.setSubscriptionConnectionPoolSize(easyRedisProperties.getSubscriptionConnectionPoolSize())
|
||||
.setConnectTimeout(easyRedisProperties.getConnectTimeout())
|
||||
.setClientName(easyRedisProperties.getClientName());
|
||||
// Redis 没有密码时不要传空字符串,否则部分 Redis 服务会按“空密码认证”处理。
|
||||
if (StringUtils.hasText(easyRedisProperties.getPassword())) {
|
||||
serverConfig.setPassword(easyRedisProperties.getPassword());
|
||||
}
|
||||
return serverConfig;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.laker.admin.infrastructure.cache.redis;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* 自定义配置
|
||||
*
|
||||
* @author laker
|
||||
*/
|
||||
@Configuration
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "easy.spring.redis")
|
||||
@ConditionalOnProperty(prefix = "easy.features", name = "redis", havingValue = "true")
|
||||
public class EasyRedisProperties {
|
||||
private String address = "redis://localhost:6379";
|
||||
private String password;
|
||||
private int database = 0;
|
||||
private String clientName = "easy-next-admin";
|
||||
private int timeout = 3000;
|
||||
private int connectTimeout = 3000;
|
||||
private int connectionMinimumIdleSize = 1;
|
||||
private int connectionPoolSize = 5;
|
||||
private int subscriptionConnectionMinimumIdleSize = 1;
|
||||
private int subscriptionConnectionPoolSize = 5;
|
||||
/**
|
||||
* Redisson 默认 5000ms 会启动 DNSMonitor;单节点内网 Redis 默认关闭,避免无意义的 PRO/DNSMonitor 噪声。
|
||||
*/
|
||||
private long dnsMonitoringInterval = -1;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.laker.admin.infrastructure.id;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 平台内部标识生成入口。
|
||||
*
|
||||
* <p>业务表主键继续交给 MyBatis-Plus 雪花 ID;这里用于 traceId、锁 token、文件名等非业务主键场景。
|
||||
* 统一入口后,后续如果要切换成 ULID、NanoId 或带业务前缀的编号,不需要改业务代码。</p>
|
||||
*/
|
||||
public final class EasyIdGenerator {
|
||||
|
||||
private EasyIdGenerator() {
|
||||
}
|
||||
|
||||
public static String uuid32() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReadWriteLock;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
/**
|
||||
* 基于 ConcurrentHashMap 实现的重复请求限制器
|
||||
* <pre>
|
||||
* 1.支持自动过期时间设置、缓存淘汰
|
||||
* 2.支持并发请求,线程安全 (ConcurrentHashMap) uuid 保证唯一性
|
||||
* 3.通过定时清理过期项控制内存占用
|
||||
* 4.高效的并发访问,提供原子操作(compute)
|
||||
* </pre>
|
||||
*/
|
||||
@Slf4j
|
||||
public class ConcurrentHashMapDuplicateRequestLimiter implements DuplicateRequestLimiter, AutoCloseable {
|
||||
|
||||
// 存储 key 及其最近请求时间和超时时间
|
||||
private final ConcurrentHashMap<String, ExpiringEntry> requestMap = new ConcurrentHashMap<>();
|
||||
// 读写锁,用于保证并发requestMap.compute 和 requestMap.entrySet().removeIf 的线程安全
|
||||
private final ReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
private final ScheduledExecutorService cleanupScheduler;
|
||||
|
||||
public ConcurrentHashMapDuplicateRequestLimiter() {
|
||||
// 初始化定时任务清理过期key
|
||||
cleanupScheduler = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "easy-duplicate-cleanup");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
cleanupScheduler.scheduleAtFixedRate(this::cleanUp, 1, 1, TimeUnit.MINUTES);
|
||||
log.debug("Duplicate request cleanup task started, interval=1m");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean tryRequest(String key, long timeout) {
|
||||
// 读锁,读读不互斥,读写互斥
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
// 使用 nanoTime() 获取更高精度的时间戳
|
||||
long now = System.nanoTime(); // 返回纳秒级别的时间戳
|
||||
String uuid = UUID.randomUUID().toString(); // 生成唯一标识符
|
||||
log.debug("Checking duplicate request, key={}, timeout={}s", key, timeout);
|
||||
// 计算并更新 key 对应的 ExpiringEntry
|
||||
//compute(key, (k, v) -> newValue)用于在并发环境下安全地更新 key 对应的值。
|
||||
ExpiringEntry entry = requestMap.compute(key, (k, v) -> handleEntry(k, v, now, timeout, uuid));
|
||||
// 如果返回的Entry uuid 与当前请求的 uuid 相同,则允许请求
|
||||
boolean isRequestAllowed = entry.uuid.equals(uuid);
|
||||
log.debug("Duplicate request check completed, key={}, allowed={}", key, isRequestAllowed);
|
||||
// 返回是否允许请求
|
||||
return isRequestAllowed;
|
||||
} catch (Exception e) {
|
||||
log.error("Error occurred during request processing.", e);
|
||||
return false;
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理并返回更新后的 ExpiringEntry
|
||||
*/
|
||||
private ExpiringEntry handleEntry(String key, ExpiringEntry currentEntry, long now, long timeout, String uuid) {
|
||||
if (currentEntry == null || isExpired(currentEntry, now)) {
|
||||
log.debug("Duplicate request key is new or expired, key={}", key);
|
||||
return new ExpiringEntry(now, TimeUnit.SECONDS.toNanos(timeout), uuid);
|
||||
}
|
||||
log.debug("Duplicate request key is still active, key={}", key);
|
||||
return currentEntry; // 维持原数据,拦截请求
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断条目是否过期
|
||||
*/
|
||||
private boolean isExpired(ExpiringEntry entry, long now) {
|
||||
return now - entry.timestamp >= entry.timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 key 的超时时间清理过期 key
|
||||
*/
|
||||
@VisibleForTesting
|
||||
protected void cleanUp() {
|
||||
// 写锁,读写互斥
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
// 使用 nanoTime() 获取更高精度的时间戳
|
||||
long now = System.nanoTime(); // 返回纳秒级别的时间戳
|
||||
// 记录清理前的 map 大小
|
||||
int beforeSize = requestMap.size();
|
||||
|
||||
// 遍历 requestMap 中的每一个条目,并检查它们的过期时间
|
||||
boolean removed = requestMap.entrySet().removeIf(entry -> {
|
||||
ExpiringEntry value = entry.getValue();
|
||||
if (isExpired(value, now)) {
|
||||
log.trace("Expired duplicate request entry will be removed, key={}", entry.getKey());
|
||||
return true; // 删除过期条目
|
||||
}
|
||||
return false; // 保留未过期的条目
|
||||
});
|
||||
// 记录清理后的 map 大小
|
||||
int afterSize = requestMap.size();
|
||||
if (removed || log.isTraceEnabled()) {
|
||||
log.debug("Expired duplicate request entries cleaned, removed={}, remaining={}", beforeSize - afterSize, afterSize);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error occurred during cleanup.", e);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
cleanupScheduler.shutdownNow();
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储 key 对应的上次访问时间 & 超时时间
|
||||
*/
|
||||
private static class ExpiringEntry {
|
||||
long timestamp; // 上次访问时间 (纳秒)
|
||||
long timeout; // 该 key 的超时时间(纳秒)
|
||||
String uuid; // 请求的唯一标识符, 用于区分并发请求,只有uuid相同请求才会被允许
|
||||
|
||||
ExpiringEntry(long timestamp, long timeout, String uuid) {
|
||||
this.timestamp = timestamp;
|
||||
this.timeout = timeout;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "{" +
|
||||
"timestamp=" + timestamp +
|
||||
", timeout=" + timeout / 1000000000 +
|
||||
"s, uuid=" + uuid +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
/**
|
||||
* 重复请求限制器
|
||||
*/
|
||||
public interface DuplicateRequestLimiter {
|
||||
/**
|
||||
* 尝试提交请求,若返回 true 则表示可以执行,false 表示被拦截
|
||||
*
|
||||
* @param key 请求 key
|
||||
* @param timeout 超时时间(秒)
|
||||
* @return 是否允许执行请求
|
||||
*/
|
||||
boolean tryRequest(String key, long timeout);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* 仅用于防止重复提交,不保证幂等性
|
||||
* 1.防止重复提交,是指用户在提交表单时,由于网络延迟、用户重复点击等原因,导致多次提交表单的情况。是短期内重复提交的问题。
|
||||
* 2.幂等性,是指同一个请求,无论调用多少次,结果都是一样的。是长期内重复提交的问题。
|
||||
* 3.防止重复提交,不保证幂等性。但是幂等性一定保证了防止重复提交。
|
||||
* </pre>
|
||||
*
|
||||
* @author easynext
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Inherited
|
||||
public @interface EasyDuplicateRequestLimiter {
|
||||
/**
|
||||
* 业务key,例如下单业务 order
|
||||
*/
|
||||
String businessKey();
|
||||
|
||||
/**
|
||||
* 业务参数,用于做更细粒度锁,例如锁到具体 订单id #orderId
|
||||
*/
|
||||
String businessParam() default "";
|
||||
|
||||
/**
|
||||
* 是否用户隔离,默认启用
|
||||
*/
|
||||
boolean userLimit() default true;
|
||||
|
||||
/**
|
||||
* 防重复提交的超时时间,单位: 秒
|
||||
*/
|
||||
int timeout() default 2;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
import com.laker.admin.common.exception.BusinessException;
|
||||
import com.laker.admin.infrastructure.security.context.EasySecurityContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.Signature;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.context.expression.MethodBasedEvaluationContext;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.core.ParameterNameDiscoverer;
|
||||
import org.springframework.expression.EvaluationContext;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* 仅用于防止重复提交/防并发,不保证幂等性
|
||||
* <pre>
|
||||
* 防止重复提交,是指用户在提交表单时,由于网络延迟、用户重复点击等原因,导致多次提交表单的情况。是短期内重复提交的问题。
|
||||
* 幂等性,是指同一个请求,无论调用多少次,结果都是一样的。是长期内重复提交的问题。
|
||||
* 防止重复提交,不保证幂等性。但是幂等性一定保证了防止重复提交。
|
||||
* 1 、前端防抖,按钮点击后立即禁用,等接口返回后再视情况启用或跳转页面。
|
||||
* 2 、后端限流,同一来源相同参数在一定时间间隔内只允许调用一次。这个方案的好处是通用,且可以顺便减轻接口被非法调用的压力。
|
||||
* 3 、使用令牌,前端提交数据前先获取一个令牌,后端限制令牌只能使用一次。
|
||||
* </pre>
|
||||
* <a href="https://blog.csdn.net/abu935009066/article/details/117471885">...</a>
|
||||
*
|
||||
* @author easynext
|
||||
*/
|
||||
@Component
|
||||
@Aspect
|
||||
@Slf4j
|
||||
public class EasyDuplicateRequestLimiterAspect {
|
||||
private final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
|
||||
private final ExpressionParser PARSER = new SpelExpressionParser();
|
||||
private final DuplicateRequestLimiter duplicateRequestLimiter;
|
||||
|
||||
public EasyDuplicateRequestLimiterAspect(DuplicateRequestLimiter duplicateRequestLimiter) {
|
||||
this.duplicateRequestLimiter = duplicateRequestLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* 获取注解参数的方式:
|
||||
* 方式1: 直接使用注解参数
|
||||
* 方式2: 使用method.getAnnotation(EasyRepeatSubmitLimit.class)
|
||||
*
|
||||
* "@annotation"用于匹配那些带有指定注解的方法。也就是说,当 某个方法被指定的注解标记时,该方法就会成为切入点的一部分。
|
||||
* "@within"用于匹配那些所在类带有指定注解的所有方法。只要 类被指定的注解标记,该类中的所有方法都会成为切入点的一部分。
|
||||
* " @annotation 关注的是方法上的注解,只有被注解标记的方法才会被匹配。
|
||||
* " @within 关注的是类上的注解,只要类被注解标记,该类中的所有方法都会被匹配。
|
||||
* </pre>
|
||||
*/
|
||||
@Around("@annotation(easyDuplicateRequestLimiterParam)")
|
||||
public Object handleSubmit(ProceedingJoinPoint joinPoint,
|
||||
EasyDuplicateRequestLimiter easyDuplicateRequestLimiterParam) throws Throwable {
|
||||
// 1.获取类上的注解 和 方法上的注解,方法上的注解优先级高
|
||||
final EasyDuplicateRequestLimiter easyDuplicateRequestLimiter = getDuplicateRequestLimiter(joinPoint);
|
||||
// 2.获取注解参数
|
||||
int timeout = easyDuplicateRequestLimiter.timeout();
|
||||
// 3.获取重复提交key
|
||||
String key = getDuplicateRequestKey(joinPoint, easyDuplicateRequestLimiter);
|
||||
// 4.限流
|
||||
if (!duplicateRequestLimiter.tryRequest(key, timeout)) {
|
||||
throw new BusinessException("请勿重复访问!");
|
||||
}
|
||||
return joinPoint.proceed();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取类上的注解 和 方法上的注解,方法上的注解优先级高
|
||||
*
|
||||
* @param joinPoint 切点
|
||||
* @return EasyDuplicateRequestLimiter
|
||||
*/
|
||||
private static EasyDuplicateRequestLimiter getDuplicateRequestLimiter(ProceedingJoinPoint joinPoint) {
|
||||
final Signature signature = joinPoint.getSignature();
|
||||
// 获取方法
|
||||
Method method = ((MethodSignature) signature).getMethod();
|
||||
// 获取方法上的注解
|
||||
EasyDuplicateRequestLimiter easyDuplicateRequestLimiter = method.getAnnotation(EasyDuplicateRequestLimiter.class);
|
||||
// 如果方法上的注解不存在,则获取类上的注解
|
||||
if (easyDuplicateRequestLimiter == null) {
|
||||
easyDuplicateRequestLimiter = method.getDeclaringClass().getAnnotation(EasyDuplicateRequestLimiter.class);
|
||||
}
|
||||
// 如果还是没有找到注解,则抛出异常
|
||||
if (easyDuplicateRequestLimiter == null) {
|
||||
throw new BusinessException("Annotation EasyDuplicateRequestLimiter not found on method or class.");
|
||||
}
|
||||
return easyDuplicateRequestLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重复提交key
|
||||
* example: businessKey:userId?:businessParam
|
||||
*
|
||||
* @param joinPoint 切点
|
||||
* @param easyDuplicateRequestLimiter 注解
|
||||
* @return String
|
||||
*/
|
||||
private String getDuplicateRequestKey(ProceedingJoinPoint joinPoint, EasyDuplicateRequestLimiter easyDuplicateRequestLimiter) {
|
||||
String businessKey = easyDuplicateRequestLimiter.businessKey();
|
||||
boolean userLimit = easyDuplicateRequestLimiter.userLimit();
|
||||
String businessParam = easyDuplicateRequestLimiter.businessParam();
|
||||
if (userLimit) {
|
||||
Long userId = EasySecurityContext.getUserId();
|
||||
businessKey = businessKey + ":" + (userId == null ? "anonymous" : userId);
|
||||
}
|
||||
|
||||
if (StringUtils.hasText(businessParam)) {
|
||||
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
|
||||
// 创建SpEL表达式解析器
|
||||
EvaluationContext context = new MethodBasedEvaluationContext(null, method, joinPoint.getArgs(), NAME_DISCOVERER);
|
||||
String key = PARSER.parseExpression(businessParam).getValue(context, String.class);
|
||||
key = key == null ? "" : key.trim();
|
||||
businessKey = businessKey + ":" + key;
|
||||
}
|
||||
return businessKey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
@Configuration
|
||||
public class EasyDuplicateRequestLimiterConfig {
|
||||
|
||||
@Bean
|
||||
public DuplicateRequestLimiter duplicateRequestLimiter(
|
||||
@Autowired(required = false) StringRedisTemplate redisTemplate) {
|
||||
// 如果 RedisTemplate 存在,则使用 RedisDuplicateRequestLimiter
|
||||
if (redisTemplate != null) {
|
||||
return new RedisDuplicateRequestLimiter(redisTemplate);
|
||||
} else { // 否则使用 ConcurrentHashMapDuplicateRequestLimiter
|
||||
return new ConcurrentHashMapDuplicateRequestLimiter();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.laker.admin.infrastructure.idempotency.duplicate;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class RedisDuplicateRequestLimiter implements DuplicateRequestLimiter {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
|
||||
public RedisDuplicateRequestLimiter(StringRedisTemplate redisTemplate) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean tryRequest(String key, long time) {
|
||||
// 使用 Redis 锁防止重复提交
|
||||
return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "LOCK", time, TimeUnit.SECONDS));
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* <pre>
|
||||
* 幂等注解
|
||||
* 1.幂等表
|
||||
* Redis: 使用 String 类型存储,Key 是幂等 Key, Value 默认为 1。
|
||||
* Mysql: 需要创建一张记录表。(过期的数据需要定时清理,也可以永久存储)
|
||||
* CREATE TABLE `infra_idempotent_record` (
|
||||
* `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
* `key` varchar(255) NOT NULL COMMENT '幂等键',
|
||||
* `expire_time` timestamp NOT NULL COMMENT '过期时间',
|
||||
* `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
* PRIMARY KEY (`id`),
|
||||
* UNIQUE KEY `unique_key` (`key`)
|
||||
* ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等记录';
|
||||
* 2.token令牌
|
||||
* 为每次请求生成请求唯一键,服务端对每个唯一键进行生命周期管控。规定时间内只允许一次请求,非第一次请求都属于重复提交。
|
||||
* 但是前后端改造大,后端要给出单独生成token令牌接口,前端要在每次调用时候先获取token令牌。
|
||||
* </pre>
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface Idempotent {
|
||||
|
||||
/**
|
||||
* 幂等Key 支持 SpEL(Spring Expression Language)表达式 #param.id
|
||||
*/
|
||||
String key();
|
||||
|
||||
/**
|
||||
* 触发幂等失败逻辑时,返回的错误提示信息
|
||||
*/
|
||||
String message() default "已经处理过,请勿重复提交!";
|
||||
|
||||
/**
|
||||
* 设置防重令牌 Key 过期时间,单位秒,默认 1 小时
|
||||
*/
|
||||
long expireTime() default 3600L;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
@Aspect
|
||||
@Component
|
||||
@Slf4j
|
||||
public class IdempotentAspect {
|
||||
|
||||
private final ExpressionParser parser = new SpelExpressionParser();
|
||||
private final IdempotentHandler idempotentService;
|
||||
|
||||
public IdempotentAspect(IdempotentHandler idempotentService) {
|
||||
this.idempotentService = idempotentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 环绕通知
|
||||
*
|
||||
* @param joinPoint 连接点
|
||||
* @return 方法执行结果
|
||||
* @throws Throwable 异常
|
||||
*/
|
||||
@Around("@annotation(Idempotent)")
|
||||
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
Method method = signature.getMethod();
|
||||
String methodName = method.getName();
|
||||
|
||||
// 判断当前是否在事务中,如果不在事务中,抛出异常并记录日志
|
||||
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
|
||||
log.error("方法 {} 未在事务中执行,幂等操作要求在事务内进行", methodName);
|
||||
throw new IllegalStateException("方法 " + methodName + " 必须在事务中执行");
|
||||
}
|
||||
|
||||
Idempotent idempotent = method.getAnnotation(Idempotent.class);
|
||||
String key = null;
|
||||
try {
|
||||
key = parseSpelKey(joinPoint, idempotent.key());
|
||||
} catch (Exception e) {
|
||||
log.error("解析幂等键的 SpEL 表达式失败,方法: {}", methodName, e);
|
||||
throw new RuntimeException("解析幂等键失败", e);
|
||||
}
|
||||
|
||||
|
||||
long expireTime = idempotent.expireTime();
|
||||
boolean isIdempotentSet = false;
|
||||
try {
|
||||
isIdempotentSet = idempotentService.checkAndSet(key, expireTime);
|
||||
if (isIdempotentSet) {
|
||||
log.info("幂等键 {} 设置成功,开始执行方法 {}", key, methodName);
|
||||
Object result = joinPoint.proceed();
|
||||
log.info("方法 {} 执行成功,幂等操作完成", methodName);
|
||||
return result;
|
||||
} else {
|
||||
log.info("幂等键 {} 已存在,方法 {} 重复调用,返回错误信息: {}", key, methodName, idempotent.message());
|
||||
throw new RuntimeException(idempotent.message());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (isIdempotentSet) {
|
||||
try {
|
||||
idempotentService.remove(key);
|
||||
log.info("方法 {} 执行异常,已移除幂等键 {}", methodName, key);
|
||||
} catch (Exception removeException) {
|
||||
log.error("移除幂等键 {} 失败,方法: {}", key, methodName, removeException);
|
||||
}
|
||||
}
|
||||
log.error("方法 {} 执行过程中出现异常", methodName, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private String parseSpelKey(ProceedingJoinPoint joinPoint, String spelExpression) {
|
||||
StandardEvaluationContext context = new StandardEvaluationContext();
|
||||
Object[] args = joinPoint.getArgs();
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
String[] parameterNames = signature.getParameterNames();
|
||||
for (int i = 0; i < parameterNames.length; i++) {
|
||||
context.setVariable(parameterNames[i], args[i]);
|
||||
}
|
||||
return parser.parseExpression(spelExpression).getValue(context, String.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class IdempotentConfig {
|
||||
|
||||
@Bean
|
||||
public IdempotentHandler idempotentHandler(ObjectProvider<StringRedisTemplate> redisTemplateProvider,
|
||||
JdbcTemplate jdbcTemplate) {
|
||||
// 约定优于配置:启用 Redis 后幂等键进入 Redis,否则回退 MySQL 表。
|
||||
StringRedisTemplate redisTemplate = redisTemplateProvider.getIfAvailable();
|
||||
return redisTemplate == null
|
||||
? new MysqlIdempotentHandler(jdbcTemplate)
|
||||
: new RedisIdempotentHandler(redisTemplate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
public interface IdempotentHandler {
|
||||
boolean checkAndSet(String key, long expireTime);
|
||||
void remove(String key);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
|
||||
public class MysqlIdempotentHandler implements IdempotentHandler {
|
||||
|
||||
// "INSERT INTO infra_idempotent_record (`key`, expire_time) VALUES (?, ?) " +
|
||||
// "ON DUPLICATE KEY UPDATE `key` = `key`";
|
||||
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
public MysqlIdempotentHandler(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkAndSet(String key, long expireTime) {
|
||||
// 数据库表中已经为 `key` 字段添加了唯一约束
|
||||
String sql = "SELECT COUNT(*) FROM infra_idempotent_record WHERE `key` = ? AND expire_time > ?";
|
||||
Timestamp now = Timestamp.from(Instant.now());
|
||||
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, key, now);
|
||||
if (count != null && count > 0) {
|
||||
return false;
|
||||
}
|
||||
cleanExpiredRecords();
|
||||
Timestamp expire = Timestamp.from(Instant.now().plusSeconds(expireTime));
|
||||
sql = "INSERT ignore INTO infra_idempotent_record (`key`, expire_time) VALUES (?, ?) ";
|
||||
int rowsAffected = jdbcTemplate.update(sql, key, expire);
|
||||
return rowsAffected == 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
String sql = "DELETE FROM infra_idempotent_record WHERE `key` = ?";
|
||||
jdbcTemplate.update(sql, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的幂等记录。
|
||||
*/
|
||||
public void cleanExpiredRecords() {
|
||||
String sql = "DELETE FROM infra_idempotent_record WHERE expire_time < ?";
|
||||
Timestamp now = Timestamp.from(Instant.now());
|
||||
jdbcTemplate.update(sql, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.laker.admin.infrastructure.idempotency.idempotent;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class RedisIdempotentHandler implements IdempotentHandler {
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
public RedisIdempotentHandler(StringRedisTemplate stringRedisTemplate) {
|
||||
this.stringRedisTemplate = stringRedisTemplate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean checkAndSet(String key, long expireTime) {
|
||||
return stringRedisTemplate.opsForValue().setIfAbsent(key, "1", expireTime, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String key) {
|
||||
stringRedisTemplate.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.laker.admin.infrastructure.json;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 平台 JSON 编解码门面。
|
||||
*
|
||||
* <p>业务和基础设施代码通过该类使用 JSON,避免散落依赖 ObjectMapper 和 Jackson 异常。
|
||||
* Jackson 仍由 {@code EasyJacksonCustomizer} 统一配置。</p>
|
||||
*/
|
||||
@Component
|
||||
public class EasyJsonCodec {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public EasyJsonCodec(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public String toJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new EasyJsonException("JSON 序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public <T> T fromJson(String json, Class<T> valueType) {
|
||||
try {
|
||||
return objectMapper.readValue(json, valueType);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new EasyJsonException("JSON 反序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public <T> T fromJson(String json, TypeReference<T> typeReference) {
|
||||
try {
|
||||
return objectMapper.readValue(json, typeReference);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new EasyJsonException("JSON 反序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public JsonNode readTree(String json) {
|
||||
try {
|
||||
return objectMapper.readTree(json);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new EasyJsonException("JSON 树解析失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.laker.admin.infrastructure.json;
|
||||
|
||||
/**
|
||||
* JSON 编解码异常。
|
||||
*/
|
||||
public class EasyJsonException extends RuntimeException {
|
||||
|
||||
public EasyJsonException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user