暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
+16
View File
@@ -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
View File
@@ -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/
+49
View File
@@ -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
+42
View File
@@ -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,也不允许通过公网直接调用对方内部服务。
+62
View File
@@ -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和消费幂等表';
+26
View File
@@ -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) { }
}
+48
View File
@@ -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
+44
View File
@@ -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");
}
}
+15
View File
@@ -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);
}
}
@@ -0,0 +1 @@
com.baiyue.common.audit.BaiyueAuditAutoConfiguration
+28
View File
@@ -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();
}
@@ -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);
}
}
+30
View File
@@ -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;
}
}
@@ -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());
}
}
@@ -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));
}
}
+14
View File
@@ -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>
@@ -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;
}
@@ -0,0 +1 @@
com.baiyue.common.openapi.BaiyueOpenApiAutoConfiguration
+54
View File
@@ -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>
@@ -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;
}
}
@@ -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; }
}
@@ -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(); }
}
@@ -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;
}
}
@@ -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)));
}
}
@@ -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";
}
}
@@ -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;
}
}
@@ -0,0 +1,2 @@
com.baiyue.common.security.HmacJwtDecoderAutoConfiguration
com.baiyue.common.security.BaiyueSecurityAutoConfiguration
@@ -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");
}
}
@@ -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();
}
}
+40
View File
@@ -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