Compare commits

...

3 Commits

23 changed files with 1010 additions and 53 deletions

3
.gitignore vendored
View File

@@ -147,3 +147,6 @@ fabric.properties
/CLAUDE.md
/API_USAGE.md
/.omc/
/src/test/
/docs/Feature-Ticket-API-Guide.md

View File

@@ -16,7 +16,7 @@
<version>1.0</version>
<name>springboot-init</name>
<properties>
<java.version>1.8</java.version>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
@@ -129,7 +129,11 @@
<artifactId>x-file-storage-spring</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>4.5.1</version> <scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -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 TicketURI: {}", request.getRequestURI());
writeErrorResponse(response, ErrorCode.TICKET_MISSING, "缺少 Feature Ticket");
return false;
}
// 校验设备 ID 是否存在
if (StrUtil.isBlank(machineId)) {
log.warn("请求缺少设备 IDURI: {}", 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();
}
}
}

View File

@@ -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";
}

View File

@@ -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, "功能代码不匹配");
/**
* 状态码

View File

@@ -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);
}
}

View File

@@ -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"
);
}
}

View File

@@ -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;
}

View File

@@ -10,6 +10,8 @@ import com.yupi.springbootinit.service.AiCommentService;
import com.yupi.springbootinit.service.AiTemplateService;
import com.yupi.springbootinit.service.CommonService;
import com.yupi.springbootinit.service.CountryInfoService;
import com.yupi.springbootinit.service.SystemNoticeService;
import com.yupi.springbootinit.model.entity.SystemNotice;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@@ -39,6 +41,9 @@ public class CommonController {
@Resource
private AiCommentService aiCommentService;
@Resource
private SystemNoticeService systemNoticeService;
@PostMapping("country_info")
public BaseResponse<List<CountryInfoVO>> countryInfo() {
@@ -74,4 +79,11 @@ public class CommonController {
public BaseResponse<String> health(){
return ResultUtils.success("ok");
}
@GetMapping("notice")
public BaseResponse<List<SystemNotice>> getActiveNotice(){
return ResultUtils.success(systemNoticeService.getActiveNoticeList());
}
}

View File

@@ -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;
}
}
}

View File

@@ -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());
}
}

View File

@@ -0,0 +1,12 @@
package com.yupi.springbootinit.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yupi.springbootinit.model.entity.SystemNotice;
/*
* @author: ziin
* @date: 2026/2/10 20:25
*/
public interface SystemNoticeMapper extends BaseMapper<SystemNotice> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,107 @@
package com.yupi.springbootinit.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2026/2/10 20:25
*/
/**
* 通知公告表
*/
@ApiModel(description="通知公告表")
@Data
@TableName(value = "system_notice")
public class SystemNotice {
/**
* 公告ID
*/
@TableId(value = "id", type = IdType.AUTO)
@ApiModelProperty(value="公告ID")
private Long id;
/**
* 公告标题
*/
@TableField(value = "title")
@ApiModelProperty(value="公告标题")
private String title;
/**
* 公告内容
*/
@TableField(value = "content")
@ApiModelProperty(value="公告内容")
private String content;
/**
* 公告类型1通知 2公告
*/
@TableField(value = "`type`")
@ApiModelProperty(value="公告类型1通知 2公告")
private Byte type;
/**
* 公告状态0正常 1关闭
*/
@TableField(value = "`status`")
@ApiModelProperty(value="公告状态0正常 1关闭")
private Byte status;
/**
* 创建者
*/
@TableField(value = "creator")
@ApiModelProperty(value="创建者")
private String creator;
/**
* 创建时间
*/
@TableField(value = "create_time")
@ApiModelProperty(value="创建时间")
private Date createTime;
/**
* 更新者
*/
@TableField(value = "updater")
@ApiModelProperty(value="更新者")
private String updater;
/**
* 更新时间
*/
@TableField(value = "update_time")
@ApiModelProperty(value="更新时间")
private Date updateTime;
/**
* 是否删除
*/
@TableField(value = "deleted")
@ApiModelProperty(value="是否删除")
private Boolean deleted;
/**
* 租户编号
*/
@TableField(value = "tenant_id")
@ApiModelProperty(value="租户编号")
private Long tenantId;
/**
* 分类
*/
@TableField(value = "category")
@ApiModelProperty(value="分类")
private String category;
}

View File

@@ -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;
/**
* 账号数量
*/

View File

@@ -1,5 +1,7 @@
package com.yupi.springbootinit.model.vo.user;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.util.Date;
@@ -36,5 +38,16 @@ public class SystemUsersVO {
private Date aiExpireTime;
private Date crawlExpireTime;
private Byte aiReplay;
private Byte crawl;
private Byte bigBrother;
private Byte aiChat;
private Byte webAi;
}

View File

@@ -0,0 +1,20 @@
package com.yupi.springbootinit.service;
import com.yupi.springbootinit.model.entity.SystemNotice;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
/*
* @author: ziin
* @date: 2026/2/10 20:25
*/
public interface SystemNoticeService extends IService<SystemNotice>{
/**
* 查询当前状态为开启的公告列表
*/
List<SystemNotice> getActiveNoticeList();
}

View File

