feat: initialize EasyNextAdmin

Publish the current verified project state without local development history or personal-path artifacts.
This commit is contained in:
laker
2026-07-17 17:58:57 +08:00
commit 8c1f4c7c75
1145 changed files with 119662 additions and 0 deletions

View 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
```

View File

@@ -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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 安全问题
url: https://github.com/lakernote/easy-next-admin/security
about: 请不要公开提交漏洞细节,先阅读仓库安全策略。

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,19 @@
# 行为准则
EasyNextAdmin 希望保持直接、专业、尊重事实的协作氛围。所有 Issue、PR、评论和文档讨论都适用本准则。
## 鼓励的行为
- 围绕具体代码、设计、文档和验证结果讨论问题。
- 清楚描述复现步骤、环境、期望行为和实际行为。
- 对不同意见给出技术理由,避免人身评价。
- 发现安全问题时按 [安全策略](SECURITY.md) 处理,不公开敏感细节。
## 不接受的行为
- 人身攻击、歧视、骚扰、威胁或持续挑衅。
- 发布他人隐私、凭据、密钥、真实业务数据或未脱敏日志。
- 在无关 Issue/PR 中重复刷屏、引战或推广无关内容。
- 明知会破坏项目安全或用户数据仍提供利用方式。
维护者可以编辑、隐藏、关闭或删除违反准则的内容,并在必要时限制参与权限。

51
CONTRIBUTING.md Normal file
View 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
View 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
View File

@@ -0,0 +1,207 @@
# EasyNextAdmin
[![CI](https://github.com/lakernote/easy-next-admin/actions/workflows/ci.yml/badge.svg)](https://github.com/lakernote/easy-next-admin/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
[![Java](https://img.shields.io/badge/Java-17%2B-orange.svg)](https://adoptium.net/)
[![Vue](https://img.shields.io/badge/Vue-3-42b883.svg)](https://vuejs.org/)
[![GitHub](https://img.shields.io/badge/GitHub-lakernote%2Feasy--next--admin-0969da?logo=github&logoColor=white)](https://github.com/lakernote/easy-next-admin)
[![Gitee](https://img.shields.io/badge/Gitee-lakernote%2Feasy--next--admin-c71d23?logo=gitee&logoColor=white)](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 模板的项目 |
## 界面预览
业务工作台:
![工作台](docs/assets/screenshots/readme-dashboard.png)
核心业务流:
| 提交申请单 | 审批待办列表 | 审批处理 |
| --- | --- | --- |
| ![提交申请单](docs/assets/screenshots/readme-submit-form.png) | ![审批待办列表](docs/assets/screenshots/readme-approval-list.png) | ![审批处理](docs/assets/screenshots/readme-approval-action.png) |
<details>
<summary>查看更多界面截图</summary>
![登录入口](docs/assets/screenshots/readme-login.png)
![用户管理](docs/assets/screenshots/readme-users.png)
</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
- OpenAPIhttp://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
View File

@@ -0,0 +1,25 @@
# 安全策略
## 支持范围
当前仓库处于公开 alpha 阶段,安全修复优先覆盖 `main` 分支。生产使用前请替换默认账号密码、数据库密码、Redis 密码、域名、HTTPS 和会话安全配置。
## 报告安全问题
请不要在公开 Issue 中粘贴漏洞细节、利用步骤、凭据、日志或真实业务数据。
优先通过 GitHub Security Advisory 或仓库安全页面提供的私密渠道报告。若私密入口不可用,请先创建一个不包含利用细节的 Issue说明“存在安全问题需要维护者联系”等待维护者确认沟通方式。
报告时建议包含:
- 受影响模块或接口。
- 影响范围和触发条件。
- 最小化复现思路。
- 建议修复方向。
## 生产安全提醒
- 不要使用 README 中的演示账号密码部署生产环境。
- 不要提交 `.env`、数据库备份、访问密钥或真实用户数据。
- 对公网部署时必须开启 HTTPS并在反向代理层设置合理的请求体大小、超时和安全响应头。
- 涉及真实写操作和敏感查询的接口应使用 `@EasyPermission` 保护,并接入审计。

69
docker-compose.yml Normal file
View 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
View 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
View 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` spantag 只保留 `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、页面、权限和数据表边界。

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

19
docs/components/README.md Normal file
View File

@@ -0,0 +1,19 @@
# 技术组件文档
这里按技术组件整理 EasyNextAdmin 的可复用能力。组件文档面向二开开发者,重点回答三个问题:
- 如何接入和使用。
- 内部机制如何工作。
- 当前方案和其他方案相比有什么 tradeoff。
组件文档只描述当前仓库真实存在的能力。尚未整理完成的组件不会在这里提前占位成已完成文档。
## 已整理组件
| 组件 | 文档 | 适合场景 |
| --- | --- | --- |
| 数据权限 | [security/data-scope.md](security/data-scope.md) | 需要按角色、部门和本人范围限制列表查询、任务查询和组织数据可见性 |
## 写作模板
新增组件文档时,先复制 [_template.md](_template.md)再按真实代码补齐示例、流程、tradeoff 和验证命令。

View File

@@ -0,0 +1,33 @@
# 组件名称
## 适用场景
说明这个组件解决哪个真实后台问题,什么情况下应该使用,什么情况下不应该使用。
## 如何使用
给出最小接入步骤。示例必须来自当前项目已有代码,或是能按当前工程约束直接落地的代码片段。
## 请求或执行流程
用步骤描述一次请求或一次任务执行时,哪些类按什么顺序参与。
## 原理
解释组件的核心机制、边界和失败策略。优先说明“谁负责决策,谁负责执行”。
## 关键类、配置和表
列出入口注解、配置类、核心服务和数据库表。
## Tradeoff
比较当前方案和至少两个备选方案。说明各自优点、缺点、适用场景和不适用场景。
## 常见坑
列出二开时容易出错的地方,以及如何避免。
## 扩展建议
记录后续可以优化的方向。不要把尚未实现的优化写成当前能力。

View 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
View 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 脱公式注入至少验证一次。
- 公开生产环境必须替换初始化账号密码;如需公开体验环境,应单独准备脱敏账号和隔离数据,不复用生产配置。

View 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` 作为验证入口。

