Compare commits
315
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b077c20db | ||
|
|
0192380e81 | ||
|
|
8540147992 | ||
|
|
db80701534 | ||
|
|
274a3f34c5 | ||
|
|
5bc62a109e | ||
|
|
af646c6f1f | ||
|
|
50251240f4 | ||
|
|
808fabaf41 | ||
|
|
073cb6a577 | ||
|
|
cca1bc002e | ||
|
|
8ba04fc87d | ||
|
|
8cf6709bde | ||
|
|
f779ab56dd | ||
|
|
9eaaa9a0a0 | ||
|
|
8ff49b501f | ||
|
|
6ad438e733 | ||
|
|
885ff9dc9e | ||
|
|
e9564a21a2 | ||
|
|
c43d581ba6 | ||
|
|
b004edaf9e | ||
|
|
b780d8bad2 | ||
|
|
38e8494216 | ||
|
|
bd523036d0 | ||
|
|
6d10f9e502 | ||
|
|
226ef6a839 | ||
|
|
8bcd23f307 | ||
|
|
8b393d1906 | ||
|
|
319900e359 | ||
|
|
ef9852e757 | ||
|
|
939e90912a | ||
|
|
65cd7e0a39 | ||
|
|
bc62650ca9 | ||
|
|
18cebd3fdd | ||
|
|
1df3805334 | ||
|
|
a16b41eb8c | ||
|
|
e5fda0899e | ||
|
|
756a6b13eb | ||
|
|
e742abd1cf | ||
|
|
16dc03a2e2 | ||
|
|
5a48992d78 | ||
|
|
c331133016 | ||
|
|
af75569c78 | ||
|
|
43bbd9c1e8 | ||
|
|
74edf76e72 | ||
|
|
95849f248f | ||
|
|
f3366cb2fa | ||
|
|
0ed1531683 | ||
|
|
0380c0553b | ||
|
|
30aa0fd945 | ||
|
|
8a3f715a3b | ||
|
|
6b1d97af04 | ||
|
|
241701d9c5 | ||
|
|
47f99befb7 | ||
|
|
db86e97c0d | ||
|
|
e7dfd2790d | ||
|
|
af6d348ac3 | ||
|
|
9d863500ed | ||
|
|
f2f959e8a5 | ||
|
|
025467f9ed | ||
|
|
ae40d3f173 | ||
|
|
bac46a0f54 | ||
|
|
4063225352 | ||
|
|
5a1cd862c9 | ||
|
|
8e8e123d23 | ||
|
|
b3b8d94957 | ||
|
|
b5649cf8ae | ||
|
|
4562e3f1d1 | ||
|
|
600f3b7861 | ||
|
|
1767104406 | ||
|
|
0502ffce38 | ||
|
|
6a26b17e0a | ||
|
|
645d20665c | ||
|
|
dca1455478 | ||
|
|
4c7b0e867f | ||
|
|
cf3f9c2406 | ||
|
|
8c57f5d94e | ||
|
|
633c08045c | ||
|
|
6d50bde31e | ||
|
|
285e17f0ba | ||
|
|
0d2093054f | ||
|
|
c4e6c28da0 | ||
|
|
fcb7c11af4 | ||
|
|
d183ad6330 | ||
|
|
60895e3bf5 | ||
|
|
aaa38f825a | ||
|
|
4375beaa13 | ||
|
|
49967bbc28 | ||
|
|
584b73faf5 | ||
|
|
c2b1ab92ff | ||
|
|
d2704f68a6 | ||
|
|
505e834536 | ||
|
|
d9bbd94366 | ||
|
|
e4f65dae5f | ||
|
|
0fceece378 | ||
|
|
5a26a84f78 | ||
|
|
4d0c245b60 | ||
|
|
91c6ebca5c | ||
|
|
b90470af97 | ||
|
|
1cb4fba276 | ||
|
|
b2af5739c8 | ||
|
|
86488da6d0 | ||
|
|
5e1e281773 | ||
|
|
d7d33c4e9b | ||
|
|
1f4b09f0c9 | ||
|
|
bed917ba6c | ||
|
|
48bb752007 | ||
|
|
7c24c96f88 | ||
|
|
3809d2a028 | ||
|
|
4d49d7a2e4 | ||
|
|
bc5d9a6215 | ||
|
|
e1487162a5 | ||
|
|
7e52d0c025 | ||
|
|
8a65f18fce | ||
|
|
47c1c7922f | ||
|
|
192964419f | ||
|
|
cad2ba6e91 | ||
|
|
a02dd3a422 | ||
|
|
a59f4634cd | ||
|
|
16a2f83da6 | ||
|
|
4561245a71 | ||
|
|
386eae6785 | ||
|
|
cb30839945 | ||
|
|
bf7c214908 | ||
|
|
df0f05c74d | ||
|
|
7c2274be31 | ||
|
|
40171b7047 | ||
|
|
3f301e390b | ||
|
|
29c010e28f | ||
|
|
9b3ff06a90 | ||
|
|
9f455b6c0b | ||
|
|
7aa2b507f6 | ||
|
|
fe0f1665e5 | ||
|
|
166ebfb7ef | ||
|
|
c488c8aa02 | ||
|
|
e4372783d0 | ||
|
|
2de3fb36e9 | ||
|
|
4e79287a35 | ||
|
|
5f90bf3bc8 | ||
|
|
0e47155649 | ||
|
|
77a91b6553 | ||
|
|
f5c424b36d | ||
|
|
bceaff496e | ||
|
|
dfc1db6e8c | ||
|
|
22cebaaa7a | ||
|
|
e6eba04eda | ||
|
|
540a6f5d88 | ||
|
|
66b3a0cc9b | ||
|
|
fe60a20d95 | ||
|
|
39b9f237e0 | ||
|
|
5913c3c3ef | ||
|
|
e915086133 | ||
|
|
4630186a56 | ||
|
|
b1ca37699f | ||
|
|
4749bd3bc1 | ||
|
|
86632ef383 | ||
|
|
efcd046853 | ||
|
|
a4c47c281c | ||
|
|
3bfc11a94d | ||
|
|
84209bffd7 | ||
|
|
074a95d82b | ||
|
|
59a4eb8ed5 | ||
|
|
9ab7c01639 | ||
|
|
c9f1fe0093 | ||
|
|
013de5e921 | ||
|
|
a81f73d835 | ||
|
|
ce25a0a72d | ||
|
|
d42392dfc5 | ||
|
|
55060c7cbe | ||
|
|
d2198fff06 | ||
|
|
d15bc9b583 | ||
|
|
ac085e0f53 | ||
|
|
88390f9401 | ||
|
|
3c44f3bd51 | ||
|
|
253c0e7472 | ||
|
|
20996b050b | ||
|
|
64ba5ae249 | ||
|
|
a1bcb519c9 | ||
|
|
ea1346ee2c | ||
|
|
986e97acb6 | ||
|
|
fc554273b9 | ||
|
|
7684f3cfec | ||
|
|
14929bb257 | ||
|
|
eab51bc5ac | ||
|
|
74ce1358ae | ||
|
|
75d5fd4e26 | ||
|
|
b294695169 | ||
|
|
edd5108625 | ||
|
|
6974aeaaeb | ||
|
|
5c1c6a1a07 | ||
|
|
1200ef54ad | ||
|
|
037527b4cb | ||
|
|
047f267d4f | ||
|
|
e33fb923e4 | ||
|
|
06db4e3432 | ||
|
|
0ce264a257 | ||
|
|
b20cd9bd77 | ||
|
|
b1cd1b059d | ||
|
|
ed5b9fe77f | ||
|
|
9b88fb4b9e | ||
|
|
66d1aa0d3d | ||
|
|
789d97675b | ||
|
|
da85e30f33 | ||
|
|
2dae8b2029 | ||
|
|
e5c6e03033 | ||
|
|
aab8e37175 | ||
|
|
6b0781d120 | ||
|
|
f37ad6d83d | ||
|
|
f239bb739b | ||
|
|
0558f7ae59 | ||
|
|
57fcabe740 | ||
|
|
d49b4128bc | ||
|
|
931009a518 | ||
|
|
7b2b208467 | ||
|
|
e395292cd5 | ||
|
|
102845874b | ||
|
|
355b8629d9 | ||
|
|
13a2b90e98 | ||
|
|
87c1bfbb71 | ||
|
|
87508bba82 | ||
|
|
ef5878a85d | ||
|
|
b3d6f865b7 | ||
|
|
f690abbaeb | ||
|
|
374ceed493 | ||
|
|
3203adb5a7 | ||
|
|
96788e10de | ||
|
|
b6b5e2dc55 | ||
|
|
dca8c05d33 | ||
|
|
44bae99999 | ||
|
|
d30e975eed | ||
|
|
08f71cf17e | ||
|
|
a35cd868b9 | ||
|
|
1a2f598c9a | ||
|
|
7c0f2fc5f5 | ||
|
|
0113d19274 | ||
|
|
ef4e1d6015 | ||
|
|
d6ed4a5ad7 | ||
|
|
31d18abbf0 | ||
|
|
04c391333b | ||
|
|
74e42e91b6 | ||
|
|
92b3b9b1fb | ||
|
|
2ee78aafc2 | ||
|
|
54893aeb6b | ||
|
|
2644718b5f | ||
|
|
ced35f1600 | ||
|
|
77aa5a845e | ||
|
|
5f19cdcccb | ||
|
|
4f48015b81 | ||
|
|
38e7094520 | ||
|
|
73bfc5a65c | ||
|
|
dd8147c535 | ||
|
|
71fde99ceb | ||
|
|
fc14856ea3 | ||
|
|
0c37d40cf1 | ||
|
|
2779bf9ff7 | ||
|
|
f3c2bafec8 | ||
|
|
5951e6aa68 | ||
|
|
0d70edc020 | ||
|
|
a09639ff05 | ||
|
|
4a6f976a91 | ||
|
|
c4b0972dc9 | ||
|
|
2b81fd40e7 | ||
|
|
3f15f3ad2b | ||
|
|
c698290106 | ||
|
|
37fec1e012 | ||
|
|
1a76602872 | ||
|
|
645c483649 | ||
|
|
98ac9840c2 | ||
|
|
91f31d9567 | ||
|
|
f4c3a95772 | ||
|
|
ba5bad4429 | ||
|
|
3f597677fe | ||
|
|
7c17b1a76a | ||
|
|
a4a03a8ae8 | ||
|
|
77dc32a69a | ||
|
|
b0ea1a0c5c | ||
|
|
0dc60d835b | ||
|
|
efb01f8932 | ||
|
|
ebea3a1813 | ||
|
|
830575cc92 | ||
|
|
897f91af20 | ||
|
|
a5dc0f78c3 | ||
|
|
6964e3afa3 | ||
|
|
74db8e165d | ||
|
|
d2e3fef8f8 | ||
|
|
6e9739826c | ||
|
|
913eca7b7d | ||
|
|
584f1519d1 | ||
|
|
63eefd280b | ||
|
|
c68eedd3f3 | ||
|
|
c3a978b497 | ||
|
|
9c4bae9f5c | ||
|
|
3ecb8d7613 | ||
|
|
5f8b1f64da | ||
|
|
f3cfbdf59b | ||
|
|
dca3a48284 | ||
|
|
e6604ed680 | ||
|
|
7dd92f1c84 | ||
|
|
4df8b8c355 | ||
|
|
a11e47fe3b | ||
|
|
5ba9716e12 | ||
|
|
06f7f9d2f2 | ||
|
|
264458920b | ||
|
|
487f398eb8 | ||
|
|
91c276f520 | ||
|
|
cac377de96 | ||
|
|
9678ee0f1f | ||
|
|
496fc58a90 | ||
|
|
81c9c92f5d | ||
|
|
3791fee285 | ||
|
|
d544bbd0c5 | ||
|
|
9b7b08665e | ||
|
|
de8acc419f | ||
|
|
7e544780b4 | ||
|
|
09c5942e83 |
@@ -23,5 +23,9 @@ dependency-reduced-pom.xml
|
|||||||
reports/runtime*/
|
reports/runtime*/
|
||||||
reports/uploads/
|
reports/uploads/
|
||||||
reports/db-backup*/
|
reports/db-backup*/
|
||||||
|
reports/
|
||||||
uploads/
|
uploads/
|
||||||
.codex-temp*
|
.codex-temp*
|
||||||
|
.codex-tmp*
|
||||||
|
.codex-*.out
|
||||||
|
.codex-*.err
|
||||||
|
|||||||
@@ -1,94 +1,67 @@
|
|||||||
# 柏悦头程后端项目指引
|
# 国内头程后端项目指引
|
||||||
|
|
||||||
## 适用范围
|
## 适用范围与定位
|
||||||
|
|
||||||
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
|
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
|
||||||
- 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。
|
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
|
||||||
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
|
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
|
||||||
|
|
||||||
## 模块快速索引
|
## 不可突破的产品与数据边界
|
||||||
|
|
||||||
| 模块 | 职责 | 首选入口 |
|
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
|
||||||
|
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
|
||||||
|
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
|
||||||
|
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
|
||||||
|
|
||||||
|
## 服务路由(按关键词定位)
|
||||||
|
|
||||||
|
| 任务关键词 | 首选服务 | 先看入口 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
|
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
|
||||||
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
|
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
|
||||||
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
|
| 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 |
|
||||||
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` |
|
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
|
||||||
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
|
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
|
||||||
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
|
| 分柜、柜次、智能排柜、预装/装柜清单、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位 |
|
||||||
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
|
| 订舱、船期、拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`outbound/dispatch/`、`customs/` |
|
||||||
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
|
| 客户账期、费用池、账单、收款、成本、核销 | `finance/` | `finance/AGENTS.md`;费用来源和柜次成本快照再联查 `firstleg/AGENTS.md`,凭证再联查 `file/AGENTS.md` |
|
||||||
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
|
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
|
||||||
| `iam/` | 租户、商户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
|
| 供应商、国内整柜库存与出入库 | `warehouse/` | `warehouse/AGENTS.md`;OpenAPI `warehouse/src/main/resources/openapi/supplier-v1.yaml`、`domestic-container-v1.yaml` |
|
||||||
| `order/` | 订单主数据、租户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
|
| 客户运营统计、在途柜量 | `firstleg/` | `firstleg/AGENTS.md`;OpenAPI `firstleg/src/main/resources/openapi/customer-statistics-v1.yaml` |
|
||||||
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
|
| 平台操作日志与审计查询 | `audit-service/` | `audit-service/AGENTS.md`;平台查询接口 `/api/platform/v1/audit/logs` |
|
||||||
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
|
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
|
||||||
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
|
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
|
||||||
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
|
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
|
||||||
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
|
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
|
||||||
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
|
|
||||||
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
|
|
||||||
|
|
||||||
## 高频业务检索入口
|
## 全量验收路由
|
||||||
|
|
||||||
| 搜索目标 | 先读 |
|
- 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。
|
||||||
|---|---|
|
- `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。
|
||||||
| 后台用户管理、平台/企业账号、跨租户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 |
|
|
||||||
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` |
|
|
||||||
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 |
|
|
||||||
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
|
|
||||||
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
|
|
||||||
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
|
|
||||||
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
|
|
||||||
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
|
|
||||||
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
|
|
||||||
|
|
||||||
## 二期入库快速索引
|
## 契约、数据库与验证入口
|
||||||
|
|
||||||
| 业务链路 | API 与编排入口 | 数据、跨服务与验证入口 |
|
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
|
||||||
|---|---|---|
|
- 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。
|
||||||
| 平台入库单列表、页签与聚合详情 | `firstleg/.../controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
|
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
|
||||||
| 平台测量草稿、提交及重量 OCR 结果 | `firstleg/.../controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | 图片走 `client/MeasurementImageClient.java`,托盘库位走 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
|
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
|
||||||
| 第三方尺寸测量回传 | `firstleg/.../controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 订单货物校验走 `client/OrderCargoClient.java` → `order/.../controller/InternalOrderCargoController.java`;证据图片走 File 内部接口;配置 `config/MeasurementOpenApiProperties.java`,测试 `service/InboundMeasurementOpenApiServiceTest.java` |
|
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
|
||||||
| 测量审核、异常放行与自动完成入库 | `firstleg/.../controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` → `AutomaticInboundCompletionService.java` | 费用与入库状态编排在 `FbaPricingService.java`、`InboundCompletionOrchestrator.java`;测试 `service/MeasurementReviewServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
|
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||||
| FBA 客户规则、承运商价格与费用快照 | 主数据 `firstleg/.../controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java`;计费 `PlatformFbaPricingController.java` → `service/FbaPricingService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`、`src/main/resources/db/phase2-fba-pricing-migration.sql`;测试 `service/FbaMasterDataServiceTest.java`、`FbaPricingServiceTest.java` |
|
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||||
| 商户查看入库费用 | `firstleg/.../controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 只按已认证商户租户查询;接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` |
|
|
||||||
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
|
|
||||||
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
|
|
||||||
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
|
|
||||||
| 二期数据库部署 | 测试环境/存量环境统一入口 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql` | 覆盖 `baiyue_file`、`baiyue_firstleg`、`baiyue_warehouse`;前置、可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,不得在未核对目标库和备份的情况下执行 |
|
|
||||||
|
|
||||||
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
|
## 全量 HTTP 验收脚本路由
|
||||||
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
|
|
||||||
|
|
||||||
## 配置、数据库与验证入口
|
- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。
|
||||||
|
- Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||||
|
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||||
|
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||||
|
- HTTP 全量通过后,才进入 Admin/Client 真实页面调用、浏览器样式和 PDA 页面/设备验收;自动化 HTTP 通过不代表视觉验收完成。
|
||||||
|
|
||||||
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
|
## 阶段文档入口
|
||||||
- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。
|
|
||||||
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
|
|
||||||
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
|
|
||||||
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
|
|
||||||
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
|
|
||||||
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
|
|
||||||
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
|
|
||||||
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
|
|
||||||
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
|
|
||||||
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
|
|
||||||
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
|
|
||||||
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
|
|
||||||
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
|
|
||||||
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
|
|
||||||
|
|
||||||
## 通用修改约束
|
- 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md`
|
||||||
|
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
|
||||||
|
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
|
||||||
|
- 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束
|
||||||
|
|
||||||
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
|
全量验收任务再读取对应阶段进度表、验收报告和证据矩阵;普通代码定位只读取当前链路涉及的范围、契约、模块指引和代码。
|
||||||
- 商户数据必须保留租户维度,并由服务端执行租户、访问域、数据范围和按钮权限校验。
|
|
||||||
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
|
|
||||||
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
|
|
||||||
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
|
|
||||||
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
|
|
||||||
|
|
||||||
## 本地内部服务令牌
|
|
||||||
|
|
||||||
- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。
|
|
||||||
- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。
|
|
||||||
|
|||||||
Vendored
+20
-4
@@ -1,5 +1,5 @@
|
|||||||
def serviceModules = [
|
def serviceModules = [
|
||||||
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print',
|
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
'integration-service', 'event-bridge-service', 'audit-service'
|
'integration-service', 'event-bridge-service', 'audit-service'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ def serviceCheckboxes = [
|
|||||||
'auth': 'SERVICE_AUTH',
|
'auth': 'SERVICE_AUTH',
|
||||||
'iam': 'SERVICE_IAM',
|
'iam': 'SERVICE_IAM',
|
||||||
'firstleg': 'SERVICE_FIRSTLEG',
|
'firstleg': 'SERVICE_FIRSTLEG',
|
||||||
|
'finance': 'SERVICE_FINANCE',
|
||||||
'order': 'SERVICE_ORDER',
|
'order': 'SERVICE_ORDER',
|
||||||
'warehouse': 'SERVICE_WAREHOUSE',
|
'warehouse': 'SERVICE_WAREHOUSE',
|
||||||
'file': 'SERVICE_FILE',
|
'file': 'SERVICE_FILE',
|
||||||
@@ -23,6 +24,8 @@ def commonPaths = [
|
|||||||
'Jenkinsfile.service', 'deploy/'
|
'Jenkinsfile.service', 'deploy/'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def databaseMigrationPaths = ['deploy/sql/']
|
||||||
|
|
||||||
def downstreamResults = [:]
|
def downstreamResults = [:]
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
@@ -41,6 +44,7 @@ pipeline {
|
|||||||
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)')
|
||||||
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)')
|
||||||
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)')
|
||||||
|
booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)')
|
||||||
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
|
booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)')
|
||||||
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
|
booleanParam(name: 'SERVICE_WAREHOUSE', defaultValue: false, description: '仓库与往来单位服务(warehouse)')
|
||||||
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
booleanParam(name: 'SERVICE_FILE', defaultValue: false, description: '文件中心(file)')
|
||||||
@@ -81,6 +85,7 @@ pipeline {
|
|||||||
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
error('DOWNSTREAM_JOB_PREFIX 不能为空')
|
||||||
}
|
}
|
||||||
env.RESOLVED_JOB_PREFIX = jobPrefix
|
env.RESOLVED_JOB_PREFIX = jobPrefix
|
||||||
|
env.DATABASE_CHANGE_DETECTED = 'false'
|
||||||
|
|
||||||
List selected = []
|
List selected = []
|
||||||
if (params.BUILD_MODE == 'ALL') {
|
if (params.BUILD_MODE == 'ALL') {
|
||||||
@@ -99,8 +104,13 @@ pipeline {
|
|||||||
} else {
|
} else {
|
||||||
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
|
||||||
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
.trim().split('\\n').collect { it.trim() }.findAll { it }
|
||||||
|
boolean databaseChanged = changed.any { path ->
|
||||||
|
databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
|
}
|
||||||
|
env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false'
|
||||||
boolean commonChanged = changed.any { path ->
|
boolean commonChanged = changed.any { path ->
|
||||||
commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) }
|
||||||
|
!databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) }
|
||||||
}
|
}
|
||||||
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
|
||||||
changed.any { it == module || it.startsWith("${module}/") }
|
changed.any { it == module || it.startsWith("${module}/") }
|
||||||
@@ -117,13 +127,19 @@ pipeline {
|
|||||||
env.SELECTED_SERVICES = selected.join(',')
|
env.SELECTED_SERVICES = selected.join(',')
|
||||||
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
currentBuild.description = '没有需要构建的业务服务'
|
currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
|
||||||
|
? '仅数据库迁移变更 | 需单独审核执行'
|
||||||
|
: '没有需要构建的业务服务'
|
||||||
} else {
|
} else {
|
||||||
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
|
||||||
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}"
|
String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : ''
|
||||||
|
currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}"
|
||||||
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
|
||||||
}
|
}
|
||||||
|
if (env.DATABASE_CHANGE_DETECTED == 'true') {
|
||||||
|
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1,5 +1,5 @@
|
|||||||
def serviceModules = [
|
def serviceModules = [
|
||||||
'gateway', 'auth', 'iam', 'firstleg', 'order', 'warehouse', 'file', 'print',
|
'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print',
|
||||||
'integration-service', 'event-bridge-service', 'audit-service'
|
'integration-service', 'event-bridge-service', 'audit-service'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ def serviceJars = [
|
|||||||
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar',
|
||||||
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar',
|
||||||
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar',
|
||||||
|
'finance': 'finance/target/finance-service-1.0.0-SNAPSHOT.jar',
|
||||||
'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar',
|
'order': 'order/target/order-service-1.0.0-SNAPSHOT.jar',
|
||||||
'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar',
|
'warehouse': 'warehouse/target/warehouse-service-1.0.0-SNAPSHOT.jar',
|
||||||
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
|
'file': 'file/target/file-service-1.0.0-SNAPSHOT.jar',
|
||||||
@@ -109,7 +110,14 @@ pipeline {
|
|||||||
stage('Maven 测试与打包') {
|
stage('Maven 测试与打包') {
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
String testOption = params.SKIP_TESTS ? '-DskipTests' : ''
|
if (params.ENVIRONMENT == 'production' && params.SKIP_TESTS) {
|
||||||
|
error('生产环境禁止跳过测试')
|
||||||
|
}
|
||||||
|
boolean skipTestExecution = params.ENVIRONMENT == 'test' || params.SKIP_TESTS
|
||||||
|
String testOption = skipTestExecution ? '-DskipTests' : ''
|
||||||
|
if (params.ENVIRONMENT == 'test') {
|
||||||
|
echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行'
|
||||||
|
}
|
||||||
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
# 柏悦头程平台
|
# 柏悦头程平台
|
||||||
|
|
||||||
本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
本仓库承载国内头程业务,负责统一登录、客户与企业成员、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
||||||
|
|
||||||
|
> 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。
|
||||||
|
|
||||||
## 模块
|
## 模块
|
||||||
|
|
||||||
- `auth`:统一登录、令牌签发与刷新。
|
- `auth`:统一登录、令牌签发与刷新。
|
||||||
- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。
|
- `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。
|
||||||
- `order`:订单主数据及订单状态编排。
|
- `order`:订单主数据及订单状态编排。
|
||||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||||
- `warehouse`:国内仓储能力。
|
- `warehouse`:国内仓储能力。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
|
| 应用入口 | `src/main/java/com/baiyue/audit/BaiyueAuditApplication.java` |
|
||||||
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
|
| 内部接收 API | `controller/AuditIngestController.java`(`/internal/audit/events`) |
|
||||||
|
| 平台日志查询 API | `controller/PlatformAuditLogController.java`(`/api/platform/v1/audit/logs`)、`service/AuditLogQueryService.java`;列表与详情统一使用 `platform:audit:list` |
|
||||||
| 接收 DTO | `dto/AuditIngestRequest.java` |
|
| 接收 DTO | `dto/AuditIngestRequest.java` |
|
||||||
| 入库编排 | `service/AuditIngestService.java` |
|
| 入库编排 | `service/AuditIngestService.java` |
|
||||||
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
|
| 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` |
|
||||||
@@ -23,5 +24,6 @@
|
|||||||
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
|
- `POST /internal/audit/events` 成功接收保留真实 HTTP 202;请求进入 `common-web` 异常处理后的业务/校验失败遵循 HTTP 200 + body code。不要把 202 改成普通 200,也不要把业务失败重新改成 HTTP 4xx/5xx。
|
||||||
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
|
- `common-web/CommonWebAutoConfiguration` 负责在本服务仅扫描 `com.baiyue.audit` 时注册统一异常处理和 Correlation 过滤器;相关改动需连同本模块做启动/上下文验证。
|
||||||
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
|
- 入库前确保密码、Token、证件等敏感信息已脱敏;新增字段时同步检查 `../common-audit/src/main/java/com/baiyue/common/audit/AuditEvent.java` 与发送实现。
|
||||||
|
- 平台日志列表不得返回变更前后快照,详情才返回已脱敏 JSON;客户账号不得访问平台查询接口。
|
||||||
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
|
- 审计链路故障不得阻塞核心交易;保留幂等、超时、容量和留存策略的演进空间。
|
||||||
- 当前没有专属自动化测试;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
|
- 自动化测试位于 `src/test/java/com/baiyue/audit/`,覆盖平台日志查询、Controller 权限、MQ 接收/死信和持久化;从父目录运行 `mvn -pl audit-service -am test`,并按改动验证接收和持久化结果。
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
<artifactId>baiyue-common-openapi</artifactId>
|
<artifactId>baiyue-common-openapi</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baiyue.shared</groupId>
|
||||||
|
<artifactId>baiyue-common-security</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.baomidou</groupId>
|
<groupId>com.baomidou</groupId>
|
||||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
@@ -36,6 +41,10 @@
|
|||||||
<artifactId>mysql-connector-j</artifactId>
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.rocketmq</groupId>
|
||||||
|
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.alibaba.cloud</groupId>
|
<groupId>com.alibaba.cloud</groupId>
|
||||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package com.baiyue.audit;
|
|||||||
import org.mybatis.spring.annotation.MapperScan;
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||||
*/
|
*/
|
||||||
@MapperScan("com.baiyue.audit.mapper")
|
@MapperScan("com.baiyue.audit.mapper")
|
||||||
|
@EnableScheduling
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
public class BaiyueAuditApplication {
|
public class BaiyueAuditApplication {
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
import org.springframework.web.bind.annotation.RequestHeader;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,8 +29,13 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/internal/audit/events")
|
@RequestMapping("/internal/audit/events")
|
||||||
public class AuditIngestController {
|
public class AuditIngestController {
|
||||||
|
private static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private AuditIngestService service;
|
private AuditIngestService service;
|
||||||
|
|
||||||
|
@Value("${baiyue.security.internal-service-token:}")
|
||||||
|
private String internalServiceToken;
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Ingest business audit event / 写入业务审计事件",
|
summary = "Ingest business audit event / 写入业务审计事件",
|
||||||
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
|
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
|
||||||
@@ -35,9 +44,25 @@ public class AuditIngestController {
|
|||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
|
||||||
})
|
})
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
public ResponseEntity<ApiResponse<Void>> ingest(
|
||||||
public ApiResponse<Void> ingest(@Valid @RequestBody AuditIngestRequest event) {
|
@RequestHeader(value = INTERNAL_SERVICE_TOKEN_HEADER, required = false) String providedToken,
|
||||||
|
@Valid @RequestBody AuditIngestRequest event) {
|
||||||
|
if (!tokenMatches(providedToken)) {
|
||||||
|
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||||
|
.body(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.FORBIDDEN,
|
||||||
|
"Internal service token is invalid", null, null));
|
||||||
|
}
|
||||||
service.ingest(event);
|
service.ingest(event);
|
||||||
return ApiResponse.success(null);
|
return ResponseEntity.status(HttpStatus.ACCEPTED).body(ApiResponse.success(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean tokenMatches(String providedToken) {
|
||||||
|
if (providedToken == null || providedToken.isBlank()
|
||||||
|
|| internalServiceToken == null || internalServiceToken.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return MessageDigest.isEqual(
|
||||||
|
internalServiceToken.getBytes(StandardCharsets.UTF_8),
|
||||||
|
providedToken.getBytes(StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.baiyue.audit.controller;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditLogQuery;
|
||||||
|
import com.baiyue.audit.dto.AuditLogResponses;
|
||||||
|
import com.baiyue.audit.service.AuditLogQueryService;
|
||||||
|
import com.baiyue.audit.service.AuditPlatformScopeGuard;
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** 平台端统一操作日志查询入口。 */
|
||||||
|
@RestController
|
||||||
|
@Validated
|
||||||
|
@RequestMapping("/api/platform/v1/audit/logs")
|
||||||
|
@Tag(name = "Platform audit logs / 平台操作日志")
|
||||||
|
public class PlatformAuditLogController {
|
||||||
|
@Resource
|
||||||
|
private AuditLogQueryService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@PreAuthorize("hasAuthority('platform:audit:list')")
|
||||||
|
@Operation(summary = "分页查询操作日志", description = "仅平台端可查询,列表不返回变更前后快照。默认按发生时间、日志 ID 倒序。")
|
||||||
|
public ApiResponse<PageResult<AuditLogResponses.ListItem>> page(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||||
|
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
|
||||||
|
@RequestParam(required = false) Long accountId,
|
||||||
|
@RequestParam(required = false) Long operatorUserId,
|
||||||
|
@RequestParam(required = false) String eventType,
|
||||||
|
@RequestParam(required = false) String resourceType,
|
||||||
|
@RequestParam(required = false) String resourceId,
|
||||||
|
@RequestParam(required = false) Boolean success,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredFrom,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant occurredTo,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
AuditPlatformScopeGuard.requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.page(new AuditLogQuery(accountId, operatorUserId, eventType,
|
||||||
|
resourceType, resourceId, success, occurredFrom, occurredTo, keyword), page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@PreAuthorize("hasAuthority('platform:audit:list')")
|
||||||
|
@Operation(summary = "查看操作日志详情", description = "仅平台端可查询,详情返回已脱敏的变更前后快照。")
|
||||||
|
public ApiResponse<AuditLogResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@PathVariable @Min(1) Long id) {
|
||||||
|
AuditPlatformScopeGuard.requirePlatform(user);
|
||||||
|
return ApiResponse.success(service.detail(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import java.time.Instant;
|
|||||||
*/
|
*/
|
||||||
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
|
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
|
||||||
public record AuditIngestRequest(
|
public record AuditIngestRequest(
|
||||||
|
@Schema(description = "Global idempotency event ID / 全局幂等事件ID", example = "b150c69d-8813-46b7-9a7c-81bfa5b91021")
|
||||||
|
@Size(max = 64) String eventId,
|
||||||
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotBlank @Size(max = 64) String eventType,
|
@NotBlank @Size(max = 64) String eventType,
|
||||||
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.baiyue.audit.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** 平台操作日志筛选条件。accountId 对应审计库中的 tenant_id。 */
|
||||||
|
@Schema(name = "AuditLogQuery", description = "平台操作日志分页筛选条件")
|
||||||
|
public record AuditLogQuery(
|
||||||
|
Long accountId,
|
||||||
|
Long operatorUserId,
|
||||||
|
String eventType,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId,
|
||||||
|
Boolean success,
|
||||||
|
Instant occurredFrom,
|
||||||
|
Instant occurredTo,
|
||||||
|
String keyword) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.baiyue.audit.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/** 平台审计日志查询响应。列表刻意不包含前后快照,详情才返回完整证据。 */
|
||||||
|
public final class AuditLogResponses {
|
||||||
|
private AuditLogResponses() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "AuditLogListItem", description = "操作日志列表项,不包含变更前后快照")
|
||||||
|
public record ListItem(
|
||||||
|
Long id,
|
||||||
|
String eventId,
|
||||||
|
String eventType,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId,
|
||||||
|
String description,
|
||||||
|
Long accountId,
|
||||||
|
Long operatorTenantId,
|
||||||
|
Long userId,
|
||||||
|
String username,
|
||||||
|
String appCode,
|
||||||
|
String clientType,
|
||||||
|
String requestId,
|
||||||
|
String traceId,
|
||||||
|
String serviceName,
|
||||||
|
Boolean success,
|
||||||
|
String errorCode,
|
||||||
|
String errorMessage,
|
||||||
|
Instant occurredAt,
|
||||||
|
LocalDateTime createdAt) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Schema(name = "AuditLogDetail", description = "操作日志详情,包含已脱敏的变更前后快照")
|
||||||
|
public record Detail(
|
||||||
|
Long id,
|
||||||
|
String eventId,
|
||||||
|
String eventType,
|
||||||
|
String resourceType,
|
||||||
|
String resourceId,
|
||||||
|
String description,
|
||||||
|
String requestId,
|
||||||
|
String traceId,
|
||||||
|
Long accountId,
|
||||||
|
Long operatorTenantId,
|
||||||
|
Long userId,
|
||||||
|
String username,
|
||||||
|
String appCode,
|
||||||
|
String clientType,
|
||||||
|
String httpMethod,
|
||||||
|
String requestUri,
|
||||||
|
String clientIp,
|
||||||
|
Long durationMs,
|
||||||
|
Boolean success,
|
||||||
|
String errorCode,
|
||||||
|
String errorMessage,
|
||||||
|
String beforeData,
|
||||||
|
String afterData,
|
||||||
|
String serviceName,
|
||||||
|
Instant occurredAt,
|
||||||
|
LocalDateTime createdAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ public class AuditLogEntity {
|
|||||||
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
|
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
|
||||||
@TableId(type = IdType.AUTO)
|
@TableId(type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private String eventId;
|
||||||
private String eventType;
|
private String eventType;
|
||||||
private String resourceType;
|
private String resourceType;
|
||||||
private String resourceId;
|
private String resourceId;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.audit.mq;
|
||||||
|
|
||||||
|
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 将 RocketMQ 最终死信转存数据库,供补偿任务恢复。 */
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-consumer-enabled", havingValue = "true")
|
||||||
|
@RocketMQMessageListener(
|
||||||
|
topic = "${baiyue.audit.rocketmq.dead-letter-topic}",
|
||||||
|
consumerGroup = "${baiyue.audit.rocketmq.dead-letter-group}")
|
||||||
|
public class AuditDeadLetterConsumer implements RocketMQListener<String> {
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper json;
|
||||||
|
@Resource
|
||||||
|
private AuditDeadLetterRepository repository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(String payload) {
|
||||||
|
repository.save(eventId(payload), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String eventId(String payload) {
|
||||||
|
try {
|
||||||
|
String value = json.readTree(payload).path("eventId").asText();
|
||||||
|
if (!value.isBlank()) return value;
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// 无法解析的毒消息也必须使用确定性 ID 收入死信表。
|
||||||
|
}
|
||||||
|
return "invalid-" + UUID.nameUUIDFromBytes(payload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.audit.mq;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||||
|
import com.baiyue.audit.service.AuditIngestService;
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQListener;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** RocketMQ 审计事件消费者;数据库 event_id 唯一键保证重复消息安全。 */
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "consumer-enabled", havingValue = "true")
|
||||||
|
@RocketMQMessageListener(
|
||||||
|
topic = "${baiyue.audit.rocketmq.topic}",
|
||||||
|
selectorExpression = "AUDIT",
|
||||||
|
consumerGroup = "${baiyue.audit.rocketmq.consumer-group}")
|
||||||
|
public class AuditRocketMqConsumer implements RocketMQListener<String> {
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper json;
|
||||||
|
@Resource
|
||||||
|
private AuditIngestService service;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(String payload) {
|
||||||
|
try {
|
||||||
|
service.ingest(json.readValue(payload, AuditIngestRequest.class));
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalArgumentException("审计消息格式无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package com.baiyue.audit.repository;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 审计 RocketMQ 死信及补偿状态仓储。 */
|
||||||
|
@Repository
|
||||||
|
public class AuditDeadLetterRepository {
|
||||||
|
@Resource
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public void save(String eventId, String payload) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO sys_audit_dead_letter(event_id,payload_text,status,retry_count,next_retry_at,created_at)
|
||||||
|
VALUES (?,?,'PENDING',0,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||||
|
ON DUPLICATE KEY UPDATE payload_text=VALUES(payload_text)
|
||||||
|
""", eventId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Long> pendingIds(int limit) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT id FROM sys_audit_dead_letter
|
||||||
|
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||||
|
ORDER BY next_retry_at,id LIMIT ?
|
||||||
|
""", Long.class, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean claim(long id, String owner, int leaseSeconds) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_dead_letter SET status='PROCESSING',lease_owner=?,
|
||||||
|
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),retry_count=retry_count+1
|
||||||
|
WHERE id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||||
|
""", owner, leaseSeconds, id) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeadRow find(long id) {
|
||||||
|
List<DeadRow> rows = jdbc.query(
|
||||||
|
"SELECT id,event_id,payload_text,retry_count FROM sys_audit_dead_letter WHERE id=?",
|
||||||
|
(rs, rowNum) -> new DeadRow(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getInt(4)), id);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void processed(long id, String owner) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE sys_audit_dead_letter SET status='PROCESSED',processed_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL,lease_until=NULL,last_error=NULL
|
||||||
|
WHERE id=? AND status='PROCESSING' AND lease_owner=?
|
||||||
|
""", id, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void failed(long id, String owner, int maxAttempts, String error) {
|
||||||
|
jdbc.update("""
|
||||||
|
UPDATE sys_audit_dead_letter
|
||||||
|
SET status=IF(retry_count>=?,'DEAD','PENDING'),
|
||||||
|
next_retry_at=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL 60 SECOND),
|
||||||
|
lease_owner=NULL,lease_until=NULL,last_error=?
|
||||||
|
WHERE id=? AND status='PROCESSING' AND lease_owner=?
|
||||||
|
""", maxAttempts, error, id, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int releaseExpired() {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_dead_letter SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL,lease_until=NULL
|
||||||
|
WHERE status='PROCESSING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DeadRow(long id, String eventId, String payload, int retryCount) { }
|
||||||
|
}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
package com.baiyue.audit.repository;
|
package com.baiyue.audit.repository;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||||
import com.baiyue.audit.entity.AuditLogEntity;
|
import com.baiyue.audit.entity.AuditLogEntity;
|
||||||
import com.baiyue.audit.mapper.AuditLogMapper;
|
import com.baiyue.audit.mapper.AuditLogMapper;
|
||||||
|
import com.baiyue.audit.dto.AuditLogQuery;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
|
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class AuditEventRepository {
|
public class AuditEventRepository {
|
||||||
private final AuditLogMapper mapper;
|
@Resource
|
||||||
|
private AuditLogMapper mapper;
|
||||||
|
|
||||||
public AuditEventRepository(AuditLogMapper mapper) {
|
public boolean save(AuditIngestRequest event, String eventId) {
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void save(AuditIngestRequest event) {
|
|
||||||
AuditLogEntity entity = new AuditLogEntity();
|
AuditLogEntity entity = new AuditLogEntity();
|
||||||
|
entity.setEventId(eventId);
|
||||||
entity.setEventType(event.eventType());
|
entity.setEventType(event.eventType());
|
||||||
entity.setResourceType(event.resourceType());
|
entity.setResourceType(event.resourceType());
|
||||||
entity.setResourceId(event.resourceId());
|
entity.setResourceId(event.resourceId());
|
||||||
@@ -41,7 +47,40 @@ public class AuditEventRepository {
|
|||||||
entity.setAfterData(jsonOrNull(event.afterData()));
|
entity.setAfterData(jsonOrNull(event.afterData()));
|
||||||
entity.setOccurredAt(event.occurredAt());
|
entity.setOccurredAt(event.occurredAt());
|
||||||
entity.setServiceName(event.serviceName());
|
entity.setServiceName(event.serviceName());
|
||||||
mapper.insert(entity);
|
try {
|
||||||
|
mapper.insert(entity);
|
||||||
|
return true;
|
||||||
|
} catch (DuplicateKeyException ignored) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PageResult<AuditLogEntity> page(AuditLogQuery query, long page, long size) {
|
||||||
|
LambdaQueryWrapper<AuditLogEntity> wrapper = new LambdaQueryWrapper<AuditLogEntity>()
|
||||||
|
.eq(query.accountId() != null, AuditLogEntity::getTenantId, query.accountId())
|
||||||
|
.eq(query.operatorUserId() != null, AuditLogEntity::getUserId, query.operatorUserId())
|
||||||
|
.eq(StringUtils.hasText(query.eventType()), AuditLogEntity::getEventType, query.eventType())
|
||||||
|
.eq(StringUtils.hasText(query.resourceType()), AuditLogEntity::getResourceType, query.resourceType())
|
||||||
|
.eq(StringUtils.hasText(query.resourceId()), AuditLogEntity::getResourceId, query.resourceId())
|
||||||
|
.eq(query.success() != null, AuditLogEntity::getSuccess, query.success())
|
||||||
|
.ge(query.occurredFrom() != null, AuditLogEntity::getOccurredAt, query.occurredFrom())
|
||||||
|
.le(query.occurredTo() != null, AuditLogEntity::getOccurredAt, query.occurredTo())
|
||||||
|
.orderByDesc(AuditLogEntity::getOccurredAt)
|
||||||
|
.orderByDesc(AuditLogEntity::getId);
|
||||||
|
if (StringUtils.hasText(query.keyword())) {
|
||||||
|
String keyword = query.keyword().trim();
|
||||||
|
wrapper.and(condition -> condition
|
||||||
|
.like(AuditLogEntity::getDescription, keyword)
|
||||||
|
.or().like(AuditLogEntity::getUsername, keyword)
|
||||||
|
.or().like(AuditLogEntity::getResourceId, keyword)
|
||||||
|
.or().like(AuditLogEntity::getRequestId, keyword)
|
||||||
|
.or().like(AuditLogEntity::getTraceId, keyword));
|
||||||
|
}
|
||||||
|
return PageResult.from(mapper.selectPage(new Page<>(page, size), wrapper));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditLogEntity findById(Long id) {
|
||||||
|
return mapper.selectById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String jsonOrNull(String value) {
|
private static String jsonOrNull(String value) {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.baiyue.audit.service;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||||
|
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/** 定时重放已持久化的审计死信,达到上限后保留为 DEAD 供人工处理。 */
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(prefix = "baiyue.audit.rocketmq", name = "dead-letter-compensation-enabled", havingValue = "true")
|
||||||
|
public class AuditDeadLetterCompensator {
|
||||||
|
@Resource
|
||||||
|
private AuditDeadLetterRepository repository;
|
||||||
|
@Resource
|
||||||
|
private AuditIngestService service;
|
||||||
|
@Resource
|
||||||
|
private ObjectMapper json;
|
||||||
|
@Value("${spring.application.name:audit-service}-${random.uuid}")
|
||||||
|
private String owner;
|
||||||
|
@Value("${baiyue.audit.rocketmq.dead-letter-max-attempts:5}")
|
||||||
|
private int maxAttempts;
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${baiyue.audit.rocketmq.dead-letter-poll-ms:10000}")
|
||||||
|
public void compensate() {
|
||||||
|
repository.releaseExpired();
|
||||||
|
for (Long id : repository.pendingIds(50)) {
|
||||||
|
if (!repository.claim(id, owner, 60)) continue;
|
||||||
|
AuditDeadLetterRepository.DeadRow row = repository.find(id);
|
||||||
|
if (row == null) continue;
|
||||||
|
try {
|
||||||
|
service.ingest(json.readValue(row.payload(), AuditIngestRequest.class));
|
||||||
|
repository.processed(id, owner);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
repository.failed(id, owner, maxAttempts, safe(exception.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
String message = value == null ? "死信补偿失败" : value;
|
||||||
|
return message.length() <= 1000 ? message : message.substring(0, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ import com.baiyue.audit.dto.AuditIngestRequest;
|
|||||||
import com.baiyue.audit.repository.AuditEventRepository;
|
import com.baiyue.audit.repository.AuditEventRepository;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
|
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
|
||||||
@@ -16,7 +19,8 @@ public class AuditIngestService {
|
|||||||
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void ingest(AuditIngestRequest event) {
|
public boolean ingest(AuditIngestRequest event) {
|
||||||
repository.save(event);
|
String eventId = StringUtils.hasText(event.eventId()) ? event.eventId() : UUID.randomUUID().toString();
|
||||||
|
return repository.save(event, eventId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.baiyue.audit.service;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditLogQuery;
|
||||||
|
import com.baiyue.audit.dto.AuditLogResponses;
|
||||||
|
import com.baiyue.audit.entity.AuditLogEntity;
|
||||||
|
import com.baiyue.audit.repository.AuditEventRepository;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/** 平台统一操作日志查询服务。 */
|
||||||
|
@Service
|
||||||
|
public class AuditLogQueryService {
|
||||||
|
@Resource
|
||||||
|
private AuditEventRepository repository;
|
||||||
|
|
||||||
|
public PageResult<AuditLogResponses.ListItem> page(AuditLogQuery query, long page, long size) {
|
||||||
|
PageResult<AuditLogEntity> source = repository.page(query, page, size);
|
||||||
|
return new PageResult<>(source.records().stream().map(this::toListItem).toList(),
|
||||||
|
source.page(), source.size(), source.total(), source.pages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuditLogResponses.Detail detail(Long id) {
|
||||||
|
AuditLogEntity entity = repository.findById(id);
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "操作日志不存在");
|
||||||
|
}
|
||||||
|
return new AuditLogResponses.Detail(
|
||||||
|
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
|
||||||
|
entity.getDescription(), entity.getRequestId(), entity.getTraceId(), entity.getTenantId(),
|
||||||
|
entity.getOperatorTenantId(), entity.getUserId(), entity.getUsername(), entity.getAppCode(),
|
||||||
|
entity.getClientType(), entity.getHttpMethod(), entity.getRequestUri(), entity.getClientIp(),
|
||||||
|
entity.getDurationMs(), entity.getSuccess(), entity.getErrorCode(), entity.getErrorMessage(),
|
||||||
|
entity.getBeforeData(), entity.getAfterData(), entity.getServiceName(), entity.getOccurredAt(),
|
||||||
|
entity.getCreatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditLogResponses.ListItem toListItem(AuditLogEntity entity) {
|
||||||
|
return new AuditLogResponses.ListItem(
|
||||||
|
entity.getId(), entity.getEventId(), entity.getEventType(), entity.getResourceType(), entity.getResourceId(),
|
||||||
|
entity.getDescription(), entity.getTenantId(), entity.getOperatorTenantId(), entity.getUserId(),
|
||||||
|
entity.getUsername(), entity.getAppCode(), entity.getClientType(), entity.getRequestId(),
|
||||||
|
entity.getTraceId(), entity.getServiceName(), entity.getSuccess(), entity.getErrorCode(),
|
||||||
|
entity.getErrorMessage(), entity.getOccurredAt(), entity.getCreatedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.baiyue.audit.service;
|
||||||
|
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
|
||||||
|
/** 审计日志只允许平台端账号读取,避免商户端通过通用日志接口获取跨账号记录。 */
|
||||||
|
public final class AuditPlatformScopeGuard {
|
||||||
|
private AuditPlatformScopeGuard() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void requirePlatform(AuthenticatedUser user) {
|
||||||
|
if (user == null || user.userId() == null) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "请先登录");
|
||||||
|
}
|
||||||
|
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "仅平台端可查看操作日志");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,19 @@ spring:
|
|||||||
namespace: ${NACOS_NAMESPACE:public}
|
namespace: ${NACOS_NAMESPACE:public}
|
||||||
|
|
||||||
baiyue.audit.enabled: false
|
baiyue.audit.enabled: false
|
||||||
|
baiyue:
|
||||||
|
audit:
|
||||||
|
rocketmq:
|
||||||
|
consumer-enabled: ${AUDIT_MQ_CONSUMER_ENABLED:false}
|
||||||
|
topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1
|
||||||
|
consumer-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1
|
||||||
|
dead-letter-consumer-enabled: ${AUDIT_DLQ_CONSUMER_ENABLED:false}
|
||||||
|
dead-letter-topic: "%DLQ%${MQ_TOPIC_PREFIX:test-}baiyue-audit-consumer-v1"
|
||||||
|
dead-letter-group: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-dlq-handler-v1
|
||||||
|
dead-letter-compensation-enabled: ${AUDIT_DLQ_COMPENSATION_ENABLED:false}
|
||||||
|
dead-letter-max-attempts: ${AUDIT_DLQ_MAX_ATTEMPTS:5}
|
||||||
|
rocketmq:
|
||||||
|
name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876}
|
||||||
management.endpoints.web.exposure.include: health,info,prometheus
|
management.endpoints.web.exposure.include: health,info,prometheus
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
@@ -35,6 +48,6 @@ logging:
|
|||||||
rollingpolicy:
|
rollingpolicy:
|
||||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
||||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
||||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}"
|
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
||||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
||||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ USE baiyue_audit;
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sys_audit_log (
|
CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
|
||||||
|
event_id VARCHAR(64) DEFAULT NULL COMMENT '审计事件全局唯一ID',
|
||||||
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
|
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
|
||||||
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
|
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
|
||||||
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
|
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
|
||||||
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
|
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
|
||||||
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
|
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
|
||||||
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
|
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
|
||||||
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID',
|
tenant_id BIGINT DEFAULT NULL COMMENT '客户ID和数据隔离键;平台级审计可为空或为0',
|
||||||
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID',
|
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属客户ID;平台操作可为空或为0',
|
||||||
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
||||||
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
|
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
|
||||||
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
|
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
|
||||||
@@ -29,8 +30,26 @@ CREATE TABLE IF NOT EXISTS sys_audit_log (
|
|||||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
|
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_audit_event_id (event_id),
|
||||||
KEY idx_audit_tenant_time (tenant_id, occurred_at),
|
KEY idx_audit_tenant_time (tenant_id, occurred_at),
|
||||||
KEY idx_audit_event_time (event_type, occurred_at),
|
KEY idx_audit_event_time (event_type, occurred_at),
|
||||||
KEY idx_audit_user_time (user_id, occurred_at),
|
KEY idx_audit_user_time (user_id, occurred_at),
|
||||||
KEY idx_audit_request (request_id)
|
KEY idx_audit_request (request_id)
|
||||||
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
|
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
|
||||||
|
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||||
|
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
|
||||||
|
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
|
||||||
|
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
|
||||||
|
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
|
||||||
|
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
|
||||||
|
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
|
||||||
|
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_audit_dead_event (event_id),
|
||||||
|
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
|
||||||
|
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
USE baiyue_audit;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS add_audit_event_id;
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE add_audit_event_id()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.STATISTICS
|
||||||
|
WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id (event_id);
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
CALL add_audit_event_id();
|
||||||
|
DROP PROCEDURE IF EXISTS add_audit_event_id;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_audit_dead_letter (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '死信记录自增ID',
|
||||||
|
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||||
|
payload_text LONGTEXT NOT NULL COMMENT 'RocketMQ死信原始载荷',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '状态:PENDING待补偿、PROCESSING处理中、PROCESSED已处理、DEAD永久失败',
|
||||||
|
retry_count INT NOT NULL DEFAULT 0 COMMENT '死信重试次数',
|
||||||
|
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许补偿时间',
|
||||||
|
lease_owner VARCHAR(128) NULL COMMENT '当前补偿租约持有实例',
|
||||||
|
lease_until DATETIME(3) NULL COMMENT '当前补偿租约截止时间',
|
||||||
|
last_error VARCHAR(1000) NULL COMMENT '最近一次补偿错误',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '死信记录创建时间',
|
||||||
|
processed_at DATETIME(3) NULL COMMENT '补偿处理完成时间',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_audit_dead_event (event_id),
|
||||||
|
KEY idx_audit_dead_retry (status, next_retry_at, lease_until)
|
||||||
|
) ENGINE=InnoDB COMMENT='审计消息死信补偿表';
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.baiyue.audit.controller;
|
||||||
|
|
||||||
|
import com.baiyue.audit.service.AuditIngestService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(AuditIngestController.class)
|
||||||
|
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
|
||||||
|
class AuditIngestControllerTest {
|
||||||
|
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private AuditIngestService service;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private AuditIngestController controller;
|
||||||
|
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidInternalToken() throws Exception {
|
||||||
|
mockMvc.perform(post("/internal/audit/events")
|
||||||
|
.header("X-Internal-Service-Token", "wrong-token")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(validRequest()))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value("A0403"));
|
||||||
|
|
||||||
|
verify(service, never()).ingest(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsEventWithInternalToken() throws Exception {
|
||||||
|
mockMvc.perform(post("/internal/audit/events")
|
||||||
|
.header("X-Internal-Service-Token", TOKEN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(validRequest()))
|
||||||
|
.andExpect(status().isAccepted())
|
||||||
|
.andExpect(jsonPath("$.code").value("200"));
|
||||||
|
|
||||||
|
verify(service).ingest(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validRequest() {
|
||||||
|
return """
|
||||||
|
{
|
||||||
|
"eventType":"ROLE_UPDATE",
|
||||||
|
"resourceType":"ROLE",
|
||||||
|
"durationMs":0,
|
||||||
|
"success":true,
|
||||||
|
"occurredAt":"2026-08-05T00:00:00Z"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
package com.baiyue.audit.controller;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditLogQuery;
|
||||||
|
import com.baiyue.audit.dto.AuditLogResponses;
|
||||||
|
import com.baiyue.audit.service.AuditLogQueryService;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(PlatformAuditLogController.class)
|
||||||
|
class PlatformAuditLogControllerContractTest {
|
||||||
|
@MockBean
|
||||||
|
private AuditLogQueryService service;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private PlatformAuditLogController controller;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformCanQueryLogList() {
|
||||||
|
when(service.page(any(AuditLogQuery.class), org.mockito.ArgumentMatchers.eq(1L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(20L)))
|
||||||
|
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||||
|
|
||||||
|
var response = controller.page(user("PLATFORM"), 1, 20, 10001L, 7L, "ROLE_UPDATE", "ROLE", "9",
|
||||||
|
true, null, null, "管理员");
|
||||||
|
|
||||||
|
assertThat(response.successful()).isTrue();
|
||||||
|
verify(service).page(new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
|
||||||
|
null, null, "管理员"), 1, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merchantCannotReadPlatformLogs() {
|
||||||
|
assertThatThrownBy(() -> controller.page(user("MERCHANT"), 1, 20, null, null, null, null,
|
||||||
|
null, null, null, null, null)).isInstanceOf(BizException.class)
|
||||||
|
.hasMessage("仅平台端可查看操作日志");
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformApplicationWithCustomerTenantCannotReadLogs() {
|
||||||
|
AuthenticatedUser malformed = new AuthenticatedUser(7L, 10001L, 10001L, 1L,
|
||||||
|
"异常平台账号", "PLATFORM", "WEB", Set.of("platform:audit:list"));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> controller.page(malformed, 1, 20, null, null, null, null,
|
||||||
|
null, null, null, null, null)).isInstanceOf(BizException.class)
|
||||||
|
.hasMessage("仅平台端可查看操作日志");
|
||||||
|
verifyNoInteractions(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformCanReadLogDetail() {
|
||||||
|
AuditLogResponses.Detail detail = new AuditLogResponses.Detail(8L, "audit-1", "ROLE_UPDATE", "ROLE", "9", null,
|
||||||
|
null, null, null, null, 7L, "管理员", "PLATFORM", "WEB", null, null, null, 1L,
|
||||||
|
true, null, null, "{}", "{}", "iam-service", null, null);
|
||||||
|
when(service.detail(8L)).thenReturn(detail);
|
||||||
|
|
||||||
|
assertThat(controller.detail(user("PLATFORM"), 8L).data()).isEqualTo(detail);
|
||||||
|
verify(service).detail(8L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthenticatedUser user(String appCode) {
|
||||||
|
return new AuthenticatedUser(7L, 0L, 0L, 1L, "管理员", appCode, "WEB", Set.of("platform:audit:list"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.baiyue.audit.mq;
|
||||||
|
|
||||||
|
import com.baiyue.audit.repository.AuditDeadLetterRepository;
|
||||||
|
import com.baiyue.audit.service.AuditDeadLetterCompensator;
|
||||||
|
import com.baiyue.audit.service.AuditIngestService;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(classes = {
|
||||||
|
AuditMqReliabilityTest.Config.class,
|
||||||
|
AuditRocketMqConsumer.class,
|
||||||
|
AuditDeadLetterConsumer.class,
|
||||||
|
AuditDeadLetterCompensator.class
|
||||||
|
})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"baiyue.audit.rocketmq.consumer-enabled=true",
|
||||||
|
"baiyue.audit.rocketmq.dead-letter-consumer-enabled=true",
|
||||||
|
"baiyue.audit.rocketmq.dead-letter-compensation-enabled=true",
|
||||||
|
"baiyue.audit.rocketmq.topic=test-audit",
|
||||||
|
"baiyue.audit.rocketmq.consumer-group=test-audit-consumer",
|
||||||
|
"baiyue.audit.rocketmq.dead-letter-topic=%DLQ%test-audit-consumer",
|
||||||
|
"baiyue.audit.rocketmq.dead-letter-group=test-audit-dlq",
|
||||||
|
"spring.application.name=audit-service-test"
|
||||||
|
})
|
||||||
|
class AuditMqReliabilityTest {
|
||||||
|
private static final String PAYLOAD = """
|
||||||
|
{"eventId":"event-1","eventType":"ROLE_UPDATE","resourceType":"ROLE",
|
||||||
|
"durationMs":10,"success":true,"occurredAt":"2026-08-05T00:00:00Z"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
@MockBean
|
||||||
|
private AuditIngestService service;
|
||||||
|
@MockBean
|
||||||
|
private AuditDeadLetterRepository repository;
|
||||||
|
@Resource
|
||||||
|
private AuditRocketMqConsumer consumer;
|
||||||
|
@Resource
|
||||||
|
private AuditDeadLetterConsumer deadLetterConsumer;
|
||||||
|
@Resource
|
||||||
|
private AuditDeadLetterCompensator compensator;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void consumesAuditEvent() {
|
||||||
|
consumer.onMessage(PAYLOAD);
|
||||||
|
|
||||||
|
verify(service).ingest(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidPayload() {
|
||||||
|
assertThatThrownBy(() -> consumer.onMessage("not-json"))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesDeadLetter() {
|
||||||
|
deadLetterConsumer.onMessage(PAYLOAD);
|
||||||
|
|
||||||
|
verify(repository).save("event-1", PAYLOAD);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compensatesDeadLetter() {
|
||||||
|
when(repository.pendingIds(50)).thenReturn(java.util.List.of(7L));
|
||||||
|
when(repository.claim(eq(7L), anyString(), eq(60))).thenReturn(true);
|
||||||
|
when(repository.find(7L)).thenReturn(
|
||||||
|
new AuditDeadLetterRepository.DeadRow(7L, "event-1", PAYLOAD, 1));
|
||||||
|
|
||||||
|
compensator.compensate();
|
||||||
|
|
||||||
|
verify(service).ingest(any());
|
||||||
|
verify(repository).processed(eq(7L), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retriesFailedCompensation() {
|
||||||
|
when(repository.pendingIds(50)).thenReturn(java.util.List.of(8L));
|
||||||
|
when(repository.claim(eq(8L), anyString(), eq(60))).thenReturn(true);
|
||||||
|
when(repository.find(8L)).thenReturn(
|
||||||
|
new AuditDeadLetterRepository.DeadRow(8L, "event-2", "not-json", 1));
|
||||||
|
|
||||||
|
compensator.compensate();
|
||||||
|
|
||||||
|
verify(repository).failed(eq(8L), anyString(), eq(5), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class Config {
|
||||||
|
@Bean
|
||||||
|
ObjectMapper objectMapper() {
|
||||||
|
return new ObjectMapper().findAndRegisterModules();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.baiyue.audit.service;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||||
|
import com.baiyue.audit.repository.AuditEventRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(AuditIngestService.class)
|
||||||
|
class AuditIngestIdempotencyTest {
|
||||||
|
@MockBean
|
||||||
|
private AuditEventRepository repository;
|
||||||
|
@Resource
|
||||||
|
private AuditIngestService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsStableEventIdForMqRedelivery() {
|
||||||
|
AuditIngestRequest request = request("audit-1");
|
||||||
|
when(repository.save(request, "audit-1")).thenReturn(false);
|
||||||
|
|
||||||
|
assertThat(service.ingest(request)).isFalse();
|
||||||
|
|
||||||
|
verify(repository).save(request, "audit-1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditIngestRequest request(String eventId) {
|
||||||
|
return new AuditIngestRequest(eventId, "ROLE_UPDATE", "ROLE", "1", "修改角色",
|
||||||
|
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB", "PUT",
|
||||||
|
"/roles/1", "127.0.0.1", 10, true, null, null, null, null,
|
||||||
|
Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.baiyue.audit.service;
|
||||||
|
|
||||||
|
import com.baiyue.audit.dto.AuditLogQuery;
|
||||||
|
import com.baiyue.audit.dto.AuditLogResponses;
|
||||||
|
import com.baiyue.audit.entity.AuditLogEntity;
|
||||||
|
import com.baiyue.audit.repository.AuditEventRepository;
|
||||||
|
import com.baiyue.common.core.api.PageResult;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(AuditLogQueryService.class)
|
||||||
|
class AuditLogQueryServiceTest {
|
||||||
|
@MockBean
|
||||||
|
private AuditEventRepository repository;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private AuditLogQueryService service;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pageDoesNotExposeSnapshots() {
|
||||||
|
AuditLogQuery query = new AuditLogQuery(10001L, 7L, "ROLE_UPDATE", "ROLE", "9", true,
|
||||||
|
null, null, "管理员");
|
||||||
|
when(repository.page(query, 1, 20)).thenReturn(new PageResult<>(List.of(log(8L)), 1, 20, 1, 1));
|
||||||
|
|
||||||
|
PageResult<AuditLogResponses.ListItem> result = service.page(query, 1, 20);
|
||||||
|
|
||||||
|
assertThat(result.records()).singleElement().satisfies(item -> {
|
||||||
|
assertThat(item.id()).isEqualTo(8L);
|
||||||
|
assertThat(item.accountId()).isEqualTo(10001L);
|
||||||
|
assertThat(item.eventType()).isEqualTo("ROLE_UPDATE");
|
||||||
|
});
|
||||||
|
assertThat(AuditLogResponses.ListItem.class.getRecordComponents())
|
||||||
|
.extracting(component -> component.getName())
|
||||||
|
.doesNotContain("beforeData", "afterData");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detailReturnsSanitizedSnapshots() {
|
||||||
|
when(repository.findById(8L)).thenReturn(log(8L));
|
||||||
|
|
||||||
|
AuditLogResponses.Detail detail = service.detail(8L);
|
||||||
|
|
||||||
|
assertThat(detail.beforeData()).isEqualTo("{\"name\":\"旧角色\"}");
|
||||||
|
assertThat(detail.afterData()).isEqualTo("{\"name\":\"新角色\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingLogIsReportedAsNotFound() {
|
||||||
|
when(repository.findById(8L)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.detail(8L)).isInstanceOf(BizException.class)
|
||||||
|
.hasMessage("操作日志不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditLogEntity log(Long id) {
|
||||||
|
AuditLogEntity entity = new AuditLogEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
entity.setEventId("audit-8");
|
||||||
|
entity.setEventType("ROLE_UPDATE");
|
||||||
|
entity.setResourceType("ROLE");
|
||||||
|
entity.setResourceId("9");
|
||||||
|
entity.setDescription("管理员修改角色权限");
|
||||||
|
entity.setTenantId(10001L);
|
||||||
|
entity.setOperatorTenantId(0L);
|
||||||
|
entity.setUserId(7L);
|
||||||
|
entity.setUsername("管理员");
|
||||||
|
entity.setAppCode("PLATFORM");
|
||||||
|
entity.setClientType("WEB");
|
||||||
|
entity.setRequestId("request-8");
|
||||||
|
entity.setTraceId("trace-8");
|
||||||
|
entity.setSuccess(true);
|
||||||
|
entity.setBeforeData("{\"name\":\"旧角色\"}");
|
||||||
|
entity.setAfterData("{\"name\":\"新角色\"}");
|
||||||
|
entity.setServiceName("iam-service");
|
||||||
|
entity.setOccurredAt(Instant.parse("2026-08-05T10:00:00Z"));
|
||||||
|
entity.setCreatedAt(LocalDateTime.of(2026, 8, 5, 18, 0));
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-24
@@ -1,32 +1,31 @@
|
|||||||
# Auth Service 模块指引
|
# Auth Service 模块指引
|
||||||
|
|
||||||
## 职责边界
|
## 职责与边界
|
||||||
|
|
||||||
- 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。
|
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
|
||||||
- 租户、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。
|
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
|
||||||
|
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
|
||||||
|
|
||||||
## 快速索引
|
## 模块入口
|
||||||
|
|
||||||
| 功能 | 入口 |
|
| 目标 | 入口 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` |
|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
|
||||||
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) |
|
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
|
||||||
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 |
|
| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 |
|
||||||
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` |
|
| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` |
|
||||||
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 |
|
| 注册协作 | `service/MerchantRegistrationService.java` |
|
||||||
| 商户注册协作 | `service/MerchantRegistrationService.java` |
|
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
|
||||||
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
|
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
|
||||||
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
|
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
|
||||||
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、租户、成员与应用端 |
|
| 测试/验证 | `src/test/java/com/baiyue/auth/`;`mvn -pl auth -am test` |
|
||||||
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
|
|
||||||
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
|
|
||||||
|
|
||||||
## 修改与验证
|
## 修改约束
|
||||||
|
|
||||||
- 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。
|
- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
|
||||||
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
|
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
|
||||||
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。
|
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
|
||||||
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
|
|
||||||
- 平台统一用户管理跨租户移动成员时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 默认租户、成员和应用端;修改该契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。
|
## 全量验收导航
|
||||||
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
|
|
||||||
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
|
一期、二期、三期及财务全量验收均复用本模块认证能力;按本次链路读取对应阶段/专题文档,重点核对 Token、应用域、客户隔离、失效和跨 Auth/IAM 补偿,不因历史阶段划分排除其他消费者。
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 国内与海外 JWT/JWKS 冻结契约
|
||||||
|
|
||||||
|
## 双轨边界
|
||||||
|
|
||||||
|
### 国内现有链路
|
||||||
|
|
||||||
|
- 登录、刷新、退出和代登录接口保持原契约,不修改 `TokenResponse`。
|
||||||
|
- 签名算法保持 `HS256`,继续使用 `JWT_SECRET`。
|
||||||
|
- Access Token 默认有效期保持 24 分钟,Refresh Token 默认有效期保持 7 天。
|
||||||
|
- 业务服务继续使用国内 HMAC 验签与 Redis 会话校验,账号停用、退出和 Token 版本失效规则不变。
|
||||||
|
- 国内 Token 保持现有 snake_case 声明,不增加海外 `aud`,也不要求配置 RSA/JWK。
|
||||||
|
|
||||||
|
### 海外尾程链路
|
||||||
|
|
||||||
|
- 海外能力默认关闭:`OVERSEAS_JWT_ENABLED=false`。
|
||||||
|
- 已通过国内 HS256 与 Redis 校验的登录用户调用 `POST /api/auth/overseas/token`,兑换独立的海外短时 Token。
|
||||||
|
- 海外 Token 使用 `RS256`,默认有效期 15 分钟,不签发 Refresh Token。
|
||||||
|
- 尾程必须配置 `baiyue.security.jwt-mode=jwks` 和 `baiyue.security.session-mode=claims`,只做 JWKS 验签和本地安全投影校验,不创建或访问国内 Redis 会话校验器。
|
||||||
|
- 海外功能未启用时,兑换接口返回“海外认证未启用”;JWKS Controller、海外签发服务和 `overseasJwtEncoder` 均不创建,Auth 仍可正常启动并签发国内 HS256 Token。
|
||||||
|
|
||||||
|
## 海外签发契约
|
||||||
|
|
||||||
|
- `issuer`:`auth-service`
|
||||||
|
- `audience`:`lastleg-api`
|
||||||
|
- 算法白名单:仅 `RS256`
|
||||||
|
- Access Token 默认有效期:15 分钟
|
||||||
|
- 兑换接口:`POST /api/auth/overseas/token`
|
||||||
|
- JWKS 路径:`/api/auth/.well-known/jwks.json`
|
||||||
|
- Auth 直连地址:`http://{auth-host}:9100/api/auth/.well-known/jwks.json`
|
||||||
|
- JWKS HTTP 缓存:5 分钟,响应 `Cache-Control: public, max-age=300`
|
||||||
|
|
||||||
|
海外 Token 的 JSON 声明名称和类型固定如下:
|
||||||
|
|
||||||
|
| 声明 | JSON 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `sub` | string | 用户 ID 的字符串形式 |
|
||||||
|
| `jti` | string | Token 唯一标识 |
|
||||||
|
| `iat` | number | 签发时间 |
|
||||||
|
| `exp` | number | 过期时间 |
|
||||||
|
| `aud` | string array | 固定包含 `lastleg-api` |
|
||||||
|
| `userId` | number | 认证用户 ID |
|
||||||
|
| `tenantId` | number | 当前客户 ID;平台账号为平台作用域值 |
|
||||||
|
| `appCode` | string | `PLATFORM` 或 `MERCHANT` |
|
||||||
|
| `roles` | string array | 当前角色编码快照 |
|
||||||
|
| `authorities` | string array | 当前权限编码快照 |
|
||||||
|
| `permissionVersion` | number | IAM 单调权限版本 |
|
||||||
|
|
||||||
|
海外 Token 从 V1 起只使用上述 camelCase 自定义声明,不读取国内 snake_case 声明。
|
||||||
|
|
||||||
|
## 海外配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
baiyue:
|
||||||
|
security:
|
||||||
|
overseas-jwt:
|
||||||
|
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||||
|
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||||
|
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||||
|
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||||
|
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||||
|
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||||
|
```
|
||||||
|
|
||||||
|
只有显式启用海外能力时才校验 `OVERSEAS_JWT_RSA_JWK_SET` 和 `OVERSEAS_JWT_CURRENT_KID`。启用后配置不合法必须启动失败,避免错误密钥签发。
|
||||||
|
|
||||||
|
## 密钥轮换
|
||||||
|
|
||||||
|
- JWK Set 只允许 RSA 密钥。
|
||||||
|
- `OVERSEAS_JWT_CURRENT_KID` 必须指向唯一的当前签名私钥,JWT Header 写入该 `kid`。
|
||||||
|
- 非当前密钥只能保留公钥;JWKS 只发布公钥,不得输出私钥参数。
|
||||||
|
- 轮换时同时发布当前公钥和上一公钥,上一公钥至少保留 20 分钟,即 15 分钟最大 Token 生命周期加 5 分钟 JWKS 缓存。
|
||||||
|
- 重叠期结束后移除上一公钥,禁止复用历史 `kid`。
|
||||||
|
|
||||||
|
## IAM 安全事件
|
||||||
|
|
||||||
|
安全事件的 `eventType`、版本、状态枚举和 payload 以 `iam/SECURITY-EVENT-AUDIT.md` 为唯一契约。尾程维护用户状态、客户状态和权限版本的本地投影,请求期间不反向同步访问国内 IAM。
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<artifactId>auth-service</artifactId>
|
<artifactId>auth-service</artifactId>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
||||||
|
<dependency><groupId>org.apache.rocketmq</groupId><artifactId>rocketmq-spring-boot-starter</artifactId></dependency>
|
||||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
||||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.baiyue.auth.client;
|
package com.baiyue.auth.client;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
@@ -7,21 +10,23 @@ import feign.FeignException;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。
|
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为 Redis 会话组装授权快照。
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class AuthorizationLoader {
|
public class AuthorizationLoader {
|
||||||
private final IamAuthorizationClient client;
|
@Resource
|
||||||
private final String internalToken;
|
private IamAuthorizationClient client;
|
||||||
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
|
private String internalToken;
|
||||||
|
|
||||||
public AuthorizationLoader(IamAuthorizationClient client,
|
@PostConstruct
|
||||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
void validateConfiguration() {
|
||||||
if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||||
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||||
}
|
}
|
||||||
this.client = client;
|
|
||||||
this.internalToken = internalToken;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthorizationSnapshot load(AuthAccount account) {
|
public AuthorizationSnapshot load(AuthAccount account) {
|
||||||
@@ -39,10 +44,17 @@ public class AuthorizationLoader {
|
|||||||
}
|
}
|
||||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
||||||
}
|
}
|
||||||
if (response.data() == null) {
|
AuthorizationSnapshot snapshot = response.data();
|
||||||
|
if (snapshot == null) {
|
||||||
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
||||||
}
|
}
|
||||||
return response.data();
|
if (!Objects.equals(account.getId(), snapshot.getUserId())
|
||||||
|
|| !Objects.equals(account.getDefaultTenantId(), snapshot.getTenantId())
|
||||||
|
|| !Objects.equals(account.getDefaultMembershipId(), snapshot.getMembershipId())
|
||||||
|
|| !Objects.equals(account.getDefaultAppCode(), snapshot.getAppCode())) {
|
||||||
|
throw new BizException(ResultCode.FORBIDDEN, "IAM 授权快照身份与账号归属不一致");
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
} catch (FeignException exception) {
|
} catch (FeignException exception) {
|
||||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
||||||
} catch (BizException exception) {
|
} catch (BizException exception) {
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ public class AuthorizationSnapshot {
|
|||||||
private String clientType;
|
private String clientType;
|
||||||
private Set<String> roles;
|
private Set<String> roles;
|
||||||
private Set<String> permissions;
|
private Set<String> permissions;
|
||||||
|
private Long permissionVersion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.baiyue.auth.config;
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
|
||||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
@@ -10,14 +9,11 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
|||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||||
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
|
||||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||||
@@ -25,15 +21,21 @@ import java.nio.charset.StandardCharsets;
|
|||||||
@Configuration
|
@Configuration
|
||||||
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||||
public class AuthSecurityConfig {
|
public class AuthSecurityConfig {
|
||||||
@Bean
|
static final String[] PUBLIC_AUTH_ENDPOINTS = {
|
||||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/auth/refresh",
|
||||||
|
"/api/auth/logout",
|
||||||
|
"/api/auth/impersonations/exchange",
|
||||||
|
"/api/auth/.well-known/jwks.json",
|
||||||
|
"/actuator/health",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html"
|
||||||
|
};
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||||
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
|
||||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
|
||||||
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||||
@@ -47,8 +49,7 @@ public class AuthSecurityConfig {
|
|||||||
.exceptionHandling(errors -> errors
|
.exceptionHandling(errors -> errors
|
||||||
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout",
|
.requestMatchers(PUBLIC_AUTH_ENDPOINTS).permitAll()
|
||||||
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
|
||||||
.requestMatchers("/internal/auth/**").permitAll()
|
.requestMatchers("/internal/auth/**").permitAll()
|
||||||
.anyRequest().authenticated())
|
.anyRequest().authenticated())
|
||||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import java.time.Duration;
|
|||||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||||
*/
|
*/
|
||||||
@ConfigurationProperties(prefix = "baiyue.security")
|
@ConfigurationProperties(prefix = "baiyue.security")
|
||||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
public record AuthSecurityProperties(String jwtSecret, String issuer, String audience,
|
||||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||||
int maxLoginFailures, Duration lockDuration) {
|
int maxLoginFailures, Duration lockDuration) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
public class DomesticJwtConfiguration {
|
||||||
|
@Bean("jwtEncoder")
|
||||||
|
@Primary
|
||||||
|
JwtEncoder jwtEncoder(AuthSecurityProperties properties) {
|
||||||
|
byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||||
|
return new NimbusJwtEncoder(new ImmutableSecret<>(secret));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@EnableConfigurationProperties(OverseasJwtProperties.class)
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class OverseasJwtConfiguration {
|
||||||
|
@Bean("overseasJwtEncoder")
|
||||||
|
JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) {
|
||||||
|
try {
|
||||||
|
JWKSet set = JWKSet.parse(properties.rsaJwkSet());
|
||||||
|
RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null;
|
||||||
|
if (current == null || !current.isPrivate()) {
|
||||||
|
throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥");
|
||||||
|
}
|
||||||
|
if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) {
|
||||||
|
throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥");
|
||||||
|
}
|
||||||
|
if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID())
|
||||||
|
&& jwk instanceof RSAKey rsa && rsa.isPrivate())) {
|
||||||
|
throw new IllegalStateException("海外非当前密钥只能配置公钥");
|
||||||
|
}
|
||||||
|
return new NimbusJwtEncoder(new ImmutableJWKSet<>(set));
|
||||||
|
} catch (java.text.ParseException exception) {
|
||||||
|
throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/** 海外尾程 RS256 短时令牌配置,默认关闭。 */
|
||||||
|
@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt")
|
||||||
|
public record OverseasJwtProperties(boolean enabled, String issuer, String audience,
|
||||||
|
String rsaJwkSet, String currentKid, Duration accessTokenTtl) { }
|
||||||
@@ -8,6 +8,8 @@ import com.baiyue.auth.dto.MerchantRegisterRequest;
|
|||||||
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
||||||
import com.baiyue.auth.service.MerchantRegistrationService;
|
import com.baiyue.auth.service.MerchantRegistrationService;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||||
@@ -18,7 +20,11 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos;
|
||||||
import com.baiyue.auth.service.AccountManagementService;
|
import com.baiyue.auth.service.AccountManagementService;
|
||||||
|
import com.baiyue.auth.service.OverseasTokenService;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,16 +40,40 @@ public class AuthController {
|
|||||||
private MerchantRegistrationService registrationService;
|
private MerchantRegistrationService registrationService;
|
||||||
@Resource
|
@Resource
|
||||||
private AccountManagementService accountManagementService;
|
private AccountManagementService accountManagementService;
|
||||||
|
@Resource
|
||||||
|
private ObjectProvider<OverseasTokenService> overseasTokens;
|
||||||
|
|
||||||
|
@PostMapping("/overseas/token")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。")
|
||||||
|
public ApiResponse<OverseasTokenService.OverseasTokenResponse> exchangeOverseasToken(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||||
|
OverseasTokenService service = overseasTokens.getIfAvailable();
|
||||||
|
if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用");
|
||||||
|
return ApiResponse.success(service.exchange(user.userId()));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/password")
|
@PutMapping("/password")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@PreAuthorize("isAuthenticated()")
|
||||||
@Operation(summary = "修改当前用户密码")
|
@Operation(summary = "修改当前用户密码")
|
||||||
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@Valid @RequestBody ChangePasswordRequest request) {
|
@Valid @RequestBody ChangePasswordRequest request) {
|
||||||
|
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
|
||||||
accountManagementService.changePassword(user.userId(), request);
|
accountManagementService.changePassword(user.userId(), request);
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/password/forced")
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
@Operation(summary = "完成强制改密")
|
||||||
|
public ApiResponse<Void> forcedChangePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@Valid @RequestBody ForcedPasswordChangeRequest request) {
|
||||||
|
if (user.impersonated()) throw new BizException(ResultCode.FORBIDDEN, "代登录会话不能修改密码");
|
||||||
|
if (!user.passwordChangeRequired()) throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
|
||||||
|
accountManagementService.forcedChangePassword(user.userId(), request);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
@SecurityRequirements
|
@SecurityRequirements
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
@Operation(summary = "Merchant registration / 客户注册",
|
@Operation(summary = "Merchant registration / 客户注册",
|
||||||
@@ -82,4 +112,11 @@ public class AuthController {
|
|||||||
authService.logout(request.refreshToken());
|
authService.logout(request.refreshToken());
|
||||||
return ApiResponse.success(null);
|
return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SecurityRequirements
|
||||||
|
@PostMapping("/impersonations/exchange")
|
||||||
|
public ApiResponse<AuthService.ImpersonationTokenResponse> exchangeImpersonation(
|
||||||
|
@Valid @RequestBody AccountManagementDtos.ImpersonationExchangeRequest request) {
|
||||||
|
return ApiResponse.success(authService.exchangeImpersonationTicket(request.code()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.baiyue.auth.controller;
|
|||||||
|
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||||
import com.baiyue.auth.service.AccountManagementService;
|
import com.baiyue.auth.service.AccountManagementService;
|
||||||
|
import com.baiyue.auth.service.AuthService;
|
||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
@@ -20,22 +21,33 @@ import java.security.MessageDigest;
|
|||||||
public class InternalAccountController {
|
public class InternalAccountController {
|
||||||
@Resource
|
@Resource
|
||||||
private AccountManagementService service;
|
private AccountManagementService service;
|
||||||
|
@Resource
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
private final String configuredToken;
|
@Value("${baiyue.security.internal-service-token}")
|
||||||
|
private String configuredToken;
|
||||||
private byte[] tokenBytes;
|
private byte[] tokenBytes;
|
||||||
|
|
||||||
public InternalAccountController(
|
@PostConstruct
|
||||||
@Value("${baiyue.security.internal-service-token}") String configuredToken) {
|
void init() {
|
||||||
this.configuredToken = configuredToken;
|
byte[] configured = configuredToken == null
|
||||||
|
? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8);
|
||||||
|
if (configured.length < 32) {
|
||||||
|
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||||
|
}
|
||||||
|
tokenBytes = configured;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); }
|
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@Valid @RequestBody CreateAccountRequest request) {
|
@Valid @RequestBody CreateAccountRequest request) {
|
||||||
verify(token); return ApiResponse.success(service.create(request));
|
verify(token); return ApiResponse.success(service.create(request));
|
||||||
}
|
}
|
||||||
|
@GetMapping("/availability")
|
||||||
|
public ApiResponse<Boolean> availability(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@RequestParam String username) {
|
||||||
|
verify(token); return ApiResponse.success(service.usernameAvailable(username));
|
||||||
|
}
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id) {
|
@PathVariable Long id) {
|
||||||
@@ -46,6 +58,12 @@ public class InternalAccountController {
|
|||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
|
||||||
verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
|
verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
|
@PostMapping("/sessions/revoke")
|
||||||
|
public ApiResponse<Void> revokeSessions(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@Valid @RequestBody RevokeAccountSessionsRequest request) {
|
||||||
|
verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null);
|
||||||
|
}
|
||||||
@PutMapping("/{id}/profile")
|
@PutMapping("/{id}/profile")
|
||||||
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
|
||||||
@@ -54,7 +72,20 @@ public class InternalAccountController {
|
|||||||
@PutMapping("/{id}/password")
|
@PutMapping("/{id}/password")
|
||||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
||||||
verify(token); service.resetPassword(id, request); return ApiResponse.success(null);
|
verify(token); service.resetPassword(id, request, request.forceChange()); return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/impersonation-tickets")
|
||||||
|
public ApiResponse<ImpersonationTicketResponse> impersonationTicket(
|
||||||
|
@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@Valid @RequestBody ImpersonationTicketRequest request) {
|
||||||
|
verify(token); return ApiResponse.success(authService.createImpersonationTicket(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/tenant/{tenantId}/archive")
|
||||||
|
public ApiResponse<Void> archiveTenant(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
@PathVariable Long tenantId) {
|
||||||
|
verify(token); service.archiveTenant(tenantId); return ApiResponse.success(null);
|
||||||
}
|
}
|
||||||
@PutMapping("/{id}/scope")
|
@PutMapping("/{id}/scope")
|
||||||
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class JwksController {
|
||||||
|
private final OverseasJwtProperties properties;
|
||||||
|
|
||||||
|
public JwksController(OverseasJwtProperties properties) { this.properties = properties; }
|
||||||
|
|
||||||
|
@GetMapping("/api/auth/.well-known/jwks.json")
|
||||||
|
@SecurityRequirements
|
||||||
|
@Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。")
|
||||||
|
public ResponseEntity<Map<String, Object>> jwks() throws Exception {
|
||||||
|
return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
|
||||||
|
.body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package com.baiyue.auth.dto;
|
|||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import jakarta.validation.constraints.Pattern;
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/** 账号安全及内部账号管理契约。 */
|
/** 账号安全及内部账号管理契约。 */
|
||||||
public final class AccountManagementDtos {
|
public final class AccountManagementDtos {
|
||||||
@@ -14,6 +16,10 @@ public final class AccountManagementDtos {
|
|||||||
public record ChangePasswordRequest(@NotBlank String oldPassword,
|
public record ChangePasswordRequest(@NotBlank String oldPassword,
|
||||||
@NotBlank @Size(min = 8, max = 64)
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||||
|
public record ForcedPasswordChangeRequest(
|
||||||
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||||
|
String newPassword) { }
|
||||||
public record CreateAccountRequest(@NotNull Long accountId,
|
public record CreateAccountRequest(@NotNull Long accountId,
|
||||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||||
@NotBlank @Size(min = 8, max = 64)
|
@NotBlank @Size(min = 8, max = 64)
|
||||||
@@ -31,8 +37,23 @@ public final class AccountManagementDtos {
|
|||||||
}
|
}
|
||||||
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
|
||||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||||
|
public record RevokeAccountSessionsRequest(
|
||||||
|
@NotEmpty @Size(max = 500) Set<@NotNull Long> accountIds) {
|
||||||
|
public RevokeAccountSessionsRequest {
|
||||||
|
accountIds = Set.copyOf(accountIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
||||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword,
|
||||||
|
Boolean forceChangePassword) {
|
||||||
|
public boolean forceChange() { return Boolean.TRUE.equals(forceChangePassword); }
|
||||||
|
}
|
||||||
|
public record ResetPasswordOptions(boolean forceChangePassword) { }
|
||||||
|
public record ImpersonationTicketRequest(@NotNull Long targetAuthUserId, @NotNull Long tenantId,
|
||||||
|
@NotNull Long membershipId, @NotNull Long actorUserId,
|
||||||
|
@NotNull Long actorMembershipId, @NotBlank String reason) { }
|
||||||
|
public record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
|
||||||
|
public record ImpersonationExchangeRequest(@NotBlank String code) { }
|
||||||
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
|
||||||
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
|
||||||
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
public record AccountSummary(Long accountId, String username, String displayName, String status,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class AuthAccount {
|
|||||||
private Integer failedLoginCount;
|
private Integer failedLoginCount;
|
||||||
private LocalDateTime lockedUntil;
|
private LocalDateTime lockedUntil;
|
||||||
private Integer tokenVersion;
|
private Integer tokenVersion;
|
||||||
|
private Boolean passwordChangeRequired;
|
||||||
private Long defaultTenantId;
|
private Long defaultTenantId;
|
||||||
private Long operatorTenantId;
|
private Long operatorTenantId;
|
||||||
private Long defaultMembershipId;
|
private Long defaultMembershipId;
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
package com.baiyue.auth.repository;
|
package com.baiyue.auth.repository;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthAccount;
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
import com.baiyue.auth.mapper.AuthAccountMapper;
|
import com.baiyue.auth.mapper.AuthAccountMapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class AuthAccountRepository {
|
public class AuthAccountRepository {
|
||||||
private final AuthAccountMapper mapper;
|
@Resource
|
||||||
|
private AuthAccountMapper mapper;
|
||||||
public AuthAccountRepository(AuthAccountMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AuthAccount findByUsername(String username) {
|
public AuthAccount findByUsername(String username) {
|
||||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||||
@@ -33,6 +33,12 @@ public class AuthAccountRepository {
|
|||||||
return mapper.selectById(id);
|
return mapper.selectById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AuthAccount> findMerchantByTenant(Long tenantId) {
|
||||||
|
return mapper.selectList(new LambdaQueryWrapper<AuthAccount>()
|
||||||
|
.eq(AuthAccount::getDefaultTenantId, tenantId)
|
||||||
|
.eq(AuthAccount::getDefaultAppCode, "MERCHANT"));
|
||||||
|
}
|
||||||
|
|
||||||
public void save(AuthAccount account) {
|
public void save(AuthAccount account) {
|
||||||
mapper.updateById(account);
|
mapper.updateById(account);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.baiyue.auth.repository;
|
package com.baiyue.auth.repository;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||||
import com.baiyue.auth.mapper.AuthRefreshTokenMapper;
|
import com.baiyue.auth.mapper.AuthRefreshTokenMapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
@@ -10,11 +12,8 @@ import org.springframework.stereotype.Repository;
|
|||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public class AuthRefreshTokenRepository {
|
public class AuthRefreshTokenRepository {
|
||||||
private final AuthRefreshTokenMapper mapper;
|
@Resource
|
||||||
|
private AuthRefreshTokenMapper mapper;
|
||||||
public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) {
|
|
||||||
this.mapper = mapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) {
|
public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) {
|
||||||
String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import com.baiyue.common.audit.BusinessAudit;
|
|||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
|
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
|
||||||
@Service
|
@Service
|
||||||
@@ -21,17 +22,38 @@ public class AccountManagementService {
|
|||||||
@Resource private PasswordEncoder passwordEncoder;
|
@Resource private PasswordEncoder passwordEncoder;
|
||||||
@Resource private TokenSessionStore tokenSessionStore;
|
@Resource private TokenSessionStore tokenSessionStore;
|
||||||
|
|
||||||
|
public boolean usernameAvailable(String username) {
|
||||||
|
return username != null && !username.isBlank() && !repository.existsByUsername(username.trim());
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
||||||
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
||||||
AuthAccount account = required(accountId);
|
AuthAccount account = required(accountId);
|
||||||
|
if (Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "请先使用强制改密接口设置新密码");
|
||||||
|
}
|
||||||
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
|
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
|
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
|
||||||
}
|
}
|
||||||
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
||||||
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
|
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
|
||||||
}
|
}
|
||||||
setPasswordAndRevoke(account, request.newPassword());
|
setPasswordAndRevoke(account, request.newPassword(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "PASSWORD_FORCED_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
|
description = "完成管理员重置后的强制改密", captureAfter = false)
|
||||||
|
public void forcedChangePassword(Long accountId, ForcedPasswordChangeRequest request) {
|
||||||
|
AuthAccount account = required(accountId);
|
||||||
|
if (!Boolean.TRUE.equals(account.getPasswordChangeRequired())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "当前账号不需要强制改密");
|
||||||
|
}
|
||||||
|
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
||||||
|
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与当前密码相同");
|
||||||
|
}
|
||||||
|
setPasswordAndRevoke(account, request.newPassword(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -77,6 +99,14 @@ public class AccountManagementService {
|
|||||||
bumpVersion(account);
|
bumpVersion(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "ACCOUNT_SESSIONS_REVOKE", resourceType = "ACCOUNT_BATCH",
|
||||||
|
resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话",
|
||||||
|
captureBefore = false, captureAfter = false)
|
||||||
|
public void revokeSessions(Set<Long> accountIds) {
|
||||||
|
accountIds.forEach(accountId -> bumpVersion(required(accountId)));
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
@BusinessAudit(event = "ACCOUNT_PROFILE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
|
||||||
description = "同步账号显示名称", captureAfter = false)
|
description = "同步账号显示名称", captureAfter = false)
|
||||||
@@ -89,8 +119,21 @@ public class AccountManagementService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||||
public void resetPassword(Long accountId, ResetPasswordRequest request) {
|
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
|
||||||
setPasswordAndRevoke(required(accountId), request.newPassword());
|
setPasswordAndRevoke(required(accountId), request.newPassword(), forceChangePassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "CUSTOMER_ACCOUNT_ARCHIVE", resourceType = "TENANT", resourceId = "#tenantId",
|
||||||
|
description = "归档客户并撤销客户账号", captureBefore = false, captureAfter = false)
|
||||||
|
public void archiveTenant(Long tenantId) {
|
||||||
|
for (AuthAccount account : repository.findMerchantByTenant(tenantId)) {
|
||||||
|
account.setAccountStatus("DISABLED");
|
||||||
|
account.setPasswordChangeRequired(false);
|
||||||
|
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
|
||||||
|
repository.save(account);
|
||||||
|
tokenSessionStore.revokeAll(account.getId());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -121,10 +164,11 @@ public class AccountManagementService {
|
|||||||
|
|
||||||
public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
|
public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
|
||||||
|
|
||||||
private void setPasswordAndRevoke(AuthAccount account, String password) {
|
private void setPasswordAndRevoke(AuthAccount account, String password, boolean forceChangePassword) {
|
||||||
account.setPasswordHash(passwordEncoder.encode(password));
|
account.setPasswordHash(passwordEncoder.encode(password));
|
||||||
account.setFailedLoginCount(0);
|
account.setFailedLoginCount(0);
|
||||||
account.setLockedUntil(null);
|
account.setLockedUntil(null);
|
||||||
|
account.setPasswordChangeRequired(forceChangePassword);
|
||||||
bumpVersion(account);
|
bumpVersion(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.baiyue.auth.entity.AuthAccount;
|
|||||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
import com.baiyue.auth.dto.LoginRequest;
|
import com.baiyue.auth.dto.LoginRequest;
|
||||||
import com.baiyue.auth.dto.TokenResponse;
|
import com.baiyue.auth.dto.TokenResponse;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ImpersonationTicketResponse;
|
||||||
import com.baiyue.common.audit.BusinessAudit;
|
import com.baiyue.common.audit.BusinessAudit;
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
@@ -27,9 +29,11 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
|
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、小 JWT 签发、刷新令牌轮换与注销事务。
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
@@ -105,7 +109,8 @@ public class AuthService {
|
|||||||
String jwtId = UUID.randomUUID().toString();
|
String jwtId = UUID.randomUUID().toString();
|
||||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
.issuer(properties.issuer()).id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
|
.id(jwtId).issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
.subject(account.getId().toString())
|
.subject(account.getId().toString())
|
||||||
.claim("user_id", account.getId())
|
.claim("user_id", account.getId())
|
||||||
.claim("username", account.getUsername())
|
.claim("username", account.getUsername())
|
||||||
@@ -114,22 +119,85 @@ public class AuthService {
|
|||||||
.claim("membership_id", account.getDefaultMembershipId())
|
.claim("membership_id", account.getDefaultMembershipId())
|
||||||
.claim("app_code", account.getDefaultAppCode())
|
.claim("app_code", account.getDefaultAppCode())
|
||||||
.claim("client_type", authorization.getClientType())
|
.claim("client_type", authorization.getClientType())
|
||||||
.claim("roles", authorization.getRoles())
|
|
||||||
.claim("permissions", authorization.getPermissions())
|
|
||||||
.claim("token_version", account.getTokenVersion())
|
.claim("token_version", account.getTokenVersion())
|
||||||
|
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired()))
|
||||||
|
.claim("userId", account.getId())
|
||||||
|
.claim("tenantId", account.getDefaultTenantId())
|
||||||
|
.claim("appCode", account.getDefaultAppCode())
|
||||||
|
.claim("permissionVersion", account.getTokenVersion())
|
||||||
.build();
|
.build();
|
||||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
signingHeader(), claims)).getTokenValue();
|
||||||
|
|
||||||
byte[] random = new byte[48];
|
byte[] random = new byte[48];
|
||||||
secureRandom.nextBytes(random);
|
secureRandom.nextBytes(random);
|
||||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||||
tokenSessionStore.store(jwtId, account.getId(), rawRefresh,
|
tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh,
|
||||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "IMPERSONATION",
|
||||||
|
resourceId = "#request.tenantId", description = "创建客户代登录授权码", captureBefore = false, captureAfter = false)
|
||||||
|
public ImpersonationTicketResponse createImpersonationTicket(ImpersonationTicketRequest request) {
|
||||||
|
AuthAccount account = accountRepository.findById(request.targetAuthUserId());
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus())
|
||||||
|
|| !"MERCHANT".equals(account.getDefaultAppCode())
|
||||||
|
|| !request.tenantId().equals(account.getDefaultTenantId())
|
||||||
|
|| !request.membershipId().equals(account.getDefaultMembershipId())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "目标客户管理员账号不可代登录");
|
||||||
|
}
|
||||||
|
byte[] bytes = new byte[48];
|
||||||
|
secureRandom.nextBytes(bytes);
|
||||||
|
String code = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
|
String ticketId = UUID.randomUUID().toString();
|
||||||
|
tokenSessionStore.storeImpersonationTicket(code,
|
||||||
|
new TokenSessionStore.ImpersonationTicket(ticketId, account.getId(), request.tenantId(),
|
||||||
|
request.membershipId(), request.actorUserId(), request.actorMembershipId(), request.reason()),
|
||||||
|
Duration.ofSeconds(60));
|
||||||
|
return new ImpersonationTicketResponse(code, ticketId, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@BusinessAudit(event = "CUSTOMER_IMPERSONATION_EXCHANGE", resourceType = "IMPERSONATION",
|
||||||
|
resourceId = "'IMPERSONATION_EXCHANGE'", description = "兑换客户代登录授权码", captureBefore = false, captureAfter = false)
|
||||||
|
public ImpersonationTokenResponse exchangeImpersonationTicket(String code) {
|
||||||
|
TokenSessionStore.ImpersonationTicket ticket = tokenSessionStore.consumeImpersonationTicket(code);
|
||||||
|
if (ticket == null) throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
|
||||||
|
AuthAccount account = accountRepository.findById(ticket.targetAuthUserId());
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "目标客户账号不可用");
|
||||||
|
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||||
|
Instant issuedAt = Instant.now();
|
||||||
|
Instant expiresAt = issuedAt.plus(Duration.ofMinutes(15));
|
||||||
|
String jwtId = UUID.randomUUID().toString();
|
||||||
|
JwtClaimsSet claims = JwtClaimsSet.builder().issuer(properties.issuer())
|
||||||
|
.audience(List.of(properties.audience())).id(jwtId)
|
||||||
|
.issuedAt(issuedAt).expiresAt(expiresAt).subject(account.getId().toString())
|
||||||
|
.claim("user_id", account.getId()).claim("username", account.getUsername())
|
||||||
|
.claim("tenant_id", account.getDefaultTenantId()).claim("operator_tenant_id", account.getOperatorTenantId())
|
||||||
|
.claim("membership_id", account.getDefaultMembershipId()).claim("app_code", "MERCHANT")
|
||||||
|
.claim("client_type", authorization.getClientType()).claim("token_version", account.getTokenVersion())
|
||||||
|
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||||
|
.claim("appCode", "MERCHANT")
|
||||||
|
.claim("permissionVersion", account.getTokenVersion())
|
||||||
|
.claim("impersonated", true).claim("impersonation_id", ticket.ticketId())
|
||||||
|
.claim("actor_user_id", ticket.actorUserId()).claim("actor_membership_id", ticket.actorMembershipId())
|
||||||
|
.claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build();
|
||||||
|
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue();
|
||||||
|
tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(),
|
||||||
|
authorization.getPermissions(), Duration.ofMinutes(15));
|
||||||
|
return new ImpersonationTokenResponse("Bearer", accessToken, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||||
|
|
||||||
|
private JwsHeader signingHeader() {
|
||||||
|
return JwsHeader.with(MacAlgorithm.HS256).build();
|
||||||
|
}
|
||||||
|
|
||||||
private static BizException invalidCredentials() {
|
private static BizException invalidCredentials() {
|
||||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true")
|
||||||
|
public class OverseasTokenService {
|
||||||
|
@Resource private AuthAccountRepository accounts;
|
||||||
|
@Resource private AuthorizationLoader authorizationLoader;
|
||||||
|
@Resource private OverseasJwtProperties properties;
|
||||||
|
@Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder;
|
||||||
|
|
||||||
|
public OverseasTokenResponse exchange(long authenticatedUserId) {
|
||||||
|
AuthAccount account = accounts.findById(authenticatedUserId);
|
||||||
|
if (account == null || !"ACTIVE".equals(account.getAccountStatus())) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||||
|
}
|
||||||
|
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||||
|
Instant issuedAt = Instant.now();
|
||||||
|
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||||
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
|
.issuer(properties.issuer()).audience(List.of(properties.audience()))
|
||||||
|
.subject(account.getId().toString()).id(UUID.randomUUID().toString())
|
||||||
|
.issuedAt(issuedAt).expiresAt(expiresAt)
|
||||||
|
.claim("userId", account.getId()).claim("tenantId", account.getDefaultTenantId())
|
||||||
|
.claim("appCode", account.getDefaultAppCode()).claim("roles", authorization.getRoles())
|
||||||
|
.claim("authorities", authorization.getPermissions())
|
||||||
|
.claim("permissionVersion", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion())
|
||||||
|
.build();
|
||||||
|
String token = encoder.encode(JwtEncoderParameters.from(
|
||||||
|
JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue();
|
||||||
|
return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn) { }
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.baiyue.auth.service;
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
|
||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.common.security.RedisTokenKeys;
|
import com.baiyue.common.security.RedisTokenKeys;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.dao.DataAccessException;
|
import org.springframework.dao.DataAccessException;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.data.redis.core.SetOperations;
|
import org.springframework.data.redis.core.SetOperations;
|
||||||
@@ -25,24 +29,27 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TokenSessionStore {
|
public class TokenSessionStore {
|
||||||
private final StringRedisTemplate redisTemplate;
|
@Autowired
|
||||||
private final ObjectMapper objectMapper;
|
private StringRedisTemplate redisTemplate;
|
||||||
private final String keyPrefix;
|
@Resource
|
||||||
|
private ObjectMapper objectMapper;
|
||||||
|
@Value("${baiyue.security.token-key-prefix:baiyue:auth}")
|
||||||
|
private String keyPrefix;
|
||||||
|
|
||||||
public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper,
|
@PostConstruct
|
||||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
void normalizeKeyPrefix() {
|
||||||
this.redisTemplate = redisTemplate;
|
keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||||
this.objectMapper = objectMapper;
|
|
||||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void store(String jwtId, Long userId, String rawRefreshToken,
|
public void store(String jwtId, Long userId, Set<String> roles, Set<String> permissions,
|
||||||
Duration accessTtl, Duration refreshTtl) {
|
String rawRefreshToken, Duration accessTtl, Duration refreshTtl) {
|
||||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||||
String userKey = userSessionsKey(userId);
|
String userKey = userSessionsKey(userId);
|
||||||
try {
|
try {
|
||||||
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl);
|
String accessValue = objectMapper.writeValueAsString(
|
||||||
|
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||||
|
redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl);
|
||||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
||||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||||
@@ -54,6 +61,41 @@ public class TokenSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void storeAccessOnly(String jwtId, Long userId, Set<String> roles,
|
||||||
|
Set<String> permissions, Duration ttl) {
|
||||||
|
try {
|
||||||
|
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||||
|
String accessValue = objectMapper.writeValueAsString(
|
||||||
|
new AccessSession(1, userId, normalized(roles), normalized(permissions)));
|
||||||
|
redisTemplate.opsForValue().set(accessKey, accessValue, ttl);
|
||||||
|
redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey);
|
||||||
|
redisTemplate.expire(userSessionsKey(userId), ttl);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw unavailable(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void storeImpersonationTicket(String rawCode, ImpersonationTicket ticket, Duration ttl) {
|
||||||
|
try {
|
||||||
|
redisTemplate.opsForValue().set(keyPrefix + ":impersonation:" + sha256(rawCode),
|
||||||
|
objectMapper.writeValueAsString(ticket), ttl);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw unavailable(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImpersonationTicket consumeImpersonationTicket(String rawCode) {
|
||||||
|
try {
|
||||||
|
String value = redisTemplate.opsForValue().getAndDelete(keyPrefix + ":impersonation:" + sha256(rawCode));
|
||||||
|
if (value == null || value.isBlank()) return null;
|
||||||
|
return objectMapper.readValue(value, ImpersonationTicket.class);
|
||||||
|
} catch (DataAccessException exception) {
|
||||||
|
throw unavailable(exception);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new BizException(ResultCode.UNAUTHORIZED, "代登录授权码无效或已过期");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public RefreshSession consume(String rawRefreshToken) {
|
public RefreshSession consume(String rawRefreshToken) {
|
||||||
try {
|
try {
|
||||||
String value = redisTemplate.opsForValue().getAndDelete(
|
String value = redisTemplate.opsForValue().getAndDelete(
|
||||||
@@ -97,7 +139,7 @@ public class TokenSessionStore {
|
|||||||
while (cursor.hasNext()) {
|
while (cursor.hasNext()) {
|
||||||
String key = cursor.next();
|
String key = cursor.next();
|
||||||
String value = redisTemplate.opsForValue().get(key);
|
String value = redisTemplate.opsForValue().get(key);
|
||||||
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) {
|
if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) {
|
||||||
result.add(key);
|
result.add(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -112,6 +154,16 @@ public class TokenSessionStore {
|
|||||||
catch (Exception ignored) { return false; }
|
catch (Exception ignored) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean accessBelongsTo(String value, Long userId) {
|
||||||
|
if (value == null || value.isBlank()) return false;
|
||||||
|
try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); }
|
||||||
|
catch (Exception ignored) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> normalized(Set<String> values) {
|
||||||
|
return values == null ? Set.of() : Set.copyOf(values);
|
||||||
|
}
|
||||||
|
|
||||||
private String userSessionsKey(Long userId) {
|
private String userSessionsKey(Long userId) {
|
||||||
return keyPrefix + ":user-sessions:" + userId;
|
return keyPrefix + ":user-sessions:" + userId;
|
||||||
}
|
}
|
||||||
@@ -131,4 +183,13 @@ public class TokenSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||||
|
public record AccessSession(int version, Long userId, Set<String> roles, Set<String> permissions) {
|
||||||
|
public AccessSession {
|
||||||
|
roles = normalized(roles);
|
||||||
|
permissions = normalized(permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public record ImpersonationTicket(String ticketId, Long targetAuthUserId, Long tenantId,
|
||||||
|
Long membershipId, Long actorUserId, Long actorMembershipId,
|
||||||
|
String reason) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,18 @@ baiyue.security:
|
|||||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
|
||||||
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth}
|
||||||
issuer: ${JWT_ISSUER:auth-service}
|
issuer: ${JWT_ISSUER:auth-service}
|
||||||
access-token-ttl: ${ACCESS_TOKEN_TTL:30m}
|
audience: ${JWT_AUDIENCE:lastleg-api}
|
||||||
|
access-token-ttl: ${ACCESS_TOKEN_TTL:24m}
|
||||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||||
|
overseas-jwt:
|
||||||
|
enabled: ${OVERSEAS_JWT_ENABLED:false}
|
||||||
|
issuer: ${OVERSEAS_JWT_ISSUER:auth-service}
|
||||||
|
audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api}
|
||||||
|
rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:}
|
||||||
|
current-kid: ${OVERSEAS_JWT_CURRENT_KID:}
|
||||||
|
access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}
|
||||||
|
|
||||||
baiyue.clients:
|
baiyue.clients:
|
||||||
iam:
|
iam:
|
||||||
@@ -52,6 +60,6 @@ logging:
|
|||||||
rollingpolicy:
|
rollingpolicy:
|
||||||
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
file-name-pattern: "${LOGGING_ROLLING_FILE_NAME_PATTERN:${LOG_PATH:./logs}/${spring.application.name}.%d{yyyy-MM-dd}.%i.log.gz}"
|
||||||
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
max-file-size: "${LOGGING_ROLLING_MAX_FILE_SIZE:100MB}"
|
||||||
max-history: "${LOGGING_ROLLING_MAX_HISTORY:30}"
|
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
|
||||||
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
|
||||||
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
USE baiyue_auth;
|
||||||
|
|
||||||
|
-- 可靠审计本地 Outbox;与认证业务表位于同一数据库。
|
||||||
|
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
|
||||||
|
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
|
||||||
|
event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型',
|
||||||
|
resource_type VARCHAR(64) NOT NULL COMMENT '资源类型',
|
||||||
|
resource_id VARCHAR(128) NULL COMMENT '资源ID',
|
||||||
|
service_name VARCHAR(64) NULL COMMENT '来源服务',
|
||||||
|
payload_json JSON NOT NULL COMMENT '脱敏后的审计事件',
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD进入死信',
|
||||||
|
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前周期发送次数',
|
||||||
|
compensation_count INT NOT NULL DEFAULT 0 COMMENT '自动补偿次数',
|
||||||
|
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下一次重试时间',
|
||||||
|
lease_owner VARCHAR(128) NULL COMMENT '当前租约持有者',
|
||||||
|
lease_until DATETIME(3) NULL COMMENT '发送租约截止时间',
|
||||||
|
last_error VARCHAR(1000) NULL COMMENT '最近一次发送错误信息',
|
||||||
|
dead_at DATETIME(3) NULL COMMENT '进入死信时间',
|
||||||
|
sent_at DATETIME(3) NULL COMMENT '成功发送时间',
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录更新时间',
|
||||||
|
PRIMARY KEY (event_id),
|
||||||
|
KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
|
||||||
|
KEY idx_audit_outbox_retention (status, sent_at)
|
||||||
|
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
|
||||||
@@ -8,14 +8,15 @@ CREATE TABLE IF NOT EXISTS auth_account (
|
|||||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||||
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
||||||
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
||||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED',
|
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
|
||||||
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||||
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
|
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
|
||||||
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
|
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
|
||||||
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
|
password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT '管理员重置后是否强制下次登录改密',
|
||||||
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
|
default_tenant_id BIGINT NOT NULL COMMENT '默认业务作用域ID:0为平台,正数为客户ID',
|
||||||
default_membership_id BIGINT DEFAULT NULL COMMENT '默认租户成员关系ID',
|
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体作用域ID:0为平台,正数为客户ID,用于平台端与客户端身份隔离',
|
||||||
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT或PLATFORM',
|
default_membership_id BIGINT DEFAULT NULL COMMENT '默认客户成员关系ID;平台账号可为空',
|
||||||
|
default_app_code VARCHAR(20) NOT NULL COMMENT '默认应用端:MERCHANT企业客户端、PLATFORM平台后台',
|
||||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||||
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
|
last_login_at DATETIME(3) DEFAULT NULL COMMENT '最近一次成功登录时间',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||||
@@ -29,9 +30,9 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
|
|||||||
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
|
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
|
||||||
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
|
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
|
||||||
user_id BIGINT NOT NULL COMMENT '认证账号ID',
|
user_id BIGINT NOT NULL COMMENT '认证账号ID',
|
||||||
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID',
|
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务作用域ID:0为平台,正数为客户ID',
|
||||||
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID',
|
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体作用域ID:0为平台,正数为客户ID',
|
||||||
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端',
|
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端:MERCHANT企业客户端、PLATFORM平台后台',
|
||||||
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
|
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
|
||||||
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
|
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
|
||||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
|
||||||
@@ -39,6 +40,6 @@ CREATE TABLE IF NOT EXISTS auth_refresh_token (
|
|||||||
UNIQUE KEY uk_auth_refresh_hash (token_hash),
|
UNIQUE KEY uk_auth_refresh_hash (token_hash),
|
||||||
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
|
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
|
||||||
KEY idx_auth_refresh_expire (expires_at, revoked_at)
|
KEY idx_auth_refresh_expire (expires_at, revoked_at)
|
||||||
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态';
|
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、平台或客户作用域、有效期和轮换/吊销状态';
|
||||||
|
|
||||||
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
|
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
|
||||||
|
|||||||
@@ -6,19 +6,20 @@ SET NAMES utf8mb4;
|
|||||||
|
|
||||||
INSERT INTO auth_account (
|
INSERT INTO auth_account (
|
||||||
id, username, password_hash, display_name, account_status,
|
id, username, password_hash, display_name, account_status,
|
||||||
failed_login_count, token_version, default_tenant_id,
|
failed_login_count, token_version, password_change_required, default_tenant_id,
|
||||||
operator_tenant_id, default_membership_id, default_app_code, deleted
|
operator_tenant_id, default_membership_id, default_app_code, deleted
|
||||||
) VALUES
|
) VALUES
|
||||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||||
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0),
|
'平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0),
|
||||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||||
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0)
|
'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
password_hash = VALUES(password_hash),
|
password_hash = VALUES(password_hash),
|
||||||
display_name = VALUES(display_name),
|
display_name = VALUES(display_name),
|
||||||
account_status = 'ACTIVE',
|
account_status = 'ACTIVE',
|
||||||
failed_login_count = 0,
|
failed_login_count = 0,
|
||||||
locked_until = NULL,
|
locked_until = NULL,
|
||||||
|
password_change_required = VALUES(password_change_required),
|
||||||
default_tenant_id = VALUES(default_tenant_id),
|
default_tenant_id = VALUES(default_tenant_id),
|
||||||
operator_tenant_id = VALUES(operator_tenant_id),
|
operator_tenant_id = VALUES(operator_tenant_id),
|
||||||
default_membership_id = VALUES(default_membership_id),
|
default_membership_id = VALUES(default_membership_id),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
USE baiyue_auth;
|
||||||
|
|
||||||
|
SET @add_password_change_required = IF(
|
||||||
|
EXISTS(SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE() AND table_name = 'auth_account'
|
||||||
|
AND column_name = 'password_change_required'),
|
||||||
|
'SELECT 1',
|
||||||
|
'ALTER TABLE auth_account ADD COLUMN password_change_required TINYINT NOT NULL DEFAULT 0 COMMENT ''管理员重置后是否强制下次登录改密'' AFTER token_version');
|
||||||
|
PREPARE phase1_customer_account_sql FROM @add_password_change_required;
|
||||||
|
EXECUTE phase1_customer_account_sql;
|
||||||
|
DEALLOCATE PREPARE phase1_customer_account_sql;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.baiyue.auth.client;
|
||||||
|
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
|
import com.baiyue.common.core.exception.BizException;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AuthorizationLoaderTest {
|
||||||
|
private static final String INTERNAL_TOKEN = "test-internal-service-token-at-least-32-bytes";
|
||||||
|
|
||||||
|
@Mock private IamAuthorizationClient client;
|
||||||
|
@InjectMocks private AuthorizationLoader loader;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(loader, "internalToken", INTERNAL_TOKEN);
|
||||||
|
loader.validateConfiguration();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsSnapshotWhenEchoedIdentityMatchesAccount() {
|
||||||
|
AuthAccount account = account();
|
||||||
|
AuthorizationSnapshot snapshot = snapshot(42L, 10001L, 9L, "MERCHANT");
|
||||||
|
when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot));
|
||||||
|
|
||||||
|
assertSame(snapshot, loader.load(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEveryMismatchedEchoedIdentityField() {
|
||||||
|
AuthAccount account = account();
|
||||||
|
|
||||||
|
assertIdentityMismatch(account, snapshot(43L, 10001L, 9L, "MERCHANT"));
|
||||||
|
assertIdentityMismatch(account, snapshot(42L, 10002L, 9L, "MERCHANT"));
|
||||||
|
assertIdentityMismatch(account, snapshot(42L, 10001L, 10L, "MERCHANT"));
|
||||||
|
assertIdentityMismatch(account, snapshot(42L, 10001L, 9L, "PLATFORM"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertIdentityMismatch(AuthAccount account, AuthorizationSnapshot snapshot) {
|
||||||
|
when(client.load(eq(INTERNAL_TOKEN), any())).thenReturn(ApiResponse.success(snapshot));
|
||||||
|
|
||||||
|
BizException exception = assertThrows(BizException.class, () -> loader.load(account));
|
||||||
|
|
||||||
|
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthAccount account() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(42L);
|
||||||
|
account.setDefaultTenantId(10001L);
|
||||||
|
account.setDefaultMembershipId(9L);
|
||||||
|
account.setDefaultAppCode("MERCHANT");
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthorizationSnapshot snapshot(Long userId, Long tenantId, Long membershipId, String appCode) {
|
||||||
|
return new AuthorizationSnapshot(userId, tenantId, membershipId, appCode, appCode,
|
||||||
|
Set.of("MERCHANT_ADMIN"), Set.of("merchant:profile:view"), 1L);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class AuthSecurityConfigTest {
|
||||||
|
@Test
|
||||||
|
void permitsAnonymousImpersonationCodeExchange() {
|
||||||
|
assertTrue(Arrays.asList(AuthSecurityConfig.PUBLIC_AUTH_ENDPOINTS)
|
||||||
|
.contains("/api/auth/impersonations/exchange"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class AuthTokenTtlConfigurationContractTest {
|
||||||
|
@Test
|
||||||
|
void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception {
|
||||||
|
String configuration = Files.readString(
|
||||||
|
Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
assertThat(configuration)
|
||||||
|
.contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}")
|
||||||
|
.contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}")
|
||||||
|
.contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}")
|
||||||
|
.doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.auth.config;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class DualTrackJwtConfigurationTest {
|
||||||
|
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class))
|
||||||
|
.withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties(
|
||||||
|
"domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api",
|
||||||
|
java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5,
|
||||||
|
java.time.Duration.ofMinutes(15)))
|
||||||
|
.withPropertyValues(
|
||||||
|
"baiyue.security.overseas-jwt.enabled=false");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startsDomesticEncoderWithoutRsaConfiguration() {
|
||||||
|
runner.run(context -> {
|
||||||
|
assertThat(context).hasNotFailed();
|
||||||
|
assertThat(context).hasBean("jwtEncoder");
|
||||||
|
assertThat(context).doesNotHaveBean("overseasJwtEncoder");
|
||||||
|
assertThat(context).hasSingleBean(JwtEncoder.class);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class InternalAccountControllerConfigurationTest {
|
||||||
|
@Test
|
||||||
|
void rejectsEmptyAndShortInternalServiceTokens() {
|
||||||
|
assertTokenRejected(null);
|
||||||
|
assertTokenRejected("");
|
||||||
|
assertTokenRejected("short-token");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertTokenRejected(String token) {
|
||||||
|
InternalAccountController controller = new InternalAccountController();
|
||||||
|
ReflectionTestUtils.setField(controller, "configuredToken", token);
|
||||||
|
assertThrows(IllegalStateException.class, controller::init);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.auth.controller;
|
||||||
|
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.jwk.JWKSet;
|
||||||
|
import com.nimbusds.jose.jwk.KeyUse;
|
||||||
|
import com.nimbusds.jose.jwk.RSAKey;
|
||||||
|
import com.nimbusds.jose.util.Base64URL;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class JwksControllerTest {
|
||||||
|
@Test
|
||||||
|
void publishesOnlyPublicKeyMaterialForCurrentAndPreviousKeys() throws Exception {
|
||||||
|
RSAKey current = key("current", true);
|
||||||
|
RSAKey previous = key("previous", false);
|
||||||
|
String json = new JWKSet(List.of(current, previous)).toString(false);
|
||||||
|
var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json,
|
||||||
|
"current", Duration.ofMinutes(15));
|
||||||
|
|
||||||
|
var body = new JwksController(properties).jwks().getBody();
|
||||||
|
String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body);
|
||||||
|
|
||||||
|
assertThat(published).contains("current", "previous", "\"n\"", "\"e\"")
|
||||||
|
.doesNotContain("\"d\"", "\"p\"", "\"q\"", "\"dp\"", "\"dq\"", "\"qi\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private RSAKey key(String kid, boolean privatePart) {
|
||||||
|
RSAKey.Builder builder = new RSAKey.Builder(
|
||||||
|
Base64URL.encode(BigInteger.valueOf(3233)), Base64URL.encode(BigInteger.valueOf(17)))
|
||||||
|
.keyID(kid).keyUse(KeyUse.SIGNATURE).algorithm(JWSAlgorithm.RS256);
|
||||||
|
if (privatePart) builder.privateExponent(Base64URL.encode(BigInteger.valueOf(2753)));
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.baiyue.auth.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class InitialAccountSeedSqlContractTest {
|
||||||
|
@Test
|
||||||
|
void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception {
|
||||||
|
assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql")
|
||||||
|
.getContentAsString(StandardCharsets.UTF_8));
|
||||||
|
assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertAccountSeed(String sql) {
|
||||||
|
assertThat(sql)
|
||||||
|
.contains("(1000001, 'platform_admin'")
|
||||||
|
.contains("'平台系统管理员', 'ACTIVE'")
|
||||||
|
.contains("(1000002, 'merchant_admin'")
|
||||||
|
.contains("'IT 测试客户管理员', 'ACTIVE'")
|
||||||
|
.contains("password_change_required")
|
||||||
|
.doesNotContain("一期演示企业管理员");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path unifiedSql() {
|
||||||
|
Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.baiyue.auth.service;
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||||
|
import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
|
||||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||||
@@ -11,25 +12,31 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@DisplayName("账号管理服务")
|
@DisplayName("账号管理服务")
|
||||||
|
@SpringJUnitConfig(classes = {AccountManagementService.class, AccountManagementServiceTest.TestConfig.class})
|
||||||
class AccountManagementServiceTest {
|
class AccountManagementServiceTest {
|
||||||
private final AuthAccountRepository repository = mock(AuthAccountRepository.class);
|
@MockBean private AuthAccountRepository repository;
|
||||||
private final TokenSessionStore sessions = mock(TokenSessionStore.class);
|
@MockBean private TokenSessionStore sessions;
|
||||||
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
|
@Resource private PasswordEncoder encoder;
|
||||||
private final AccountManagementService service = new AccountManagementService();
|
@Resource private AccountManagementService service;
|
||||||
private AuthAccount account;
|
private AuthAccount account;
|
||||||
|
|
||||||
@BeforeEach void setUp() {
|
@BeforeEach void setUp() {
|
||||||
ReflectionTestUtils.setField(service, "repository", repository);
|
|
||||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
|
||||||
ReflectionTestUtils.setField(service, "tokenSessionStore", sessions);
|
|
||||||
account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1"));
|
account = new AuthAccount(); account.setId(10L); account.setPasswordHash(encoder.encode("OldPass1"));
|
||||||
account.setTokenVersion(2); account.setAccountStatus("ACTIVE");
|
account.setTokenVersion(2); account.setAccountStatus("ACTIVE");
|
||||||
when(repository.findById(10L)).thenReturn(account);
|
when(repository.findById(10L)).thenReturn(account);
|
||||||
@@ -50,6 +57,25 @@ class AccountManagementServiceTest {
|
|||||||
verify(sessions, never()).revokeAll(any());
|
verify(sessions, never()).revokeAll(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void forcedPasswordChangeRequiresResetFlagAndClearsIt() {
|
||||||
|
account.setPasswordChangeRequired(true);
|
||||||
|
|
||||||
|
service.forcedChangePassword(10L, new ForcedPasswordChangeRequest("NewPass2"));
|
||||||
|
|
||||||
|
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
|
||||||
|
assertFalse(account.getPasswordChangeRequired());
|
||||||
|
verify(repository).save(account);
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void normalPasswordChangeIsRejectedUntilForcedChangeIsCompleted() {
|
||||||
|
account.setPasswordChangeRequired(true);
|
||||||
|
|
||||||
|
assertThrows(BizException.class,
|
||||||
|
() -> service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2")));
|
||||||
|
verify(repository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test void disableAccountRevokesAllSessions() {
|
@Test void disableAccountRevokesAllSessions() {
|
||||||
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
|
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
|
||||||
assertEquals("DISABLED", account.getAccountStatus());
|
assertEquals("DISABLED", account.getAccountStatus());
|
||||||
@@ -71,6 +97,13 @@ class AccountManagementServiceTest {
|
|||||||
assertNotEquals("Password1", captor.getValue().getPasswordHash());
|
assertNotEquals("Password1", captor.getValue().getPasswordHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() {
|
||||||
|
when(repository.existsByUsername("13800138000")).thenReturn(true);
|
||||||
|
|
||||||
|
assertFalse(service.usernameAvailable("13800138000"));
|
||||||
|
assertTrue(service.usernameAvailable("13900139000"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
|
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
|
||||||
when(repository.findById(20L)).thenReturn(null);
|
when(repository.findById(20L)).thenReturn(null);
|
||||||
when(repository.existsByUsername("merchant.user")).thenReturn(false);
|
when(repository.existsByUsername("merchant.user")).thenReturn(false);
|
||||||
@@ -123,4 +156,62 @@ class AccountManagementServiceTest {
|
|||||||
verify(repository).save(account);
|
verify(repository).save(account);
|
||||||
verify(sessions).revokeAll(10L);
|
verify(sessions).revokeAll(10L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
|
||||||
|
AuthAccount second = new AuthAccount();
|
||||||
|
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
|
||||||
|
when(repository.findById(11L)).thenReturn(second);
|
||||||
|
|
||||||
|
service.revokeSessions(Set.of(10L, 11L));
|
||||||
|
|
||||||
|
assertEquals(3, account.getTokenVersion());
|
||||||
|
assertEquals(8, second.getTokenVersion());
|
||||||
|
verify(repository).save(account);
|
||||||
|
verify(repository).save(second);
|
||||||
|
verify(sessions).revokeAll(10L);
|
||||||
|
verify(sessions).revokeAll(11L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void archiveTenantDisablesEveryMerchantAccountAndRevokesSessions() {
|
||||||
|
AuthAccount owner = merchantAccount(20L, 10001L, "ACTIVE", 3);
|
||||||
|
AuthAccount member = merchantAccount(21L, 10001L, "DISABLED", null);
|
||||||
|
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of(owner, member));
|
||||||
|
|
||||||
|
service.archiveTenant(10001L);
|
||||||
|
|
||||||
|
assertEquals("DISABLED", owner.getAccountStatus());
|
||||||
|
assertEquals(4, owner.getTokenVersion());
|
||||||
|
assertEquals("DISABLED", member.getAccountStatus());
|
||||||
|
assertEquals(1, member.getTokenVersion());
|
||||||
|
verify(repository).save(owner);
|
||||||
|
verify(repository).save(member);
|
||||||
|
verify(sessions).revokeAll(20L);
|
||||||
|
verify(sessions).revokeAll(21L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test void archiveTenantIsSuccessfulWhenCustomerHasNoAccounts() {
|
||||||
|
when(repository.findMerchantByTenant(10001L)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.archiveTenant(10001L);
|
||||||
|
|
||||||
|
verify(repository, never()).save(any());
|
||||||
|
verify(sessions, never()).revokeAll(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuthAccount merchantAccount(long id, long tenantId, String status, Integer tokenVersion) {
|
||||||
|
AuthAccount value = new AuthAccount();
|
||||||
|
value.setId(id);
|
||||||
|
value.setDefaultTenantId(tenantId);
|
||||||
|
value.setDefaultAppCode("MERCHANT");
|
||||||
|
value.setAccountStatus(status);
|
||||||
|
value.setTokenVersion(tokenVersion);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
@Configuration
|
||||||
|
static class TestConfig {
|
||||||
|
@Bean
|
||||||
|
PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.config.AuthSecurityProperties;
|
||||||
|
import com.baiyue.auth.dto.LoginRequest;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(classes = {AuthService.class, AuthServiceSmallJwtTest.TestConfig.class})
|
||||||
|
class AuthServiceSmallJwtTest {
|
||||||
|
@MockBean private AuthAccountRepository repository;
|
||||||
|
@MockBean private PasswordEncoder passwordEncoder;
|
||||||
|
@MockBean private JwtEncoder jwtEncoder;
|
||||||
|
@MockBean private AuthorizationLoader authorizationLoader;
|
||||||
|
@MockBean private TokenSessionStore tokenSessionStore;
|
||||||
|
@Resource private AuthService service;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class TestConfig {
|
||||||
|
@Bean
|
||||||
|
AuthSecurityProperties properties() {
|
||||||
|
return new AuthSecurityProperties("unused", "auth-service", "lastleg-api",
|
||||||
|
Duration.ofMinutes(30),
|
||||||
|
Duration.ofDays(7), 5, Duration.ofMinutes(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signsCrossRegionClaimsAndStoresSession() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(42L);
|
||||||
|
account.setUsername("operator");
|
||||||
|
account.setPasswordHash("hash");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultTenantId(0L);
|
||||||
|
account.setOperatorTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(84L);
|
||||||
|
account.setDefaultAppCode("PLATFORM");
|
||||||
|
account.setTokenVersion(1);
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
|
||||||
|
"WEB", Set.of("PLATFORM_ADMIN"), Set.of("platform:order:list"));
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
|
||||||
|
when(repository.findByUsername("operator")).thenReturn(account);
|
||||||
|
when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("small-token");
|
||||||
|
|
||||||
|
service.login(new LoginRequest("operator", "secret"));
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
|
assertThat(jwt.getValue().getClaims().getClaims())
|
||||||
|
.containsEntry("user_id", 42L)
|
||||||
|
.containsEntry("userId", 42L)
|
||||||
|
.containsEntry("tenantId", 0L)
|
||||||
|
.containsEntry("appCode", "PLATFORM")
|
||||||
|
.containsEntry("permissionVersion", 1)
|
||||||
|
.containsEntry("membership_id", 84L)
|
||||||
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
|
assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
|
assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256);
|
||||||
|
assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull();
|
||||||
|
verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("PLATFORM_ADMIN")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void impersonationTokenAlsoKeepsRolesAndPermissionsOutOfJwt() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(43L);
|
||||||
|
account.setUsername("merchant-owner");
|
||||||
|
account.setAccountStatus("ACTIVE");
|
||||||
|
account.setDefaultTenantId(10001L);
|
||||||
|
account.setOperatorTenantId(0L);
|
||||||
|
account.setDefaultMembershipId(85L);
|
||||||
|
account.setDefaultAppCode("MERCHANT");
|
||||||
|
account.setTokenVersion(2);
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(43L, 10001L, 85L, "MERCHANT",
|
||||||
|
"WEB", Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"));
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
TokenSessionStore.ImpersonationTicket ticket = new TokenSessionStore.ImpersonationTicket(
|
||||||
|
"ticket-1", 43L, 10001L, 85L, 42L, 84L, "support");
|
||||||
|
|
||||||
|
when(tokenSessionStore.consumeImpersonationTicket("one-time-code")).thenReturn(ticket);
|
||||||
|
when(repository.findById(43L)).thenReturn(account);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(jwtEncoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("small-impersonation-token");
|
||||||
|
|
||||||
|
service.exchangeImpersonationTicket("one-time-code");
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> jwt = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(jwtEncoder).encode(jwt.capture());
|
||||||
|
assertThat(jwt.getValue().getClaims().getClaims())
|
||||||
|
.containsEntry("appCode", "MERCHANT")
|
||||||
|
.containsEntry("impersonated", true)
|
||||||
|
.containsEntry("membership_id", 85L)
|
||||||
|
.doesNotContainKeys("roles", "authorities", "permissions", "scope");
|
||||||
|
verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")),
|
||||||
|
org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,31 +7,32 @@ import com.baiyue.auth.repository.AuthAccountRepository;
|
|||||||
import com.baiyue.common.core.api.ApiResponse;
|
import com.baiyue.common.core.api.ApiResponse;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
import org.springframework.transaction.support.TransactionSynchronization;
|
import org.springframework.transaction.support.TransactionSynchronization;
|
||||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(MerchantRegistrationService.class)
|
||||||
|
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||||
class MerchantRegistrationServiceTest {
|
class MerchantRegistrationServiceTest {
|
||||||
|
@MockBean private AuthAccountRepository accounts;
|
||||||
|
@MockBean private PasswordEncoder encoder;
|
||||||
|
@MockBean private IamOnboardingClient onboarding;
|
||||||
|
@Resource private MerchantRegistrationService service;
|
||||||
@Test
|
@Test
|
||||||
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
|
||||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
|
||||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
|
||||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
|
||||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
|
||||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
|
||||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
|
||||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
|
||||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||||
@@ -59,14 +60,6 @@ class MerchantRegistrationServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void failedAuthInsertCompensatesTheIamRegistration() {
|
void failedAuthInsertCompensatesTheIamRegistration() {
|
||||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
|
||||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
|
||||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
|
||||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
|
||||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
|
||||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
|
||||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
|
||||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||||
@@ -87,14 +80,6 @@ class MerchantRegistrationServiceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
|
||||||
AuthAccountRepository accounts = mock(AuthAccountRepository.class);
|
|
||||||
PasswordEncoder encoder = mock(PasswordEncoder.class);
|
|
||||||
IamOnboardingClient onboarding = mock(IamOnboardingClient.class);
|
|
||||||
MerchantRegistrationService service = new MerchantRegistrationService();
|
|
||||||
ReflectionTestUtils.setField(service, "accountRepository", accounts);
|
|
||||||
ReflectionTestUtils.setField(service, "passwordEncoder", encoder);
|
|
||||||
ReflectionTestUtils.setField(service, "onboardingClient", onboarding);
|
|
||||||
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
|
|
||||||
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
when(encoder.encode("Password123")).thenReturn("encoded-password");
|
||||||
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
|
||||||
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.baiyue.auth.service;
|
||||||
|
|
||||||
|
import com.baiyue.auth.client.AuthorizationLoader;
|
||||||
|
import com.baiyue.auth.client.AuthorizationSnapshot;
|
||||||
|
import com.baiyue.auth.config.OverseasJwtProperties;
|
||||||
|
import com.baiyue.auth.entity.AuthAccount;
|
||||||
|
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class})
|
||||||
|
@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true")
|
||||||
|
class OverseasTokenServiceTest {
|
||||||
|
@MockBean private AuthAccountRepository accounts;
|
||||||
|
@MockBean private AuthorizationLoader authorizationLoader;
|
||||||
|
@MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder;
|
||||||
|
@Resource private OverseasTokenService service;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class Config {
|
||||||
|
@Bean OverseasJwtProperties properties() {
|
||||||
|
return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid",
|
||||||
|
Duration.ofMinutes(15));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signsShortRs256TokenWithCrossRegionClaims() {
|
||||||
|
AuthAccount account = new AuthAccount();
|
||||||
|
account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(100L);
|
||||||
|
account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT");
|
||||||
|
AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 100L, 84L, "MERCHANT", "WEB",
|
||||||
|
Set.of("MERCHANT_ADMIN"), Set.of("merchant:order:list"), 7L);
|
||||||
|
Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
|
||||||
|
when(accounts.findById(42L)).thenReturn(account);
|
||||||
|
when(authorizationLoader.load(account)).thenReturn(snapshot);
|
||||||
|
when(encoder.encode(any())).thenReturn(encoded);
|
||||||
|
when(encoded.getTokenValue()).thenReturn("overseas-token");
|
||||||
|
|
||||||
|
var response = service.exchange(42L);
|
||||||
|
|
||||||
|
ArgumentCaptor<JwtEncoderParameters> captor = ArgumentCaptor.forClass(JwtEncoderParameters.class);
|
||||||
|
verify(encoder).encode(captor.capture());
|
||||||
|
var parameters = captor.getValue();
|
||||||
|
assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
|
||||||
|
assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid");
|
||||||
|
assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api");
|
||||||
|
assertThat(parameters.getClaims().getClaims())
|
||||||
|
.containsEntry("userId", 42L).containsEntry("tenantId", 100L)
|
||||||
|
.containsEntry("appCode", "MERCHANT").containsEntry("permissionVersion", 7L)
|
||||||
|
.containsEntry("roles", Set.of("MERCHANT_ADMIN"))
|
||||||
|
.containsEntry("authorities", Set.of("merchant:order:list"))
|
||||||
|
.doesNotContainKeys("user_id", "tenant_id", "app_code");
|
||||||
|
assertThat(response.expiresIn()).isEqualTo(900);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,15 @@ package com.baiyue.auth.service;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
import org.springframework.data.redis.core.ValueOperations;
|
import org.springframework.data.redis.core.ValueOperations;
|
||||||
import org.springframework.data.redis.core.SetOperations;
|
import org.springframework.data.redis.core.SetOperations;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -18,32 +24,48 @@ import static org.mockito.Mockito.when;
|
|||||||
import static org.mockito.Mockito.eq;
|
import static org.mockito.Mockito.eq;
|
||||||
|
|
||||||
@DisplayName("令牌会话存储")
|
@DisplayName("令牌会话存储")
|
||||||
|
@SpringJUnitConfig(classes = {TokenSessionStore.class, TokenSessionStoreTest.TestConfig.class})
|
||||||
|
@TestPropertySource(properties = "baiyue.security.token-key-prefix=baiyue:auth")
|
||||||
class TokenSessionStoreTest {
|
class TokenSessionStoreTest {
|
||||||
|
@MockBean
|
||||||
|
private StringRedisTemplate redis;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private TokenSessionStore store;
|
||||||
|
|
||||||
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
static class TestConfig {
|
||||||
|
@Bean
|
||||||
|
ObjectMapper objectMapper() {
|
||||||
|
return new ObjectMapper();
|
||||||
|
}
|
||||||
|
}
|
||||||
@Test
|
@Test
|
||||||
void storesAccessAndHashedRefreshWithIndependentTtls() {
|
void storesAccessAndHashedRefreshWithIndependentTtls() {
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||||
when(redis.opsForValue()).thenReturn(values);
|
when(redis.opsForValue()).thenReturn(values);
|
||||||
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth:");
|
|
||||||
|
|
||||||
store.store("jti-1", 42L, "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"),
|
||||||
|
"raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7));
|
||||||
|
|
||||||
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30));
|
verify(values).set(eq("baiyue:auth:access:jti-1"),
|
||||||
|
org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1")
|
||||||
|
&& value.contains("\"userId\":42")
|
||||||
|
&& value.contains("ADMIN") && value.contains("order:read")),
|
||||||
|
eq(Duration.ofMinutes(30)));
|
||||||
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
|
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
|
||||||
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception {
|
void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception {
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||||
when(redis.opsForValue()).thenReturn(values);
|
when(redis.opsForValue()).thenReturn(values);
|
||||||
String json = new ObjectMapper().writeValueAsString(
|
String json = new ObjectMapper().writeValueAsString(
|
||||||
new TokenSessionStore.RefreshSession(42L, "jti-1"));
|
new TokenSessionStore.RefreshSession(42L, "jti-1"));
|
||||||
when(values.getAndDelete(anyString())).thenReturn(json);
|
when(values.getAndDelete(anyString())).thenReturn(json);
|
||||||
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth");
|
|
||||||
|
|
||||||
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
|
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
|
||||||
|
|
||||||
@@ -55,26 +77,23 @@ class TokenSessionStoreTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void treatsAlreadyConsumedRefreshAsInvalid() {
|
void treatsAlreadyConsumedRefreshAsInvalid() {
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||||
when(redis.opsForValue()).thenReturn(values);
|
when(redis.opsForValue()).thenReturn(values);
|
||||||
when(values.getAndDelete(anyString())).thenReturn(null);
|
when(values.getAndDelete(anyString())).thenReturn(null);
|
||||||
|
|
||||||
assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth")
|
assertThat(store.consume("raw-refresh")).isNull();
|
||||||
.consume("raw-refresh")).isNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revokesAllIndexedSessionsForUser() {
|
void revokesAllIndexedSessionsForUser() {
|
||||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
SetOperations<String, String> sets = mock(SetOperations.class);
|
SetOperations<String, String> sets = mock(SetOperations.class);
|
||||||
when(redis.opsForSet()).thenReturn(sets);
|
when(redis.opsForSet()).thenReturn(sets);
|
||||||
when(sets.members("baiyue:auth:user-sessions:42"))
|
when(sets.members("baiyue:auth:user-sessions:42"))
|
||||||
.thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"));
|
.thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"));
|
||||||
|
|
||||||
new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L);
|
store.revokeAll(42L);
|
||||||
|
|
||||||
verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")));
|
verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")));
|
||||||
verify(redis).delete("baiyue:auth:user-sessions:42");
|
verify(redis).delete("baiyue:auth:user-sessions:42");
|
||||||
|
|||||||
@@ -11,5 +11,16 @@
|
|||||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
||||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||||
|
<dependency><groupId>org.springframework</groupId><artifactId>spring-jdbc</artifactId></dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.rocketmq</groupId>
|
||||||
|
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import java.time.Instant;
|
|||||||
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
|
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
|
||||||
*/
|
*/
|
||||||
public record AuditEvent(
|
public record AuditEvent(
|
||||||
String eventType, String resourceType, String resourceId, String description,
|
String eventId, String eventType, String resourceType, String resourceId, String description,
|
||||||
String requestId, String traceId, Long tenantId, Long operatorTenantId,
|
String requestId, String traceId, Long tenantId, Long operatorTenantId,
|
||||||
Long userId, String username, String appCode, String clientType,
|
Long userId, String username, String appCode, String clientType,
|
||||||
String httpMethod, String requestUri, String clientIp, long durationMs,
|
String httpMethod, String requestUri, String clientIp, long durationMs,
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.apache.rocketmq.spring.support.RocketMQHeaders;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.messaging.support.MessageBuilder;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
|
||||||
|
/** 带租约、退避、死信和自动补偿的审计 Outbox 发布器。 */
|
||||||
|
public class AuditOutboxPublisher {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuditOutboxPublisher.class);
|
||||||
|
private static final int[] BACKOFF_SECONDS = {5, 15, 60, 300, 900};
|
||||||
|
private final AuditOutboxRepository repository;
|
||||||
|
private final RocketMQTemplate rocketMQ;
|
||||||
|
private final AuditProperties properties;
|
||||||
|
private final String owner;
|
||||||
|
|
||||||
|
public AuditOutboxPublisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
|
||||||
|
AuditProperties properties, String owner) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.rocketMQ = rocketMQ;
|
||||||
|
this.properties = properties;
|
||||||
|
this.owner = owner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${baiyue.audit.outbox.poll-interval-ms:5000}")
|
||||||
|
public void publish() {
|
||||||
|
AuditProperties.Outbox config = properties.getOutbox();
|
||||||
|
repository.releaseExpiredClaims();
|
||||||
|
repository.compensateDead(config.getCompensationDelayMinutes(), config.getMaxCompensations());
|
||||||
|
repository.deleteSent(config.getRetentionDays());
|
||||||
|
for (String eventId : repository.publishableIds(config.getBatchSize())) {
|
||||||
|
if (!repository.claim(eventId, owner, config.getLeaseSeconds())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
AuditOutboxRepository.OutboxRow row = repository.find(eventId);
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var message = MessageBuilder.withPayload(row.payloadJson())
|
||||||
|
.setHeader(RocketMQHeaders.KEYS, eventId)
|
||||||
|
.setHeader("eventId", eventId)
|
||||||
|
.build();
|
||||||
|
rocketMQ.syncSend(properties.getRocketmq().getTopic() + ":AUDIT", message,
|
||||||
|
properties.getRocketmq().getSendTimeoutMs());
|
||||||
|
repository.sent(eventId, owner);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
int index = Math.min(Math.max(row.attemptCount(), 1) - 1, BACKOFF_SECONDS.length - 1);
|
||||||
|
repository.failed(eventId, owner, config.getMaxAttempts(), BACKOFF_SECONDS[index],
|
||||||
|
safe(exception.getMessage()));
|
||||||
|
log.warn("审计事件投递失败 eventId={} attempt={}", eventId, row.attemptCount(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String safe(String value) {
|
||||||
|
String message = value == null ? "RocketMQ 发送失败" : value;
|
||||||
|
return message.length() <= 1000 ? message : message.substring(0, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 业务库本地审计 Outbox 仓储。 */
|
||||||
|
public class AuditOutboxRepository {
|
||||||
|
@Resource
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public void append(AuditEvent event, String payload) {
|
||||||
|
jdbc.update("""
|
||||||
|
INSERT INTO sys_audit_outbox
|
||||||
|
(event_id,event_type,resource_type,resource_id,service_name,payload_json,status,
|
||||||
|
attempt_count,compensation_count,next_retry_at,created_at,updated_at)
|
||||||
|
VALUES (?,?,?,?,?,CAST(? AS JSON),'PENDING',0,0,CURRENT_TIMESTAMP(3),
|
||||||
|
CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3))
|
||||||
|
""", event.eventId(), event.eventType(), event.resourceType(), event.resourceId(),
|
||||||
|
event.serviceName(), payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> publishableIds(int limit) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
SELECT event_id FROM sys_audit_outbox
|
||||||
|
WHERE status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||||
|
ORDER BY next_retry_at,created_at LIMIT ?
|
||||||
|
""", String.class, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean claim(String eventId, String owner, int leaseSeconds) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_outbox SET status='SENDING',lease_owner=?,
|
||||||
|
lease_until=DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND),
|
||||||
|
attempt_count=attempt_count+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE event_id=? AND status='PENDING' AND next_retry_at<=CURRENT_TIMESTAMP(3)
|
||||||
|
""", owner, leaseSeconds, eventId) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutboxRow find(String eventId) {
|
||||||
|
List<OutboxRow> rows = jdbc.query(
|
||||||
|
"SELECT event_id,payload_json,attempt_count FROM sys_audit_outbox WHERE event_id=?",
|
||||||
|
(rs, rowNum) -> new OutboxRow(rs.getString(1), rs.getString(2), rs.getInt(3)), eventId);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean sent(String eventId, String owner) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_outbox SET status='SENT',sent_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL,lease_until=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE event_id=? AND status='SENDING' AND lease_owner=?
|
||||||
|
""", eventId, owner) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean failed(String eventId, String owner, int maxAttempts, int backoffSeconds, String error) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_outbox
|
||||||
|
SET status=IF(attempt_count>=?,'DEAD','PENDING'),
|
||||||
|
next_retry_at=IF(attempt_count>=?,next_retry_at,
|
||||||
|
DATE_ADD(CURRENT_TIMESTAMP(3),INTERVAL ? SECOND)),
|
||||||
|
dead_at=IF(attempt_count>=?,CURRENT_TIMESTAMP(3),dead_at),
|
||||||
|
lease_owner=NULL,lease_until=NULL,last_error=?,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE event_id=? AND status='SENDING' AND lease_owner=?
|
||||||
|
""", maxAttempts, maxAttempts, backoffSeconds, maxAttempts, error, eventId, owner) == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int releaseExpiredClaims() {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_outbox SET status='PENDING',next_retry_at=CURRENT_TIMESTAMP(3),
|
||||||
|
lease_owner=NULL,lease_until=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE status='SENDING' AND lease_until<=CURRENT_TIMESTAMP(3) LIMIT 100
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int compensateDead(int delayMinutes, int maxCompensations) {
|
||||||
|
return jdbc.update("""
|
||||||
|
UPDATE sys_audit_outbox SET status='PENDING',attempt_count=0,
|
||||||
|
compensation_count=compensation_count+1,next_retry_at=CURRENT_TIMESTAMP(3),
|
||||||
|
dead_at=NULL,last_error=NULL,updated_at=CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE status='DEAD' AND compensation_count<?
|
||||||
|
AND dead_at<=DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? MINUTE) LIMIT 50
|
||||||
|
""", maxCompensations, delayMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int deleteSent(int retentionDays) {
|
||||||
|
return jdbc.update("""
|
||||||
|
DELETE FROM sys_audit_outbox WHERE status='SENT'
|
||||||
|
AND sent_at<DATE_SUB(CURRENT_TIMESTAMP(3),INTERVAL ? DAY) LIMIT 500
|
||||||
|
""", retentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record OutboxRow(String eventId, String payloadJson, int attemptCount) { }
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计 Outbox 的 RocketMQ 发布配置。
|
||||||
|
* 该配置与基础审计配置隔离:未引入 RocketMQ 的服务仍可使用 HTTP 审计投递。
|
||||||
|
*/
|
||||||
|
@AutoConfiguration(after = BaiyueAuditAutoConfiguration.class)
|
||||||
|
@EnableScheduling
|
||||||
|
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
|
||||||
|
@ConditionalOnBean(type = {
|
||||||
|
"com.baiyue.common.audit.AuditOutboxRepository",
|
||||||
|
"org.apache.rocketmq.spring.core.RocketMQTemplate"
|
||||||
|
})
|
||||||
|
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "publisher-enabled", havingValue = "true")
|
||||||
|
public class AuditOutboxRocketMqAutoConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
AuditOutboxPublisher auditOutboxPublisher(AuditOutboxRepository repository,
|
||||||
|
RocketMQTemplate rocketMQ,
|
||||||
|
AuditProperties properties,
|
||||||
|
@Value("${spring.application.name:unknown}-${random.uuid}") String owner) {
|
||||||
|
return new AuditOutboxPublisher(repository, rocketMQ, properties, owner);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ public class AuditProperties {
|
|||||||
private String endpoint;
|
private String endpoint;
|
||||||
private int maxPayloadLength = 16000;
|
private int maxPayloadLength = 16000;
|
||||||
private String serviceName = "unknown-service";
|
private String serviceName = "unknown-service";
|
||||||
|
private final Outbox outbox = new Outbox();
|
||||||
|
private final Rocketmq rocketmq = new Rocketmq();
|
||||||
|
|
||||||
public boolean isEnabled() { return enabled; }
|
public boolean isEnabled() { return enabled; }
|
||||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
@@ -20,4 +22,44 @@ public class AuditProperties {
|
|||||||
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
|
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
|
||||||
public String getServiceName() { return serviceName; }
|
public String getServiceName() { return serviceName; }
|
||||||
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
|
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
|
||||||
|
public Outbox getOutbox() { return outbox; }
|
||||||
|
public Rocketmq getRocketmq() { return rocketmq; }
|
||||||
|
|
||||||
|
public static class Outbox {
|
||||||
|
private boolean enabled;
|
||||||
|
private boolean publisherEnabled;
|
||||||
|
private int batchSize = 50;
|
||||||
|
private int leaseSeconds = 60;
|
||||||
|
private int maxAttempts = 5;
|
||||||
|
private int maxCompensations = 2;
|
||||||
|
private int compensationDelayMinutes = 30;
|
||||||
|
private int retentionDays = 30;
|
||||||
|
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||||
|
public boolean isPublisherEnabled() { return publisherEnabled; }
|
||||||
|
public void setPublisherEnabled(boolean publisherEnabled) { this.publisherEnabled = publisherEnabled; }
|
||||||
|
public int getBatchSize() { return batchSize; }
|
||||||
|
public void setBatchSize(int batchSize) { this.batchSize = batchSize; }
|
||||||
|
public int getLeaseSeconds() { return leaseSeconds; }
|
||||||
|
public void setLeaseSeconds(int leaseSeconds) { this.leaseSeconds = leaseSeconds; }
|
||||||
|
public int getMaxAttempts() { return maxAttempts; }
|
||||||
|
public void setMaxAttempts(int maxAttempts) { this.maxAttempts = maxAttempts; }
|
||||||
|
public int getMaxCompensations() { return maxCompensations; }
|
||||||
|
public void setMaxCompensations(int maxCompensations) { this.maxCompensations = maxCompensations; }
|
||||||
|
public int getCompensationDelayMinutes() { return compensationDelayMinutes; }
|
||||||
|
public void setCompensationDelayMinutes(int compensationDelayMinutes) { this.compensationDelayMinutes = compensationDelayMinutes; }
|
||||||
|
public int getRetentionDays() { return retentionDays; }
|
||||||
|
public void setRetentionDays(int retentionDays) { this.retentionDays = retentionDays; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Rocketmq {
|
||||||
|
private String topic = "test-baiyue-audit-event-v1";
|
||||||
|
private int sendTimeoutMs = 3000;
|
||||||
|
|
||||||
|
public String getTopic() { return topic; }
|
||||||
|
public void setTopic(String topic) { this.topic = topic; }
|
||||||
|
public int getSendTimeoutMs() { return sendTimeoutMs; }
|
||||||
|
public void setSendTimeoutMs(int sendTimeoutMs) { this.sendTimeoutMs = sendTimeoutMs; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-3
@@ -3,10 +3,17 @@ package com.baiyue.common.audit;
|
|||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
|
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
|
||||||
@@ -15,9 +22,29 @@ import org.springframework.web.client.RestClient;
|
|||||||
@EnableConfigurationProperties(AuditProperties.class)
|
@EnableConfigurationProperties(AuditProperties.class)
|
||||||
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public class BaiyueAuditAutoConfiguration {
|
public class BaiyueAuditAutoConfiguration {
|
||||||
@Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) {
|
@Bean
|
||||||
return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint());
|
@ConditionalOnMissingBean
|
||||||
|
AuditSink auditSink(RestClient.Builder builder, AuditProperties p, ObjectMapper mapper,
|
||||||
|
ObjectProvider<AuditOutboxRepository> outbox,
|
||||||
|
ObjectProvider<PlatformTransactionManager> transactionManagers,
|
||||||
|
@Value("${baiyue.security.internal-service-token:}") String internalServiceToken) {
|
||||||
|
AuditOutboxRepository repository = outbox.getIfAvailable();
|
||||||
|
PlatformTransactionManager transactionManager = transactionManagers.getIfAvailable();
|
||||||
|
if (p.getOutbox().isEnabled() && p.getOutbox().isPublisherEnabled()
|
||||||
|
&& repository != null && transactionManager != null) {
|
||||||
|
TransactionTemplate transactions = new TransactionTemplate(transactionManager);
|
||||||
|
transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||||
|
return new OutboxAuditSink(repository, mapper, transactions);
|
||||||
|
}
|
||||||
|
return p.getEndpoint() == null || p.getEndpoint().isBlank()
|
||||||
|
? new LoggingAuditSink()
|
||||||
|
: new HttpAuditSink(builder, p.getEndpoint(), internalServiceToken);
|
||||||
}
|
}
|
||||||
|
@Bean
|
||||||
|
@ConditionalOnBean(JdbcTemplate.class)
|
||||||
|
@ConditionalOnProperty(prefix = "baiyue.audit.outbox", name = "enabled", havingValue = "true")
|
||||||
|
AuditOutboxRepository auditOutboxRepository() { return new AuditOutboxRepository(); }
|
||||||
|
|
||||||
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
|
@Bean SensitiveDataSanitizer sensitiveDataSanitizer(ObjectMapper mapper, AuditProperties p) { return new SensitiveDataSanitizer(mapper, p.getMaxPayloadLength()); }
|
||||||
@Bean BusinessAuditAspect businessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, AuditProperties p) { return new BusinessAuditAspect(sink, sanitizer, p.getServiceName()); }
|
@Bean BusinessAuditAspect businessAuditAspect() { return new BusinessAuditAspect(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public @interface BusinessAudit {
|
|||||||
String event();
|
String event();
|
||||||
String resourceType();
|
String resourceType();
|
||||||
String resourceId() default ""; // SpEL, e.g. #request.orderId
|
String resourceId() default ""; // SpEL, e.g. #request.orderId
|
||||||
|
/** 业务数据所属客户 ID 的可选 SpEL;平台代客操作未配置时自动识别名为 tenantId 的参数。 */
|
||||||
|
String tenantId() default "";
|
||||||
String description() default "";
|
String description() default "";
|
||||||
boolean captureBefore() default true;
|
boolean captureBefore() default true;
|
||||||
boolean captureAfter() default true;
|
boolean captureAfter() default true;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.baiyue.common.audit;
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.common.web.CorrelationContext;
|
import com.baiyue.common.web.CorrelationContext;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
import org.aspectj.lang.annotation.Around;
|
import org.aspectj.lang.annotation.Around;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
@@ -19,7 +22,7 @@ import org.springframework.web.context.request.RequestContextHolder;
|
|||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Arrays;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
|
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
|
||||||
@@ -27,19 +30,18 @@ import java.util.Arrays;
|
|||||||
@Aspect
|
@Aspect
|
||||||
public class BusinessAuditAspect {
|
public class BusinessAuditAspect {
|
||||||
private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class);
|
private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class);
|
||||||
private final AuditSink sink;
|
@Resource
|
||||||
private final SensitiveDataSanitizer sanitizer;
|
private AuditSink sink;
|
||||||
private final String serviceName;
|
@Resource
|
||||||
|
private SensitiveDataSanitizer sanitizer;
|
||||||
|
@Value("${baiyue.audit.service-name:${spring.application.name:unknown-service}}")
|
||||||
|
private String serviceName;
|
||||||
private final ExpressionParser parser = new SpelExpressionParser();
|
private final ExpressionParser parser = new SpelExpressionParser();
|
||||||
|
|
||||||
public BusinessAuditAspect(AuditSink sink, SensitiveDataSanitizer sanitizer, String serviceName) {
|
|
||||||
this.sink = sink; this.sanitizer = sanitizer; this.serviceName = serviceName;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Around("@annotation(audit)")
|
@Around("@annotation(audit)")
|
||||||
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
|
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
|
||||||
long start = System.nanoTime();
|
long start = System.nanoTime();
|
||||||
String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null;
|
String before = audit.captureBefore() ? sanitizer.sanitize(businessArguments(pjp.getArgs())) : null;
|
||||||
Object result = null; Throwable failure = null;
|
Object result = null; Throwable failure = null;
|
||||||
try { result = pjp.proceed(); return result; }
|
try { result = pjp.proceed(); return result; }
|
||||||
catch (Throwable ex) { failure = ex; throw ex; }
|
catch (Throwable ex) { failure = ex; throw ex; }
|
||||||
@@ -54,9 +56,10 @@ public class BusinessAuditAspect {
|
|||||||
HttpServletRequest req = request();
|
HttpServletRequest req = request();
|
||||||
AuthenticatedUser user = currentUser();
|
AuthenticatedUser user = currentUser();
|
||||||
String resourceId = evaluateResourceId(pjp, a.resourceId());
|
String resourceId = evaluateResourceId(pjp, a.resourceId());
|
||||||
sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(),
|
Long tenantId = resolveTenantId(pjp, a, user, req);
|
||||||
|
sink.publish(new AuditEvent(UUID.randomUUID().toString(), a.event(), a.resourceType(), resourceId, a.description(),
|
||||||
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
|
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
|
||||||
user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(),
|
tenantId,
|
||||||
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
|
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
|
||||||
user == null ? null : user.userId(), user == null ? null : user.username(),
|
user == null ? null : user.userId(), user == null ? null : user.username(),
|
||||||
user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(),
|
user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(),
|
||||||
@@ -76,11 +79,58 @@ public class BusinessAuditAspect {
|
|||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
|
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
|
||||||
}
|
}
|
||||||
|
private Object[] businessArguments(Object[] arguments) {
|
||||||
|
if (arguments == null || arguments.length == 0) return new Object[0];
|
||||||
|
return java.util.Arrays.stream(arguments)
|
||||||
|
.filter(argument -> !(argument instanceof AuthenticatedUser))
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
|
private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
|
||||||
if (expression == null || expression.isBlank()) return null;
|
if (expression == null || expression.isBlank()) return null;
|
||||||
|
Object value = evaluate(p, expression);
|
||||||
|
return value == null ? null : value.toString();
|
||||||
|
}
|
||||||
|
private Long resolveTenantId(ProceedingJoinPoint p, BusinessAudit audit, AuthenticatedUser user,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
if (user == null) return headerLong(request, "X-Tenant-Id");
|
||||||
|
if (!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0L).equals(user.tenantId())) {
|
||||||
|
return user.tenantId();
|
||||||
|
}
|
||||||
|
Long explicit = evaluatePositiveLong(p, audit.tenantId());
|
||||||
|
if (explicit != null) return explicit;
|
||||||
|
MethodSignature signature = (MethodSignature) p.getSignature();
|
||||||
|
String[] parameterNames = signature.getParameterNames();
|
||||||
|
if (parameterNames == null) {
|
||||||
|
parameterNames = new DefaultParameterNameDiscoverer().getParameterNames(signature.getMethod());
|
||||||
|
}
|
||||||
|
Object[] arguments = p.getArgs();
|
||||||
|
if (parameterNames != null) {
|
||||||
|
for (int index = 0; index < parameterNames.length && index < arguments.length; index++) {
|
||||||
|
if ("tenantId".equals(parameterNames[index])) {
|
||||||
|
Long candidate = positiveLong(arguments[index]);
|
||||||
|
if (candidate != null) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user.tenantId();
|
||||||
|
}
|
||||||
|
private Long evaluatePositiveLong(ProceedingJoinPoint p, String expression) {
|
||||||
|
if (expression == null || expression.isBlank()) return null;
|
||||||
|
return positiveLong(evaluate(p, expression));
|
||||||
|
}
|
||||||
|
private Object evaluate(ProceedingJoinPoint p, String expression) {
|
||||||
MethodSignature s = (MethodSignature) p.getSignature();
|
MethodSignature s = (MethodSignature) p.getSignature();
|
||||||
var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer());
|
var ctx = new MethodBasedEvaluationContext(p.getTarget(), s.getMethod(), p.getArgs(), new DefaultParameterNameDiscoverer());
|
||||||
Object v = parser.parseExpression(expression).getValue(ctx); return v == null ? null : v.toString();
|
return parser.parseExpression(expression).getValue(ctx);
|
||||||
|
}
|
||||||
|
private static Long positiveLong(Object value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
long candidate = value instanceof Number number ? number.longValue() : Long.parseLong(value.toString());
|
||||||
|
return candidate > 0 ? candidate : null;
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); }
|
private static String header(HttpServletRequest r, String n) { return r == null ? null : r.getHeader(n); }
|
||||||
private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } }
|
private static Long headerLong(HttpServletRequest r, String n) { try { return header(r, n) == null ? null : Long.valueOf(header(r, n)); } catch (NumberFormatException e) { return null; } }
|
||||||
|
|||||||
@@ -8,17 +8,28 @@ import org.springframework.web.client.RestClient;
|
|||||||
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
|
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
|
||||||
*/
|
*/
|
||||||
public class HttpAuditSink implements AuditSink {
|
public class HttpAuditSink implements AuditSink {
|
||||||
|
public static final String INTERNAL_SERVICE_TOKEN_HEADER = "X-Internal-Service-Token";
|
||||||
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||||
private final RestClient client;
|
private final RestClient client;
|
||||||
private final String endpoint;
|
private final String endpoint;
|
||||||
|
private final String internalServiceToken;
|
||||||
|
|
||||||
public HttpAuditSink(RestClient.Builder builder, String endpoint) {
|
public HttpAuditSink(RestClient.Builder builder, String endpoint, String internalServiceToken) {
|
||||||
this.client = builder.build();
|
this.client = builder.build();
|
||||||
this.endpoint = endpoint;
|
this.endpoint = endpoint;
|
||||||
|
this.internalServiceToken = internalServiceToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public void publish(AuditEvent event) {
|
@Override
|
||||||
try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); }
|
public void publish(AuditEvent event) {
|
||||||
|
try {
|
||||||
|
client.post()
|
||||||
|
.uri(endpoint)
|
||||||
|
.header(INTERNAL_SERVICE_TOKEN_HEADER, internalServiceToken)
|
||||||
|
.body(event)
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
catch (RuntimeException ex) {
|
catch (RuntimeException ex) {
|
||||||
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
|
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
|
||||||
ex.getClass().getSimpleName(), ex.getMessage(), event);
|
ex.getClass().getSimpleName(), ex.getMessage(), event);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.transaction.support.TransactionOperations;
|
||||||
|
|
||||||
|
/** 将审计事件先持久化到业务库 Outbox,核心业务不等待 MQ。 */
|
||||||
|
public class OutboxAuditSink implements AuditSink {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OutboxAuditSink.class);
|
||||||
|
private final AuditOutboxRepository repository;
|
||||||
|
private final ObjectMapper json;
|
||||||
|
private final TransactionOperations transactions;
|
||||||
|
|
||||||
|
public OutboxAuditSink(AuditOutboxRepository repository, ObjectMapper json,
|
||||||
|
TransactionOperations transactions) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.json = json;
|
||||||
|
this.transactions = transactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void publish(AuditEvent event) {
|
||||||
|
try {
|
||||||
|
String payload = json.writeValueAsString(event);
|
||||||
|
transactions.executeWithoutResult(status -> repository.append(event, payload));
|
||||||
|
} catch (JsonProcessingException | RuntimeException exception) {
|
||||||
|
log.error("审计事件写入本地 Outbox 失败 eventId={} eventType={}",
|
||||||
|
event.eventId(), event.eventType(), exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,11 @@ public class SensitiveDataSanitizer {
|
|||||||
JsonNode root = mapper.valueToTree(value);
|
JsonNode root = mapper.valueToTree(value);
|
||||||
redact(root);
|
redact(root);
|
||||||
String json = mapper.writeValueAsString(root);
|
String json = mapper.writeValueAsString(root);
|
||||||
return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]";
|
if (json.length() <= maxLength) return json;
|
||||||
|
ObjectNode truncated = mapper.createObjectNode();
|
||||||
|
truncated.put("_truncated", true);
|
||||||
|
truncated.put("_originalLength", json.length());
|
||||||
|
return mapper.writeValueAsString(truncated);
|
||||||
} catch (IllegalArgumentException | JsonProcessingException e) {
|
} catch (IllegalArgumentException | JsonProcessingException e) {
|
||||||
return "\"[unserializable]\"";
|
return "\"[unserializable]\"";
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -1 +1,2 @@
|
|||||||
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
|
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
|
||||||
|
com.baiyue.common.audit.AuditOutboxRocketMqAutoConfiguration
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class BusinessAuditAspectTest {
|
||||||
|
@AfterEach
|
||||||
|
void clearSecurityContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void platformOperationUsesTargetCustomerAndKeepsOperatorScope() throws Throwable {
|
||||||
|
AuditEvent event = invoke(new AuthenticatedUser(7L, 0L, 0L, 1L,
|
||||||
|
"platform.admin", "PLATFORM", "WEB", Set.of("platform:customer:edit")), 10001L);
|
||||||
|
|
||||||
|
assertThat(event.tenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(event.operatorTenantId()).isZero();
|
||||||
|
assertThat(event.userId()).isEqualTo(7L);
|
||||||
|
assertThat(event.appCode()).isEqualTo("PLATFORM");
|
||||||
|
assertThat(event.beforeData()).isEqualTo("[10001]");
|
||||||
|
assertThat(event.beforeData()).doesNotContain("platform.admin", "platform:customer:edit", "permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void merchantOperationIgnoresTargetCustomerArgument() throws Throwable {
|
||||||
|
AuditEvent event = invoke(new AuthenticatedUser(8L, 10001L, 10001L, 2L,
|
||||||
|
"merchant.user", "MERCHANT", "WEB", Set.of()), 20002L);
|
||||||
|
|
||||||
|
assertThat(event.tenantId()).isEqualTo(10001L);
|
||||||
|
assertThat(event.operatorTenantId()).isEqualTo(10001L);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent invoke(AuthenticatedUser user, long requestedTenantId) throws Throwable {
|
||||||
|
AuditSink sink = mock(AuditSink.class);
|
||||||
|
BusinessAuditAspect aspect = new BusinessAuditAspect();
|
||||||
|
ReflectionTestUtils.setField(aspect, "sink", sink);
|
||||||
|
ReflectionTestUtils.setField(aspect, "sanitizer", new SensitiveDataSanitizer(new ObjectMapper(), 1024));
|
||||||
|
ReflectionTestUtils.setField(aspect, "serviceName", "test-service");
|
||||||
|
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new UsernamePasswordAuthenticationToken(user, null, List.of()));
|
||||||
|
|
||||||
|
TestOperations target = new TestOperations();
|
||||||
|
Method method = TestOperations.class.getDeclaredMethod("update", AuthenticatedUser.class, long.class);
|
||||||
|
MethodSignature signature = mock(MethodSignature.class);
|
||||||
|
when(signature.getMethod()).thenReturn(method);
|
||||||
|
when(signature.getParameterNames()).thenReturn(null);
|
||||||
|
|
||||||
|
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
|
||||||
|
when(joinPoint.getSignature()).thenReturn(signature);
|
||||||
|
when(joinPoint.getTarget()).thenReturn(target);
|
||||||
|
when(joinPoint.getArgs()).thenReturn(new Object[]{user, requestedTenantId});
|
||||||
|
when(joinPoint.proceed()).thenReturn("ok");
|
||||||
|
|
||||||
|
aspect.around(joinPoint, method.getAnnotation(BusinessAudit.class));
|
||||||
|
|
||||||
|
ArgumentCaptor<AuditEvent> event = ArgumentCaptor.forClass(AuditEvent.class);
|
||||||
|
verify(sink).publish(event.capture());
|
||||||
|
return event.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestOperations {
|
||||||
|
@BusinessAudit(event = "TEST_UPDATE", resourceType = "TEST", captureAfter = false)
|
||||||
|
public String update(AuthenticatedUser user, long tenantId) {
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.client.ExpectedCount.once;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withAccepted;
|
||||||
|
|
||||||
|
class HttpAuditSinkTest {
|
||||||
|
private static final String ENDPOINT = "http://audit-service/internal/audit/events";
|
||||||
|
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsInternalServiceToken() {
|
||||||
|
RestClient.Builder builder = RestClient.builder();
|
||||||
|
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
|
||||||
|
server.expect(once(), requestTo(ENDPOINT))
|
||||||
|
.andExpect(method(HttpMethod.POST))
|
||||||
|
.andExpect(header(HttpAuditSink.INTERNAL_SERVICE_TOKEN_HEADER, TOKEN))
|
||||||
|
.andRespond(withAccepted().contentType(MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
new HttpAuditSink(builder, ENDPOINT, TOKEN).publish(sampleEvent());
|
||||||
|
|
||||||
|
server.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AuditEvent sampleEvent() {
|
||||||
|
return new AuditEvent("audit-1", "ROLE_UPDATE", "ROLE", "1", "update role",
|
||||||
|
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
|
||||||
|
"PUT", "/api/platform/v1/roles/1", "127.0.0.1", 10L, true,
|
||||||
|
null, null, null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.Message;
|
||||||
|
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||||
|
import org.springframework.transaction.support.TransactionOperations;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@SpringJUnitConfig(ReliableAuditDeliveryTest.Config.class)
|
||||||
|
class ReliableAuditDeliveryTest {
|
||||||
|
@MockBean
|
||||||
|
private AuditOutboxRepository repository;
|
||||||
|
@MockBean
|
||||||
|
private RocketMQTemplate rocketMQ;
|
||||||
|
@MockBean
|
||||||
|
private TransactionOperations transactions;
|
||||||
|
@Resource
|
||||||
|
private OutboxAuditSink sink;
|
||||||
|
@Resource
|
||||||
|
private AuditOutboxPublisher publisher;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storesEventBeforeDelivery() {
|
||||||
|
AuditEvent event = event("audit-1");
|
||||||
|
org.mockito.Mockito.doAnswer(invocation -> {
|
||||||
|
java.util.function.Consumer<?> callback = invocation.getArgument(0);
|
||||||
|
((java.util.function.Consumer<Object>) callback).accept(null);
|
||||||
|
return null;
|
||||||
|
}).when(transactions).executeWithoutResult(any());
|
||||||
|
|
||||||
|
sink.publish(event);
|
||||||
|
|
||||||
|
verify(repository).append(eq(event), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void marksDeliveredMessageSent() {
|
||||||
|
when(repository.publishableIds(50)).thenReturn(List.of("audit-1"));
|
||||||
|
when(repository.claim("audit-1", "test-owner", 60)).thenReturn(true);
|
||||||
|
when(repository.find("audit-1"))
|
||||||
|
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-1", "{}", 1));
|
||||||
|
|
||||||
|
publisher.publish();
|
||||||
|
|
||||||
|
verify(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
|
||||||
|
verify(repository).sent("audit-1", "test-owner");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void schedulesRetryAfterMqFailure() {
|
||||||
|
when(repository.publishableIds(50)).thenReturn(List.of("audit-2"));
|
||||||
|
when(repository.claim("audit-2", "test-owner", 60)).thenReturn(true);
|
||||||
|
when(repository.find("audit-2"))
|
||||||
|
.thenReturn(new AuditOutboxRepository.OutboxRow("audit-2", "{}", 1));
|
||||||
|
doThrow(new IllegalStateException("broker unavailable"))
|
||||||
|
.when(rocketMQ).syncSend(anyString(), any(Message.class), anyLong());
|
||||||
|
|
||||||
|
publisher.publish();
|
||||||
|
|
||||||
|
verify(repository).failed("audit-2", "test-owner", 5, 5, "broker unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuditEvent event(String id) {
|
||||||
|
return new AuditEvent(id, "ROLE_UPDATE", "ROLE", "1", "修改角色",
|
||||||
|
"request-1", "trace-1", 1L, 1L, 1L, "admin", "PLATFORM", "WEB",
|
||||||
|
"PUT", "/roles/1", "127.0.0.1", 10, true, null, null,
|
||||||
|
null, null, Instant.parse("2026-08-05T00:00:00Z"), "iam-service");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class Config {
|
||||||
|
@Bean
|
||||||
|
ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); }
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
AuditProperties properties() {
|
||||||
|
AuditProperties value = new AuditProperties();
|
||||||
|
value.getOutbox().setBatchSize(50);
|
||||||
|
value.getOutbox().setLeaseSeconds(60);
|
||||||
|
value.getOutbox().setMaxAttempts(5);
|
||||||
|
value.getRocketmq().setTopic("test-baiyue-audit-event-v1");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
OutboxAuditSink sink(AuditOutboxRepository repository, ObjectMapper json,
|
||||||
|
TransactionOperations transactions) {
|
||||||
|
return new OutboxAuditSink(repository, json, transactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
AuditOutboxPublisher publisher(AuditOutboxRepository repository, RocketMQTemplate rocketMQ,
|
||||||
|
AuditProperties properties) {
|
||||||
|
return new AuditOutboxPublisher(repository, rocketMQ, properties, "test-owner");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.baiyue.common.audit;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SensitiveDataSanitizerTest {
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void truncatedSnapshotRemainsValidJson() throws Exception {
|
||||||
|
SensitiveDataSanitizer sanitizer = new SensitiveDataSanitizer(mapper, 40);
|
||||||
|
|
||||||
|
String sanitized = sanitizer.sanitize(Map.of("description", "x".repeat(200)));
|
||||||
|
JsonNode parsed = mapper.readTree(sanitized);
|
||||||
|
|
||||||
|
assertThat(parsed.path("_truncated").asBoolean()).isTrue();
|
||||||
|
assertThat(parsed.path("_originalLength").asInt()).isGreaterThan(40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,16 +8,27 @@ import java.util.Arrays;
|
|||||||
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
|
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
|
||||||
*/
|
*/
|
||||||
public enum ResultCode {
|
public enum ResultCode {
|
||||||
|
/** 请求处理成功。 */
|
||||||
SUCCESS("200", "success", HttpStatus.OK),
|
SUCCESS("200", "success", HttpStatus.OK),
|
||||||
|
/** 请求参数不符合接口要求。 */
|
||||||
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
|
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
|
||||||
|
/** 未登录、令牌无效或登录态已过期。 */
|
||||||
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
|
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
|
||||||
|
/** 已登录但没有操作权限。 */
|
||||||
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
|
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
|
||||||
|
/** 请求的资源不存在或不在可访问范围内。 */
|
||||||
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
|
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
|
||||||
|
/** 资源版本、幂等键或当前状态发生冲突。 */
|
||||||
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
|
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
|
||||||
|
/** 字段校验、格式校验或业务前置校验未通过。 */
|
||||||
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
|
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
|
||||||
|
/** 请求频率或并发数量超过限制。 */
|
||||||
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
|
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
|
||||||
|
/** 未被更细错误码覆盖的通用业务失败。 */
|
||||||
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
|
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
|
||||||
|
/** 服务内部发生未预期异常。 */
|
||||||
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
|
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
|
||||||
|
/** 下游服务、数据库或基础设施暂不可用。 */
|
||||||
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
|
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
|
|
||||||
private final String code;
|
private final String code;
|
||||||
|
|||||||
@@ -24,5 +24,18 @@ public final class ExportContracts {
|
|||||||
|
|
||||||
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
|
public record ExportColumn(String key, String title, ExportColumnType type, Integer width) { }
|
||||||
|
|
||||||
public enum ExportColumnType { STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATETIME }
|
public enum ExportColumnType {
|
||||||
|
/** 文本列。 */
|
||||||
|
STRING,
|
||||||
|
/** 整数列。 */
|
||||||
|
INTEGER,
|
||||||
|
/** 高精度小数列。 */
|
||||||
|
DECIMAL,
|
||||||
|
/** 布尔值列。 */
|
||||||
|
BOOLEAN,
|
||||||
|
/** 日期列,不包含时间。 */
|
||||||
|
DATE,
|
||||||
|
/** 日期时间列。 */
|
||||||
|
DATETIME
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-1
@@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer {
|
|||||||
if (payload == null || payload.isNull()) {
|
if (payload == null || payload.isNull()) {
|
||||||
throw new IllegalArgumentException("事件负载不能为空");
|
throw new IllegalArgumentException("事件负载不能为空");
|
||||||
}
|
}
|
||||||
|
return write(sort(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */
|
||||||
|
public static byte[] canonicalizeLegacy(JsonNode payload) {
|
||||||
|
if (payload == null || payload.isNull()) {
|
||||||
|
throw new IllegalArgumentException("事件负载不能为空");
|
||||||
|
}
|
||||||
|
return write(sortLegacy(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] write(JsonNode payload) {
|
||||||
try {
|
try {
|
||||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
return OBJECT_MAPPER.writeValueAsBytes(payload);
|
||||||
} catch (JsonProcessingException exception) {
|
} catch (JsonProcessingException exception) {
|
||||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||||
}
|
}
|
||||||
@@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer {
|
|||||||
node.forEach(child -> result.add(sort(child)));
|
node.forEach(child -> result.add(sort(child)));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
if (node.isNumber()) {
|
||||||
|
return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros());
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode sortLegacy(JsonNode node) {
|
||||||
|
if (node.isObject()) {
|
||||||
|
var names = new java.util.ArrayList<String>();
|
||||||
|
node.fieldNames().forEachRemaining(names::add);
|
||||||
|
names.sort(String::compareTo);
|
||||||
|
var result = OBJECT_MAPPER.createObjectNode();
|
||||||
|
names.forEach(name -> result.set(name, sortLegacy(node.get(name))));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (node.isArray()) {
|
||||||
|
var result = OBJECT_MAPPER.createArrayNode();
|
||||||
|
node.forEach(child -> result.add(sortLegacy(child)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public final class EventTypes {
|
|||||||
public static final String ORDER_APPROVED = "OrderApproved";
|
public static final String ORDER_APPROVED = "OrderApproved";
|
||||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||||
|
public static final String FIRST_LEG_LOADING_PLAN_COMPLETED = "FirstLegLoadingPlanCompleted";
|
||||||
|
public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged";
|
||||||
|
public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged";
|
||||||
|
public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged";
|
||||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 头程装柜完成后交给海外尾程的不可变 V1 快照。 */
|
||||||
|
public record FirstLegLoadingPlanCompletedV1(
|
||||||
|
String loadingPlanId, String planNo, String containerBookingId,
|
||||||
|
String containerNo, String cabinetNo, String warehouseId, String warehouseName,
|
||||||
|
List<Order> orders) {
|
||||||
|
|
||||||
|
public record Order(
|
||||||
|
String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items, String customerId, String orderType, List<PalletSnapshot> pallets) {
|
||||||
|
public Order {
|
||||||
|
items = items == null ? List.of() : items;
|
||||||
|
orderType = orderType == null || orderType.isBlank() ? "FIRST_LEG" : orderType;
|
||||||
|
pallets = pallets == null ? List.of() : pallets;
|
||||||
|
}
|
||||||
|
public Order(String sourceInboundId, String rootInboundId,
|
||||||
|
String sourceOrderId, String sourceOrderNo, String rootOrderId,
|
||||||
|
String inboundCode, String destinationWarehouseId,
|
||||||
|
String destinationWarehouseCode, String destinationWarehouseName,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg, BigDecimal volumeCbm,
|
||||||
|
List<Item> items) {
|
||||||
|
this(sourceInboundId, rootInboundId, sourceOrderId, sourceOrderNo, rootOrderId,
|
||||||
|
inboundCode, destinationWarehouseId, destinationWarehouseCode,
|
||||||
|
destinationWarehouseName, packageCount, productQuantity, palletCount,
|
||||||
|
grossWeightKg, netWeightKg, volumeCbm, items, null, "FIRST_LEG", List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Item(
|
||||||
|
String sourceInboundDetailId, String skuCode,
|
||||||
|
String productNameZh, String productNameEn,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||||
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
|
String shipmentNo, String shippingMark, String palletNo, List<String> cartonMarks) {
|
||||||
|
public Item {
|
||||||
|
cartonMarks = cartonMarks == null ? List.of() : cartonMarks;
|
||||||
|
}
|
||||||
|
public Item(String sourceInboundDetailId, String skuCode,
|
||||||
|
String productNameZh, String productNameEn,
|
||||||
|
int packageCount, Integer productQuantity, Integer palletCount,
|
||||||
|
BigDecimal grossWeightKg, BigDecimal netWeightKg,
|
||||||
|
BigDecimal dimensionalWeightKg, BigDecimal volumeCbm,
|
||||||
|
String destinationWarehouseId, String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName, String fbaTrackingNo,
|
||||||
|
String shipmentNo, String shippingMark) {
|
||||||
|
this(sourceInboundDetailId, skuCode, productNameZh, productNameEn, packageCount,
|
||||||
|
productQuantity, palletCount, grossWeightKg, netWeightKg,
|
||||||
|
dimensionalWeightKg, volumeCbm, destinationWarehouseId,
|
||||||
|
destinationWarehouseCode, destinationWarehouseName, fbaTrackingNo,
|
||||||
|
shipmentNo, shippingMark, null, List.of());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键代发入仓单的托盘冻结快照。 */
|
||||||
|
public record PalletSnapshot(String palletNo, List<SkuPackageSnapshot> skuPackages) {}
|
||||||
|
|
||||||
|
/** 托盘下 SKU、箱数、数量及箱唛冻结快照。 */
|
||||||
|
public record SkuPackageSnapshot(String skuCode, String productName,
|
||||||
|
int packageCount, int productQuantity,
|
||||||
|
List<String> cartonMarks) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/** IAM 向海外服务发布的 V1 安全投影事件负载。 */
|
||||||
|
public final class IamSecurityEventsV1 {
|
||||||
|
public static final int VERSION = 1;
|
||||||
|
|
||||||
|
private IamSecurityEventsV1() { }
|
||||||
|
|
||||||
|
public record UserStatusChanged(long authUserId, long tenantId, String appCode,
|
||||||
|
String status, long permissionVersion, Instant changedAt) { }
|
||||||
|
|
||||||
|
public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { }
|
||||||
|
|
||||||
|
public record PermissionVersionChanged(long authUserId, long tenantId, String appCode,
|
||||||
|
long permissionVersion, String reason, Instant changedAt) { }
|
||||||
|
}
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
|
|
||||||
|
class EventPayloadCanonicalizerTest {
|
||||||
|
private final ObjectMapper json = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception {
|
||||||
|
var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}");
|
||||||
|
var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}");
|
||||||
|
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized));
|
||||||
|
assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)),
|
||||||
|
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception {
|
||||||
|
var negativeZero = json.readTree("{\"value\":-0.000}");
|
||||||
|
var zero = json.readTree("{\"value\":0}");
|
||||||
|
assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero));
|
||||||
|
assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)),
|
||||||
|
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero)));
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
package com.baiyue.common.event;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class FirstLegLoadingPlanCompletedV1Test {
|
||||||
|
private final ObjectMapper mapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void v1PayloadRoundTripsWithoutLosingLargeIds() throws Exception {
|
||||||
|
var item = new FirstLegLoadingPlanCompletedV1.Item("2088000000000000001", "SKU-1", "产品", "Product",
|
||||||
|
2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"), new BigDecimal("12.000"),
|
||||||
|
new BigDecimal("0.200"), "2088000000000000002", "ONT8", "ONT8", "FBA-1", "SHIP-1", "MARK");
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV1.Order("2088000000000000003", "2088000000000000004",
|
||||||
|
"2088000000000000005", "FL-1", "2088000000000000006", "RK-1", "2088000000000000002",
|
||||||
|
"ONT8", "ONT8", 2, 20, 1, new BigDecimal("10.500"), new BigDecimal("9.500"),
|
||||||
|
new BigDecimal("0.200"), List.of(item));
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV1("2088000000000000007", "P-1", "2088000000000000008",
|
||||||
|
"CONT-1", "CAB-1", "2088000000000000009", "中转仓", List.of(order));
|
||||||
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
assertThat(mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class)).isEqualTo(payload);
|
||||||
|
assertThat(json).contains("2088000000000000003", "sourceInboundDetailId");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dropshipPayloadKeepsMultiplePalletsAndCartonMarks() throws Exception {
|
||||||
|
var sku1 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||||
|
"SKU-1", "产品1", 2, 20, List.of("BOX-001", "BOX-002"));
|
||||||
|
var sku2 = new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot(
|
||||||
|
"SKU-2", "产品2", 1, 5, List.of("BOX-003"));
|
||||||
|
var order = new FirstLegLoadingPlanCompletedV1.Order("100", "100", "200", "FL-200", "200",
|
||||||
|
"RK-100", "300", "ONT8", "ONT8", 3, 25, 2, BigDecimal.ONE,
|
||||||
|
BigDecimal.ONE, BigDecimal.ONE, List.of(), "9001", "ONE_CLICK_DROPSHIP",
|
||||||
|
List.of(new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-01", List.of(sku1)),
|
||||||
|
new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-02", List.of(sku2))));
|
||||||
|
var payload = new FirstLegLoadingPlanCompletedV1("1", "LP-1", "2", "CONT-1", null,
|
||||||
|
"3", "海外仓", List.of(order));
|
||||||
|
|
||||||
|
String json = mapper.writeValueAsString(payload);
|
||||||
|
FirstLegLoadingPlanCompletedV1 restored = mapper.readValue(json, FirstLegLoadingPlanCompletedV1.class);
|
||||||
|
|
||||||
|
assertThat(restored).isEqualTo(payload);
|
||||||
|
assertThat(json).contains("ONE_CLICK_DROPSHIP", "PLT-01", "BOX-003", "customerId");
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-8
@@ -36,11 +36,8 @@ public class BaiyueOpenApiAutoConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
OperationCustomizer tenantHeaderCustomizer() {
|
OperationCustomizer correlationHeaderCustomizer() {
|
||||||
return (operation, method) -> operation
|
return (operation, method) -> operation
|
||||||
.addParametersItem(header("X-Tenant-Id", "Current tenant ID / 当前租户 ID", false))
|
|
||||||
.addParametersItem(header("X-Operator-Tenant-Id", "Operator tenant ID / 运营方租户 ID", false))
|
|
||||||
.addParametersItem(header("X-App-Code", "Application code / 应用编码", false))
|
|
||||||
.addParametersItem(header("X-Request-Id", "Request correlation ID / 请求关联 ID", false))
|
.addParametersItem(header("X-Request-Id", "Request correlation ID / 请求关联 ID", false))
|
||||||
.addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false));
|
.addParametersItem(header("X-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false));
|
||||||
}
|
}
|
||||||
@@ -62,20 +59,20 @@ public class BaiyueOpenApiAutoConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) {
|
GroupedOpenApi platformApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||||
return GroupedOpenApi.builder().group("platform")
|
return GroupedOpenApi.builder().group("platform")
|
||||||
.displayName("Platform APIs / 平台端接口")
|
.displayName("Platform APIs / 平台端接口")
|
||||||
.pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**")
|
.pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**")
|
||||||
.addOperationCustomizer(tenantHeaderCustomizer).build();
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
GroupedOpenApi merchantApi(OperationCustomizer tenantHeaderCustomizer) {
|
GroupedOpenApi merchantApi(OperationCustomizer correlationHeaderCustomizer) {
|
||||||
return GroupedOpenApi.builder().group("merchant")
|
return GroupedOpenApi.builder().group("merchant")
|
||||||
.displayName("Merchant APIs / 商户端接口")
|
.displayName("Merchant APIs / 商户端接口")
|
||||||
.pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**",
|
.pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**",
|
||||||
"/api/firstleg/**", "/api/lastleg/**")
|
"/api/firstleg/**", "/api/lastleg/**")
|
||||||
.addOperationCustomizer(tenantHeaderCustomizer).build();
|
.addOperationCustomizer(correlationHeaderCustomizer).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Parameter header(String name, String description, boolean required) {
|
private Parameter header(String name, String description, boolean required) {
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.baiyue.common.openapi;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Operation;
|
||||||
|
import io.swagger.v3.oas.models.parameters.Parameter;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springdoc.core.customizers.OperationCustomizer;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DisplayName("OpenAPI 请求头定制")
|
||||||
|
class CorrelationHeaderCustomizerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldOnlyExposeCallerControlledCorrelationHeaders() {
|
||||||
|
OperationCustomizer customizer = new BaiyueOpenApiAutoConfiguration().correlationHeaderCustomizer();
|
||||||
|
|
||||||
|
Operation operation = customizer.customize(new Operation(), null);
|
||||||
|
|
||||||
|
assertThat(operation.getParameters())
|
||||||
|
.extracting(Parameter::getName)
|
||||||
|
.containsExactly("X-Request-Id", "X-Trace-Id")
|
||||||
|
.doesNotContain("X-Tenant-Id", "X-Operator-Tenant-Id", "X-App-Code");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.baiyue.common.security;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** Server-side access session and its current authorization snapshot. */
|
||||||
|
public record AccessTokenSession(int version, Long userId, Set<String> roles,
|
||||||
|
Set<String> permissions) {
|
||||||
|
public AccessTokenSession {
|
||||||
|
if (version != 1 || userId == null || userId <= 0) {
|
||||||
|
throw new IllegalArgumentException("Invalid access token session");
|
||||||
|
}
|
||||||
|
roles = roles == null ? Set.of() : Set.copyOf(roles);
|
||||||
|
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -7,5 +7,5 @@ import org.springframework.security.oauth2.jwt.Jwt;
|
|||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface AccessTokenSessionValidator {
|
public interface AccessTokenSessionValidator {
|
||||||
void validate(Jwt jwt);
|
AccessTokenSession validate(Jwt jwt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import java.util.Set;
|
|||||||
*/
|
*/
|
||||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
String username, String appCode, String clientType,
|
String username, String appCode, String clientType,
|
||||||
Set<String> permissions) implements Principal {
|
Set<String> permissions, boolean impersonated, Long actorUserId,
|
||||||
|
Long actorMembershipId, boolean passwordChangeRequired) implements Principal {
|
||||||
|
public AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||||
|
String username, String appCode, String clientType, Set<String> permissions) {
|
||||||
|
this(userId, tenantId, operatorTenantId, membershipId, username, appCode, clientType,
|
||||||
|
permissions, false, null, null, false);
|
||||||
|
}
|
||||||
public AuthenticatedUser {
|
public AuthenticatedUser {
|
||||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||||
}
|
}
|
||||||
@@ -18,4 +24,3 @@ public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantI
|
|||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
|||||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||||
*/
|
*/
|
||||||
@AutoConfiguration(
|
@AutoConfiguration(
|
||||||
after = HmacJwtDecoderAutoConfiguration.class,
|
after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class},
|
||||||
before = SecurityAutoConfiguration.class)
|
before = SecurityAutoConfiguration.class)
|
||||||
@EnableMethodSecurity
|
@EnableMethodSecurity
|
||||||
public class BaiyueSecurityAutoConfiguration {
|
public class BaiyueSecurityAutoConfiguration {
|
||||||
@@ -47,10 +47,18 @@ public class BaiyueSecurityAutoConfiguration {
|
|||||||
@ConditionalOnMissingBean
|
@ConditionalOnMissingBean
|
||||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||||
AuthenticationEntryPoint entryPoint,
|
AuthenticationEntryPoint entryPoint,
|
||||||
StringRedisTemplate redisTemplate,
|
org.springframework.beans.factory.ObjectProvider<StringRedisTemplate> redis,
|
||||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
ObjectMapper objectMapper,
|
||||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix,
|
||||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix));
|
@Value("${baiyue.security.session-mode:redis}") String sessionMode) {
|
||||||
|
AccessTokenSessionValidator validator;
|
||||||
|
if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator();
|
||||||
|
else {
|
||||||
|
StringRedisTemplate template=redis.getIfAvailable();
|
||||||
|
if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate");
|
||||||
|
validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper);
|
||||||
|
}
|
||||||
|
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.baiyue.common.security;
|
||||||
|
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */
|
||||||
|
public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||||
|
@Override public AccessTokenSession validate(Jwt jwt) {
|
||||||
|
Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id")));
|
||||||
|
return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")),
|
||||||
|
values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions"))));
|
||||||
|
}
|
||||||
|
private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} }
|
||||||
|
private static Set<String> values(Object value){ Set<String> result=new LinkedHashSet<>(); if(value instanceof Iterable<?> it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user