@@ -76,9 +76,7 @@ public class LoginService {
// 1. 校验用户名、密码、状态、租户过期
SystemUsers user = validateUser(dto);
// 2. 按场景校验角色权限
checkRole(scene, user.getId());
// checkRole(scene, user.getId());
// 3. AI_CHAT 场景专属逻辑:缓存登录状态并动态创建 RabbitMQ 队列
if (scene.equals(LoginSceneEnum.AI_CHAT)) {
// 记录该用户已登录 AI_CHAT
@@ -95,44 +93,11 @@ public class LoginService {
.match();
rabbitAdmin.declareBinding(binding);
}
// 3. 大哥场景专属逻辑:缓存登录状态并动态创建 RabbitMQ 队列
if (scene.equals(LoginSceneEnum.BIG_BROTHER)) {
// 记录该用户已登录 BIG_BROTHER
redisTemplate.opsForValue().set("bigbrother_login:" + user.getTenantId() + ":" + user.getId(), true);
String queueName = "b.tenant." + user.getTenantId();
// 若该租户队列尚未创建,则创建队列并绑定到 HeadersExchange
Queue queue = QueueBuilder.durable(queueName).build();
rabbitAdmin.declareQueue(queue);
Map<String, Object> headers = Map.of("tenantId", user.getTenantId(), "x-match", "all");
Binding binding = BindingBuilder
.bind(queue)
.to(bigBrotherHeadersExchange) // 使用大哥专用交换机
.whereAll(headers)
.match();
rabbitAdmin.declareBinding(binding);
}
if (scene.equals(LoginSceneEnum.WEB_AI)) {
redisTemplate.opsForValue().set("webAI_login:" + user.getTenantId() + ":" + user.getId(), true);
String queueName = "w.tenant." + user.getTenantId();
// 若该租户队列尚未创建,则创建队列并绑定到 HeadersExchange
Queue queue = QueueBuilder.durable(queueName).build();
rabbitAdmin.declareQueue(queue);
Map<String, Object> headers = Map.of("tenantId", user.getTenantId(), "x-match", "all");
Binding binding = BindingBuilder
.bind(queue)
.to(webAiHeadersExchange) // 使用webAi专用交换机
.whereAll(headers)
.match();
rabbitAdmin.declareBinding(binding);
}
SystemTenant systemTenant = tenantMapper.selectById(user.getTenantId());
// 封装返回数据
SystemUsersVO vo = new SystemUsersVO();
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
// 5. Sa-Token 登录
StpUtil.login(user.getId(), scene.getSaMode());
switch (scene) {
@@ -144,25 +109,15 @@ public class LoginService {
vo.setAiExpireTime(systemTenant.getAiExpireTime());
return vo;
case HOST:
StpUtil.renewTimeout(DateUtils.dateBetween(systemTenant.getExpireTime(),DateUtil.date()));
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
vo.setExpireTime(systemTenant.getExpireTime());
return vo;
case BIG_BROTHER:
StpUtil.renewTimeout(DateUtils.dateBetween(systemTenant.getBrotherExpireTime(),DateUtil.date()));
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
vo.setBrotherExpireTime(systemTenant.getBrotherExpireTime());
return vo;
case WEB_AI:
StpUtil.renewTimeout(DateUtils.dateBetween(systemTenant.getAiExpireTime(),DateUtil.date()));
BeanUtil.copyProperties(user, vo);
vo.setTokenName(StpUtil.getTokenName());
vo.setTokenValue(StpUtil.getTokenValue());
vo.setAiExpireTime(systemTenant.getAiExpireTime());
vo.setCrawl(user.getCrawl());
vo.setAiChat(user.getAiChat());
vo.setBigBrother(user.getBigBrother());
return vo;
}
return null;

View File

@@ -0,0 +1,25 @@
package com.yupi.springbootinit.service.impl;
import org.springframework.stereotype.Service;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yupi.springbootinit.model.entity.SystemNotice;
import com.yupi.springbootinit.mapper.SystemNoticeMapper;
import com.yupi.springbootinit.service.SystemNoticeService;
/*
* @author: ziin
* @date: 2026/2/10 20:25
*/
@Service
public class SystemNoticeServiceImpl extends ServiceImpl<SystemNoticeMapper, SystemNotice> implements SystemNoticeService{
@Override
public List<SystemNotice> getActiveNoticeList() {
LambdaQueryWrapper<SystemNotice> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SystemNotice::getStatus, 0);
return this.list(queryWrapper);
}
}

View File

@@ -107,4 +107,11 @@ sa-token:
md5:
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

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yupi.springbootinit.mapper.SystemNoticeMapper">
<resultMap id="BaseResultMap" type="com.yupi.springbootinit.model.entity.SystemNotice">
<!--@mbg.generated-->
<!--@Table system_notice-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="title" jdbcType="VARCHAR" property="title" />
<result column="content" jdbcType="LONGVARCHAR" property="content" />
<result column="type" jdbcType="TINYINT" property="type" />
<result column="status" jdbcType="TINYINT" property="status" />
<result column="creator" jdbcType="VARCHAR" property="creator" />
<result column="create_time" jdbcType="TIMESTAMP" property="createTime" />
<result column="updater" jdbcType="VARCHAR" property="updater" />
<result column="update_time" jdbcType="TIMESTAMP" property="updateTime" />
<result column="deleted" jdbcType="BIT" property="deleted" />
<result column="tenant_id" jdbcType="BIGINT" property="tenantId" />
<result column="category" jdbcType="VARCHAR" property="category" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, title, content, `type`, `status`, creator, create_time, updater, update_time,
deleted, tenant_id, category
</sql>
</mapper>

Binary file not shown.