View 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 Claimtoken=A1
├─ A 停顿Lease 过期
B Claimtoken=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 的核心不是“又一套任务框架”,而是用最少模型把**固定数据集、执行历史、动态分工和故障恢复**连成闭环。

View 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、单实例锁、广播入口和可观测执行日志。复杂调度平台能力不在应用脚手架里重复实现。

View 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、K8sDocker 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` | P1429 响应标准、`Retry-After`、后台配置化策略 |
| 锁封装 | MySQL/Redis 锁、跨实例互斥 | 已具备 | `IEasyLocker``MysqlEasyLocker``RedisEasyLocker` | P1锁超时、续期、可观测指标和使用规范 |
| 事务和最终一致性 | 本地事务、Outbox、本地消息重试 | 部分具备 | `EasyLocalMessageTemplate``LocalMessageRetryJob` | P0/P1退避策略、人工处理页、积压告警、消息幂等消费 |
| 定时任务 | 任务声明、数据库启停、Cron、实例心跳、集群同步、单实例/广播执行、日志和慢任务 Trace Tree | 已具备 | `@EasyJob``JobExecutionMode``ScheduleJobManager``ScheduleInstanceRegistry``ScheduleJobLogCallback`;广播用于唤醒 Batch WorkerJob 层不静态切业务数据;设计见 `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` | P1W3C Trace Context / OpenTelemetry 可选接入;当前按项目决策先不动 |
| 结构化日志 | 本地日志、集中检索字段、OpenSearch/SLS/Loki 采集 | 部分具备 | `logback.xml` 文本日志、MDC、WebLog | P1生产 JSON appender 或采集器解析配置 |
| 安全响应头和 CORS | CSP、HSTS、CORS 白名单、WAF 参数过滤 | 部分具备 | `WafFilter``EasyCorsFilter``easy.web.security-headers` | P1CSRF、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 走 NginxK8s 走 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 | P1JSON 日志或采集器解析字段、索引模板、保留策略 |
| APM / Trace 后端 | 跨服务 trace、服务拓扑、采样分析 | 缺口 | 当前是自研 `X-Trace-Id` + 本地 Trace Tree | P1/P2OpenTelemetry 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 updatereadiness 未通过前不接流量。
- 上传文件优先外置对象存储;如果使用本地存储,必须明确 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 替代品。
- 统一日志平台:项目提供结构化输出和 traceIdOpenSearch/SLS/Loki 由企业部署。
- APM SaaS项目保持 OpenTelemetry/Micrometer 接入边界,不绑定厂商。
- 通用低代码扩展中心:容易稀释真实后台能力,不作为产品 UI 暴露。
## 推荐演进路线
### 第一阶段:应用内组件闭环
- 审计/API 日志增长治理。
- Outbox 死信和人工处理。
- 批处理治理增强。
- 指标字典和 Influx/Grafana 面板样例。
- 限流、幂等、锁、任务的组件文档。
### 第二阶段:生产观测和发布治理
- 结构化日志进入 OpenSearch/SLS/Loki。
- 前端事件上报。
- 发布检查表、回滚 runbook、数据库变更策略。
- 数据分类分级、导出审批和敏感查询审计。
- 核心 SLO 和告警规则。
### 第三阶段:按规模接入分布式平台
- 服务拆分后接配置中心、注册发现、网关。
- 多服务链路排障需要时接 OpenTelemetry Collector。
- 高异步规模后增强 Kafka 业务事件模型。
- 对接搜索、数据湖或异构系统时再引入 CDC。
- 大量文件和归档后完善对象存储生命周期。

View 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、eventsARMS 强调应用监控、链路和业务指标。 | 国内企业常见选择:云上低运维成本,适合先托管后自研沉淀。 |
| 腾讯云 TCOP | 指标、链路、日志、事件和告警统一入口,覆盖云资源和自定义监控。 | 适合已在腾讯云上部署的企业,重点借鉴云资源 + 应用统一视图。 |
| 华为云 AOM | 一站式指标、trace、日志、事件观测和告警。 | 适合政企或华为云环境,重点借鉴应用、容器、基础设施联动。 |
厂商共性可以抽象为五层:
```text
采集标准化 -> 传输管道化 -> 存储分层化 -> 分析关联化 -> 告警行动化
```
中小企业不需要一次做满,但不能跳过“标准化”和“行动化”。
## 信号体系
### Metrics
回答“是否异常、趋势如何、是否要告警”。
优先采集:
- REDRate、Errors、Duration用于 HTTP/API/远程调用。
- USEUtilization、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 或 Tempotrace。
- Alertmanager告警通知。
关键要求:
- 指标标签低基数。
- 日志结构化。
- trace 采样。
- 告警只围绕 SLO、错误率、容量和关键依赖。
### L2OpenTelemetry 标准化管道型
适合:服务增多,可能混合云、自建和托管平台。
推荐:
- 应用侧使用 OpenTelemetry SDK / Agent。
- 中间层使用 OpenTelemetry Collector。
- 后端可以是 Grafana、Elastic、Datadog、New Relic、阿里云 SLS / ARMS 等。
价值:
- 应用埋点和后端平台解耦。
- 统一 resource attributes。
- 支持多后端迁移和并行验证。
### L3SLO 驱动运营型
适合:业务已经要求稳定性承诺。
能力:
- 服务目录。
- 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 分级。
- 形成新增模块观测性接入检查。

View 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 p95800ms 或根据实际压测调整。
- 任务执行:月度 99%,失败 10 分钟内可见。
- OutboxERROR 为 0FAILED 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 不可用或下游超时演练。

View 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、参数、失败策略和固定账期分布式模式准备者只执行一次 ReaderWorker 直接消费持久化 `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` | 普通模式支持分页/游标;分布式模式只由准备者执行 ReaderWorker 通过持久化 `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
View 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 LTSDocker 镜像 `mysql:8.4`,默认端口 `3306`,库名 `easy-next-admin`root 密码 `123456`,新数据卷默认使用 `caching_sha2_password`
- Redis 7.4Docker 镜像 `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`,不要在页面里直接请求完整后端域名。

