diff --git a/.gitignore b/.gitignore index 61ecd18..76e877c 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,5 @@ fabric.properties /CLAUDE.md /API_USAGE.md /.omc/ +/src/test/ +/docs/Feature-Ticket-API-Guide.md diff --git a/pom.xml b/pom.xml index ff0f0e0..7e0b949 100644 --- a/pom.xml +++ b/pom.xml @@ -16,7 +16,7 @@ 1.0 springboot-init - 1.8 + 17 @@ -129,7 +129,11 @@ x-file-storage-spring 2.3.0 - + + org.mockito + mockito-inline + 4.5.1 test + diff --git a/src/main/java/com/yupi/springbootinit/Interceptor/FeatureTicketInterceptor.java b/src/main/java/com/yupi/springbootinit/Interceptor/FeatureTicketInterceptor.java new file mode 100644 index 0000000..2973bad --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/Interceptor/FeatureTicketInterceptor.java @@ -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 errorResponse = new BaseResponse<>(code, null, message); + + try (PrintWriter writer = response.getWriter()) { + writer.write(JsonUtils.toJsonString(errorResponse)); + writer.flush(); + } + } +} diff --git a/src/main/java/com/yupi/springbootinit/annotation/RequireFeatureTicket.java b/src/main/java/com/yupi/springbootinit/annotation/RequireFeatureTicket.java new file mode 100644 index 0000000..5055eac --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/annotation/RequireFeatureTicket.java @@ -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"; +} diff --git a/src/main/java/com/yupi/springbootinit/common/ErrorCode.java b/src/main/java/com/yupi/springbootinit/common/ErrorCode.java index bd76795..5625a9d 100644 --- a/src/main/java/com/yupi/springbootinit/common/ErrorCode.java +++ b/src/main/java/com/yupi/springbootinit/common/ErrorCode.java @@ -23,7 +23,14 @@ public enum ErrorCode { SYSTEM_ERROR(50000, "系统内部异常"), OPERATION_ERROR(50001, "操作失败"), 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, "功能代码不匹配"); /** * 状态码 diff --git a/src/main/java/com/yupi/springbootinit/component/FeatureAuthComponent.java b/src/main/java/com/yupi/springbootinit/component/FeatureAuthComponent.java new file mode 100644 index 0000000..66a9752 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/component/FeatureAuthComponent.java @@ -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); + } +} diff --git a/src/main/java/com/yupi/springbootinit/config/FeatureTicketConfig.java b/src/main/java/com/yupi/springbootinit/config/FeatureTicketConfig.java new file mode 100644 index 0000000..c74ccd4 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/config/FeatureTicketConfig.java @@ -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" + ); + } +} diff --git a/src/main/java/com/yupi/springbootinit/config/FeatureTicketProperties.java b/src/main/java/com/yupi/springbootinit/config/FeatureTicketProperties.java new file mode 100644 index 0000000..c700c45 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/config/FeatureTicketProperties.java @@ -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; +} diff --git a/src/main/java/com/yupi/springbootinit/controller/FeatureAuthController.java b/src/main/java/com/yupi/springbootinit/controller/FeatureAuthController.java new file mode 100644 index 0000000..b19d062 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/controller/FeatureAuthController.java @@ -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 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; + } + } +} diff --git a/src/main/java/com/yupi/springbootinit/controller/TaskExecuteController.java b/src/main/java/com/yupi/springbootinit/controller/TaskExecuteController.java new file mode 100644 index 0000000..869e775 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/controller/TaskExecuteController.java @@ -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> executeTask( + @RequestBody Map taskParams, + HttpServletRequest request) { + + // 从请求属性中获取已校验的 Ticket 载荷 + FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute( + FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR); + + log.info("执行任务,用户: {}, 租户: {}, 设备: {}", + payload.getUserId(), payload.getTenantId(), payload.getMachineId()); + + // 业务逻辑处理 + Map 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> queryStatus(HttpServletRequest request) { + + FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute( + FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR); + + Map 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 customHeaderTask(HttpServletRequest request) { + + FeatureTicketPayload payload = (FeatureTicketPayload) request.getAttribute( + FeatureTicketInterceptor.TICKET_PAYLOAD_ATTR); + + return ResultUtils.success("任务执行成功,用户: " + payload.getUserId()); + } +} diff --git a/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketPayload.java b/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketPayload.java new file mode 100644 index 0000000..8d9c147 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketPayload.java @@ -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; +} diff --git a/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketRequest.java b/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketRequest.java new file mode 100644 index 0000000..947b909 --- /dev/null +++ b/src/main/java/com/yupi/springbootinit/model/dto/ticket/FeatureTicketRequest.java @@ -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; +} diff --git a/src/main/java/com/yupi/springbootinit/model/entity/SystemTenant.java b/src/main/java/com/yupi/springbootinit/model/entity/SystemTenant.java index 69ea5dc..758c107 100644 --- a/src/main/java/com/yupi/springbootinit/model/entity/SystemTenant.java +++ b/src/main/java/com/yupi/springbootinit/model/entity/SystemTenant.java @@ -86,6 +86,13 @@ public class SystemTenant { @ApiModelProperty(value="过期时间") private Date expireTime; + /** + * 爬主播过期时间 + */ + @TableField(value = "crawl_expire_time") + @ApiModelProperty(value="爬主播过期时间") + private Date crawlExpireTime; + /** * ai过期时间 */ @@ -100,6 +107,7 @@ public class SystemTenant { @ApiModelProperty(value="大哥过期时间") private Date brotherExpireTime; + /** * 账号数量 */ diff --git a/src/main/java/com/yupi/springbootinit/model/vo/user/SystemUsersVO.java b/src/main/java/com/yupi/springbootinit/model/vo/user/SystemUsersVO.java index feb513b..4237d2b 100644 --- a/src/main/java/com/yupi/springbootinit/model/vo/user/SystemUsersVO.java +++ b/src/main/java/com/yupi/springbootinit/model/vo/user/SystemUsersVO.java @@ -38,6 +38,8 @@ public class SystemUsersVO { private Date aiExpireTime; + private Date crawlExpireTime; + private Byte aiReplay; private Byte crawl; @@ -47,4 +49,5 @@ public class SystemUsersVO { private Byte aiChat; private Byte webAi; + } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fd6e876..6c37b89 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -107,4 +107,11 @@ sa-token: md5: salt: (-FhqvXO,wMz -ai_log_path: /test/ai_log \ No newline at end of file +ai_log_path: /test/ai_log + +# Feature Ticket 配置 +feature-ticket: + # AES 加密密钥(必须是 16/24/32 字节,生产环境请修改) + aes-key: Ehq8aoQcJRXpgMbfZOt2ms7USgT5zGVr + # Ticket 默认有效期(秒),默认 5 分钟 + default-expire-seconds: 300 \ No newline at end of file