暂存提交
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
* text=auto
|
||||
*.java text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.md text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.ps1 text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
*.bat text eol=crlf
|
||||
*.jar binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.pdf binary
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
target/
|
||||
*.log
|
||||
.idea/
|
||||
*.iml
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
logs/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
application-local.yml
|
||||
bootstrap-local.yml
|
||||
*.hprof
|
||||
*.dump
|
||||
dependency-reduced-pom.xml
|
||||
reports/runtime*/
|
||||
reports/uploads/
|
||||
reports/db-backup*/
|
||||
uploads/
|
||||
@@ -0,0 +1,49 @@
|
||||
image: maven:3.9.9-eclipse-temurin-17
|
||||
|
||||
stages:
|
||||
- verify
|
||||
- package
|
||||
|
||||
variables:
|
||||
MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository -Dfile.encoding=UTF-8"
|
||||
MAVEN_CLI_OPTS: "--batch-mode --errors --fail-at-end --show-version"
|
||||
|
||||
cache:
|
||||
key: "maven-$CI_COMMIT_REF_SLUG"
|
||||
paths:
|
||||
- .m2/repository/
|
||||
|
||||
校验与测试:
|
||||
stage: verify
|
||||
script:
|
||||
- mvn $MAVEN_CLI_OPTS clean verify
|
||||
artifacts:
|
||||
when: always
|
||||
expire_in: 7 days
|
||||
reports:
|
||||
junit:
|
||||
- "**/target/surefire-reports/TEST-*.xml"
|
||||
paths:
|
||||
- "**/target/surefire-reports/"
|
||||
|
||||
打包服务:
|
||||
stage: package
|
||||
needs:
|
||||
- 校验与测试
|
||||
script:
|
||||
- mvn $MAVEN_CLI_OPTS -DskipTests package
|
||||
artifacts:
|
||||
expire_in: 7 days
|
||||
paths:
|
||||
- gateway/target/*.jar
|
||||
- auth/target/*.jar
|
||||
- iam/target/*.jar
|
||||
- order/target/*.jar
|
||||
- firstleg/target/*.jar
|
||||
- warehouse/target/*.jar
|
||||
- file/target/*.jar
|
||||
- print/target/*.jar
|
||||
- audit-service/target/*.jar
|
||||
- integration-service/target/*.jar
|
||||
- event-bridge-service/target/*.jar
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 百悦头程平台
|
||||
|
||||
本仓库承载国内头程业务,负责统一登录、租户、用户、角色、菜单、按钮权限、订单、国内仓储、头程履约以及国内公共能力。尾程业务不进入本仓库,通过版本化跨区域事件与尾程仓库协作。
|
||||
|
||||
## 模块
|
||||
|
||||
- `auth`:统一登录、令牌签发与刷新。
|
||||
- `iam`:租户、用户、角色、菜单、按钮权限和数据范围。
|
||||
- `order`:订单主数据及订单状态编排。
|
||||
- `firstleg`:头程揽收、入仓、装载、发运和交接。
|
||||
- `warehouse`:国内仓储能力。
|
||||
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。
|
||||
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
|
||||
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
|
||||
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
|
||||
|
||||
## 本地构建
|
||||
|
||||
```powershell
|
||||
mvn -DskipTests validate
|
||||
mvn test
|
||||
```
|
||||
|
||||
2026-07-30 一期前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
|
||||
|
||||
本地一期完整联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。
|
||||
|
||||
国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。
|
||||
|
||||
## 网关跨域配置
|
||||
|
||||
本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如:
|
||||
|
||||
```text
|
||||
CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com
|
||||
```
|
||||
|
||||
当前登录态通过 Bearer Token 传递,`CORS_ALLOW_CREDENTIALS` 默认关闭。禁止把允许来源配置成 `*`;如果以后改为 Cookie 登录,必须使用 HTTPS 明确域名,并审查 Cookie 的 `SameSite` 和 `Secure` 设置。
|
||||
|
||||
## 仓库边界
|
||||
|
||||
详细规则见 `docs/repository-boundary.md`。本仓不得引入尾程源码,不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>audit-service</artifactId>
|
||||
|
||||
<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-web</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.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.audit;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 审计微服务启动入口,初始化 Spring Boot 应用并扫描审计日志 Mapper。
|
||||
*/
|
||||
@MapperScan("com.baiyue.audit.mapper")
|
||||
@SpringBootApplication
|
||||
public class BaiyueAuditApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BaiyueAuditApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.audit.controller;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.service.AuditIngestService;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 内部审计事件接收接口,校验服务间提交的审计数据并交由审计服务持久化。
|
||||
*/
|
||||
@Tag(
|
||||
name = "Internal audit ingestion / 内部审计写入",
|
||||
description = "Internal service-to-service API for immutable business audit events. / 服务间写入不可变业务审计事件的内部接口。"
|
||||
)
|
||||
@RestController
|
||||
@RequestMapping("/internal/audit/events")
|
||||
public class AuditIngestController {
|
||||
@Resource
|
||||
private AuditIngestService service;
|
||||
@Operation(
|
||||
summary = "Ingest business audit event / 写入业务审计事件",
|
||||
description = "Validates and stores one sanitized event in baiyue_audit.sys_audit_log. / 校验并将一条脱敏事件写入 baiyue_audit.sys_audit_log。"
|
||||
)
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "202", description = "Event accepted and stored / 事件已接收并存储"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid event payload / 事件载荷无效"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "Audit storage failed / 审计存储失败")
|
||||
})
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.ACCEPTED)
|
||||
public ApiResponse<Void> ingest(@Valid @RequestBody AuditIngestRequest event) {
|
||||
service.ingest(event);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.audit.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 内部审计写入请求契约,承载操作对象、租户与用户、请求链路、执行结果及脱敏快照。
|
||||
*/
|
||||
@Schema(name = "AuditIngestRequest", description = "Internal business audit event / 内部业务审计事件")
|
||||
public record AuditIngestRequest(
|
||||
@Schema(description = "Event code / 事件编码", example = "WAREHOUSE_UPDATE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 64) String eventType,
|
||||
@Schema(description = "Resource type / 资源类型", example = "WAREHOUSE", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank @Size(max = 64) String resourceType,
|
||||
@Schema(description = "Resource identifier / 资源标识", example = "190000000000000001")
|
||||
@Size(max = 128) String resourceId,
|
||||
@Schema(description = "Bilingual event description / 中英文事件描述", example = "Update warehouse / 更新仓库")
|
||||
@Size(max = 255) String description,
|
||||
@Schema(description = "Request ID / 请求 ID", example = "req-20260728-001")
|
||||
@Size(max = 128) String requestId,
|
||||
@Schema(description = "Distributed trace ID / 分布式链路 ID", example = "trace-20260728-001")
|
||||
@Size(max = 128) String traceId,
|
||||
@Schema(description = "Business data tenant ID / 业务数据租户 ID", example = "10001")
|
||||
Long tenantId,
|
||||
@Schema(description = "Operator tenant ID / 操作人所属租户 ID", example = "0")
|
||||
Long operatorTenantId,
|
||||
@Schema(description = "Operator user ID / 操作人用户 ID", example = "90001")
|
||||
Long userId,
|
||||
@Schema(description = "Operator username / 操作人用户名", example = "platform.admin")
|
||||
@Size(max = 128) String username,
|
||||
@Schema(description = "Application scope / 应用权限域", example = "PLATFORM")
|
||||
@Size(max = 64) String appCode,
|
||||
@Schema(description = "Client type / 客户端类型", example = "WEB")
|
||||
@Size(max = 32) String clientType,
|
||||
@Schema(description = "HTTP method / HTTP 方法", example = "PUT")
|
||||
@Size(max = 16) String httpMethod,
|
||||
@Schema(description = "Request URI / 请求 URI", example = "/api/platform/v1/warehouse-admin/190000000000000001")
|
||||
@Size(max = 512) String requestUri,
|
||||
@Schema(description = "Client IP / 客户端 IP", example = "10.0.0.10")
|
||||
@Size(max = 64) String clientIp,
|
||||
@Schema(description = "Request duration in milliseconds / 请求耗时(毫秒)", example = "35")
|
||||
@PositiveOrZero long durationMs,
|
||||
@Schema(description = "Whether the business operation succeeded / 业务操作是否成功", example = "true")
|
||||
boolean success,
|
||||
@Schema(description = "Error code when failed / 失败时错误码", example = "WAREHOUSE_NOT_FOUND")
|
||||
@Size(max = 64) String errorCode,
|
||||
@Schema(description = "Sanitized error message / 脱敏后的错误信息", example = "Warehouse not found / 仓库不存在")
|
||||
@Size(max = 512) String errorMessage,
|
||||
@Schema(description = "Sanitized JSON snapshot before change / 变更前脱敏 JSON 快照", example = "{\"status\":\"INACTIVE\"}")
|
||||
String beforeData,
|
||||
@Schema(description = "Sanitized JSON snapshot after change / 变更后脱敏 JSON 快照", example = "{\"status\":\"ACTIVE\"}")
|
||||
String afterData,
|
||||
@Schema(description = "Event occurrence time in UTC / 事件发生时间(UTC)", example = "2026-07-28T07:30:00Z", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull Instant occurredAt,
|
||||
@Schema(description = "Source service name / 来源服务名称", example = "warehouse-service")
|
||||
@Size(max = 64) String serviceName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.audit.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 业务审计日志持久化实体,对应 {@code sys_audit_log} 表。
|
||||
* 用于保存跨租户敏感操作的不可变审计证据,不用于领域事件投递。
|
||||
*/
|
||||
@Schema(name = "AuditLog", description = "Stored business audit log / 已存储业务审计日志")
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("sys_audit_log")
|
||||
public class AuditLogEntity {
|
||||
@Schema(description = "Audit log ID / 审计日志 ID", example = "190000000000000201")
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String eventType;
|
||||
private String resourceType;
|
||||
private String resourceId;
|
||||
private String description;
|
||||
private String requestId;
|
||||
private String traceId;
|
||||
private Long tenantId;
|
||||
private Long operatorTenantId;
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String appCode;
|
||||
private String clientType;
|
||||
private String httpMethod;
|
||||
private String requestUri;
|
||||
private String clientIp;
|
||||
private Long durationMs;
|
||||
private Boolean success;
|
||||
private String errorCode;
|
||||
private String errorMessage;
|
||||
private String beforeData;
|
||||
private String afterData;
|
||||
private String serviceName;
|
||||
private Instant occurredAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.audit.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
|
||||
/**
|
||||
* 审计日志 MyBatis-Plus Mapper,提供 {@code sys_audit_log} 表的单表写入和查询能力。
|
||||
*/
|
||||
public interface AuditLogMapper extends BaseMapper<AuditLogEntity> {
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.audit.repository;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.entity.AuditLogEntity;
|
||||
import com.baiyue.audit.mapper.AuditLogMapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 审计事件持久化仓储,负责将内部审计请求映射并追加写入审计日志表。
|
||||
*/
|
||||
@Repository
|
||||
public class AuditEventRepository {
|
||||
private final AuditLogMapper mapper;
|
||||
|
||||
public AuditEventRepository(AuditLogMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public void save(AuditIngestRequest event) {
|
||||
AuditLogEntity entity = new AuditLogEntity();
|
||||
entity.setEventType(event.eventType());
|
||||
entity.setResourceType(event.resourceType());
|
||||
entity.setResourceId(event.resourceId());
|
||||
entity.setDescription(event.description());
|
||||
entity.setRequestId(event.requestId());
|
||||
entity.setTraceId(event.traceId());
|
||||
entity.setTenantId(event.tenantId());
|
||||
entity.setOperatorTenantId(event.operatorTenantId());
|
||||
entity.setUserId(event.userId());
|
||||
entity.setUsername(event.username());
|
||||
entity.setAppCode(event.appCode());
|
||||
entity.setClientType(event.clientType());
|
||||
entity.setHttpMethod(event.httpMethod());
|
||||
entity.setRequestUri(event.requestUri());
|
||||
entity.setClientIp(event.clientIp());
|
||||
entity.setDurationMs(event.durationMs());
|
||||
entity.setSuccess(event.success());
|
||||
entity.setErrorCode(event.errorCode());
|
||||
entity.setErrorMessage(event.errorMessage());
|
||||
entity.setBeforeData(jsonOrNull(event.beforeData()));
|
||||
entity.setAfterData(jsonOrNull(event.afterData()));
|
||||
entity.setOccurredAt(event.occurredAt());
|
||||
entity.setServiceName(event.serviceName());
|
||||
mapper.insert(entity);
|
||||
}
|
||||
|
||||
private static String jsonOrNull(String value) {
|
||||
return value == null ? "null" : value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.audit.service;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.audit.dto.AuditIngestRequest;
|
||||
import com.baiyue.audit.repository.AuditEventRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 审计事件写入应用服务,在事务内接收并持久化不可变业务审计记录。
|
||||
*/
|
||||
@Service
|
||||
public class AuditIngestService {
|
||||
@Resource
|
||||
private AuditEventRepository repository;
|
||||
|
||||
|
||||
@Transactional
|
||||
public void ingest(AuditIngestRequest event) {
|
||||
repository.save(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
server:
|
||||
port: ${AUDIT_PORT:9700}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: audit-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
|
||||
# 本地 IDEA 启动默认连接本机 MySQL;生产环境请通过 AUDIT_DB_* 环境变量覆盖。
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${AUDIT_DB_USERNAME:root}
|
||||
password: ${AUDIT_DB_PASSWORD:123456}
|
||||
|
||||
baiyue.audit.enabled: false
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
@@ -0,0 +1,36 @@
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_audit
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_audit;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sys_audit_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '审计日志自增ID',
|
||||
event_type VARCHAR(64) NOT NULL COMMENT '审计事件编码',
|
||||
resource_type VARCHAR(64) NOT NULL COMMENT '被操作资源类型',
|
||||
resource_id VARCHAR(128) DEFAULT NULL COMMENT '被操作资源ID',
|
||||
description VARCHAR(255) DEFAULT NULL COMMENT '操作说明',
|
||||
request_id VARCHAR(128) DEFAULT NULL COMMENT '请求ID,用于单次请求追踪',
|
||||
trace_id VARCHAR(128) DEFAULT NULL COMMENT '分布式链路追踪ID',
|
||||
tenant_id BIGINT DEFAULT NULL COMMENT '业务数据所属租户ID',
|
||||
operator_tenant_id BIGINT DEFAULT NULL COMMENT '操作主体所属租户ID',
|
||||
user_id BIGINT DEFAULT NULL COMMENT '操作用户ID',
|
||||
username VARCHAR(128) DEFAULT NULL COMMENT '操作用户名快照',
|
||||
app_code VARCHAR(64) DEFAULT NULL COMMENT '操作来源应用端',
|
||||
client_type VARCHAR(32) DEFAULT NULL COMMENT '客户端类型',
|
||||
http_method VARCHAR(16) DEFAULT NULL COMMENT 'HTTP请求方法',
|
||||
request_uri VARCHAR(512) DEFAULT NULL COMMENT '请求URI',
|
||||
client_ip VARCHAR(64) DEFAULT NULL COMMENT '客户端IP',
|
||||
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '请求耗时(毫秒)',
|
||||
success TINYINT NOT NULL DEFAULT 0 COMMENT '操作是否成功',
|
||||
error_code VARCHAR(64) DEFAULT NULL COMMENT '失败时的错误码',
|
||||
error_message VARCHAR(512) DEFAULT NULL COMMENT '脱敏后的失败信息',
|
||||
before_data JSON DEFAULT NULL COMMENT '变更前数据快照(已脱敏)',
|
||||
after_data JSON DEFAULT NULL COMMENT '变更后数据快照(已脱敏)',
|
||||
service_name VARCHAR(64) DEFAULT NULL COMMENT '产生日志的服务名',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务操作发生时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审计服务落库时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_audit_tenant_time (tenant_id, occurred_at),
|
||||
KEY idx_audit_event_time (event_type, occurred_at),
|
||||
KEY idx_audit_user_time (user_id, occurred_at),
|
||||
KEY idx_audit_request (request_id)
|
||||
) ENGINE=InnoDB COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表';
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>auth-service</artifactId>
|
||||
<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-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-security</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-oauth2-jose</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.auth;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
* 认证服务启动入口,负责装配统一登录、令牌签发与会话管理相关组件。
|
||||
*/
|
||||
@MapperScan("com.baiyue.auth.mapper")
|
||||
@EnableFeignClients(basePackages = "com.baiyue.auth.client")
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
public class BaiyueAuthApplication {
|
||||
public static void main(String[] args) { SpringApplication.run(BaiyueAuthApplication.class, args); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 授权快照加载器,调用 IAM 服务并校验返回身份上下文,为认证令牌组装权限声明。
|
||||
*/
|
||||
@Component
|
||||
public class AuthorizationLoader {
|
||||
private final IamAuthorizationClient client;
|
||||
private final String internalToken;
|
||||
|
||||
public AuthorizationLoader(IamAuthorizationClient client,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
if (internalToken == null || internalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
|
||||
}
|
||||
this.client = client;
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public AuthorizationSnapshot load(AuthAccount account) {
|
||||
try {
|
||||
var response = client.load(internalToken, new AuthorizationSnapshotRequest(
|
||||
account.getId(), account.getDefaultTenantId(), account.getDefaultMembershipId(),
|
||||
account.getDefaultAppCode()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
||||
}
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 400 || exception.status() == 403 || exception.status() == 404) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账号没有有效的 IAM 身份或权限");
|
||||
}
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM 权限服务暂时不可用");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* IAM 授权快照响应模型,承载用户身份、租户、角色、按钮/接口权限及数据范围。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthorizationSnapshot {
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long membershipId;
|
||||
private String appCode;
|
||||
private String clientType;
|
||||
private Set<String> roles;
|
||||
private Set<String> permissions;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* IAM 授权快照查询请求契约,描述待解析的认证账号、租户及应用权限域。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuthorizationSnapshotRequest {
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long membershipId;
|
||||
private String appCode;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
/**
|
||||
* 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。
|
||||
*/
|
||||
@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient",
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamAuthorizationClient {
|
||||
@PostMapping("/authorization-snapshot")
|
||||
ApiResponse<AuthorizationSnapshot> load(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody AuthorizationSnapshotRequest request);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.auth.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
@FeignClient(name = "iam-service", contextId = "iamOnboardingClient",
|
||||
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
|
||||
public interface IamOnboardingClient {
|
||||
@PostMapping("/merchant-registrations")
|
||||
ApiResponse<OnboardingResponse> register(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestBody OnboardingRequest request);
|
||||
|
||||
record OnboardingRequest(Long authUserId, String displayName, String companyName,
|
||||
String legalPerson, String legalPhone, String contactEmail,
|
||||
String countryCode, String address) { }
|
||||
|
||||
record OnboardingResponse(Long tenantId, Long membershipId, String tenantStatus) { }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||
import com.baiyue.common.security.JwtAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
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.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 认证服务安全配置,集中声明密码编码器、JWT 编码器以及认证接口的安全过滤链。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||
public class AuthSecurityConfig {
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(12); }
|
||||
|
||||
@Bean
|
||||
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));
|
||||
}
|
||||
|
||||
@Bean
|
||||
SecurityFilterChain authSecurityFilterChain(HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtFilter,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
AccessDeniedHandler deniedHandler) throws Exception {
|
||||
return http.csrf(csrf -> csrf.disable())
|
||||
.httpBasic(httpBasic -> httpBasic.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(errors -> errors
|
||||
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout",
|
||||
"/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
|
||||
.requestMatchers("/internal/auth/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "baiyue.security")
|
||||
public record AuthSecurityProperties(String jwtSecret, String issuer,
|
||||
Duration accessTokenTtl, Duration refreshTokenTtl,
|
||||
int maxLoginFailures, Duration lockDuration) {
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.service.AuthService;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
import com.baiyue.auth.dto.RefreshRequest;
|
||||
import com.baiyue.auth.dto.TokenResponse;
|
||||
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
||||
import com.baiyue.auth.service.MerchantRegistrationService;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
* 统一认证接口,向平台端与商户端提供登录、刷新令牌和退出登录能力。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@Tag(name = "Authentication / 统一认证", description = "Unified login, token refresh and logout. / 统一登录、令牌刷新与退出。")
|
||||
public class AuthController {
|
||||
@Resource
|
||||
private AuthService authService;
|
||||
@Resource
|
||||
private MerchantRegistrationService registrationService;
|
||||
@Resource
|
||||
private AccountManagementService accountManagementService;
|
||||
|
||||
@PutMapping("/password")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Operation(summary = "修改当前用户密码")
|
||||
public ApiResponse<Void> changePassword(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody ChangePasswordRequest request) {
|
||||
accountManagementService.changePassword(user.userId(), request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Merchant registration / 客户注册",
|
||||
description = "Create a login account and an enterprise profile awaiting one business-license upload.")
|
||||
public ApiResponse<MerchantRegisterResponse> register(@Valid @RequestBody MerchantRegisterRequest request) {
|
||||
return ApiResponse.success(registrationService.register(request));
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "Login / 登录", description = "Validate credentials and issue access and refresh tokens. / 校验账号密码并签发访问令牌和刷新令牌。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Login succeeded / 登录成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid request / 请求参数错误"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Invalid username or password / 用户名或密码错误"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "429", description = "Account temporarily locked / 账号暂时锁定")
|
||||
})
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<TokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success(authService.login(request));
|
||||
}
|
||||
|
||||
@SecurityRequirements
|
||||
@Operation(summary = "Refresh token / 刷新令牌", description = "Rotate a valid refresh token and issue a new token pair. / 轮换有效刷新令牌并签发新的令牌对。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Token refreshed / 刷新成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Refresh token invalid or expired / 刷新令牌无效或已过期")
|
||||
})
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<TokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||
return ApiResponse.success(authService.refresh(request.refreshToken()));
|
||||
}
|
||||
|
||||
@Operation(summary = "Logout / 退出登录", description = "Revoke the supplied refresh token. / 吊销指定刷新令牌。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Logged out / 退出成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required / 需要登录")
|
||||
})
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(@Valid @RequestBody RefreshRequest request) {
|
||||
authService.logout(request.refreshToken());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.baiyue.auth.controller;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
import com.baiyue.auth.service.AccountManagementService;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** 仅供可信内部服务调用的账号管理接口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/auth/accounts")
|
||||
public class InternalAccountController {
|
||||
private final AccountManagementService service;
|
||||
private final String configuredToken;
|
||||
private byte[] tokenBytes;
|
||||
|
||||
public InternalAccountController(AccountManagementService service,
|
||||
@Value("${baiyue.security.internal-service-token}") String configuredToken) {
|
||||
this.service = service;
|
||||
this.configuredToken = configuredToken;
|
||||
}
|
||||
|
||||
@PostConstruct void init() { tokenBytes = configuredToken.getBytes(StandardCharsets.UTF_8); }
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<AccountSummary> create(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@Valid @RequestBody CreateAccountRequest request) {
|
||||
verify(token); return ApiResponse.success(service.create(request));
|
||||
}
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<AccountSummary> summary(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id) {
|
||||
verify(token); return ApiResponse.success(service.summary(id));
|
||||
}
|
||||
@PutMapping("/{id}/status")
|
||||
public ApiResponse<Void> status(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody UpdateAccountStatusRequest request) {
|
||||
verify(token); service.updateStatus(id, request); return ApiResponse.success(null);
|
||||
}
|
||||
@PutMapping("/{id}/password")
|
||||
public ApiResponse<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
|
||||
verify(token); service.resetPassword(id, request); return ApiResponse.success(null);
|
||||
}
|
||||
@DeleteMapping("/{id}/compensation")
|
||||
public ApiResponse<Void> compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
|
||||
@PathVariable Long id) {
|
||||
verify(token); service.compensateDelete(id); return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private void verify(String supplied) {
|
||||
byte[] value = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(tokenBytes, value)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
|
||||
/** 账号安全及内部账号管理契约。 */
|
||||
public final class AccountManagementDtos {
|
||||
private AccountManagementDtos() { }
|
||||
|
||||
public record ChangePasswordRequest(@NotBlank String oldPassword,
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||
public record CreateAccountRequest(@NotNull Long accountId,
|
||||
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
|
||||
@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
|
||||
@NotBlank String displayName, @NotNull Long tenantId,
|
||||
@NotNull Long membershipId) { }
|
||||
public record UpdateAccountStatusRequest(@NotBlank String status) { }
|
||||
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||
public record AccountSummary(Long accountId, String username, String displayName, String status) { }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
|
||||
*/
|
||||
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
|
||||
public record LoginRequest(
|
||||
@Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
|
||||
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
|
||||
public record MerchantRegisterRequest(
|
||||
@NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
|
||||
@NotBlank @Size(min = 8, max = 72) String password,
|
||||
@NotBlank @Size(max = 64) String displayName,
|
||||
@NotBlank @Size(max = 128) String companyName,
|
||||
@NotBlank @Size(max = 64) String legalPerson,
|
||||
@NotBlank @Size(max = 32) String legalPhone,
|
||||
@Email @Size(max = 128) String contactEmail,
|
||||
@Size(min = 2, max = 2) String countryCode,
|
||||
@Size(max = 255) String address
|
||||
) { }
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
public record MerchantRegisterResponse(
|
||||
Long accountId,
|
||||
Long tenantId,
|
||||
Long membershipId,
|
||||
String tenantStatus,
|
||||
String nextStep
|
||||
) { }
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 刷新或注销请求契约,承载客户端提交的刷新令牌。
|
||||
*/
|
||||
@Schema(name = "RefreshRequest", description = "Refresh token request / 刷新令牌请求")
|
||||
public record RefreshRequest(
|
||||
@Schema(description = "Refresh token returned by login or refresh / 登录或刷新接口返回的刷新令牌", example = "eyJ...refresh-token", format = "password") @NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.baiyue.auth.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
/**
|
||||
* 认证令牌响应契约,返回访问令牌、刷新令牌及其有效期等会话信息。
|
||||
*/
|
||||
@Schema(name = "TokenResponse", description = "Access and refresh token pair / 访问令牌与刷新令牌")
|
||||
public record TokenResponse(
|
||||
@Schema(description = "Token type / 令牌类型", example = "Bearer") String tokenType,
|
||||
@Schema(description = "JWT access token / JWT 访问令牌", format = "password") String accessToken,
|
||||
@Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn,
|
||||
@Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken,
|
||||
@Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.baiyue.auth.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 认证账号持久化实体,对应表 {@code auth_account};保存统一登录账号、密码摘要、账号类型、租户归属和启停状态,是身份凭证的数据源。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("auth_account")
|
||||
public class AuthAccount {
|
||||
@TableId private Long id;
|
||||
private String username;
|
||||
private String passwordHash;
|
||||
private String displayName;
|
||||
private String accountStatus;
|
||||
private Integer failedLoginCount;
|
||||
private LocalDateTime lockedUntil;
|
||||
private Integer tokenVersion;
|
||||
private Long defaultTenantId;
|
||||
private Long operatorTenantId;
|
||||
private Long defaultMembershipId;
|
||||
private String defaultAppCode;
|
||||
@TableLogic private Integer deleted;
|
||||
private LocalDateTime lastLoginAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.auth.entity;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 刷新令牌持久化实体,对应表 {@code auth_refresh_token};保存刷新令牌摘要、访问令牌标识、有效期和撤销状态,用于令牌轮换、追踪与失效控制。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("auth_refresh_token")
|
||||
public class AuthRefreshToken {
|
||||
@TableId private Long id;
|
||||
private String tokenHash;
|
||||
private Long userId;
|
||||
private Long tenantId;
|
||||
private Long operatorTenantId;
|
||||
private String appCode;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime revokedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 认证账号 MyBatis-Plus 数据访问接口,负责 {@code auth_account} 表的基础持久化操作。
|
||||
*/
|
||||
public interface AuthAccountMapper extends BaseMapper<AuthAccount> { }
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.auth.mapper;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
/**
|
||||
* 刷新令牌 MyBatis-Plus 数据访问接口,负责 {@code auth_refresh_token} 表的基础持久化操作。
|
||||
*/
|
||||
public interface AuthRefreshTokenMapper extends BaseMapper<AuthRefreshToken> { }
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.mapper.AuthAccountMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 认证账号仓储,封装按用户名或主键查询以及账号保存操作,隔离服务层与 MyBatis-Plus 查询细节。
|
||||
*/
|
||||
@Repository
|
||||
public class AuthAccountRepository {
|
||||
private final AuthAccountMapper mapper;
|
||||
|
||||
public AuthAccountRepository(AuthAccountMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AuthAccount findByUsername(String username) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
public boolean existsByUsername(String username) {
|
||||
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
|
||||
.eq(AuthAccount::getUsername, username)) > 0;
|
||||
}
|
||||
|
||||
public void insert(AuthAccount account) { mapper.insert(account); }
|
||||
|
||||
public AuthAccount findById(Long id) {
|
||||
return mapper.selectById(id);
|
||||
}
|
||||
|
||||
public void save(AuthAccount account) {
|
||||
mapper.updateById(account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.auth.repository;
|
||||
|
||||
import com.baiyue.auth.entity.AuthRefreshToken;
|
||||
import com.baiyue.auth.mapper.AuthRefreshTokenMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 刷新令牌仓储,封装令牌摘要查询、加锁读取、创建和状态更新操作。
|
||||
*/
|
||||
@Repository
|
||||
public class AuthRefreshTokenRepository {
|
||||
private final AuthRefreshTokenMapper mapper;
|
||||
|
||||
public AuthRefreshTokenRepository(AuthRefreshTokenMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public AuthRefreshToken findByHash(String tokenHash, boolean forUpdate) {
|
||||
String limit = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AuthRefreshToken>()
|
||||
.eq(AuthRefreshToken::getTokenHash, tokenHash)
|
||||
.last(limit));
|
||||
}
|
||||
|
||||
public void insert(AuthRefreshToken token) {
|
||||
mapper.insert(token);
|
||||
}
|
||||
|
||||
public void save(AuthRefreshToken token) {
|
||||
mapper.updateById(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.*;
|
||||
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.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
|
||||
/** 账号生命周期管理,所有密码只以 BCrypt 摘要写入数据库。 */
|
||||
@Service
|
||||
public class AccountManagementService {
|
||||
@Resource private AuthAccountRepository repository;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private TokenSessionStore tokenSessionStore;
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改本人密码", captureAfter = false)
|
||||
public void changePassword(Long accountId, ChangePasswordRequest request) {
|
||||
AuthAccount account = required(accountId);
|
||||
if (!passwordEncoder.matches(request.oldPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "原密码错误");
|
||||
}
|
||||
if (passwordEncoder.matches(request.newPassword(), account.getPasswordHash())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "新密码不能与原密码相同");
|
||||
}
|
||||
setPasswordAndRevoke(account, request.newPassword());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_CREATE", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.accountId", description = "创建认证账号")
|
||||
public AccountSummary create(CreateAccountRequest request) {
|
||||
if (repository.existsByUsername(request.username()) || repository.findById(request.accountId()) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
|
||||
}
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(request.accountId());
|
||||
account.setUsername(request.username());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setFailedLoginCount(0);
|
||||
account.setTokenVersion(1);
|
||||
account.setDefaultTenantId(request.tenantId());
|
||||
account.setOperatorTenantId(request.tenantId());
|
||||
account.setDefaultMembershipId(request.membershipId());
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setDeleted(0);
|
||||
repository.insert(account);
|
||||
return summary(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_STATUS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId", description = "修改账号状态", captureAfter = false)
|
||||
public void updateStatus(Long accountId, UpdateAccountStatusRequest request) {
|
||||
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "账号状态只允许 ACTIVE 或 DISABLED");
|
||||
}
|
||||
AuthAccount account = required(accountId);
|
||||
account.setAccountStatus(request.status());
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
|
||||
public void resetPassword(Long accountId, ResetPasswordRequest request) {
|
||||
setPasswordAndRevoke(required(accountId), request.newPassword());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT",
|
||||
resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false)
|
||||
public void compensateDelete(Long accountId) {
|
||||
AuthAccount account = repository.findById(accountId);
|
||||
if (account != null) {
|
||||
account.setAccountStatus("DISABLED");
|
||||
account.setDeleted(1);
|
||||
bumpVersion(account);
|
||||
}
|
||||
}
|
||||
|
||||
public AccountSummary summary(Long accountId) { return summary(required(accountId)); }
|
||||
|
||||
private void setPasswordAndRevoke(AuthAccount account, String password) {
|
||||
account.setPasswordHash(passwordEncoder.encode(password));
|
||||
account.setFailedLoginCount(0);
|
||||
account.setLockedUntil(null);
|
||||
bumpVersion(account);
|
||||
}
|
||||
|
||||
private void bumpVersion(AuthAccount account) {
|
||||
account.setTokenVersion((account.getTokenVersion() == null ? 0 : account.getTokenVersion()) + 1);
|
||||
repository.save(account);
|
||||
tokenSessionStore.revokeAll(account.getId());
|
||||
}
|
||||
|
||||
private AuthAccount required(Long id) {
|
||||
AuthAccount account = repository.findById(id);
|
||||
if (account == null) throw new BizException(ResultCode.NOT_FOUND, "账号不存在");
|
||||
return account;
|
||||
}
|
||||
|
||||
private AccountSummary summary(AuthAccount a) {
|
||||
return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.auth.dto.LoginRequest;
|
||||
import com.baiyue.auth.dto.TokenResponse;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 统一认证应用服务,编排密码校验、IAM 权限快照加载、JWT 签发、刷新令牌轮换与注销事务。
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
@Resource
|
||||
private AuthAccountRepository accountRepository;
|
||||
@Resource
|
||||
private PasswordEncoder passwordEncoder;
|
||||
@Resource
|
||||
private JwtEncoder jwtEncoder;
|
||||
@Resource
|
||||
private AuthSecurityProperties properties;
|
||||
@Resource
|
||||
private AuthorizationLoader authorizationLoader;
|
||||
@Resource
|
||||
private TokenSessionStore tokenSessionStore;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
|
||||
description = "User login / 用户登录", captureAfter = false)
|
||||
public TokenResponse login(LoginRequest request) {
|
||||
AuthAccount account = accountRepository.findByUsername(request.username());
|
||||
if (account == null) throw invalidCredentials();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!"ACTIVE".equals(account.getAccountStatus()))
|
||||
throw new BizException(ResultCode.FORBIDDEN, "账号已禁用");
|
||||
if (account.getLockedUntil() != null && account.getLockedUntil().isAfter(now))
|
||||
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "账号暂时锁定,请稍后重试");
|
||||
if (!passwordEncoder.matches(request.password(), account.getPasswordHash())) {
|
||||
recordFailure(account, now);
|
||||
throw invalidCredentials();
|
||||
}
|
||||
account.setFailedLoginCount(0);
|
||||
account.setLockedUntil(null);
|
||||
account.setLastLoginAt(now);
|
||||
accountRepository.save(account);
|
||||
return issue(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "AUTH_TOKEN_REFRESH", resourceType = "REFRESH_TOKEN",
|
||||
description = "Refresh access token / 刷新访问令牌", captureBefore = false, captureAfter = false)
|
||||
public TokenResponse refresh(String rawRefreshToken) {
|
||||
TokenSessionStore.RefreshSession session = tokenSessionStore.consume(rawRefreshToken);
|
||||
if (session == null) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌无效或已过期");
|
||||
}
|
||||
AuthAccount account = accountRepository.findById(session.userId());
|
||||
if (account == null || !"ACTIVE".equals(account.getAccountStatus()))
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用");
|
||||
return issue(account);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "AUTH_LOGOUT", resourceType = "REFRESH_TOKEN",
|
||||
description = "User logout / 用户退出", captureBefore = false, captureAfter = false)
|
||||
public void logout(String rawRefreshToken) {
|
||||
tokenSessionStore.revoke(rawRefreshToken);
|
||||
}
|
||||
|
||||
private void recordFailure(AuthAccount account, LocalDateTime now) {
|
||||
int failures = account.getFailedLoginCount() == null ? 1 : account.getFailedLoginCount() + 1;
|
||||
account.setFailedLoginCount(failures);
|
||||
if (failures >= properties.maxLoginFailures()) account.setLockedUntil(now.plus(properties.lockDuration()));
|
||||
accountRepository.save(account);
|
||||
}
|
||||
|
||||
private TokenResponse issue(AuthAccount account) {
|
||||
AuthorizationSnapshot authorization = authorizationLoader.load(account);
|
||||
Instant issuedAt = Instant.now();
|
||||
String jwtId = UUID.randomUUID().toString();
|
||||
Instant expiresAt = issuedAt.plus(properties.accessTokenTtl());
|
||||
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||
.issuer(properties.issuer()).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", account.getDefaultAppCode())
|
||||
.claim("client_type", authorization.getClientType())
|
||||
.claim("roles", authorization.getRoles())
|
||||
.claim("permissions", authorization.getPermissions())
|
||||
.claim("token_version", account.getTokenVersion())
|
||||
.build();
|
||||
String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(
|
||||
JwsHeader.with(MacAlgorithm.HS256).build(), claims)).getTokenValue();
|
||||
|
||||
byte[] random = new byte[48];
|
||||
secureRandom.nextBytes(random);
|
||||
String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
|
||||
tokenSessionStore.store(jwtId, account.getId(), rawRefresh,
|
||||
properties.accessTokenTtl(), properties.refreshTokenTtl());
|
||||
return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(),
|
||||
rawRefresh, properties.refreshTokenTtl().toSeconds());
|
||||
}
|
||||
|
||||
private static BizException invalidCredentials() {
|
||||
return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.client.IamOnboardingClient;
|
||||
import com.baiyue.auth.dto.MerchantRegisterRequest;
|
||||
import com.baiyue.auth.dto.MerchantRegisterResponse;
|
||||
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 com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Service
|
||||
public class MerchantRegistrationService {
|
||||
@Resource private AuthAccountRepository accountRepository;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private IamOnboardingClient onboardingClient;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "MERCHANT_REGISTER", resourceType = "ACCOUNT",
|
||||
resourceId = "#request.username", description = "商户注册", captureAfter = false)
|
||||
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
|
||||
if (accountRepository.existsByUsername(request.username())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Username already exists");
|
||||
}
|
||||
long accountId = IdWorker.getId();
|
||||
IamOnboardingClient.OnboardingResponse onboarding;
|
||||
try {
|
||||
var response = onboardingClient.register(internalToken,
|
||||
new IamOnboardingClient.OnboardingRequest(accountId, request.displayName(),
|
||||
request.companyName(), request.legalPerson(), request.legalPhone(),
|
||||
request.contactEmail(), request.countryCode(), request.address()));
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration failed");
|
||||
}
|
||||
onboarding = response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "Enterprise already exists");
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM registration service is unavailable");
|
||||
}
|
||||
|
||||
AuthAccount account = new AuthAccount();
|
||||
account.setId(accountId);
|
||||
account.setUsername(request.username());
|
||||
account.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
account.setDisplayName(request.displayName());
|
||||
account.setAccountStatus("ACTIVE");
|
||||
account.setFailedLoginCount(0);
|
||||
account.setTokenVersion(1);
|
||||
account.setDefaultTenantId(onboarding.tenantId());
|
||||
account.setOperatorTenantId(onboarding.tenantId());
|
||||
account.setDefaultMembershipId(onboarding.membershipId());
|
||||
account.setDefaultAppCode("MERCHANT");
|
||||
account.setDeleted(0);
|
||||
accountRepository.insert(account);
|
||||
return new MerchantRegisterResponse(accountId, onboarding.tenantId(), onboarding.membershipId(),
|
||||
onboarding.tenantStatus(), "LOGIN_AND_UPLOAD_BUSINESS_LICENSE");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.RedisTokenKeys;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
import org.springframework.data.redis.core.Cursor;
|
||||
import org.springframework.data.redis.core.ScanOptions;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Set;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。
|
||||
*/
|
||||
@Component
|
||||
public class TokenSessionStore {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String keyPrefix;
|
||||
|
||||
public TokenSessionStore(StringRedisTemplate redisTemplate, ObjectMapper objectMapper,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
}
|
||||
|
||||
public void store(String jwtId, Long userId, String rawRefreshToken,
|
||||
Duration accessTtl, Duration refreshTtl) {
|
||||
String accessKey = RedisTokenKeys.access(keyPrefix, jwtId);
|
||||
String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken));
|
||||
String userKey = userSessionsKey(userId);
|
||||
try {
|
||||
redisTemplate.opsForValue().set(accessKey, userId.toString(), accessTtl);
|
||||
String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId));
|
||||
redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl);
|
||||
SetOperations<String, String> sets = redisTemplate.opsForSet();
|
||||
if (sets != null) sets.add(userKey, accessKey, refreshKey);
|
||||
redisTemplate.expire(userKey, refreshTtl);
|
||||
} catch (Exception exception) {
|
||||
try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { }
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public RefreshSession consume(String rawRefreshToken) {
|
||||
try {
|
||||
String value = redisTemplate.opsForValue().getAndDelete(
|
||||
RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)));
|
||||
if (value == null || value.isBlank()) return null;
|
||||
RefreshSession session = objectMapper.readValue(value, RefreshSession.class);
|
||||
redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId()));
|
||||
return session;
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
} catch (Exception exception) {
|
||||
throw new BizException(ResultCode.UNAUTHORIZED, "刷新令牌会话无效");
|
||||
}
|
||||
}
|
||||
|
||||
public void revoke(String rawRefreshToken) {
|
||||
consume(rawRefreshToken);
|
||||
}
|
||||
|
||||
/** 撤销该账号当前已登记的全部访问令牌和刷新令牌。 */
|
||||
public void revokeAll(Long userId) {
|
||||
String userKey = userSessionsKey(userId);
|
||||
try {
|
||||
Set<String> keys = redisTemplate.opsForSet().members(userKey);
|
||||
if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys);
|
||||
// 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。
|
||||
List<String> legacyKeys = new ArrayList<>();
|
||||
collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false);
|
||||
collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true);
|
||||
if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys);
|
||||
redisTemplate.delete(userKey);
|
||||
} catch (DataAccessException exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void collectLegacyKeys(String pattern, Long userId, List<String> result, boolean refresh) {
|
||||
Cursor<String> cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build());
|
||||
if (cursor == null) return;
|
||||
try (cursor) {
|
||||
while (cursor.hasNext()) {
|
||||
String key = cursor.next();
|
||||
String value = redisTemplate.opsForValue().get(key);
|
||||
if ((!refresh && userId.toString().equals(value)) || (refresh && belongsTo(value, userId))) {
|
||||
result.add(key);
|
||||
}
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
throw unavailable(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean belongsTo(String value, Long userId) {
|
||||
if (value == null || value.isBlank()) return false;
|
||||
try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); }
|
||||
catch (Exception ignored) { return false; }
|
||||
}
|
||||
|
||||
private String userSessionsKey(Long userId) {
|
||||
return keyPrefix + ":user-sessions:" + userId;
|
||||
}
|
||||
|
||||
private static BizException unavailable(Exception cause) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"Redis 令牌会话服务暂时不可用: " + cause.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public record RefreshSession(Long userId, String accessJwtId) { }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
server:
|
||||
port: ${AUTH_PORT:9100}
|
||||
spring:
|
||||
application:
|
||||
name: auth-service
|
||||
config:
|
||||
import:
|
||||
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
|
||||
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:127.0.0.1}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: ${REDIS_TIMEOUT:3s}
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
username: ${NACOS_USERNAME:nacos}
|
||||
password: ${NACOS_PASSWORD:nacos}
|
||||
discovery:
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:true}
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
group: ${NACOS_GROUP:BAIYUE}
|
||||
namespace: ${NACOS_NAMESPACE:public}
|
||||
config.namespace: ${NACOS_NAMESPACE:public}
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
|
||||
username: ${AUTH_DB_USERNAME:root}
|
||||
password: ${AUTH_DB_PASSWORD:123456}
|
||||
|
||||
baiyue.security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
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}
|
||||
issuer: ${JWT_ISSUER:auth-service}
|
||||
access-token-ttl: ${ACCESS_TOKEN_TTL:30m}
|
||||
refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}
|
||||
max-login-failures: ${MAX_LOGIN_FAILURES:5}
|
||||
lock-duration: ${LOGIN_LOCK_DURATION:15m}
|
||||
|
||||
baiyue.clients:
|
||||
iam:
|
||||
url: ${IAM_SERVICE_URL:}
|
||||
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
@@ -0,0 +1,44 @@
|
||||
SET NAMES utf8mb4;
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_auth
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_auth;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_account (
|
||||
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
|
||||
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
|
||||
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
|
||||
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
|
||||
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE、DISABLED、LOCKED',
|
||||
failed_login_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||
locked_until DATETIME(3) DEFAULT NULL COMMENT '临时锁定截止时间',
|
||||
token_version INT NOT NULL DEFAULT 1 COMMENT '令牌版本,递增后可使历史令牌失效',
|
||||
default_tenant_id BIGINT NOT NULL COMMENT '默认业务租户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '操作主体租户ID,平台端与商户端身份隔离',
|
||||
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 '逻辑删除标记',
|
||||
last_login_at DATETIME(3) DEFAULT 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 (id),
|
||||
UNIQUE KEY uk_auth_account_username (username),
|
||||
KEY idx_auth_account_status (account_status, locked_until)
|
||||
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_refresh_token (
|
||||
id BIGINT NOT NULL COMMENT '刷新令牌记录ID(雪花ID)',
|
||||
token_hash CHAR(64) NOT NULL COMMENT '刷新令牌SHA-256摘要,不保存令牌明文',
|
||||
user_id BIGINT NOT NULL COMMENT '认证账号ID',
|
||||
tenant_id BIGINT NOT NULL COMMENT '令牌当前业务租户ID',
|
||||
operator_tenant_id BIGINT NOT NULL COMMENT '令牌操作主体租户ID',
|
||||
app_code VARCHAR(20) NOT NULL COMMENT '令牌所属应用端',
|
||||
expires_at DATETIME(3) NOT NULL COMMENT '到期时间',
|
||||
revoked_at DATETIME(3) DEFAULT NULL COMMENT '吊销时间,非空表示不可继续使用',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '签发时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_auth_refresh_hash (token_hash),
|
||||
KEY idx_auth_refresh_user (user_id, revoked_at, expires_at),
|
||||
KEY idx_auth_refresh_expire (expires_at, revoked_at)
|
||||
) ENGINE=InnoDB COMMENT='刷新令牌表:保存令牌摘要、租户上下文、有效期和轮换/吊销状态';
|
||||
|
||||
-- 初始管理员密码必须由部署脚本生成 BCrypt 哈希后显式写入,仓库内禁止提供默认密码。
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Phase-one local administrator accounts.
|
||||
-- Development-only initial password: ChangeMe123!
|
||||
-- The BCrypt hash is intentionally stored instead of plaintext.
|
||||
USE baiyue_auth;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO auth_account (
|
||||
id, username, password_hash, display_name, account_status,
|
||||
failed_login_count, token_version, default_tenant_id,
|
||||
operator_tenant_id, default_membership_id, default_app_code, deleted
|
||||
) VALUES
|
||||
(1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'平台管理员', 'ACTIVE', 0, 1, 0, 0, 3000001, 'PLATFORM', 0),
|
||||
(1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96',
|
||||
'一期演示企业管理员', 'ACTIVE', 0, 1, 10001, 10001, 3000002, 'MERCHANT', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
password_hash = VALUES(password_hash),
|
||||
display_name = VALUES(display_name),
|
||||
account_status = 'ACTIVE',
|
||||
failed_login_count = 0,
|
||||
locked_until = NULL,
|
||||
default_tenant_id = VALUES(default_tenant_id),
|
||||
operator_tenant_id = VALUES(operator_tenant_id),
|
||||
default_membership_id = VALUES(default_membership_id),
|
||||
default_app_code = VALUES(default_app_code),
|
||||
deleted = 0;
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
|
||||
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
|
||||
import com.baiyue.auth.entity.AuthAccount;
|
||||
import com.baiyue.auth.repository.AuthAccountRepository;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AccountManagementServiceTest {
|
||||
private final AuthAccountRepository repository = mock(AuthAccountRepository.class);
|
||||
private final TokenSessionStore sessions = mock(TokenSessionStore.class);
|
||||
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(4);
|
||||
private final AccountManagementService service = new AccountManagementService();
|
||||
private AuthAccount account;
|
||||
|
||||
@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.setTokenVersion(2); account.setAccountStatus("ACTIVE");
|
||||
when(repository.findById(10L)).thenReturn(account);
|
||||
}
|
||||
|
||||
@Test void 修改密码校验旧密码并撤销全部会话() {
|
||||
service.changePassword(10L, new ChangePasswordRequest("OldPass1", "NewPass2"));
|
||||
assertTrue(encoder.matches("NewPass2", account.getPasswordHash()));
|
||||
assertEquals(3, account.getTokenVersion());
|
||||
verify(repository).save(account);
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
|
||||
@Test void 原密码错误不得写入() {
|
||||
assertThrows(BizException.class,
|
||||
() -> service.changePassword(10L, new ChangePasswordRequest("WrongPass1", "NewPass2")));
|
||||
verify(repository, never()).save(any());
|
||||
verify(sessions, never()).revokeAll(any());
|
||||
}
|
||||
|
||||
@Test void 停用账号撤销全部会话() {
|
||||
service.updateStatus(10L, new UpdateAccountStatusRequest("DISABLED"));
|
||||
assertEquals("DISABLED", account.getAccountStatus());
|
||||
verify(sessions).revokeAll(10L);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.baiyue.auth.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.data.redis.core.SetOperations;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.eq;
|
||||
|
||||
class TokenSessionStoreTest {
|
||||
@Test
|
||||
void storesAccessAndHashedRefreshWithIndependentTtls() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
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));
|
||||
|
||||
verify(values).set("baiyue:auth:access:jti-1", "42", Duration.ofMinutes(30));
|
||||
verify(values).set(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"),
|
||||
org.mockito.ArgumentMatchers.contains("\"userId\":42"), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallyConsumesRefreshAndRevokesItsAccessSession() throws Exception {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
String json = new ObjectMapper().writeValueAsString(
|
||||
new TokenSessionStore.RefreshSession(42L, "jti-1"));
|
||||
when(values.getAndDelete(anyString())).thenReturn(json);
|
||||
TokenSessionStore store = new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth");
|
||||
|
||||
TokenSessionStore.RefreshSession session = store.consume("raw-refresh");
|
||||
|
||||
assertThat(session.userId()).isEqualTo(42L);
|
||||
assertThat(session.accessJwtId()).isEqualTo("jti-1");
|
||||
verify(values).getAndDelete(org.mockito.ArgumentMatchers.startsWith("baiyue:auth:refresh:"));
|
||||
verify(redis).delete("baiyue:auth:access:jti-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsAlreadyConsumedRefreshAsInvalid() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ValueOperations<String, String> values = mock(ValueOperations.class);
|
||||
when(redis.opsForValue()).thenReturn(values);
|
||||
when(values.getAndDelete(anyString())).thenReturn(null);
|
||||
|
||||
assertThat(new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth")
|
||||
.consume("raw-refresh")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokesAllIndexedSessionsForUser() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
SetOperations<String, String> sets = mock(SetOperations.class);
|
||||
when(redis.opsForSet()).thenReturn(sets);
|
||||
when(sets.members("baiyue:auth:user-sessions:42"))
|
||||
.thenReturn(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b"));
|
||||
|
||||
new TokenSessionStore(redis, new ObjectMapper(), "baiyue:auth").revokeAll(42L);
|
||||
|
||||
verify(redis).delete(eq(Set.of("baiyue:auth:access:a", "baiyue:auth:refresh:b")));
|
||||
verify(redis).delete("baiyue:auth:user-sessions:42");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-audit</artifactId>
|
||||
<dependencies>
|
||||
<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-web</artifactId></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 跨服务业务审计事件契约,记录操作对象、操作者、租户、请求上下文、执行结果和变更快照。
|
||||
*/
|
||||
public record AuditEvent(
|
||||
String eventType, String resourceType, String resourceId, String description,
|
||||
String requestId, String traceId, Long tenantId, 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, Instant occurredAt, String serviceName
|
||||
) { }
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 业务审计配置模型,控制审计开关、输出方式、远程地址、服务名和快照长度限制。
|
||||
*/
|
||||
@ConfigurationProperties("baiyue.audit")
|
||||
public class AuditProperties {
|
||||
private boolean enabled = true;
|
||||
private String endpoint;
|
||||
private int maxPayloadLength = 16000;
|
||||
private String serviceName = "unknown-service";
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
public String getEndpoint() { return endpoint; }
|
||||
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
|
||||
public int getMaxPayloadLength() { return maxPayloadLength; }
|
||||
public void setMaxPayloadLength(int maxPayloadLength) { this.maxPayloadLength = maxPayloadLength; }
|
||||
public String getServiceName() { return serviceName; }
|
||||
public void setServiceName(String serviceName) { this.serviceName = serviceName; }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
/**
|
||||
* 业务审计事件输出接口,用于解耦审计事件采集与日志或远程服务等落地方式。
|
||||
*/
|
||||
public interface AuditSink {
|
||||
void publish(AuditEvent event);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* 业务审计自动配置,按配置创建脱敏器、审计输出端和审计切面。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(AuditProperties.class)
|
||||
@ConditionalOnProperty(prefix = "baiyue.audit", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class BaiyueAuditAutoConfiguration {
|
||||
@Bean @ConditionalOnMissingBean AuditSink auditSink(RestClient.Builder builder, AuditProperties p) {
|
||||
return p.getEndpoint() == null || p.getEndpoint().isBlank() ? new LoggingAuditSink() : new HttpAuditSink(builder, p.getEndpoint());
|
||||
}
|
||||
@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()); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* 业务审计方法注解,声明事件类型、资源标识、描述以及是否采集变更前后数据。
|
||||
*/
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface BusinessAudit {
|
||||
String event();
|
||||
String resourceType();
|
||||
String resourceId() default ""; // SpEL, e.g. #request.orderId
|
||||
String description() default "";
|
||||
boolean captureBefore() default true;
|
||||
boolean captureAfter() default true;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.expression.MethodBasedEvaluationContext;
|
||||
import org.springframework.core.DefaultParameterNameDiscoverer;
|
||||
import org.springframework.expression.ExpressionParser;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* 业务审计切面,拦截审计注解方法并采集操作者、租户、请求、耗时、结果及脱敏前后快照。
|
||||
*/
|
||||
@Aspect
|
||||
public class BusinessAuditAspect {
|
||||
private static final Logger log = LoggerFactory.getLogger(BusinessAuditAspect.class);
|
||||
private final AuditSink sink;
|
||||
private final SensitiveDataSanitizer sanitizer;
|
||||
private final String serviceName;
|
||||
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)")
|
||||
public Object around(ProceedingJoinPoint pjp, BusinessAudit audit) throws Throwable {
|
||||
long start = System.nanoTime();
|
||||
String before = audit.captureBefore() ? sanitizer.sanitize(pjp.getArgs()) : null;
|
||||
Object result = null; Throwable failure = null;
|
||||
try { result = pjp.proceed(); return result; }
|
||||
catch (Throwable ex) { failure = ex; throw ex; }
|
||||
finally {
|
||||
try { publish(pjp, audit, before, result, failure, (System.nanoTime() - start) / 1_000_000); }
|
||||
catch (RuntimeException ex) { log.warn("Audit capture failed", ex); }
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(ProceedingJoinPoint pjp, BusinessAudit a, String before, Object result,
|
||||
Throwable failure, long duration) {
|
||||
HttpServletRequest req = request();
|
||||
AuthenticatedUser user = currentUser();
|
||||
String resourceId = evaluateResourceId(pjp, a.resourceId());
|
||||
sink.publish(new AuditEvent(a.event(), a.resourceType(), resourceId, a.description(),
|
||||
req == null ? null : CorrelationContext.requestId(req), req == null ? null : CorrelationContext.traceId(req),
|
||||
user == null ? headerLong(req, "X-Tenant-Id") : user.tenantId(),
|
||||
user == null ? headerLong(req, "X-Operator-Tenant-Id") : user.operatorTenantId(),
|
||||
user == null ? null : user.userId(), user == null ? null : user.username(),
|
||||
user == null ? header(req, "X-App-Code") : user.appCode(), user == null ? null : user.clientType(),
|
||||
req == null ? null : req.getMethod(), req == null ? null : req.getRequestURI(),
|
||||
req == null ? null : clientIp(req), duration, failure == null,
|
||||
failure == null ? null : failure.getClass().getSimpleName(),
|
||||
failure == null ? null : failure.getMessage(), before,
|
||||
a.captureAfter() ? sanitizer.sanitize(result) : null, Instant.now(),
|
||||
serviceName));
|
||||
}
|
||||
|
||||
private HttpServletRequest request() {
|
||||
var attrs = RequestContextHolder.getRequestAttributes();
|
||||
return attrs instanceof ServletRequestAttributes s ? s.getRequest() : null;
|
||||
}
|
||||
private AuthenticatedUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
return auth != null && auth.getPrincipal() instanceof AuthenticatedUser u ? u : null;
|
||||
}
|
||||
private String evaluateResourceId(ProceedingJoinPoint p, String expression) {
|
||||
if (expression == null || expression.isBlank()) return null;
|
||||
MethodSignature s = (MethodSignature) p.getSignature();
|
||||
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();
|
||||
}
|
||||
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 String clientIp(HttpServletRequest r) { String x = r.getHeader("X-Forwarded-For"); return x == null ? r.getRemoteAddr() : x.split(",")[0].trim(); }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/**
|
||||
* HTTP 审计输出实现,将结构化业务审计事件发送到独立审计服务。
|
||||
*/
|
||||
public class HttpAuditSink implements AuditSink {
|
||||
private static final Logger auditLog = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||
private final RestClient client;
|
||||
private final String endpoint;
|
||||
|
||||
public HttpAuditSink(RestClient.Builder builder, String endpoint) {
|
||||
this.client = builder.build();
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
@Override public void publish(AuditEvent event) {
|
||||
try { client.post().uri(endpoint).body(event).retrieve().toBodilessEntity(); }
|
||||
catch (RuntimeException ex) {
|
||||
auditLog.warn("deliveryFailed errorType={} errorMessage={} audit={}",
|
||||
ex.getClass().getSimpleName(), ex.getMessage(), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* 日志审计输出实现,在未配置远程审计服务时将结构化审计事件写入应用日志。
|
||||
*/
|
||||
public class LoggingAuditSink implements AuditSink {
|
||||
private static final Logger log = LoggerFactory.getLogger("BAIYUE_AUDIT");
|
||||
@Override public void publish(AuditEvent event) { log.info("audit={}", event); }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.common.audit;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 审计数据脱敏器,在记录请求快照前递归屏蔽密码、令牌、密钥等敏感字段。
|
||||
*/
|
||||
public class SensitiveDataSanitizer {
|
||||
private static final Set<String> SENSITIVE = Set.of("password", "passwordhash", "token", "accesstoken",
|
||||
"refreshtoken", "authorization", "secret", "idcard", "bankcard", "mobile", "phone", "email");
|
||||
private final ObjectMapper mapper;
|
||||
private final int maxLength;
|
||||
|
||||
public SensitiveDataSanitizer(ObjectMapper mapper, int maxLength) {
|
||||
this.mapper = mapper;
|
||||
this.maxLength = maxLength;
|
||||
}
|
||||
|
||||
public String sanitize(Object value) {
|
||||
if (value == null) return null;
|
||||
try {
|
||||
JsonNode root = mapper.valueToTree(value);
|
||||
redact(root);
|
||||
String json = mapper.writeValueAsString(root);
|
||||
return json.length() <= maxLength ? json : json.substring(0, maxLength) + "...[truncated]";
|
||||
} catch (IllegalArgumentException | JsonProcessingException e) {
|
||||
return "\"[unserializable]\"";
|
||||
}
|
||||
}
|
||||
|
||||
private void redact(JsonNode node) {
|
||||
if (node == null) return;
|
||||
if (node.isObject()) {
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
String normalized = field.getKey().replace("_", "").toLowerCase(Locale.ROOT);
|
||||
if (SENSITIVE.stream().anyMatch(normalized::contains)) {
|
||||
((ObjectNode) node).put(field.getKey(), "******");
|
||||
} else redact(field.getValue());
|
||||
}
|
||||
} else if (node.isArray()) node.forEach(this::redact);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
<name>baiyue-common-core</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springframework</groupId><artifactId>spring-web</artifactId></dependency>
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.common.core.api;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 统一 API 响应封装,包含结果码、消息、业务数据、请求标识和响应时间。
|
||||
*/
|
||||
public record ApiResponse<T>(
|
||||
String code,
|
||||
String message,
|
||||
T data,
|
||||
String requestId,
|
||||
String traceId,
|
||||
Instant timestamp) {
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return of(ResultCode.SUCCESS, ResultCode.SUCCESS.message(), data, null, null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(T data, String requestId, String traceId) {
|
||||
return of(ResultCode.SUCCESS, ResultCode.SUCCESS.message(), data, requestId, traceId);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> failure(ResultCode resultCode, String message,
|
||||
String requestId, String traceId) {
|
||||
return of(resultCode, message, null, requestId, traceId);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> of(ResultCode resultCode, String message, T data,
|
||||
String requestId, String traceId) {
|
||||
return new ApiResponse<>(resultCode.code(), message, data, requestId, traceId, Instant.now());
|
||||
}
|
||||
|
||||
public boolean successful() {
|
||||
return ResultCode.SUCCESS.code().equals(code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.common.core.api;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 统一分页响应模型,将 MyBatis-Plus 分页结果转换为稳定的接口契约。
|
||||
*/
|
||||
public record PageResult<T>(List<T> records, long page, long size, long total, long pages) {
|
||||
public PageResult {
|
||||
records = records == null ? List.of() : List.copyOf(records);
|
||||
if (page < 1 || size < 1 || total < 0 || pages < 0) {
|
||||
throw new IllegalArgumentException("Invalid pagination metadata");
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> PageResult<T> from(IPage<T> source) {
|
||||
return new PageResult<>(source.getRecords(), source.getCurrent(), source.getSize(),
|
||||
source.getTotal(), source.getPages());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.common.core.api;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* 统一接口结果码枚举,定义业务错误编码、默认消息和对应 HTTP 状态。
|
||||
*/
|
||||
public enum ResultCode {
|
||||
SUCCESS("0", "success", HttpStatus.OK),
|
||||
BAD_REQUEST("A0400", "Bad request", HttpStatus.BAD_REQUEST),
|
||||
UNAUTHORIZED("A0401", "Authentication required", HttpStatus.UNAUTHORIZED),
|
||||
FORBIDDEN("A0403", "Access denied", HttpStatus.FORBIDDEN),
|
||||
NOT_FOUND("A0404", "Resource not found", HttpStatus.NOT_FOUND),
|
||||
CONFLICT("A0409", "Resource conflict", HttpStatus.CONFLICT),
|
||||
VALIDATION_FAILED("A0422", "Validation failed", HttpStatus.UNPROCESSABLE_ENTITY),
|
||||
TOO_MANY_REQUESTS("A0429", "Too many requests", HttpStatus.TOO_MANY_REQUESTS),
|
||||
BUSINESS_ERROR("B0001", "Business operation failed", HttpStatus.BAD_REQUEST),
|
||||
INTERNAL_ERROR("S0500", "Internal server error", HttpStatus.INTERNAL_SERVER_ERROR),
|
||||
SERVICE_UNAVAILABLE("S0503", "Service unavailable", HttpStatus.SERVICE_UNAVAILABLE);
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
private final HttpStatus httpStatus;
|
||||
|
||||
ResultCode(String code, String message, HttpStatus httpStatus) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.httpStatus = httpStatus;
|
||||
}
|
||||
|
||||
public String code() { return code; }
|
||||
public String message() { return message; }
|
||||
public HttpStatus httpStatus() { return httpStatus; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.common.core.exception;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
/**
|
||||
* 统一业务异常,携带标准结果码供全局异常处理器转换为 API 错误响应。
|
||||
*/
|
||||
public class BizException extends RuntimeException {
|
||||
private final ResultCode resultCode;
|
||||
|
||||
public BizException(String message) {
|
||||
this(ResultCode.BUSINESS_ERROR, message);
|
||||
}
|
||||
|
||||
public BizException(ResultCode resultCode) {
|
||||
this(resultCode, resultCode.message());
|
||||
}
|
||||
|
||||
public BizException(ResultCode resultCode, String message) {
|
||||
super(message);
|
||||
this.resultCode = resultCode;
|
||||
}
|
||||
|
||||
public ResultCode getResultCode() {
|
||||
return resultCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.common.core.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.Version;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 业务持久化实体公共基类,统一维护创建/更新审计字段、逻辑删除标记和乐观锁版本号。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public abstract class BaseEntity {
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Long createdBy;
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createdAt;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private Long updatedBy;
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updatedAt;
|
||||
@TableLogic
|
||||
private Boolean deleted;
|
||||
@Version
|
||||
private Integer version;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.common.core.mybatis;
|
||||
|
||||
/**
|
||||
* 审计操作者提供接口,为 MyBatis-Plus 自动填充字段提供当前用户标识。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AuditorProvider {
|
||||
Long currentUserId();
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.common.core.mybatis;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 公共配置,注册分页、乐观锁、防全表更新删除和审计字段自动填充能力。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class MybatisPlusConfiguration {
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL);
|
||||
pagination.setOverflow(false);
|
||||
pagination.setMaxLimit(500L);
|
||||
interceptor.addInnerInterceptor(pagination);
|
||||
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public AuditorProvider auditorProvider() {
|
||||
return () -> null;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(MetaObjectHandler.class)
|
||||
public MetaObjectHandler auditMetaObjectHandler(AuditorProvider auditorProvider) {
|
||||
return new MetaObjectHandler() {
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
|
||||
strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
|
||||
strictInsertFill(metaObject, "createdBy", Long.class, auditorProvider.currentUserId());
|
||||
strictInsertFill(metaObject, "updatedBy", Long.class, auditorProvider.currentUserId());
|
||||
strictInsertFill(metaObject, "deleted", Boolean.class, false);
|
||||
strictInsertFill(metaObject, "version", Integer.class, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
|
||||
strictUpdateFill(metaObject, "updatedBy", Long.class, auditorProvider.currentUserId());
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.common.core;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class ApiResponseTest {
|
||||
@Test
|
||||
void createsSuccessfulResponseWithCorrelationIds() {
|
||||
ApiResponse<String> response = ApiResponse.success("ok", "request-1", "trace-1");
|
||||
assertThat(response.code()).isEqualTo(ResultCode.SUCCESS.code());
|
||||
assertThat(response.successful()).isTrue();
|
||||
assertThat(response.requestId()).isEqualTo("request-1");
|
||||
assertThat(response.timestamp()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageResultIsImmutableAndRejectsInvalidMetadata() {
|
||||
PageResult<String> result = new PageResult<>(List.of("a"), 1, 20, 1, 1);
|
||||
assertThat(result.records()).containsExactly("a");
|
||||
assertThatThrownBy(() -> new PageResult<>(List.of(), 0, 20, 0, 0))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>common-event-contract</artifactId>
|
||||
<name>common-event-contract</name>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 跨服务和跨区域传输使用的不可变事件信封。
|
||||
*
|
||||
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
|
||||
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
|
||||
*
|
||||
* @param eventId 事件全局唯一 ID,使用标准 UUID 或 26 位 ULID
|
||||
* @param eventType 事件类型,不包含版本号
|
||||
* @param eventVersion 事件契约版本,从 1 开始
|
||||
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
|
||||
* @param sourceRegion 事件来源业务单元
|
||||
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
|
||||
* @param tenantId 事件所属客户租户 ID
|
||||
* @param aggregateType 聚合类型
|
||||
* @param aggregateId 聚合 ID
|
||||
* @param aggregateVersion 聚合版本,用于阻止乱序事件覆盖新状态
|
||||
* @param handoverVersion 跨区交接版本;非交接事件可为空
|
||||
* @param traceId 分布式链路 ID
|
||||
* @param correlationId 同一业务流程的关联 ID
|
||||
* @param causationId 直接触发当前事件的上游事件 ID;根事件可为空
|
||||
* @param payload 事件业务负载
|
||||
* @param payloadHash 负载摘要,格式为 sha256 加冒号和 64 位十六进制字符
|
||||
*/
|
||||
public record EventEnvelope(
|
||||
String eventId,
|
||||
String eventType,
|
||||
int eventVersion,
|
||||
Instant occurredAt,
|
||||
RegionCode sourceRegion,
|
||||
RegionCode targetRegion,
|
||||
String tenantId,
|
||||
String aggregateType,
|
||||
String aggregateId,
|
||||
long aggregateVersion,
|
||||
Long handoverVersion,
|
||||
String traceId,
|
||||
String correlationId,
|
||||
String causationId,
|
||||
JsonNode payload,
|
||||
String payloadHash
|
||||
) {
|
||||
/**
|
||||
* 校验事件信封,不符合基础契约时抛出异常。
|
||||
*/
|
||||
public EventEnvelope requireValid() {
|
||||
EventEnvelopeValidator.requireValid(this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件信封基础校验器。
|
||||
*
|
||||
* <p>这里只校验所有事件共有的传输约束,具体事件负载由对应业务消费者按事件版本校验。</p>
|
||||
*/
|
||||
public final class EventEnvelopeValidator {
|
||||
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]{1,63}$");
|
||||
private static final Pattern AGGREGATE_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{1,63}$");
|
||||
private static final int MAX_ID_LENGTH = 128;
|
||||
|
||||
private EventEnvelopeValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回全部基础校验错误;合法时返回空列表。
|
||||
*/
|
||||
public static List<String> validate(EventEnvelope envelope) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
if (envelope == null) {
|
||||
return List.of("事件信封不能为空");
|
||||
}
|
||||
if (!EventIdentifiers.isSupported(envelope.eventId())) {
|
||||
errors.add("eventId 必须是标准 UUID 或 26 位 ULID");
|
||||
}
|
||||
if (envelope.eventType() == null || !EVENT_TYPE_PATTERN.matcher(envelope.eventType()).matches()) {
|
||||
errors.add("eventType 必须以大写字母开头且只能包含字母和数字,长度为 2 至 64 位");
|
||||
}
|
||||
if (envelope.eventVersion() < 1) {
|
||||
errors.add("eventVersion 必须大于等于 1");
|
||||
}
|
||||
if (envelope.occurredAt() == null) {
|
||||
errors.add("occurredAt 不能为空");
|
||||
}
|
||||
if (envelope.sourceRegion() == null) {
|
||||
errors.add("sourceRegion 不能为空");
|
||||
}
|
||||
if (envelope.targetRegion() == null) {
|
||||
errors.add("targetRegion 不能为空");
|
||||
}
|
||||
requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
|
||||
if (envelope.aggregateType() == null
|
||||
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
|
||||
errors.add("aggregateType 必须使用大写字母、数字或下划线,长度为 2 至 64 位");
|
||||
}
|
||||
requireText(errors, "aggregateId", envelope.aggregateId(), MAX_ID_LENGTH);
|
||||
if (envelope.aggregateVersion() < 0) {
|
||||
errors.add("aggregateVersion 不能小于 0");
|
||||
}
|
||||
if (envelope.handoverVersion() != null && envelope.handoverVersion() < 1) {
|
||||
errors.add("handoverVersion 存在时必须大于等于 1");
|
||||
}
|
||||
requireText(errors, "traceId", envelope.traceId(), MAX_ID_LENGTH);
|
||||
requireText(errors, "correlationId", envelope.correlationId(), MAX_ID_LENGTH);
|
||||
if (envelope.causationId() != null && !EventIdentifiers.isSupported(envelope.causationId())) {
|
||||
errors.add("causationId 存在时必须是标准 UUID 或 26 位 ULID");
|
||||
}
|
||||
if (envelope.payload() == null || envelope.payload().isNull()) {
|
||||
errors.add("payload 不能为空");
|
||||
}
|
||||
if (!EventPayloadHash.hasValidFormat(envelope.payloadHash())) {
|
||||
errors.add("payloadHash 必须使用 sha256 加冒号和 64 位十六进制字符格式");
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验事件信封,存在错误时抛出包含全部原因的异常。
|
||||
*/
|
||||
public static void requireValid(EventEnvelope envelope) {
|
||||
List<String> errors = validate(envelope);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException(String.join(";", errors));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(List<String> errors, String field, String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
errors.add(field + " 不能为空");
|
||||
} else if (value.length() > maxLength) {
|
||||
errors.add(field + " 长度不能超过 " + maxLength + " 位");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件标识生成与格式判断工具。
|
||||
*/
|
||||
public final class EventIdentifiers {
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$");
|
||||
private static final Pattern ULID_PATTERN = Pattern.compile(
|
||||
"^[0-7][0-9A-HJKMNP-TV-Z]{25}$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private EventIdentifiers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准 UUID 字符串。
|
||||
*/
|
||||
public static String newUuid() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为标准 UUID 或 26 位 ULID。
|
||||
*/
|
||||
public static boolean isSupported(String value) {
|
||||
return value != null && (UUID_PATTERN.matcher(value).matches() || ULID_PATTERN.matcher(value).matches());
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
/**
|
||||
* 将 JSON 负载转换为稳定字节序列,避免对象字段顺序不同导致摘要不一致。
|
||||
*/
|
||||
public final class EventPayloadCanonicalizer {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
|
||||
private EventPayloadCanonicalizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归排序对象字段并输出 UTF-8 JSON 字节。
|
||||
*/
|
||||
public static byte[] canonicalize(JsonNode payload) {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode sort(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, sort(node.get(name))));
|
||||
return result;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
var result = OBJECT_MAPPER.createArrayNode();
|
||||
node.forEach(child -> result.add(sort(child)));
|
||||
return result;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件负载 SHA-256 摘要工具。
|
||||
*/
|
||||
public final class EventPayloadHash {
|
||||
private static final Pattern HASH_PATTERN = Pattern.compile("^sha256:[0-9a-fA-F]{64}$");
|
||||
|
||||
private EventPayloadHash() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调用方提供的规范化负载字节摘要。
|
||||
*/
|
||||
public static String sha256(byte[] canonicalPayload) {
|
||||
if (canonicalPayload == null) {
|
||||
throw new IllegalArgumentException("规范化负载字节不能为空");
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonicalPayload);
|
||||
return "sha256:" + HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("当前运行环境不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断摘要是否符合约定格式。
|
||||
*/
|
||||
public static boolean hasValidFormat(String value) {
|
||||
return value != null && HASH_PATTERN.matcher(value).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用常量时间比较校验规范化负载字节与摘要是否一致。
|
||||
*/
|
||||
public static boolean matches(byte[] canonicalPayload, String expectedHash) {
|
||||
if (canonicalPayload == null || !hasValidFormat(expectedHash)) {
|
||||
return false;
|
||||
}
|
||||
byte[] actual = sha256(canonicalPayload).getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] expected = expectedHash.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII);
|
||||
return MessageDigest.isEqual(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/**
|
||||
* 跨服务和跨区域事件类型常量。
|
||||
*
|
||||
* <p>事件版本由事件信封的 {@code eventVersion} 单独表达,类型名称本身不携带版本号。</p>
|
||||
*/
|
||||
public final class EventTypes {
|
||||
public static final String ORDER_APPROVED = "OrderApproved";
|
||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
public static final String LAST_MILE_COMPLETED = "LastMileCompleted";
|
||||
public static final String TRACKING_MILESTONE_PROJECTED = "TrackingMilestoneProjected";
|
||||
|
||||
private EventTypes() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/**
|
||||
* 事件来源与目标部署区域。
|
||||
*
|
||||
* <p>区域表示独立业务单元,不表示用户当前所在地。</p>
|
||||
*/
|
||||
public enum RegionCode {
|
||||
/** 国内业务单元。 */
|
||||
CN,
|
||||
/** 海外业务单元。 */
|
||||
OVERSEAS
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 国内与海外业务单元共享的稳定事件契约。
|
||||
*/
|
||||
package com.baiyue.common.event;
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class EventEnvelopeTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception {
|
||||
EventEnvelope source = validEnvelope(EventIdentifiers.newUuid());
|
||||
|
||||
source.requireValid();
|
||||
String json = objectMapper.writeValueAsString(source);
|
||||
EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class);
|
||||
|
||||
assertThat(restored).isEqualTo(source);
|
||||
assertThat(objectMapper.readTree(json).path("eventType").asText())
|
||||
.isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 合法Ulid可以作为事件标识() {
|
||||
EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W");
|
||||
|
||||
assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验器一次返回全部基础错误() {
|
||||
EventEnvelope envelope = new EventEnvelope(
|
||||
"bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ",
|
||||
-1, 0L, " ", " ", "bad-cause", null, "bad-hash");
|
||||
|
||||
List<String> errors = EventEnvelopeValidator.validate(envelope);
|
||||
|
||||
assertThat(errors).hasSizeGreaterThanOrEqualTo(10);
|
||||
assertThatThrownBy(envelope::requireValid)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("eventId")
|
||||
.hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 负载摘要可计算并校验() {
|
||||
byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8);
|
||||
String hash = EventPayloadHash.sha256(payload);
|
||||
|
||||
assertThat(hash).matches("sha256:[0-9a-f]{64}");
|
||||
assertThat(EventPayloadHash.matches(payload, hash)).isTrue();
|
||||
assertThat(EventPayloadHash.matches("{}".getBytes(StandardCharsets.UTF_8), hash)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 对象字段顺序不同仍产生相同规范摘要() throws Exception {
|
||||
JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}");
|
||||
JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}");
|
||||
|
||||
assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first)))
|
||||
.isEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 数组顺序仍作为业务语义保留() throws Exception {
|
||||
JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}");
|
||||
JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}");
|
||||
|
||||
assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first)))
|
||||
.isNotEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second)));
|
||||
}
|
||||
|
||||
private EventEnvelope validEnvelope(String eventId) {
|
||||
JsonNode payload = objectMapper.createObjectNode()
|
||||
.put("containerNo", "MSCU1234567")
|
||||
.put("orderId", "90001");
|
||||
byte[] canonicalPayload = payload.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return new EventEnvelope(
|
||||
eventId,
|
||||
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
|
||||
1,
|
||||
Instant.parse("2026-07-29T08:30:00Z"),
|
||||
RegionCode.CN,
|
||||
RegionCode.OVERSEAS,
|
||||
"20001",
|
||||
"FIRST_LEG_SHIPMENT",
|
||||
"90001",
|
||||
7,
|
||||
1L,
|
||||
"trace-90001",
|
||||
"handover-90001",
|
||||
null,
|
||||
payload,
|
||||
EventPayloadHash.sha256(canonicalPayload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-openapi</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springdoc</groupId><artifactId>springdoc-openapi-starter-webmvc-ui</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><optional>true</optional></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.common.openapi;
|
||||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.parameters.HeaderParameter;
|
||||
import io.swagger.v3.oas.models.parameters.Parameter;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
import org.springdoc.core.customizers.OperationCustomizer;
|
||||
import org.springdoc.core.models.GroupedOpenApi;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* OpenAPI 公共自动配置,根据服务配置生成统一的接口文档元数据和 JWT 安全方案。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(OpenApiProperties.class)
|
||||
@ConditionalOnProperty(prefix = "baiyue.openapi", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class BaiyueOpenApiAutoConfiguration {
|
||||
public static final String BEARER = "bearerAuth";
|
||||
|
||||
@Bean
|
||||
OpenAPI baiyueOpenApi(OpenApiProperties p) {
|
||||
return new OpenAPI()
|
||||
.info(new Info().title(p.getTitle()).description(p.getDescription()).version(p.getVersion()))
|
||||
.components(new Components().addSecuritySchemes(BEARER,
|
||||
new SecurityScheme().name(BEARER).type(SecurityScheme.Type.HTTP)
|
||||
.scheme("bearer").bearerFormat("JWT")))
|
||||
.addSecurityItem(new SecurityRequirement().addList(BEARER));
|
||||
}
|
||||
|
||||
@Bean
|
||||
OperationCustomizer tenantHeaderCustomizer() {
|
||||
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-Trace-Id", "Distributed trace ID / 分布式追踪 ID", false));
|
||||
}
|
||||
|
||||
@Bean
|
||||
GroupedOpenApi platformApi(OperationCustomizer tenantHeaderCustomizer) {
|
||||
return GroupedOpenApi.builder().group("platform")
|
||||
.displayName("Platform APIs / 平台端接口")
|
||||
.pathsToMatch("/api/platform/**", "/api/auth/**", "/api/iam/platform/**")
|
||||
.addOperationCustomizer(tenantHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
GroupedOpenApi merchantApi(OperationCustomizer tenantHeaderCustomizer) {
|
||||
return GroupedOpenApi.builder().group("merchant")
|
||||
.displayName("Merchant APIs / 商户端接口")
|
||||
.pathsToMatch("/api/merchant/**", "/api/orders/**", "/api/warehouse/**",
|
||||
"/api/firstleg/**", "/api/lastleg/**")
|
||||
.addOperationCustomizer(tenantHeaderCustomizer).build();
|
||||
}
|
||||
|
||||
private Parameter header(String name, String description, boolean required) {
|
||||
return new HeaderParameter().name(name).description(description).required(required)
|
||||
.schema(new io.swagger.v3.oas.models.media.StringSchema());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.common.openapi;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* OpenAPI 文档配置模型,定义文档开关、服务信息、联系人、许可证和服务器地址。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ConfigurationProperties("baiyue.openapi")
|
||||
public class OpenApiProperties {
|
||||
private String title = "Baiyue Platform API / 柏悦平台接口";
|
||||
private String description = "Logistics SaaS API / 物流 SaaS 接口";
|
||||
private String version = "v1";
|
||||
private boolean enabled = true;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.baiyue.common.openapi.BaiyueOpenApiAutoConfiguration
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-security</artifactId>
|
||||
<name>baiyue-common-security</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-web</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-oauth2-resource-server</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-oauth2-jose</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* 访问令牌会话有效性校验扩展点,用于在 JWT 校验之外确认服务端登录会话状态。
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AccessTokenSessionValidator {
|
||||
void validate(Jwt jwt);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import java.security.Principal;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 统一认证用户主体,携带用户、租户、成员身份、应用端、客户端类型及权限信息。
|
||||
*/
|
||||
public record AuthenticatedUser(Long userId, Long tenantId, Long operatorTenantId, Long membershipId,
|
||||
String username, String appCode, String clientType,
|
||||
Set<String> permissions) implements Principal {
|
||||
public AuthenticatedUser {
|
||||
permissions = permissions == null ? Set.of() : Set.copyOf(permissions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 系统 JWT 认证令牌实现,封装已认证用户主体、原始令牌和权限集合。
|
||||
*/
|
||||
public final class BaiyueJwtAuthenticationToken extends AbstractAuthenticationToken {
|
||||
private final AuthenticatedUser principal;
|
||||
private final String tokenValue;
|
||||
|
||||
public BaiyueJwtAuthenticationToken(AuthenticatedUser principal, String tokenValue,
|
||||
Collection<? extends GrantedAuthority> authorities) {
|
||||
super(authorities);
|
||||
this.principal = principal;
|
||||
this.tokenValue = tokenValue;
|
||||
setAuthenticated(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCredentials() { return tokenValue; }
|
||||
|
||||
@Override
|
||||
public AuthenticatedUser getPrincipal() { return principal; }
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.baiyue.common.core.mybatis.AuditorProvider;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
/**
|
||||
* Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。
|
||||
*/
|
||||
@AutoConfiguration(
|
||||
after = HmacJwtDecoderAutoConfiguration.class,
|
||||
before = SecurityAutoConfiguration.class)
|
||||
@EnableMethodSecurity
|
||||
public class BaiyueSecurityAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
JwtClaimsMapper jwtClaimsMapper() { return new JwtClaimsMapper(); }
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AuthenticationEntryPoint.class)
|
||||
AuthenticationEntryPoint jsonAuthenticationEntryPoint(ObjectMapper objectMapper) {
|
||||
return new JsonAuthenticationEntryPoint(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(AccessDeniedHandler.class)
|
||||
AccessDeniedHandler jsonAccessDeniedHandler(ObjectMapper objectMapper) {
|
||||
return new JsonAccessDeniedHandler(objectMapper);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint entryPoint,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix) {
|
||||
return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint,
|
||||
new RedisAccessTokenSessionValidator(redisTemplate, keyPrefix));
|
||||
}
|
||||
|
||||
@Bean
|
||||
FilterRegistrationBean<JwtAuthenticationFilter> disableContainerRegistration(
|
||||
JwtAuthenticationFilter filter) {
|
||||
FilterRegistrationBean<JwtAuthenticationFilter> registration = new FilterRegistrationBean<>(filter);
|
||||
registration.setEnabled(false);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(SecurityFilterChain.class)
|
||||
SecurityFilterChain baiyueSecurityFilterChain(
|
||||
HttpSecurity http, JwtAuthenticationFilter jwtFilter,
|
||||
AuthenticationEntryPoint entryPoint, AccessDeniedHandler deniedHandler) throws Exception {
|
||||
return http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.httpBasic(httpBasic -> httpBasic.disable())
|
||||
.formLogin(form -> form.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.exceptionHandling(errors -> errors
|
||||
.authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
|
||||
.requestMatchers("/internal/**").permitAll()
|
||||
.requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT")
|
||||
.requestMatchers("/api/platform/**").hasAuthority("APP_PLATFORM")
|
||||
.anyRequest().authenticated())
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
AuditorProvider securityAuditorProvider() { return new SecurityAuditorProvider(); }
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。
|
||||
*/
|
||||
@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class)
|
||||
@ConditionalOnProperty(name = "baiyue.security.jwt-secret")
|
||||
public class HmacJwtDecoderAutoConfiguration {
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(JwtDecoder.class)
|
||||
JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret,
|
||||
@Value("${baiyue.security.issuer:auth-service}") String issuer) {
|
||||
byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8);
|
||||
if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节");
|
||||
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256"))
|
||||
.macAlgorithm(MacAlgorithm.HS256).build();
|
||||
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer));
|
||||
return decoder;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
/**
|
||||
* JWT 认证失败异常,表示令牌无效、过期或对应登录会话已失效。
|
||||
*/
|
||||
public class InvalidJwtAuthenticationException extends AuthenticationException {
|
||||
public InvalidJwtAuthenticationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 访问拒绝处理器,以统一 JSON 结构返回 HTTP 403 错误。
|
||||
*/
|
||||
public class JsonAccessDeniedHandler implements AccessDeniedHandler {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JsonAccessDeniedHandler(ObjectMapper objectMapper) { this.objectMapper = objectMapper; }
|
||||
|
||||
@Override
|
||||
public void handle(HttpServletRequest request, HttpServletResponse response,
|
||||
AccessDeniedException exception) throws IOException {
|
||||
ResultCode code = ResultCode.FORBIDDEN;
|
||||
response.setStatus(code.httpStatus().value());
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getWriter(), ApiResponse.failure(code, code.message(),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request)));
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 未认证请求处理器,以统一 JSON 结构返回 HTTP 401 错误。
|
||||
*/
|
||||
public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JsonAuthenticationEntryPoint(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commence(HttpServletRequest request, HttpServletResponse response,
|
||||
AuthenticationException authException) throws IOException {
|
||||
ResultCode code = ResultCode.UNAUTHORIZED;
|
||||
response.setStatus(code.httpStatus().value());
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
objectMapper.writeValue(response.getWriter(), ApiResponse.failure(code, code.message(),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
import org.springframework.security.oauth2.jwt.JwtException;
|
||||
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Servlet JWT 认证过滤器,解析 Bearer 令牌、校验会话并建立当前请求的安全上下文。
|
||||
*/
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String BEARER = "Bearer ";
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final JwtClaimsMapper claimsMapper;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final AccessTokenSessionValidator sessionValidator;
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint) {
|
||||
this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> { });
|
||||
}
|
||||
|
||||
public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper,
|
||||
AuthenticationEntryPoint authenticationEntryPoint,
|
||||
AccessTokenSessionValidator sessionValidator) {
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.claimsMapper = claimsMapper;
|
||||
this.authenticationEntryPoint = authenticationEntryPoint;
|
||||
this.sessionValidator = sessionValidator;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||
if (header == null || !header.regionMatches(true, 0, BEARER, 0, BEARER.length())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if (SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String tokenValue = header.substring(BEARER.length()).trim();
|
||||
try {
|
||||
Jwt jwt = jwtDecoder.decode(tokenValue);
|
||||
sessionValidator.validate(jwt);
|
||||
Authentication authentication = claimsMapper.map(jwt);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (JwtException | IllegalArgumentException exception) {
|
||||
SecurityContextHolder.clearContext();
|
||||
authenticationEntryPoint.commence(request, response,
|
||||
new InvalidJwtAuthenticationException("Invalid or expired access token", exception));
|
||||
} finally {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* JWT 声明映射器,将令牌中的用户、租户、端类型和权限声明转换为认证主体与授权集合。
|
||||
*/
|
||||
public class JwtClaimsMapper {
|
||||
public BaiyueJwtAuthenticationToken map(Jwt jwt) {
|
||||
Set<String> permissions = new LinkedHashSet<>();
|
||||
addClaimValues(permissions, jwt.getClaims().get("authorities"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("permissions"));
|
||||
addClaimValues(permissions, jwt.getClaims().get("scope"));
|
||||
addRoles(permissions, jwt.getClaims().get("roles"));
|
||||
|
||||
String appCode = jwt.getClaimAsString("app_code");
|
||||
if (appCode != null && !appCode.isBlank()) {
|
||||
permissions.add("APP_" + appCode.toUpperCase(java.util.Locale.ROOT));
|
||||
}
|
||||
String username = firstNonBlank(jwt.getClaimAsString("username"),
|
||||
jwt.getClaimAsString("preferred_username"), jwt.getSubject());
|
||||
Long userId = longClaim(jwt, "user_id");
|
||||
AuthenticatedUser user = new AuthenticatedUser(
|
||||
userId == null ? longValue(jwt.getSubject()) : userId,
|
||||
longClaim(jwt, "tenant_id"),
|
||||
longClaim(jwt, "operator_tenant_id"),
|
||||
longClaim(jwt, "membership_id"),
|
||||
username,
|
||||
jwt.getClaimAsString("app_code"),
|
||||
jwt.getClaimAsString("client_type"),
|
||||
permissions);
|
||||
List<SimpleGrantedAuthority> authorities = permissions.stream()
|
||||
.map(SimpleGrantedAuthority::new).toList();
|
||||
return new BaiyueJwtAuthenticationToken(user, jwt.getTokenValue(), authorities);
|
||||
}
|
||||
|
||||
private void addClaimValues(Set<String> target, Object value) {
|
||||
if (value instanceof Collection<?> collection) {
|
||||
collection.stream().map(Object::toString).filter(s -> !s.isBlank()).forEach(target::add);
|
||||
} else if (value instanceof String text) {
|
||||
for (String item : text.split("[ ,]+")) {
|
||||
if (!item.isBlank()) target.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addRoles(Set<String> target, Object value) {
|
||||
Set<String> roles = new LinkedHashSet<>();
|
||||
addClaimValues(roles, value);
|
||||
roles.stream().map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role).forEach(target::add);
|
||||
}
|
||||
|
||||
private Long longValue(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
try { return Long.valueOf(value); }
|
||||
catch (NumberFormatException ignored) { return null; }
|
||||
}
|
||||
|
||||
private Long longClaim(Jwt jwt, String name) {
|
||||
Object value = jwt.getClaims().get(name);
|
||||
if (value instanceof Number number) return number.longValue();
|
||||
if (value instanceof String text) return longValue(text);
|
||||
return null;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) if (value != null && !value.isBlank()) return value;
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
/**
|
||||
* Redis 访问令牌会话校验器,根据 JWT ID 判断登录会话是否仍然有效。
|
||||
*/
|
||||
public final class RedisAccessTokenSessionValidator implements AccessTokenSessionValidator {
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final String keyPrefix;
|
||||
|
||||
public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.keyPrefix = RedisTokenKeys.normalize(keyPrefix);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(Jwt jwt) {
|
||||
String jwtId = jwt.getId();
|
||||
if (jwtId == null || jwtId.isBlank()) {
|
||||
throw new IllegalArgumentException("Access token has no jti");
|
||||
}
|
||||
try {
|
||||
if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) {
|
||||
throw new IllegalArgumentException("Access token session is revoked or expired");
|
||||
}
|
||||
} catch (org.springframework.dao.DataAccessException exception) {
|
||||
throw new IllegalArgumentException("Token session store is unavailable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
/**
|
||||
* Redis 令牌键工具,规范访问令牌会话键前缀及键名拼装规则。
|
||||
*/
|
||||
public final class RedisTokenKeys {
|
||||
private RedisTokenKeys() { }
|
||||
|
||||
public static String access(String prefix, String jwtId) {
|
||||
return normalize(prefix) + ":access:" + jwtId;
|
||||
}
|
||||
|
||||
public static String refresh(String prefix, String refreshHash) {
|
||||
return normalize(prefix) + ":refresh:" + refreshHash;
|
||||
}
|
||||
|
||||
public static String normalize(String prefix) {
|
||||
String value = prefix == null || prefix.isBlank() ? "baiyue:auth" : prefix.trim();
|
||||
while (value.endsWith(":")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.baiyue.common.core.mybatis.AuditorProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/**
|
||||
* Spring Security 审计操作者提供器,从当前认证主体提取用户 ID 供持久化审计字段自动填充。
|
||||
*/
|
||||
public class SecurityAuditorProvider implements AuditorProvider {
|
||||
@Override
|
||||
public Long currentUserId() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return authentication != null && authentication.getPrincipal() instanceof AuthenticatedUser user
|
||||
? user.userId() : null;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
|
||||
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class JwtAuthenticationFilterTest {
|
||||
@AfterEach
|
||||
void clearContext() { SecurityContextHolder.clearContext(); }
|
||||
|
||||
@Test
|
||||
void authenticatesValidBearerDuringFilterChain() throws Exception {
|
||||
Jwt jwt = Jwt.withTokenValue("valid").header("alg", "none").subject("alice")
|
||||
.issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("user_id", 1L).build();
|
||||
JwtDecoder decoder = token -> jwt;
|
||||
JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(),
|
||||
new JsonAuthenticationEntryPoint(new ObjectMapper()));
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicBoolean invoked = new AtomicBoolean();
|
||||
filter.doFilter(request, response, (request1, response1) -> {
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
|
||||
invoked.set(true);
|
||||
});
|
||||
assertThat(invoked).isTrue();
|
||||
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class JwtClaimsMapperTest {
|
||||
@Test
|
||||
void mapsTenantUserRolesAndPermissions() {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
.header("alg", "none")
|
||||
.subject("subject")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("user_id", 42L)
|
||||
.claim("tenant_id", "7")
|
||||
.claim("username", "alice")
|
||||
.claim("app_code", "MERCHANT")
|
||||
.claim("client_type", "MERCHANT")
|
||||
.claim("permissions", List.of("order:read", "order:create"))
|
||||
.claim("roles", List.of("ADMIN"))
|
||||
.build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt);
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(42L);
|
||||
assertThat(authentication.getPrincipal().tenantId()).isEqualTo(7L);
|
||||
assertThat(authentication.getAuthorities()).extracting("authority")
|
||||
.contains("order:read", "order:create", "ROLE_ADMIN", "APP_MERCHANT");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToNumericSubjectForUserId() {
|
||||
Jwt jwt = Jwt.withTokenValue("token")
|
||||
.header("alg", "none")
|
||||
.subject("99")
|
||||
.issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60))
|
||||
.claim("tenant_id", 0L)
|
||||
.claim("app_code", "PLATFORM")
|
||||
.build();
|
||||
|
||||
BaiyueJwtAuthenticationToken authentication = new JwtClaimsMapper().map(jwt);
|
||||
assertThat(authentication.getPrincipal().userId()).isEqualTo(99L);
|
||||
assertThat(authentication.getAuthorities()).extracting("authority").contains("APP_PLATFORM");
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.baiyue.common.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.RedisConnectionFailureException;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class RedisAccessTokenSessionValidatorTest {
|
||||
@Test
|
||||
void acceptsActiveSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(true);
|
||||
|
||||
assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1"))).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRevokedSession() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1")).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("revoked or expired");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenRedisIsUnavailable() {
|
||||
StringRedisTemplate redis = mock(StringRedisTemplate.class);
|
||||
when(redis.hasKey("baiyue:auth:access:jti-1"))
|
||||
.thenThrow(new RedisConnectionFailureException("offline"));
|
||||
|
||||
assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth")
|
||||
.validate(jwt("jti-1")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("unavailable");
|
||||
}
|
||||
|
||||
private Jwt jwt(String jwtId) {
|
||||
return Jwt.withTokenValue("token").header("alg", "none")
|
||||
.subject("1").claim("jti", jwtId).issuedAt(Instant.now())
|
||||
.expiresAt(Instant.now().plusSeconds(60)).build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.baiyue.firstleg</groupId>
|
||||
<artifactId>baiyue-firstleg-platform</artifactId>
|
||||
<version>1.0.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-web</artifactId>
|
||||
<name>baiyue-common-web</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId>
|
||||
<artifactId>baiyue-common-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.common.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.MDC;
|
||||
|
||||
/**
|
||||
* 请求关联标识上下文工具,从请求属性或日志 MDC 中读取当前请求 ID。
|
||||
*/
|
||||
public final class CorrelationContext {
|
||||
public static final String REQUEST_ID = "requestId";
|
||||
public static final String TRACE_ID = "traceId";
|
||||
public static final String REQUEST_ID_HEADER = "X-Request-Id";
|
||||
public static final String TRACE_ID_HEADER = "X-Trace-Id";
|
||||
|
||||
private CorrelationContext() {}
|
||||
|
||||
public static String requestId(HttpServletRequest request) {
|
||||
return value(request, REQUEST_ID);
|
||||
}
|
||||
|
||||
public static String traceId(HttpServletRequest request) {
|
||||
return value(request, TRACE_ID);
|
||||
}
|
||||
|
||||
private static String value(HttpServletRequest request, String key) {
|
||||
Object attribute = request.getAttribute(key);
|
||||
return attribute == null ? MDC.get(key) : attribute.toString();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user