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:
Bruno Gao
2026-02-12 13:02:36 +08:00
co-authored by Qwen-Coder
parent 1849110f3f
commit b7355b33d8
12 changed files with 789 additions and 0 deletions
@@ -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;
@@ -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);
}
}
@@ -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);
}
}
@@ -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());
}
}