feat(core): 新增 Feature Ticket 鉴权体系与任务执行接口
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -148,3 +148,5 @@ fabric.properties
|
|||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/API_USAGE.md
|
/API_USAGE.md
|
||||||
/.omc/
|
/.omc/
|
||||||
|
/src/test/
|
||||||
|
/docs/Feature-Ticket-API-Guide.md
|
||||||
|
|||||||
8
pom.xml
8
pom.xml
@@ -16,7 +16,7 @@
|
|||||||
<version>1.0</version>
|
<version>1.0</version>
|
||||||
<name>springboot-init</name>
|
<name>springboot-init</name>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>1.8</java.version>
|
<java.version>17</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -129,7 +129,11 @@
|
|||||||
<artifactId>x-file-storage-spring</artifactId>
|
<artifactId>x-file-storage-spring</artifactId>
|
||||||
<version>2.3.0</version>
|
<version>2.3.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-inline</artifactId>
|
||||||
|
<version>4.5.1</version> <scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.yupi.springbootinit.Interceptor;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.yupi.springbootinit.annotation.RequireFeatureTicket;
|
||||||
|
import com.yupi.springbootinit.common.BaseResponse;
|
||||||
|
import com.yupi.springbootinit.common.ErrorCode;
|
||||||
|
import com.yupi.springbootinit.component.FeatureAuthComponent;
|
||||||
|
import com.yupi.springbootinit.exception.BusinessException;
|
||||||
|
import com.yupi.springbootinit.model.dto.ticket.FeatureTicketPayload;
|
||||||
|
import com.yupi.springbootinit.utils.JsonUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 拦截器
|
||||||
|
* 拦截带有 @RequireFeatureTicket 注解的接口
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class FeatureTicketInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FeatureTicketInterceptor.class);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FeatureAuthComponent featureAuthComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求属性 Key:存储解析后的 Ticket 载荷
|
||||||
|
*/
|
||||||
|
public static final String TICKET_PAYLOAD_ATTR = "FEATURE_TICKET_PAYLOAD";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
// 仅处理 Controller 方法
|
||||||
|
if (!(handler instanceof HandlerMethod)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
HandlerMethod handlerMethod = (HandlerMethod) handler;
|
||||||
|
|
||||||
|
// 获取方法或类上的注解
|
||||||
|
RequireFeatureTicket annotation = handlerMethod.getMethodAnnotation(RequireFeatureTicket.class);
|
||||||
|
if (annotation == null) {
|
||||||
|
annotation = handlerMethod.getBeanType().getAnnotation(RequireFeatureTicket.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有注解,放行
|
||||||
|
if (annotation == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取请求头
|
||||||
|
String ticketHeader = annotation.ticketHeader();
|
||||||
|
String machineIdHeader = annotation.machineIdHeader();
|
||||||
|
String requiredFeatureCode = annotation.featureCode();
|
||||||
|
|
||||||
|
String ticket = request.getHeader(ticketHeader);
|
||||||
|
String machineId = request.getHeader(machineIdHeader);
|
||||||
|
|
||||||
|
// 校验 Ticket 是否存在
|
||||||
|
if (StrUtil.isBlank(ticket)) {
|
||||||
|
log.warn("请求缺少 Feature Ticket,URI: {}", request.getRequestURI());
|
||||||
|
writeErrorResponse(response, ErrorCode.TICKET_MISSING, "缺少 Feature Ticket");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验设备 ID 是否存在
|
||||||
|
if (StrUtil.isBlank(machineId)) {
|
||||||
|
log.warn("请求缺少设备 ID,URI: {}", request.getRequestURI());
|
||||||
|
writeErrorResponse(response, ErrorCode.PARAMS_ERROR, "缺少设备 ID");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 校验 Ticket
|
||||||
|
FeatureTicketPayload payload;
|
||||||
|
if (StrUtil.isNotBlank(requiredFeatureCode)) {
|
||||||
|
payload = featureAuthComponent.validateTicket(ticket, machineId, requiredFeatureCode);
|
||||||
|
} else {
|
||||||
|
payload = featureAuthComponent.validateTicket(ticket, machineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将载荷存入请求属性,供后续业务使用
|
||||||
|
request.setAttribute(TICKET_PAYLOAD_ATTR, payload);
|
||||||
|
|
||||||
|
log.debug("Feature Ticket 校验通过,用户: {}, 租户: {}, 功能: {}",
|
||||||
|
payload.getUserId(), payload.getTenantId(), payload.getFeatureCode());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
log.warn("Feature Ticket 校验失败: {}", e.getMessage());
|
||||||
|
writeErrorResponse(response, e.getCode(), e.getMessage());
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Feature Ticket 校验异常", e);
|
||||||
|
writeErrorResponse(response, ErrorCode.SYSTEM_ERROR.getCode(), "Ticket 校验异常");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入错误响应
|
||||||
|
*/
|
||||||
|
private void writeErrorResponse(HttpServletResponse response, ErrorCode errorCode, String message) throws IOException {
|
||||||
|
writeErrorResponse(response, errorCode.getCode(), message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入错误响应
|
||||||
|
*/
|
||||||
|
private void writeErrorResponse(HttpServletResponse response, int code, String message) throws IOException {
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
|
||||||
|
BaseResponse<Object> errorResponse = new BaseResponse<>(code, null, message);
|
||||||
|
|
||||||
|
try (PrintWriter writer = response.getWriter()) {
|
||||||
|
writer.write(JsonUtils.toJsonString(errorResponse));
|
||||||
|
writer.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.yupi.springbootinit.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 校验注解
|
||||||
|
* 标注在需要 Ticket 校验的接口方法上
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface RequireFeatureTicket {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 需要的功能代码,为空时不校验功能代码
|
||||||
|
*/
|
||||||
|
String featureCode() default "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备 ID 的请求头名称
|
||||||
|
*/
|
||||||
|
String machineIdHeader() default "X-Machine-Id";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket 的请求头名称
|
||||||
|
*/
|
||||||
|
String ticketHeader() default "X-Feature-Ticket";
|
||||||
|
}
|
||||||
@@ -23,7 +23,14 @@ public enum ErrorCode {
|
|||||||
SYSTEM_ERROR(50000, "系统内部异常"),
|
SYSTEM_ERROR(50000, "系统内部异常"),
|
||||||
OPERATION_ERROR(50001, "操作失败"),
|
OPERATION_ERROR(50001, "操作失败"),
|
||||||
QUEUE_ERROR(60001, "队列消息添加失败"),
|
QUEUE_ERROR(60001, "队列消息添加失败"),
|
||||||
QUEUE_CONSUMPTION_FAILURE(60001, "队列消息消费失败");
|
QUEUE_CONSUMPTION_FAILURE(60001, "队列消息消费失败"),
|
||||||
|
|
||||||
|
// Feature Ticket 相关错误码
|
||||||
|
TICKET_MISSING(40901, "缺少 Feature Ticket"),
|
||||||
|
TICKET_INVALID(40902, "Ticket 无效或已损坏"),
|
||||||
|
TICKET_EXPIRED(40903, "Ticket 已过期"),
|
||||||
|
TICKET_MACHINE_MISMATCH(40904, "设备 ID 不匹配"),
|
||||||
|
TICKET_FEATURE_MISMATCH(40905, "功能代码不匹配");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.yupi.springbootinit.component;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import cn.hutool.crypto.SecureUtil;
|
||||||
|
import cn.hutool.crypto.symmetric.AES;
|
||||||
|
import com.yupi.springbootinit.common.ErrorCode;
|
||||||
|
import com.yupi.springbootinit.config.FeatureTicketProperties;
|
||||||
|
import com.yupi.springbootinit.exception.BusinessException;
|
||||||
|
import com.yupi.springbootinit.model.dto.ticket.FeatureTicketPayload;
|
||||||
|
import com.yupi.springbootinit.utils.JsonUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.annotation.PostConstruct;
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 认证组件
|
||||||
|
* 负责 Ticket 的生成、加密、解密和校验
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class FeatureAuthComponent {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FeatureAuthComponent.class);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FeatureTicketProperties ticketProperties;
|
||||||
|
|
||||||
|
private AES aes;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
// 使用配置的 AES 密钥初始化加密器
|
||||||
|
byte[] keyBytes = ticketProperties.getAesKey().getBytes(StandardCharsets.UTF_8);
|
||||||
|
// 确保密钥长度为 16 字节(AES-128)
|
||||||
|
if (keyBytes.length < 16) {
|
||||||
|
byte[] paddedKey = new byte[16];
|
||||||
|
System.arraycopy(keyBytes, 0, paddedKey, 0, keyBytes.length);
|
||||||
|
keyBytes = paddedKey;
|
||||||
|
} else if (keyBytes.length > 16 && keyBytes.length < 24) {
|
||||||
|
byte[] truncatedKey = new byte[16];
|
||||||
|
System.arraycopy(keyBytes, 0, truncatedKey, 0, 16);
|
||||||
|
keyBytes = truncatedKey;
|
||||||
|
} else if (keyBytes.length > 24 && keyBytes.length < 32) {
|
||||||
|
byte[] truncatedKey = new byte[24];
|
||||||
|
System.arraycopy(keyBytes, 0, truncatedKey, 0, 24);
|
||||||
|
keyBytes = truncatedKey;
|
||||||
|
} else if (keyBytes.length > 32) {
|
||||||
|
byte[] truncatedKey = new byte[32];
|
||||||
|
System.arraycopy(keyBytes, 0, truncatedKey, 0, 32);
|
||||||
|
keyBytes = truncatedKey;
|
||||||
|
}
|
||||||
|
this.aes = SecureUtil.aes(keyBytes);
|
||||||
|
log.info("FeatureAuthComponent 初始化完成,AES 密钥长度: {} 字节", keyBytes.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Feature Ticket
|
||||||
|
*
|
||||||
|
* @param tenantId 租户 ID
|
||||||
|
* @param userId 用户 ID
|
||||||
|
* @param machineId 设备 ID
|
||||||
|
* @param featureCode 功能代码
|
||||||
|
* @param expireSeconds 有效期(秒)
|
||||||
|
* @return 加密后的 Base64 字符串
|
||||||
|
*/
|
||||||
|
public String generateTicket(Long tenantId, Long userId, String machineId,
|
||||||
|
String featureCode, Long expireSeconds) {
|
||||||
|
if (tenantId == null || userId == null || StrUtil.isBlank(machineId) || StrUtil.isBlank(featureCode)) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成 Ticket 参数不完整");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算过期时间
|
||||||
|
long expireTime = expireSeconds != null ? expireSeconds : ticketProperties.getDefaultExpireSeconds();
|
||||||
|
long expiryTimestamp = System.currentTimeMillis() + (expireTime * 1000);
|
||||||
|
|
||||||
|
// 构建载荷
|
||||||
|
FeatureTicketPayload payload = FeatureTicketPayload.builder()
|
||||||
|
.tenantId(tenantId)
|
||||||
|
.userId(userId)
|
||||||
|
.machineId(machineId)
|
||||||
|
.featureCode(featureCode)
|
||||||
|
.expiryTimestamp(expiryTimestamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// 序列化为 JSON
|
||||||
|
String jsonPayload = JsonUtils.toJsonString(payload);
|
||||||
|
|
||||||
|
// AES 加密
|
||||||
|
byte[] encryptedBytes = aes.encrypt(jsonPayload.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// Base64 编码(URL 安全)
|
||||||
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密 Ticket
|
||||||
|
*
|
||||||
|
* @param encryptedTicket 加密的 Ticket 字符串
|
||||||
|
* @return 解密后的载荷
|
||||||
|
*/
|
||||||
|
public FeatureTicketPayload decryptTicket(String encryptedTicket) {
|
||||||
|
if (StrUtil.isBlank(encryptedTicket)) {
|
||||||
|
throw new BusinessException(ErrorCode.TICKET_INVALID, "Ticket 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Base64 解码
|
||||||
|
byte[] encryptedBytes = Base64.getUrlDecoder().decode(encryptedTicket);
|
||||||
|
|
||||||
|
// AES 解密
|
||||||
|
byte[] decryptedBytes = aes.decrypt(encryptedBytes);
|
||||||
|
String jsonPayload = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
// 反序列化
|
||||||
|
return JsonUtils.parseObject(jsonPayload, FeatureTicketPayload.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Ticket 解密失败: {}", e.getMessage());
|
||||||
|
throw new BusinessException(ErrorCode.TICKET_INVALID, "Ticket 无效或已损坏");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 Ticket
|
||||||
|
*
|
||||||
|
* @param encryptedTicket 加密的 Ticket
|
||||||
|
* @param requestMachineId 请求中的设备 ID
|
||||||
|
* @param requiredFeatureCode 需要的功能代码(可选,为 null 时不校验)
|
||||||
|
* @return 校验通过的载荷
|
||||||
|
*/
|
||||||
|
public FeatureTicketPayload validateTicket(String encryptedTicket, String requestMachineId,
|
||||||
|
String requiredFeatureCode) {
|
||||||
|
// 解密 Ticket
|
||||||
|
FeatureTicketPayload payload = decryptTicket(encryptedTicket);
|
||||||
|
|
||||||
|
// 校验过期时间
|
||||||
|
if (payload.getExpiryTimestamp() == null || System.currentTimeMillis() > payload.getExpiryTimestamp()) {
|
||||||
|
throw new BusinessException(ErrorCode.TICKET_EXPIRED, "Ticket 已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验设备 ID
|
||||||
|
if (StrUtil.isBlank(requestMachineId)) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求中缺少设备 ID");
|
||||||
|
}
|
||||||
|
if (!requestMachineId.equals(payload.getMachineId())) {
|
||||||
|
log.warn("设备 ID 不匹配,请求: {}, Ticket: {}", requestMachineId, payload.getMachineId());
|
||||||
|
throw new BusinessException(ErrorCode.TICKET_MACHINE_MISMATCH, "设备 ID 不匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验功能代码(如果指定)
|
||||||
|
if (StrUtil.isNotBlank(requiredFeatureCode) && !requiredFeatureCode.equals(payload.getFeatureCode())) {
|
||||||
|
log.warn("功能代码不匹配,需要: {}, Ticket: {}", requiredFeatureCode, payload.getFeatureCode());
|
||||||
|
throw new BusinessException(ErrorCode.TICKET_FEATURE_MISMATCH, "功能代码不匹配");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化校验(仅校验有效性和设备 ID)
|
||||||
|
*
|
||||||
|
* @param encryptedTicket 加密的 Ticket
|
||||||
|
* @param requestMachineId 请求中的设备 ID
|
||||||
|
* @return 校验通过的载荷
|
||||||
|
*/
|
||||||
|
public FeatureTicketPayload validateTicket(String encryptedTicket, String requestMachineId) {
|
||||||
|
return validateTicket(encryptedTicket, requestMachineId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.yupi.springbootinit.config;
|
||||||
|
|
||||||
|
import com.yupi.springbootinit.Interceptor.FeatureTicketInterceptor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 拦截器配置
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class FeatureTicketConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FeatureTicketInterceptor featureTicketInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
// 注册 Feature Ticket 拦截器
|
||||||
|
// 拦截所有请求,但只有带 @RequireFeatureTicket 注解的接口才会进行校验
|
||||||
|
registry.addInterceptor(featureTicketInterceptor)
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns(
|
||||||
|
// 排除静态资源
|
||||||
|
"/static/**",
|
||||||
|
"/webjars/**",
|
||||||
|
// 排除 Swagger 文档
|
||||||
|
"/doc.html",
|
||||||
|
"/swagger-resources/**",
|
||||||
|
"/v2/api-docs/**",
|
||||||
|
"/v3/api-docs/**",
|
||||||
|
// 排除错误页面
|
||||||
|
"/error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.yupi.springbootinit.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 配置属性
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "feature-ticket")
|
||||||
|
public class FeatureTicketProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES 加密密钥(必须是 16/24/32 字节)
|
||||||
|
*/
|
||||||
|
private String aesKey = "Ehq8aoQcJRXpgMbfZOt2ms7USgT5zGVr";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ticket 默认有效期(秒),默认 5 分钟
|
||||||
|
*/
|
||||||
|
private Long defaultExpireSeconds = 300L;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.yupi.springbootinit.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.yupi.springbootinit.common.BaseResponse;
|
||||||
|
import com.yupi.springbootinit.common.ErrorCode;
|
||||||
|
import com.yupi.springbootinit.common.ResultUtils;
|
||||||
|
import com.yupi.springbootinit.component.FeatureAuthComponent;
|
||||||
|
import com.yupi.springbootinit.exception.BusinessException;
|
||||||
|
import com.yupi.springbootinit.model.dto.ticket.FeatureTicketRequest;
|
||||||
|
import com.yupi.springbootinit.model.entity.SystemTenant;
|
||||||
|
import com.yupi.springbootinit.model.entity.SystemUsers;
|
||||||
|
import com.yupi.springbootinit.service.SystemTenantService;
|
||||||
|
import com.yupi.springbootinit.service.SystemUsersService;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 认证控制器
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/auth")
|
||||||
|
@Api(tags = "Feature Ticket 认证")
|
||||||
|
public class FeatureAuthController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(FeatureAuthController.class);
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private FeatureAuthComponent featureAuthComponent;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SystemUsersService systemUsersService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SystemTenantService systemTenantService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求 Feature Ticket
|
||||||
|
* 根据当前登录用户,校验其所属租户和权限后生成 Ticket
|
||||||
|
*/
|
||||||
|
@PostMapping("/request-ticket")
|
||||||
|
@ApiOperation(value = "请求 Feature Ticket", notes = "根据当前登录用户生成功能准考证")
|
||||||
|
public BaseResponse<String> requestTicket(@RequestBody FeatureTicketRequest request) {
|
||||||
|
// 参数校验
|
||||||
|
if (request == null || StrUtil.isBlank(request.getMachineId()) || StrUtil.isBlank(request.getFeatureCode())) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "设备ID和功能代码不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取当前登录用户
|
||||||
|
if (!StpUtil.isLogin()) {
|
||||||
|
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
SystemUsers user = systemUsersService.getById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 校验用户状态
|
||||||
|
if (user.getStatus() != null && user.getStatus() == 1) {
|
||||||
|
throw new BusinessException(ErrorCode.USER_DISABLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 获取租户信息
|
||||||
|
Long tenantId = user.getTenantId();
|
||||||
|
if (tenantId == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户未关联租户");
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemTenant tenant = systemTenantService.getById(tenantId);
|
||||||
|
if (tenant == null) {
|
||||||
|
throw new BusinessException(ErrorCode.TENANT_NAME_NOT_EXISTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 校验租户状态
|
||||||
|
if (tenant.getStatus() != null && tenant.getStatus() == 1) {
|
||||||
|
throw new BusinessException(ErrorCode.FORBIDDEN_ERROR, "租户已停用");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 校验租户是否过期(根据功能代码选择不同的过期时间字段)
|
||||||
|
String featureCode = request.getFeatureCode();
|
||||||
|
Date expireTime = getFeatureExpireTime(tenant, featureCode);
|
||||||
|
if (expireTime != null && expireTime.before(new Date())) {
|
||||||
|
throw new BusinessException(ErrorCode.PACKAGE_EXPIRED, "该功能套餐已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 校验用户是否有该功能的权限
|
||||||
|
if (!checkUserFeaturePermission(user, featureCode)) {
|
||||||
|
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无该功能权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 生成 Ticket
|
||||||
|
String ticket = featureAuthComponent.generateTicket(
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
request.getMachineId(),
|
||||||
|
featureCode,
|
||||||
|
request.getExpireSeconds()
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("用户 {} 成功获取 Feature Ticket,功能: {}, 设备: {}",
|
||||||
|
userId, featureCode, request.getMachineId());
|
||||||
|
|
||||||
|
return ResultUtils.success(ticket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据功能代码获取对应的过期时间
|
||||||
|
*/
|
||||||
|
private Date getFeatureExpireTime(SystemTenant tenant, String featureCode) {
|
||||||
|
if (featureCode == null) {
|
||||||
|
return tenant.getExpireTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (featureCode.toUpperCase()) {
|
||||||
|
case "CRAWL":
|
||||||
|
case "HOST_CRAWL":
|
||||||
|
return tenant.getCrawlExpireTime();
|
||||||
|
case "AI":
|
||||||
|
case "AI_CHAT":
|
||||||
|
return tenant.getAiExpireTime();
|
||||||
|
case "BROTHER":
|
||||||
|
case "BIG_BROTHER":
|
||||||
|
return tenant.getBrotherExpireTime();
|
||||||
|
default:
|
||||||
|
return tenant.getExpireTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验用户是否有指定功能的权限
|
||||||
|
*/
|
||||||
|
private boolean checkUserFeaturePermission(SystemUsers user, String featureCode) {
|
||||||
|
if (featureCode == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (featureCode.toUpperCase()) {
|
||||||
|
case "CRAWL":
|
||||||
|
case "HOST_CRAWL":
|
||||||
|
return user.getCrawl() != null && user.getCrawl() == 1;
|
||||||
|
case "AI":
|
||||||
|
case "AI_CHAT":
|
||||||
|
return user.getAiChat() != null && user.getAiChat() == 1;
|
||||||
|
case "BROTHER":
|
||||||
|
case "BIG_BROTHER":
|
||||||
|
return user.getBigBrother() != null && user.getBigBrother() == 1;
|
||||||
|
case "WEB_AI":
|
||||||
|
return user.getWebAi() != null && user.getWebAi() == 1;
|
||||||
|
default:
|
||||||
|
// 未知功能代码,默认不允许
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.yupi.springbootinit.controller;
|
||||||
|
|
||||||
|
import com.yupi.springbootinit.Interceptor.FeatureTicketInterceptor;
|
||||||
|
import com.yupi.springbootinit.annotation.RequireFeatureTicket;
|
||||||
|
import com.yupi.springbootinit.common.BaseResponse;
|
||||||
|
import com.yupi.springbootinit.common.ResultUtils;
|
||||||
|
import com.yupi.springbootinit.model.dto.ticket.FeatureTicketPayload;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
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.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务执行控制器(示例)
|
||||||
|
* 展示如何使用 @RequireFeatureTicket 注解保护接口
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/v1")
|
||||||
|
@Api(tags = "任务执行(示例)")
|
||||||
|
public class TaskExecuteController {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TaskExecuteController.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行任务接口(受 Feature Ticket 保护)
|
||||||
|
*
|
||||||
|
* 调用此接口需要:
|
||||||
|
* 1. 请求头 X-Feature-Ticket: 有效的 Ticket
|
||||||
|
* 2. 请求头 X-Machine-Id: 与 Ticket 中一致的设备 ID
|
||||||
|
*/
|
||||||
|
@PostMapping("/execute-task")
|
||||||
|
@RequireFeatureTicket(featureCode = "CRAWL")
|
||||||
|
@ApiOperation(value = "执行任务", notes = "需要 Feature Ticket 校验,功能代码: CRAWL")
|
||||||
|
public BaseResponse<Map<String, Object>> executeTask(
|
||||||
|
@RequestBody Map<String, Object> taskParams,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
// 从请求属性中获取已校验的 Ticket 载荷
|
||||||
|
FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute(
|
||||||
|
FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR);
|
||||||
|
|
||||||
|
log.info("执行任务,用户: {}, 租户: {}, 设备: {}",
|
||||||
|
payload.getUserId(), payload.getTenantId(), payload.getMachineId());
|
||||||
|
|
||||||
|
// 业务逻辑处理
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("taskId", System.currentTimeMillis());
|
||||||
|
result.put("tenantId", payload.getTenantId());
|
||||||
|
result.put("userId", payload.getUserId());
|
||||||
|
result.put("message", "任务已提交执行");
|
||||||
|
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 另一个受保护的接口示例(不限定功能代码)
|
||||||
|
*
|
||||||
|
* 只校验 Ticket 有效性和设备 ID,不校验功能代码
|
||||||
|
*/
|
||||||
|
@PostMapping("/query-status")
|
||||||
|
@RequireFeatureTicket
|
||||||
|
@ApiOperation(value = "查询状态", notes = "需要 Feature Ticket 校验,不限定功能代码")
|
||||||
|
public BaseResponse<Map<String, Object>> queryStatus(HttpServletRequest request) {
|
||||||
|
|
||||||
|
FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute(
|
||||||
|
FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("status", "running");
|
||||||
|
result.put("featureCode", payload.getFeatureCode());
|
||||||
|
result.put("tenantId", payload.getTenantId());
|
||||||
|
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义请求头名称的示例
|
||||||
|
*/
|
||||||
|
@PostMapping("/custom-header-task")
|
||||||
|
@RequireFeatureTicket(
|
||||||
|
featureCode = "AI_CHAT",
|
||||||
|
ticketHeader = "Authorization-Ticket",
|
||||||
|
machineIdHeader = "Device-Id"
|
||||||
|
)
|
||||||
|
@ApiOperation(value = "自定义请求头任务", notes = "使用自定义请求头名称")
|
||||||
|
public BaseResponse<String> customHeaderTask(HttpServletRequest request) {
|
||||||
|
|
||||||
|
FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute(
|
||||||
|
FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR);
|
||||||
|
|
||||||
|
return ResultUtils.success("任务执行成功,用户: " + payload.getUserId());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.yupi.springbootinit.model.dto.ticket;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Ticket 载荷
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class FeatureTicketPayload implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 租户 ID
|
||||||
|
*/
|
||||||
|
private Long tenantId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备 ID(机器码)
|
||||||
|
*/
|
||||||
|
private String machineId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能代码
|
||||||
|
*/
|
||||||
|
private String featureCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过期时间戳(毫秒)
|
||||||
|
*/
|
||||||
|
private Long expiryTimestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.yupi.springbootinit.model.dto.ticket;
|
||||||
|
|
||||||
|
import io.swagger.annotations.ApiModel;
|
||||||
|
import io.swagger.annotations.ApiModelProperty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求 Feature Ticket 的参数
|
||||||
|
*
|
||||||
|
* @author ziin
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@ApiModel(description = "请求 Feature Ticket 的参数")
|
||||||
|
public class FeatureTicketRequest implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备 ID(机器码)
|
||||||
|
*/
|
||||||
|
@ApiModelProperty(value = "设备ID(机器码)", required = true)
|
||||||
|
private String machineId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能代码
|
||||||
|
*/
|
||||||
|
@ApiModelProperty(value = "功能代码", required = true)
|
||||||
|
private String featureCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义有效期(秒),可选
|
||||||
|
*/
|
||||||
|
@ApiModelProperty(value = "自定义有效期(秒),可选")
|
||||||
|
private Long expireSeconds;
|
||||||
|
}
|
||||||
@@ -86,6 +86,13 @@ public class SystemTenant {
|
|||||||
@ApiModelProperty(value="过期时间")
|
@ApiModelProperty(value="过期时间")
|
||||||
private Date expireTime;
|
private Date expireTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 爬主播过期时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "crawl_expire_time")
|
||||||
|
@ApiModelProperty(value="爬主播过期时间")
|
||||||
|
private Date crawlExpireTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ai过期时间
|
* ai过期时间
|
||||||
*/
|
*/
|
||||||
@@ -100,6 +107,7 @@ public class SystemTenant {
|
|||||||
@ApiModelProperty(value="大哥过期时间")
|
@ApiModelProperty(value="大哥过期时间")
|
||||||
private Date brotherExpireTime;
|
private Date brotherExpireTime;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账号数量
|
* 账号数量
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ public class SystemUsersVO {
|
|||||||
|
|
||||||
private Date aiExpireTime;
|
private Date aiExpireTime;
|
||||||
|
|
||||||
|
private Date crawlExpireTime;
|
||||||
|
|
||||||
private Byte aiReplay;
|
private Byte aiReplay;
|
||||||
|
|
||||||
private Byte crawl;
|
private Byte crawl;
|
||||||
@@ -47,4 +49,5 @@ public class SystemUsersVO {
|
|||||||
private Byte aiChat;
|
private Byte aiChat;
|
||||||
|
|
||||||
private Byte webAi;
|
private Byte webAi;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -108,3 +108,10 @@ md5:
|
|||||||
salt: (-FhqvXO,wMz
|
salt: (-FhqvXO,wMz
|
||||||
|
|
||||||
ai_log_path: /test/ai_log
|
ai_log_path: /test/ai_log
|
||||||
|
|
||||||
|
# Feature Ticket 配置
|
||||||
|
feature-ticket:
|
||||||
|
# AES 加密密钥(必须是 16/24/32 字节,生产环境请修改)
|
||||||
|
aes-key: Ehq8aoQcJRXpgMbfZOt2ms7USgT5zGVr
|
||||||
|
# Ticket 默认有效期(秒),默认 5 分钟
|
||||||
|
default-expire-seconds: 300
|
||||||
Reference in New Issue
Block a user