feat: implement batch 2 domain exceptions and tenant context
- Create ErrorCode enum with comprehensive error codes - Create DomainException as base class for business exceptions - Implement TenantScope value object for tenant context - Implement TenantContext with ThreadLocal management - Add SecurityLogMarker constants for security events - Add comprehensive unit tests for all components - Follow M2 architecture specifications for error handling and multi-tenancy Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
/**
|
||||
* 安全相关日志标记常量。
|
||||
*
|
||||
* <p>用于 Filter / Interceptor 中标识安全事件,便于日志平台检索和告警。
|
||||
* 这些标记不是 ErrorCode(不会出现在 API 响应中),仅作为日志分类标签。
|
||||
*
|
||||
* <h3>配套 Micrometer 计数器建议</h3>
|
||||
* <ul>
|
||||
* <li>{@link #LEGACY_HEADER_FALLBACK} → {@code security.header.legacy_fallback}</li>
|
||||
* <li>{@link #LEGACY_HEADER_REDUNDANT} → {@code security.header.legacy_redundant}</li>
|
||||
* <li>{@link #DEPT_SCOPE_ALL_DEV_ONLY} → {@code security.dept.scope_all_dev}</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href="docs/contracts/security-context.md">安全上下文契约</a>
|
||||
*/
|
||||
public final class SecurityLogMarker {
|
||||
|
||||
private SecurityLogMarker() {
|
||||
// 常量类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* dev 环境回退到旧 Header 时记录。
|
||||
* 日志级别:WARN
|
||||
*/
|
||||
public static final String LEGACY_HEADER_FALLBACK = "LEGACY_HEADER_FALLBACK";
|
||||
|
||||
/**
|
||||
* 新旧 Header 同时存在且值一致时记录(冗余但不冲突)。
|
||||
* 日志级别:WARN
|
||||
*/
|
||||
public static final String LEGACY_HEADER_REDUNDANT = "LEGACY_HEADER_REDUNDANT";
|
||||
|
||||
/**
|
||||
* dev 环境 deptId=0 跨部门查询时记录。
|
||||
* 日志级别:WARN
|
||||
*/
|
||||
public static final String DEPT_SCOPE_ALL_DEV_ONLY = "DEPT_SCOPE_ALL_DEV_ONLY";
|
||||
|
||||
/**
|
||||
* 缺少必要的租户 Header 时记录。
|
||||
* 日志级别:WARN
|
||||
*/
|
||||
public static final String MISSING_REQUIRED_HEADER = "MISSING_REQUIRED_HEADER";
|
||||
|
||||
/**
|
||||
* Header 值格式非法时记录。
|
||||
* 日志级别:WARN
|
||||
*/
|
||||
public static final String INVALID_HEADER_VALUE = "INVALID_HEADER_VALUE";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 租户上下文 —— 基于 ThreadLocal 的请求级租户信息持有者。
|
||||
*
|
||||
* <p>生命周期由 {@code TenantContextFilter}(批次 3)管理:
|
||||
* 请求进入时 {@link #set(TenantScope)},请求结束时在 {@code finally} 中 {@link #clear()}。
|
||||
*
|
||||
* <h3>使用规范</h3>
|
||||
* <ul>
|
||||
* <li>业务代码通过静态方法读取,禁止直接操作 ThreadLocal</li>
|
||||
* <li>异步场景需手动传播(批次 3 提供工具)</li>
|
||||
* <li>批处理/无上下文场景使用 {@link #getUserIdOrDefault()} 返回 0L</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see TenantScope
|
||||
* @see <a href="docs/contracts/security-context.md">安全上下文契约</a>
|
||||
*/
|
||||
public final class TenantContext {
|
||||
|
||||
private static final ThreadLocal<TenantScope> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private TenantContext() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前线程的租户 scope。
|
||||
*
|
||||
* @throws NullPointerException 如果 scope 为 null
|
||||
*/
|
||||
public static void set(TenantScope scope) {
|
||||
HOLDER.set(Objects.requireNonNull(scope, "scope must not be null"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前线程的租户 scope,可能为空。
|
||||
*/
|
||||
public static Optional<TenantScope> get() {
|
||||
return Optional.ofNullable(HOLDER.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前线程的租户 scope,不存在时抛出异常。
|
||||
*
|
||||
* @throws IllegalStateException 如果 TenantScope 未设置
|
||||
*/
|
||||
public static TenantScope require() {
|
||||
TenantScope scope = HOLDER.get();
|
||||
if (scope == null) {
|
||||
throw new IllegalStateException(
|
||||
"TenantContext is empty. Ensure TenantContextFilter is configured.");
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 tenantId,不存在时抛出异常。
|
||||
*/
|
||||
public static String getTenantId() {
|
||||
return require().getTenantId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 deptId,不存在时抛出异常。
|
||||
*/
|
||||
public static String getDeptId() {
|
||||
return require().getDeptId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 userId,不存在时抛出异常。
|
||||
*/
|
||||
public static Long getUserId() {
|
||||
return require().getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 userId,不存在时返回 0L(用于 AuditorAware 等无上下文场景)。
|
||||
*/
|
||||
public static Long getUserIdOrDefault() {
|
||||
return get().map(TenantScope::getUserId).orElse(0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色集合,不存在时返回空集。
|
||||
*/
|
||||
public static Set<String> getRoles() {
|
||||
return get().map(TenantScope::getRoles).orElse(Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前线程的租户 scope。
|
||||
* 必须在 Filter 的 finally 块中调用。
|
||||
*/
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 租户范围值对象 —— 封装当前请求的多租户上下文字段。
|
||||
*
|
||||
* <p>所有字段不可变。由 {@code TenantContextFilter}(批次 3)从 HTTP Header 解析后
|
||||
* 通过 {@link TenantContext#set(TenantScope)} 存入 ThreadLocal。
|
||||
*
|
||||
* <h3>字段语义(冻结契约)</h3>
|
||||
* <ul>
|
||||
* <li>{@code tenantId} — 租户 ID,必填,来自 X-Tenant-Id</li>
|
||||
* <li>{@code deptId} — 部门 ID,必填,来自 X-Dept-Id;"0" 表示跨部门(需 SCOPE_ALL_DEPT 权限)</li>
|
||||
* <li>{@code userId} — 用户 ID,必填,来自 X-User-Id</li>
|
||||
* <li>{@code roles} — 用户角色集合,来自 X-User-Roles(逗号分隔)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see TenantContext
|
||||
* @see <a href="docs/contracts/security-context.md">安全上下文契约</a>
|
||||
*/
|
||||
public final class TenantScope {
|
||||
|
||||
private final String tenantId;
|
||||
private final String deptId;
|
||||
private final Long userId;
|
||||
private final Set<String> roles;
|
||||
|
||||
public TenantScope(String tenantId, String deptId, Long userId, Set<String> roles) {
|
||||
this.tenantId = Objects.requireNonNull(tenantId, "tenantId must not be null");
|
||||
this.deptId = Objects.requireNonNull(deptId, "deptId must not be null");
|
||||
this.userId = Objects.requireNonNull(userId, "userId must not be null");
|
||||
// Set.copyOf: 防御性拷贝 + 不可变快照;roles==null 时用空集合
|
||||
this.roles = roles != null ? Set.copyOf(roles) : Set.of();
|
||||
}
|
||||
|
||||
public String getTenantId() {
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public String getDeptId() {
|
||||
return deptId;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public Set<String> getRoles() {
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前 scope 是否为跨部门(deptId = "0")。
|
||||
*/
|
||||
public boolean isCrossDept() {
|
||||
return "0".equals(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否持有指定角色。
|
||||
*/
|
||||
public boolean hasRole(String role) {
|
||||
return roles.contains(role);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "TenantScope{tenantId='"
|
||||
+ tenantId
|
||||
+ "', deptId='"
|
||||
+ deptId
|
||||
+ "', userId="
|
||||
+ userId
|
||||
+ ", roles="
|
||||
+ roles
|
||||
+ '}';
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof TenantScope that)) return false;
|
||||
return tenantId.equals(that.tenantId)
|
||||
&& deptId.equals(that.deptId)
|
||||
&& userId.equals(that.userId)
|
||||
&& roles.equals(that.roles);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(tenantId, deptId, userId, roles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 应用层上下文 —— 租户 scope、请求级上下文传播。
|
||||
*
|
||||
* <p>{@link com.ljwx.tmpl.application.context.TenantContext} 为 ThreadLocal 持有者,
|
||||
* {@link com.ljwx.tmpl.application.context.TenantScope} 为不可变值对象。
|
||||
*/
|
||||
package com.ljwx.tmpl.application.context;
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* SecurityLogMarker 常量类的契约测试。
|
||||
*/
|
||||
class SecurityLogMarkerTest {
|
||||
|
||||
@Test
|
||||
void allConstants_shouldBePublicStaticFinalString() {
|
||||
for (Field field : SecurityLogMarker.class.getDeclaredFields()) {
|
||||
if (field.isSynthetic()) continue;
|
||||
int mod = field.getModifiers();
|
||||
assertTrue(Modifier.isPublic(mod), field.getName() + " should be public");
|
||||
assertTrue(Modifier.isStatic(mod), field.getName() + " should be static");
|
||||
assertTrue(Modifier.isFinal(mod), field.getName() + " should be final");
|
||||
assertEquals(String.class, field.getType(),
|
||||
field.getName() + " should be String");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allConstants_shouldBeNonBlank() throws Exception {
|
||||
for (Field field : SecurityLogMarker.class.getDeclaredFields()) {
|
||||
if (field.isSynthetic()) continue;
|
||||
if (!Modifier.isStatic(field.getModifiers())) continue;
|
||||
String value = (String) field.get(null);
|
||||
assertNotNull(value, field.getName() + " should not be null");
|
||||
assertFalse(value.isBlank(), field.getName() + " should not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allConstants_shouldBeUnique() throws Exception {
|
||||
Set<String> values = new HashSet<>();
|
||||
for (Field field : SecurityLogMarker.class.getDeclaredFields()) {
|
||||
if (field.isSynthetic()) continue;
|
||||
if (!Modifier.isStatic(field.getModifiers())) continue;
|
||||
String value = (String) field.get(null);
|
||||
assertTrue(values.add(value),
|
||||
"Duplicate constant value: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_shouldBePrivate() throws Exception {
|
||||
Constructor<?> ctor = SecurityLogMarker.class.getDeclaredConstructor();
|
||||
assertTrue(Modifier.isPrivate(ctor.getModifiers()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expectedConstants_shouldExist() {
|
||||
assertNotNull(SecurityLogMarker.LEGACY_HEADER_FALLBACK);
|
||||
assertNotNull(SecurityLogMarker.LEGACY_HEADER_REDUNDANT);
|
||||
assertNotNull(SecurityLogMarker.DEPT_SCOPE_ALL_DEV_ONLY);
|
||||
assertNotNull(SecurityLogMarker.MISSING_REQUIRED_HEADER);
|
||||
assertNotNull(SecurityLogMarker.INVALID_HEADER_VALUE);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* TenantContext ThreadLocal 行为测试。
|
||||
*/
|
||||
class TenantContextTest {
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void get_whenEmpty_shouldReturnEmpty() {
|
||||
assertEquals(Optional.empty(), TenantContext.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void require_whenEmpty_shouldThrow() {
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
TenantContext::require);
|
||||
assertTrue(ex.getMessage().contains("TenantContext is empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void set_withNull_shouldThrow() {
|
||||
assertThrows(NullPointerException.class, () -> TenantContext.set(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setAndGet_shouldReturnScope() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 42L, Set.of("ADMIN"));
|
||||
TenantContext.set(scope);
|
||||
|
||||
assertTrue(TenantContext.get().isPresent());
|
||||
assertEquals(scope, TenantContext.get().get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void require_afterSet_shouldReturnScope() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 42L, Set.of());
|
||||
TenantContext.set(scope);
|
||||
|
||||
assertSame(scope, TenantContext.require());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getTenantId_afterSet_shouldReturnValue() {
|
||||
TenantContext.set(new TenantScope("T001", "100", 42L, Set.of()));
|
||||
assertEquals("T001", TenantContext.getTenantId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDeptId_afterSet_shouldReturnValue() {
|
||||
TenantContext.set(new TenantScope("T001", "200", 42L, Set.of()));
|
||||
assertEquals("200", TenantContext.getDeptId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserId_afterSet_shouldReturnValue() {
|
||||
TenantContext.set(new TenantScope("T001", "100", 42L, Set.of()));
|
||||
assertEquals(42L, TenantContext.getUserId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserIdOrDefault_whenEmpty_shouldReturnZero() {
|
||||
assertEquals(0L, TenantContext.getUserIdOrDefault());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUserIdOrDefault_whenSet_shouldReturnActual() {
|
||||
TenantContext.set(new TenantScope("T001", "100", 99L, Set.of()));
|
||||
assertEquals(99L, TenantContext.getUserIdOrDefault());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRoles_whenEmpty_shouldReturnEmptySet() {
|
||||
assertTrue(TenantContext.getRoles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRoles_whenSet_shouldReturnRoles() {
|
||||
TenantContext.set(new TenantScope("T001", "100", 1L, Set.of("ADMIN", "USER")));
|
||||
assertEquals(Set.of("ADMIN", "USER"), TenantContext.getRoles());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clear_shouldRemoveScope() {
|
||||
TenantContext.set(new TenantScope("T001", "100", 42L, Set.of()));
|
||||
TenantContext.clear();
|
||||
|
||||
assertTrue(TenantContext.get().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void clear_whenAlreadyEmpty_shouldNotThrow() {
|
||||
assertDoesNotThrow(TenantContext::clear);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.ljwx.tmpl.application.context;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* TenantScope 值对象的行为测试。
|
||||
*/
|
||||
class TenantScopeTest {
|
||||
|
||||
@Test
|
||||
void constructor_shouldRejectNullTenantId() {
|
||||
assertThrows(NullPointerException.class, () ->
|
||||
new TenantScope(null, "100", 1L, Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_shouldRejectNullDeptId() {
|
||||
assertThrows(NullPointerException.class, () ->
|
||||
new TenantScope("T001", null, 1L, Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_shouldRejectNullUserId() {
|
||||
assertThrows(NullPointerException.class, () ->
|
||||
new TenantScope("T001", "100", null, Set.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withNullRoles_shouldDefaultToEmptySet() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 1L, null);
|
||||
assertNotNull(scope.getRoles());
|
||||
assertTrue(scope.getRoles().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roles_shouldBeUnmodifiable() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 1L, Set.of("ADMIN"));
|
||||
assertThrows(UnsupportedOperationException.class, () ->
|
||||
scope.getRoles().add("HACKER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void roles_shouldBeDefensiveCopiedSnapshot() {
|
||||
Set<String> roles = new HashSet<>();
|
||||
roles.add("ADMIN");
|
||||
|
||||
TenantScope scope = new TenantScope("T001", "100", 1L, roles);
|
||||
|
||||
roles.add("HACKER"); // mutate original
|
||||
|
||||
assertTrue(scope.getRoles().contains("ADMIN"));
|
||||
assertFalse(scope.getRoles().contains("HACKER")); // should NOT be affected
|
||||
}
|
||||
|
||||
@Test
|
||||
void isCrossDept_shouldReturnTrueForZero() {
|
||||
TenantScope scope = new TenantScope("T001", "0", 1L, Set.of());
|
||||
assertTrue(scope.isCrossDept());
|
||||
}
|
||||
|
||||
@Test
|
||||
void isCrossDept_shouldReturnFalseForNonZero() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 1L, Set.of());
|
||||
assertFalse(scope.isCrossDept());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hasRole_shouldMatchExact() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 1L,
|
||||
Set.of("ADMIN", "SCOPE_ALL_DEPT"));
|
||||
assertTrue(scope.hasRole("ADMIN"));
|
||||
assertTrue(scope.hasRole("SCOPE_ALL_DEPT"));
|
||||
assertFalse(scope.hasRole("USER"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void equals_shouldCompareAllFields() {
|
||||
TenantScope a = new TenantScope("T001", "100", 1L, Set.of("ADMIN"));
|
||||
TenantScope b = new TenantScope("T001", "100", 1L, Set.of("ADMIN"));
|
||||
TenantScope c = new TenantScope("T002", "100", 1L, Set.of("ADMIN"));
|
||||
|
||||
assertEquals(a, b);
|
||||
assertEquals(a.hashCode(), b.hashCode());
|
||||
assertNotEquals(a, c);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toString_shouldContainAllFields() {
|
||||
TenantScope scope = new TenantScope("T001", "100", 42L, Set.of("ADMIN"));
|
||||
String str = scope.toString();
|
||||
assertTrue(str.contains("T001"));
|
||||
assertTrue(str.contains("100"));
|
||||
assertTrue(str.contains("42"));
|
||||
assertTrue(str.contains("ADMIN"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.ljwx.tmpl.domain.exception;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 领域异常 —— 所有业务异常的基类。
|
||||
*
|
||||
* <p>持有 {@link ErrorCode} 与可选的 detail 消息。
|
||||
* detail 优先作为 API 响应的 message,为空时回退到 {@code ErrorCode.defaultMessage}。
|
||||
*
|
||||
* <h3>使用示例</h3>
|
||||
* <pre>{@code
|
||||
* // 仅错误码(使用默认消息)
|
||||
* throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID);
|
||||
*
|
||||
* // 错误码 + 自定义详情
|
||||
* throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "金额不能为负数: " + amount);
|
||||
*
|
||||
* // 错误码 + 详情 + 原始异常
|
||||
* throw new DomainException(ErrorCode.COMMON_INTERNAL_ERROR, "保存失败", cause);
|
||||
* }</pre>
|
||||
*/
|
||||
public class DomainException extends RuntimeException {
|
||||
|
||||
private final ErrorCode errorCode;
|
||||
private final String detail;
|
||||
|
||||
public DomainException(ErrorCode errorCode) {
|
||||
super(Objects.requireNonNull(errorCode, "errorCode must not be null").getDefaultMessage());
|
||||
this.errorCode = errorCode;
|
||||
this.detail = null;
|
||||
}
|
||||
|
||||
public DomainException(ErrorCode errorCode, String detail) {
|
||||
super(detail != null ? detail : Objects.requireNonNull(errorCode, "errorCode must not be null").getDefaultMessage());
|
||||
this.errorCode = errorCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public DomainException(ErrorCode errorCode, String detail, Throwable cause) {
|
||||
super(
|
||||
detail != null
|
||||
? detail
|
||||
: Objects.requireNonNull(errorCode, "errorCode must not be null").getDefaultMessage(),
|
||||
cause);
|
||||
this.errorCode = errorCode;
|
||||
this.detail = detail;
|
||||
}
|
||||
|
||||
public ErrorCode getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回用于 API 响应的消息。
|
||||
* detail 优先;为 null 时回退到 ErrorCode 的默认消息。
|
||||
*/
|
||||
public String getDetail() {
|
||||
return detail != null ? detail : errorCode.getDefaultMessage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ljwx.tmpl.domain.exception;
|
||||
|
||||
/**
|
||||
* 全局错误码枚举 —— 唯一映射源。
|
||||
*
|
||||
* <p>每个枚举自带 HTTP 状态码与默认消息,{@code GlobalExceptionHandler}
|
||||
* 仅负责将其翻译为 {@code ApiResponse},不做额外映射。
|
||||
*
|
||||
* <h3>不可变契约</h3>
|
||||
* <ul>
|
||||
* <li>已发布的枚举 name 与 httpStatus <b>不可修改</b></li>
|
||||
* <li>废弃时使用 {@code @Deprecated},不可删除</li>
|
||||
* <li>只可新增</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see DomainException
|
||||
* @see <a href="docs/contracts/errors.md">错误码契约文档</a>
|
||||
*/
|
||||
public enum ErrorCode {
|
||||
|
||||
// ==================== COMMON(通用) ====================
|
||||
COMMON_INVALID_PARAM(400, "参数校验失败"),
|
||||
COMMON_NOT_FOUND(404, "资源不存在"),
|
||||
COMMON_RESOURCE_ALREADY_EXISTS(409, "资源已存在"),
|
||||
COMMON_OPTIMISTIC_LOCK(409, "数据已被修改,请刷新重试"),
|
||||
COMMON_INTERNAL_ERROR(500, "系统内部错误"),
|
||||
|
||||
// ==================== AUTH(认证/租户) ====================
|
||||
AUTH_MISSING_TENANT(400, "缺少租户标识(X-Tenant-Id)"),
|
||||
AUTH_MISSING_DEPT(400, "缺少部门标识(X-Dept-Id)"),
|
||||
AUTH_MISSING_USER(400, "缺少用户标识(X-User-Id)"),
|
||||
AUTH_INVALID_USER_ID(400, "用户标识格式不合法"),
|
||||
AUTH_HEADER_CONFLICT(400, "新旧租户 Header 值不一致"),
|
||||
AUTH_LEGACY_HEADER_NOT_ALLOWED(400, "当前环境不允许使用旧版 Header"),
|
||||
AUTH_INVALID_HEADER_VALUE(400, "Header 值格式非法"),
|
||||
AUTH_ACCESS_DENIED(403, "无权限"),
|
||||
AUTH_FORBIDDEN_SCOPE(403, "无权访问该部门范围的数据"),
|
||||
|
||||
// ==================== ORDER(示例领域) ====================
|
||||
ORDER_NOT_FOUND(404, "订单不存在"),
|
||||
ORDER_AMOUNT_INVALID(422, "订单金额不合法"),
|
||||
ORDER_STATUS_INVALID(422, "订单状态不允许此操作"),
|
||||
;
|
||||
|
||||
private final int httpStatus;
|
||||
private final String defaultMessage;
|
||||
|
||||
ErrorCode(int httpStatus, String defaultMessage) {
|
||||
this.httpStatus = httpStatus;
|
||||
this.defaultMessage = defaultMessage;
|
||||
}
|
||||
|
||||
public int getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
public String getDefaultMessage() {
|
||||
return defaultMessage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 领域异常与错误码。
|
||||
*
|
||||
* <p>{@link com.ljwx.tmpl.domain.exception.ErrorCode} 为全局唯一错误码枚举,
|
||||
* {@link com.ljwx.tmpl.domain.exception.DomainException} 为所有业务异常基类。
|
||||
*/
|
||||
package com.ljwx.tmpl.domain.exception;
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ljwx.tmpl.domain.exception;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* DomainException 的行为测试。
|
||||
*/
|
||||
class DomainExceptionTest {
|
||||
|
||||
@Test
|
||||
void constructor_withErrorCodeOnly_shouldUseDefaultMessage() {
|
||||
DomainException ex = new DomainException(ErrorCode.ORDER_NOT_FOUND);
|
||||
|
||||
assertEquals(ErrorCode.ORDER_NOT_FOUND, ex.getErrorCode());
|
||||
assertEquals("订单不存在", ex.getDetail());
|
||||
assertEquals("订单不存在", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withDetail_shouldPreferDetail() {
|
||||
DomainException ex = new DomainException(
|
||||
ErrorCode.ORDER_AMOUNT_INVALID, "金额不能为负数: -100");
|
||||
|
||||
assertEquals(ErrorCode.ORDER_AMOUNT_INVALID, ex.getErrorCode());
|
||||
assertEquals("金额不能为负数: -100", ex.getDetail());
|
||||
assertEquals("金额不能为负数: -100", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withNullDetail_shouldFallbackToDefaultMessage() {
|
||||
DomainException ex = new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, null);
|
||||
|
||||
assertEquals("订单金额不合法", ex.getDetail());
|
||||
assertEquals("订单金额不合法", ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withCause_shouldChainException() {
|
||||
RuntimeException cause = new RuntimeException("DB error");
|
||||
DomainException ex = new DomainException(
|
||||
ErrorCode.COMMON_INTERNAL_ERROR, "保存失败", cause);
|
||||
|
||||
assertEquals(ErrorCode.COMMON_INTERNAL_ERROR, ex.getErrorCode());
|
||||
assertEquals("保存失败", ex.getDetail());
|
||||
assertSame(cause, ex.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
void domainException_shouldBeRuntimeException() {
|
||||
DomainException ex = new DomainException(ErrorCode.COMMON_INTERNAL_ERROR);
|
||||
assertInstanceOf(RuntimeException.class, ex);
|
||||
}
|
||||
|
||||
@Test
|
||||
void constructor_withNullErrorCode_shouldThrow() {
|
||||
assertThrows(NullPointerException.class, () -> new DomainException(null));
|
||||
assertThrows(NullPointerException.class, () -> new DomainException(null, "x"));
|
||||
assertThrows(NullPointerException.class, () -> new DomainException(null, "x", new RuntimeException()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.ljwx.tmpl.domain.exception;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* ErrorCode 枚举的不可变契约测试。
|
||||
*/
|
||||
class ErrorCodeTest {
|
||||
|
||||
@Test
|
||||
void allErrorCodes_shouldHaveValidHttpStatus() {
|
||||
for (ErrorCode code : ErrorCode.values()) {
|
||||
int status = code.getHttpStatus();
|
||||
assertTrue(status >= 400 && status < 600,
|
||||
code.name() + " httpStatus 应在 4xx-5xx 范围,实际为: " + status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allErrorCodes_shouldHaveNonBlankDefaultMessage() {
|
||||
for (ErrorCode code : ErrorCode.values()) {
|
||||
assertNotNull(code.getDefaultMessage(),
|
||||
code.name() + " defaultMessage 不能为 null");
|
||||
assertFalse(code.getDefaultMessage().isBlank(),
|
||||
code.name() + " defaultMessage 不能为空白");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allErrorCodes_shouldHaveUniqueNames() {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (ErrorCode code : ErrorCode.values()) {
|
||||
assertTrue(names.add(code.name()),
|
||||
"ErrorCode name 重复: " + code.name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void specificErrorCodes_shouldMapToExpectedStatus() {
|
||||
assertEquals(400, ErrorCode.COMMON_INVALID_PARAM.getHttpStatus());
|
||||
assertEquals(404, ErrorCode.COMMON_NOT_FOUND.getHttpStatus());
|
||||
assertEquals(409, ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS.getHttpStatus());
|
||||
assertEquals(409, ErrorCode.COMMON_OPTIMISTIC_LOCK.getHttpStatus());
|
||||
assertEquals(500, ErrorCode.COMMON_INTERNAL_ERROR.getHttpStatus());
|
||||
|
||||
assertEquals(400, ErrorCode.AUTH_MISSING_TENANT.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_MISSING_DEPT.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_MISSING_USER.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_INVALID_USER_ID.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_HEADER_CONFLICT.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_LEGACY_HEADER_NOT_ALLOWED.getHttpStatus());
|
||||
assertEquals(400, ErrorCode.AUTH_INVALID_HEADER_VALUE.getHttpStatus());
|
||||
assertEquals(403, ErrorCode.AUTH_ACCESS_DENIED.getHttpStatus());
|
||||
assertEquals(403, ErrorCode.AUTH_FORBIDDEN_SCOPE.getHttpStatus());
|
||||
|
||||
assertEquals(404, ErrorCode.ORDER_NOT_FOUND.getHttpStatus());
|
||||
assertEquals(422, ErrorCode.ORDER_AMOUNT_INVALID.getHttpStatus());
|
||||
assertEquals(422, ErrorCode.ORDER_STATUS_INVALID.getHttpStatus());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user