View 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 平台。

View 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 \"$@\"", "--"]

View 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>-->
<!-- &lt;!&ndash; 只包含 git.commit.id 属性 &ndash;&gt;-->
<!-- <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 AnalysisPMD, 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>

View File

@@ -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);
}
}

View File

@@ -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";
}

View File

@@ -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();
}
}

View File

@@ -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;
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,7 @@
/**
* 跨模块通用层。
*
* <p>只放稳定、无业务归属的基础对象,例如统一响应、通用异常、工具类和常量。
* 这里不能反向依赖 {@code module} 或 {@code infrastructure},避免公共层变成业务杂物间。</p>
*/
package com.laker.admin.common;

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,4 @@
/**
* API 文档装配。
*/
package com.laker.admin.config.api;

View 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) {
}
}

View File

@@ -0,0 +1,4 @@
/**
* 缓存装配,统一声明缓存名称和底层缓存管理器。
*/
package com.laker.admin.config.cache;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
/**
* 数据库访问装配,包括 MyBatis-Plus 插件和事务管理器。
*/
package com.laker.admin.config.database;

View File

@@ -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. 序列化时包含所有字段 ALWAYSNON_NULL不序列化null字段
* 5. 在序列化一个空对象时时不抛出异常
* 6. 忽略反序列化时在json字符串中存在, 但在java对象中不存在的属性
* 7. 序列化:日期/时间是否序列化为时间戳,这里禁止
* 8. 允许忽略未知枚举值和通过@JsonEnumDefaultValue注释指定的预定义值的功能。如果禁用未知的枚举值将引发异常。如果启用但未指定预定义的默认 Enum 值,也会引发异常。
*/
builder.simpleDateFormat(STANDARD_PATTERN)
.modules(javaTimeModule, customModule, new Jdk8Module())
.serializationInclusion(JsonInclude.Include.NON_NULL) // 序列化时包含所有字段 ALWAYSNON_NULL不序列化null字段
.failOnEmptyBeans(false) // 在序列化一个空对象时时不抛出异常
.failOnUnknownProperties(false) // 忽略反序列化时在json字符串中存在, 但在java对象中不存在的属性
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.featuresToEnable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE);
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
/**
* 项目级配置属性绑定。
*/
package com.laker.admin.config.properties;

View File

@@ -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"));
};
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,4 @@
/**
* 远程调用装配,包括 Feign、熔断和超时等基础能力。
*/
package com.laker.admin.config.remote;

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
/**
* 线程池装配,统一维护业务线程池和调度线程池。
*/
package com.laker.admin.config.thread;

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 + "/";
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
/**
* Web 入口装配,包括 Servlet Filter、MVC 扩展、静态资源和 WebSocket。
*/
package com.laker.admin.config.web;

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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());
}
}
}

View File

@@ -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<?>;
}
}

View File

@@ -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 "";
}

View File

@@ -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) {
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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("-", "");
}
}

View File

@@ -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 +
'}';
}
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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));
}
}

View File

@@ -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 支持 SpELSpring Expression Language表达式 #param.id
*/
String key();
/**
* 触发幂等失败逻辑时,返回的错误提示信息
*/
String message() default "已经处理过,请勿重复提交!";
/**
* 设置防重令牌 Key 过期时间,单位秒,默认 1 小时
*/
long expireTime() default 3600L;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,6 @@
package com.laker.admin.infrastructure.idempotency.idempotent;
public interface IdempotentHandler {
boolean checkAndSet(String key, long expireTime);
void remove(String key);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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