Merge branch 'develop' of https://gitee.com/youlaiorg/youlai-boot into develop

# Conflicts:
#	sql/mysql/youlai_admin.sql
This commit is contained in:
Ray.Hao
2026-01-03 17:35:36 +08:00
212 changed files with 3394 additions and 3207 deletions

View File

@@ -1,11 +1,12 @@
package com.youlai.boot.auth.controller;
import com.youlai.boot.auth.model.vo.CaptchaVO;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDTO;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDto;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDto;
import com.youlai.boot.auth.model.vo.CaptchaVo;
import com.youlai.boot.auth.model.dto.LoginRequest;
import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDTO;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.security.model.AuthenticationToken;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,12 +17,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* 认证控制层
*
* @author Ray.Hao
* @since 2022/10/16
* @since 0.0.1
*/
@Tag(name = "01.认证中心")
@RestController
@@ -34,18 +34,17 @@ public class AuthController {
@Operation(summary = "获取验证码")
@GetMapping("/captcha")
public Result<CaptchaVO> getCaptcha() {
CaptchaVO captcha = authService.getCaptcha();
public Result<CaptchaVo> getCaptcha() {
CaptchaVo captcha = authService.getCaptcha();
return Result.success(captcha);
}
@Operation(summary = "账号密码登录")
@PostMapping("/login")
@Log(value = "登录", module = LogModuleEnum.LOGIN)
public Result<AuthenticationToken> login(
@Parameter(description = "用户名", example = "admin") @RequestParam String username,
@Parameter(description = "密码", example = "123456") @RequestParam String password
) {
public Result<?> login(@RequestBody @Valid LoginRequest request) {
String username = request.getUsername();
String password = request.getPassword();
AuthenticationToken authenticationToken = authService.login(username, password);
return Result.success(authenticationToken);
}
@@ -82,19 +81,18 @@ public class AuthController {
@Operation(summary = "微信小程序登录(Code)")
@PostMapping("/wx/miniapp/code-login")
public Result<AuthenticationToken> loginByWxMiniAppCode(@RequestBody @Valid WxMiniAppCodeLoginDTO loginDTO) {
AuthenticationToken token = authService.loginByWxMiniAppCode(loginDTO);
public Result<AuthenticationToken> loginByWxMiniAppCode(@RequestBody @Valid WxMiniAppCodeLoginDto loginDto) {
AuthenticationToken token = authService.loginByWxMiniAppCode(loginDto);
return Result.success(token);
}
@Operation(summary = "微信小程序登录(手机号)")
@PostMapping("/wx/miniapp/phone-login")
public Result<AuthenticationToken> loginByWxMiniAppPhone(@RequestBody @Valid WxMiniAppPhoneLoginDTO loginDTO) {
AuthenticationToken token = authService.loginByWxMiniAppPhone(loginDTO);
public Result<AuthenticationToken> loginByWxMiniAppPhone(@RequestBody @Valid WxMiniAppPhoneLoginDto loginDto) {
AuthenticationToken token = authService.loginByWxMiniAppPhone(loginDto);
return Result.success(token);
}
@Operation(summary = "退出登录")
@DeleteMapping("/logout")
@Log(value = "退出登录", module = LogModuleEnum.LOGIN)

View File

@@ -0,0 +1,32 @@
package com.youlai.boot.auth.model.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 登录请求参数
*
* @author Ray.Hao
* @since 3.0.0
*/
@Schema(description = "登录请求参数")
@Data
public class LoginRequest {
@Schema(description = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
@NotBlank(message = "用户名不能为空")
private String username;
@Schema(description = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "密码不能为空")
private String password;
@Schema(description = "验证码缓存ID", example = "captcha_id_123")
private String captchaId;
@Schema(description = "验证码", example = "1234")
private String captchaCode;
}

View File

@@ -6,17 +6,16 @@ import lombok.Data;
import jakarta.validation.constraints.NotBlank;
/**
* 微信小程序Code登录请求参数
*
* @author 有来技术团队
* @since 2.0.0
*微信小程序Code登录请求参数
*/
@Schema(description = "微信小程序Code登录请求参数")
@Data
public class WxMiniAppCodeLoginDTO {
public class WxMiniAppCodeLoginDto {
@Schema(description = "微信小程序登录时获取的code", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "code不能为空")
private String code;
}
}

View File

@@ -7,13 +7,10 @@ import jakarta.validation.constraints.NotBlank;
/**
* 微信小程序手机号登录请求参数
*
* @author Ray.Hao
* @since 2.0.0
*/
@Schema(description = "微信小程序手机号登录请求参数")
@Data
public class WxMiniAppPhoneLoginDTO {
public class WxMiniAppPhoneLoginDto {
@Schema(description = "微信小程序登录时获取的code", requiredMode = Schema.RequiredMode.REQUIRED)
@NotBlank(message = "code不能为空")
@@ -25,4 +22,6 @@ public class WxMiniAppPhoneLoginDTO {
@Schema(description = "加密算法的初始向量")
private String iv;
}
}

View File

@@ -13,10 +13,10 @@ import lombok.Data;
@Schema(description = "验证码信息")
@Data
@Builder
public class CaptchaVO {
public class CaptchaVo {
@Schema(description = "验证码缓存 Key")
private String captchaKey;
@Schema(description = "验证码缓存 ID")
private String captchaId;
@Schema(description = "验证码图片Base64字符串")
private String captchaBase64;

View File

@@ -1,9 +1,9 @@
package com.youlai.boot.auth.service;
import com.youlai.boot.auth.model.vo.CaptchaVO;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDTO;
import com.youlai.boot.auth.model.vo.CaptchaVo;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDto;
import com.youlai.boot.security.model.AuthenticationToken;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDTO;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDto;
/**
* 认证服务接口
@@ -32,7 +32,7 @@ public interface AuthService {
*
* @return 验证码
*/
CaptchaVO getCaptcha();
CaptchaVo getCaptcha();
/**
* 刷新令牌
@@ -53,18 +53,18 @@ public interface AuthService {
/**
* 微信小程序Code登录
*
* @param loginDTO 登录参数
* @param loginDto 登录参数
* @return 访问令牌
*/
AuthenticationToken loginByWxMiniAppCode(WxMiniAppCodeLoginDTO loginDTO);
AuthenticationToken loginByWxMiniAppCode(WxMiniAppCodeLoginDto loginDto);
/**
* 微信小程序手机号登录
*
* @param loginDTO 登录参数
* @param loginDto 登录参数
* @return 访问令牌
*/
AuthenticationToken loginByWxMiniAppPhone(WxMiniAppPhoneLoginDTO loginDTO);
AuthenticationToken loginByWxMiniAppPhone(WxMiniAppPhoneLoginDto loginDto);
/**
* 发送短信验证码

View File

@@ -5,9 +5,9 @@ import cn.hutool.captcha.CaptchaUtil;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDTO;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDTO;
import com.youlai.boot.auth.model.vo.CaptchaVO;
import com.youlai.boot.auth.model.dto.WxMiniAppCodeLoginDto;
import com.youlai.boot.auth.model.dto.WxMiniAppPhoneLoginDto;
import com.youlai.boot.auth.model.vo.CaptchaVo;
import com.youlai.boot.auth.service.AuthService;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
@@ -70,6 +70,11 @@ public class AuthServiceImpl implements AuthService {
new UsernamePasswordAuthenticationToken(username.trim(), password);
// 2. 执行认证(认证中)
// 说明:这里的认证流程由 Spring Security 提供的 AuthenticationManager 执行。
// 默认情况下会委托给 DaoAuthenticationProvider
// 1) retrieveUser(...):内部通过 UserDetailsService.loadUserByUsername(...) 获取用户信息(本项目为 SysUserDetailsService 实现)
// 2) additionalAuthenticationChecks(...):对比请求密码与用户存储密码(由 PasswordEncoder 完成匹配)
// 认证通过后返回已认证的 Authenticationprincipal 为 SysUserDetailsauthorities 为角色/权限集合)。
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证)
@@ -152,10 +157,8 @@ public class AuthServiceImpl implements AuthService {
*/
@Override
public void logout() {
String token = SecurityUtils.getTokenFromRequest();
if (StrUtil.isNotBlank(token) && token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX )) {
token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX .length());
// 将JWT令牌加入黑名单
String token = SecurityUtils.getAccessToken();
if (StrUtil.isNotBlank(token)) {
tokenManager.invalidateToken(token);
// 清除Security上下文
SecurityContextHolder.clearContext();
@@ -168,7 +171,7 @@ public class AuthServiceImpl implements AuthService {
* @return 验证码
*/
@Override
public CaptchaVO getCaptcha() {
public CaptchaVo getCaptcha() {
String captchaType = captchaProperties.getType();
int width = captchaProperties.getWidth();
@@ -196,16 +199,16 @@ public class AuthServiceImpl implements AuthService {
String imageBase64Data = captcha.getImageBase64Data();
// 验证码文本缓存至Redis用于登录校验
String captchaKey = IdUtil.fastSimpleUUID();
String captchaId = IdUtil.fastSimpleUUID();
redisTemplate.opsForValue().set(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaKey),
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId),
captchaCode,
captchaProperties.getExpireSeconds(),
TimeUnit.SECONDS
);
return CaptchaVO.builder()
.captchaKey(captchaKey)
return CaptchaVo.builder()
.captchaId(captchaId)
.captchaBase64(imageBase64Data)
.build();
}
@@ -224,13 +227,13 @@ public class AuthServiceImpl implements AuthService {
/**
* 微信小程序Code登录
*
* @param loginDTO 登录参数
* @param loginDto 登录参数
* @return 访问令牌
*/
@Override
public AuthenticationToken loginByWxMiniAppCode(WxMiniAppCodeLoginDTO loginDTO) {
public AuthenticationToken loginByWxMiniAppCode(WxMiniAppCodeLoginDto loginDto) {
// 1. 创建微信小程序认证令牌(未认证)
WxMiniAppCodeAuthenticationToken authenticationToken = new WxMiniAppCodeAuthenticationToken(loginDTO.getCode());
WxMiniAppCodeAuthenticationToken authenticationToken = new WxMiniAppCodeAuthenticationToken(loginDto.getCode());
// 2. 执行认证(认证中)
Authentication authentication = authenticationManager.authenticate(authenticationToken);
@@ -245,16 +248,16 @@ public class AuthServiceImpl implements AuthService {
/**
* 微信小程序手机号登录
*
* @param loginDTO 登录参数
* @param loginDto 登录参数
* @return 访问令牌
*/
@Override
public AuthenticationToken loginByWxMiniAppPhone(WxMiniAppPhoneLoginDTO loginDTO) {
public AuthenticationToken loginByWxMiniAppPhone(WxMiniAppPhoneLoginDto loginDto) {
// 创建微信小程序手机号认证Token
WxMiniAppPhoneAuthenticationToken authenticationToken = new WxMiniAppPhoneAuthenticationToken(
loginDTO.getCode(),
loginDTO.getEncryptedData(),
loginDTO.getIv()
loginDto.getCode(),
loginDto.getEncryptedData(),
loginDto.getIv()
);
// 执行认证

View File

@@ -1,21 +0,0 @@
package com.youlai.boot.common.base;
import lombok.Data;
import lombok.ToString;
import java.io.Serial;
import java.io.Serializable;
/**
* 视图对象基类
*
* @author haoxr
* @since 2022/10/22
*/
@Data
@ToString
public class BaseVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
}

View File

@@ -7,10 +7,16 @@ import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionIntercepto
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.youlai.boot.plugin.mybatis.MyDataPermissionHandler;
import com.youlai.boot.plugin.mybatis.MyMetaObjectHandler;
import org.apache.ibatis.mapping.DatabaseIdProvider;
import org.apache.ibatis.mapping.VendorDatabaseIdProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import java.util.Properties;
/**
* mybatis-plus 配置类
*
@@ -21,16 +27,29 @@ import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableTransactionManagement
public class MybatisConfig {
@Value("${app.db-type:mysql}")
private String dbType;
/**
* 分页插件和数据权限插件
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
//数据权限
// 数据权限
interceptor.addInnerInterceptor(new DataPermissionInterceptor(new MyDataPermissionHandler()));
//分页插件
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 分页插件,根据配置动态选择数据库类型
DbType mpDbType = DbType.MYSQL;
String type = dbType == null ? "mysql" : dbType.toLowerCase();
if ("postgres".equals(type) || "postgresql".equals(type)) {
mpDbType = DbType.POSTGRE_SQL;
} else if ("dm".equals(type) || "dameng".equals(type)) {
// 达梦更接近 Oracle 语法,这里选择 ORACLE 方言以获得较好兼容性
mpDbType = DbType.ORACLE;
}
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(mpDbType));
return interceptor;
}
@@ -45,4 +64,17 @@ public class MybatisConfig {
return globalConfig;
}
/**
* 数据库类型自动识别
*/
@Bean
public DatabaseIdProvider databaseIdProvider() {
DatabaseIdProvider databaseIdProvider = new VendorDatabaseIdProvider();
Properties properties = new Properties();
properties.setProperty("DM", "dm");
properties.setProperty("MySQL", "mysql");
databaseIdProvider.setProperties(properties);
return databaseIdProvider;
}
}

View File

@@ -57,7 +57,7 @@ public class RepeatSubmitAspect {
boolean locked = lock.tryLock(0, expire, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException(ResultCode.USER_DUPLICATE_REQUEST);
throw new BusinessException(ResultCode.DUPLICATE_SUBMISSION);
}
return pjp.proceed();
}

View File

@@ -6,7 +6,7 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SystemConstants;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.core.web.WebResponseHelper;
import com.youlai.boot.core.web.WebResponseWriter;
import com.youlai.boot.system.service.ConfigService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -88,7 +88,7 @@ public class RateLimiterFilter extends OncePerRequestFilter {
// 判断是否限流
if (rateLimit(ip)) {
// 返回限流错误信息
WebResponseHelper.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
WebResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
return;
}

View File

@@ -56,6 +56,14 @@ public class Result<T> implements Serializable {
return result(resultCode.getCode(), StrUtil.isNotBlank(msg) ? msg : resultCode.getMsg(), null);
}
public static <T> Result<T> failed(IResultCode resultCode, T data) {
return result(resultCode.getCode(), resultCode.getMsg(), data);
}
public static <T> Result<T> failed(IResultCode resultCode, String msg, T data) {
return result(resultCode.getCode(), StrUtil.isNotBlank(msg) ? msg : resultCode.getMsg(), data);
}
private static <T> Result<T> result(IResultCode resultCode, T data) {
return result(resultCode.getCode(), resultCode.getMsg(), data);
}

View File

@@ -1,269 +1,128 @@
package com.youlai.boot.core.web;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 响应码枚举
* <p>
* 参考阿里巴巴开发手册响应码规范
* 00000 正常
* A**** 用户端错误
* B**** 系统执行出错
* C**** 调用第三方服务出错
* 参考阿里巴巴 Java 开发手册》错误码设计建议:
* 00000 表示成功。
* A**** 表示用户端错误(如参数错误、认证失败等)。
* B**** 表示当前系统执行出错(如系统超时等)。
* C**** 表示调用第三方服务出错(如中间件、数据库等外部依赖)。
* <p>
* 错误码位数与号段说明:
* - 错误码为字符串类型,共 5 位错误产生来源A/B/C + 四位数字编号。
* - 四位数字编号范围 0001~9999大类之间建议按步长 100 预留号段(如 A0200、A0300、A0400
* - 错误码后三位编号与 HTTP 状态码无关。
* <p>
* 说明:
* - 本项目仅保留实际使用的错误码,并在 A/B/C 各保留少量示例,避免枚举无限膨胀。
* - 如需扩展业务错误码,建议在对应宏观分类下按场景划分号段并保持全局唯一。
* <p>
* 附表(节选):错误码列表(示例/项目使用项)
* <pre>
* | 错误码 | 中文描述 | 说明 |
* |-------|----------------------|------------------|
* | 00000 | 成功 | 正常执行后的返回 |
* | A0001 | 用户端错误 | 一级宏观错误码 |
* | A0100 | 用户注册错误 | 二级宏观错误码 |
* | A0101 | 用户未同意隐私协议 | 二级宏观错误码 |
* | A0200 | 用户登录异常 | 二级宏观错误码 |
* | A0201 | 用户账户不存在 | 二级宏观错误码 |
* | A0202 | 用户账户被冻结 | 二级宏观错误码 |
* | A0230 | 访问令牌无效或已过期 | 令牌校验失败 |
* | A0241 | 用户验证码尝试次数超限 | 二级宏观错误码 |
* | A0300 | 访问权限异常 | 二级宏观错误码 |
* | A0301 | 访问未授权 | 二级宏观错误码 |
* | A0400 | 用户请求参数错误 | 二级宏观错误码 |
* | A0410 | 请求必填参数为空 | 二级宏观错误码 |
* | A0500 | 用户请求服务异常 | 二级宏观错误码 |
* | A0502 | 请求并发数超出限制 | 二级宏观错误码 |
* | A0506 | 请勿重复提交 | 二级宏观错误码 |
* | B0001 | 系统执行出错 | 一级宏观错误码 |
* | B0100 | 系统执行超时 | 二级宏观错误码 |
* | C0001 | 调用第三方服务出错 | 一级宏观错误码 |
* | C0113 | 接口不存在 | 二级宏观错误码 |
* | C0300 | 数据库服务出错 | 二级宏观错误码 |
* </pre>
*
* @author Ray.Hao
* @since 2020/6/23
**/
@AllArgsConstructor
@NoArgsConstructor
public enum ResultCode implements IResultCode, Serializable {
SUCCESS("00000", "一切ok"),
/** 一级宏观错误码 */
SUCCESS("00000", "成功"),
/** 一级宏观错误码:用户端错误(由客户端输入/认证/权限/请求方式等引起,需客户端配合修正) */
USER_ERROR("A0001", "用户端错误"),
/** 二级宏观错误码 */
/** 二级宏观错误码:用户端具体错误(按号段细分,便于定位是注册/登录/令牌/参数/防重等问题) */
/** A01xx用户注册错误 */
USER_REGISTRATION_ERROR("A0100", "用户注册错误"),
USER_NOT_AGREE_PRIVACY_AGREEMENT("A0101", "用户未同意隐私协议"),
REGISTRATION_COUNTRY_OR_REGION_RESTRICTED("A0102", "注册国家或地区受限"),
USERNAME_VERIFICATION_FAILED("A0110", "用户名校验失败"),
USERNAME_ALREADY_EXISTS("A0111", "用户名已存在"),
USERNAME_CONTAINS_SENSITIVE_WORDS("A0112", "用户名包含敏感词"),
USERNAME_CONTAINS_SPECIAL_CHARACTERS("A0113", "用户名包含特殊字符"),
PASSWORD_VERIFICATION_FAILED("A0120", "密码校验失败"),
PASSWORD_LENGTH_NOT_ENOUGH("A0121", "密码长度不够"),
PASSWORD_STRENGTH_NOT_ENOUGH("A0122", "密码强度不够"),
/** A013x校验码输入错误 */
VERIFICATION_CODE_INPUT_ERROR("A0130", "校验码输入错误"),
SMS_VERIFICATION_CODE_INPUT_ERROR("A0131", "短信校验码输入错误"),
EMAIL_VERIFICATION_CODE_INPUT_ERROR("A0132", "邮件校验码输入错误"),
VOICE_VERIFICATION_CODE_INPUT_ERROR("A0133", "语音校验码输入错误"),
USER_CERTIFICATE_EXCEPTION("A0140", "用户证件异常"),
USER_CERTIFICATE_TYPE_NOT_SELECTED("A0141", "用户证件类型未选择"),
MAINLAND_ID_NUMBER_VERIFICATION_ILLEGAL("A0142", "大陆身份证编号校验非法"),
USER_BASIC_INFORMATION_VERIFICATION_FAILED("A0150", "用户基本信息校验失败"),
PHONE_FORMAT_VERIFICATION_FAILED("A0151", "手机格式校验失败"),
ADDRESS_FORMAT_VERIFICATION_FAILED("A0152", "地址格式校验失败"),
EMAIL_FORMAT_VERIFICATION_FAILED("A0153", "邮箱格式校验失败"),
/** 二级宏观错误码 */
/** A02xx用户登录异常 */
USER_LOGIN_EXCEPTION("A0200", "用户登录异常"),
USER_ACCOUNT_FROZEN("A0201", "用户账户被冻结"),
USER_ACCOUNT_ABOLISHED("A0202", "用户账户已作废"),
ACCOUNT_NOT_FOUND("A0201", "用户账户不存在"),
ACCOUNT_FROZEN("A0202", "用户账户被冻结"),
USER_PASSWORD_ERROR("A0210", "用户名或密码错误"),
USER_INPUT_PASSWORD_ERROR_LIMIT_EXCEEDED("A0211", "用户输入密码错误次数超限"),
USER_NOT_EXIST("A0212", "用户不存在"),
USER_IDENTITY_VERIFICATION_FAILED("A0220", "用户身份校验失败"),
USER_FINGERPRINT_RECOGNITION_FAILED("A0221", "用户指纹识别失败"),
USER_FACE_RECOGNITION_FAILED("A0222", "用户面容识别失败"),
USER_NOT_AUTHORIZED_THIRD_PARTY_LOGIN("A0223", "用户未获得第三方登录授权"),
/** A023x令牌无效或已过期 */
ACCESS_TOKEN_INVALID("A0230", "访问令牌无效或已过期"),
REFRESH_TOKEN_INVALID("A0231", "刷新令牌无效或已过期"),
// 验证码错误
/** A024x验证码错误 */
USER_VERIFICATION_CODE_ERROR("A0240", "验证码错误"),
USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"),
USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"),
/** 二级宏观错误码 */
/** A03xx访问权限异常 */
ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"),
ACCESS_UNAUTHORIZED("A0301", "访问未授权"),
AUTHORIZATION_IN_PROGRESS("A0302", "正在授权中"),
USER_AUTHORIZATION_APPLICATION_REJECTED("A0303", "用户授权申请被拒绝"),
ACCESS_OBJECT_PRIVACY_SETTINGS_BLOCKED("A0310", "因访问对象隐私设置被拦截"),
AUTHORIZATION_EXPIRED("A0311", "授权已过期"),
NO_PERMISSION_TO_USE_API("A0312", "无权限使用 API"),
USER_ACCESS_BLOCKED("A0320", "用户访问被拦截"),
BLACKLISTED_USER("A0321", "黑名单用户"),
ACCOUNT_FROZEN("A0322", "账号被冻结"),
ILLEGAL_IP_ADDRESS("A0323", "非法 IP 地址"),
GATEWAY_ACCESS_RESTRICTED("A0324", "网关访问受限"),
REGION_BLACKLIST("A0325", "地域黑名单"),
SERVICE_ARREARS("A0330", "服务已欠费"),
USER_SIGNATURE_EXCEPTION("A0340", "用户签名异常"),
RSA_SIGNATURE_ERROR("A0341", "RSA 签名错误"),
/** 二级宏观错误码 */
/** A04xx用户请求参数错误 */
USER_REQUEST_PARAMETER_ERROR("A0400", "用户请求参数错误"),
CONTAINS_ILLEGAL_MALICIOUS_REDIRECT_LINK("A0401", "包含非法恶意跳转链接"),
INVALID_USER_INPUT("A0402", "无效的用户输入"),
REQUEST_REQUIRED_PARAMETER_IS_EMPTY("A0410", "请求必填参数为空"),
REQUEST_PARAMETER_VALUE_EXCEEDS_ALLOWED_RANGE("A0420", "请求参数值超出允许的范围"),
PARAMETER_FORMAT_MISMATCH("A0421", "参数格式不匹配"),
USER_INPUT_CONTENT_ILLEGAL("A0430", "用户输入内容非法"),
CONTAINS_PROHIBITED_SENSITIVE_WORDS("A0431", "包含违禁敏感词"),
USER_OPERATION_EXCEPTION("A0440", "用户操作异常"),
/** 二级宏观错误码 */
/** A05xx用户请求服务异常 */
USER_REQUEST_SERVICE_EXCEPTION("A0500", "用户请求服务异常"),
REQUEST_LIMIT_EXCEEDED("A0501", "请求次数超出限制"),
REQUEST_CONCURRENCY_LIMIT_EXCEEDED("A0502", "请求并发数超出限制"),
USER_OPERATION_PLEASE_WAIT("A0503", "用户操作请等待"),
WEBSOCKET_CONNECTION_EXCEPTION("A0504", "WebSocket 连接异常"),
WEBSOCKET_CONNECTION_DISCONNECTED("A0505", "WebSocket 连接断开"),
USER_DUPLICATE_REQUEST("A0506", "请求过于频繁,请稍后再试。"),
DUPLICATE_SUBMISSION("A0506", "请勿重复提交"),
/** 二级宏观错误码 */
USER_RESOURCE_EXCEPTION("A0600", "用户资源异常"),
ACCOUNT_BALANCE_INSUFFICIENT("A0601", "账户余额不足"),
USER_DISK_SPACE_INSUFFICIENT("A0602", "用户磁盘空间不足"),
USER_MEMORY_SPACE_INSUFFICIENT("A0603", "用户内存空间不足"),
USER_OSS_CAPACITY_INSUFFICIENT("A0604", "用户 OSS 容量不足"),
USER_QUOTA_EXHAUSTED("A0605", "用户配额已用光"),
USER_RESOURCE_NOT_FOUND("A0606", "用户资源不存在"),
/** 二级宏观错误码 */
/** A07xx文件处理异常 */
UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"),
UPLOAD_FILE_TYPE_MISMATCH("A0701", "上传文件类型不匹配"),
UPLOAD_FILE_TOO_LARGE("A0702", "上传文件太大"),
UPLOAD_IMAGE_TOO_LARGE("A0703", "上传图片太大"),
UPLOAD_VIDEO_TOO_LARGE("A0704", "上传视频太大"),
UPLOAD_COMPRESSED_FILE_TOO_LARGE("A0705", "上传压缩文件太大"),
DELETE_FILE_EXCEPTION("A0710", "删除文件异常"),
/** 级宏观错误码 */
USER_CURRENT_VERSION_EXCEPTION("A0800", "用户当前版本异常"),
USER_INSTALLED_VERSION_NOT_MATCH_SYSTEM("A0801", "用户安装版本与系统不匹配"),
USER_INSTALLED_VERSION_TOO_LOW("A0802", "用户安装版本过低"),
USER_INSTALLED_VERSION_TOO_HIGH("A0803", "用户安装版本过高"),
USER_INSTALLED_VERSION_EXPIRED("A0804", "用户安装版本已过期"),
USER_API_REQUEST_VERSION_NOT_MATCH("A0805", "用户 API 请求版本不匹配"),
USER_API_REQUEST_VERSION_TOO_HIGH("A0806", "用户 API 请求版本过高"),
USER_API_REQUEST_VERSION_TOO_LOW("A0807", "用户 API 请求版本过低"),
/** 二级宏观错误码 */
USER_PRIVACY_NOT_AUTHORIZED("A0900", "用户隐私未授权"),
USER_PRIVACY_NOT_SIGNED("A0901", "用户隐私未签署"),
USER_CAMERA_NOT_AUTHORIZED("A0903", "用户相机未授权"),
USER_PHOTO_LIBRARY_NOT_AUTHORIZED("A0904", "用户图片库未授权"),
USER_FILE_NOT_AUTHORIZED("A0905", "用户文件未授权"),
USER_LOCATION_INFORMATION_NOT_AUTHORIZED("A0906", "用户位置信息未授权"),
USER_CONTACTS_NOT_AUTHORIZED("A0907", "用户通讯录未授权"),
/** 二级宏观错误码 */
USER_DEVICE_EXCEPTION("A1000", "用户设备异常"),
USER_CAMERA_EXCEPTION("A1001", "用户相机异常"),
USER_MICROPHONE_EXCEPTION("A1002", "用户麦克风异常"),
USER_EARPIECE_EXCEPTION("A1003", "用户听筒异常"),
USER_SPEAKER_EXCEPTION("A1004", "用户扬声器异常"),
USER_GPS_POSITIONING_EXCEPTION("A1005", "用户 GPS 定位异常"),
/** 一级宏观错误码 */
/** 级宏观错误码:系统端错误(服务端内部异常/超时/不可用等,需后端排查修复) */
SYSTEM_ERROR("B0001", "系统执行出错"),
/** 二级宏观错误码 */
/** 二级宏观错误码:系统端具体错误(按号段细分,便于定位超时/限流/资源耗尽等) */
SYSTEM_EXECUTION_TIMEOUT("B0100", "系统执行超时"),
/** 级宏观错误码 */
SYSTEM_DISASTER_RECOVERY_FUNCTION_TRIGGERED("B0200", "系统容灾功能被触发"),
SYSTEM_RATE_LIMITING("B0210", "系统限流"),
SYSTEM_FUNCTION_DEGRADATION("B0220", "系统功能降级"),
/** 二级宏观错误码 */
SYSTEM_RESOURCE_EXCEPTION("B0300", "系统资源异常"),
SYSTEM_RESOURCE_EXHAUSTED("B0310", "系统资源耗尽"),
SYSTEM_DISK_SPACE_EXHAUSTED("B0311", "系统磁盘空间耗尽"),
SYSTEM_MEMORY_EXHAUSTED("B0312", "系统内存耗尽"),
FILE_HANDLE_EXHAUSTED("B0313", "文件句柄耗尽"),
SYSTEM_CONNECTION_POOL_EXHAUSTED("B0314", "系统连接池耗尽"),
SYSTEM_THREAD_POOL_EXHAUSTED("B0315", "系统线程池耗尽"),
SYSTEM_RESOURCE_ACCESS_EXCEPTION("B0320", "系统资源访问异常"),
SYSTEM_READ_DISK_FILE_FAILED("B0321", "系统读取磁盘文件失败"),
/** 一级宏观错误码 */
/** 级宏观错误码:第三方服务错误(外部依赖/中间件/数据库等引起,需检查依赖健康与配置) */
THIRD_PARTY_SERVICE_ERROR("C0001", "调用第三方服务出错"),
/** 二级宏观错误码 */
MIDDLEWARE_SERVICE_ERROR("C0100", "中间件服务出错"),
RPC_SERVICE_ERROR("C0110", "RPC 服务出错"),
RPC_SERVICE_NOT_FOUND("C0111", "RPC 服务未找到"),
RPC_SERVICE_NOT_REGISTERED("C0112", "RPC 服务未注册"),
/** 二级宏观错误码:第三方服务具体错误(按号段细分,便于定位是接口不存在/数据库异常等) */
INTERFACE_NOT_EXIST("C0113", "接口不存在"),
MESSAGE_SERVICE_ERROR("C0120", "消息服务出错"),
MESSAGE_DELIVERY_ERROR("C0121", "消息投递出错"),
MESSAGE_CONSUMPTION_ERROR("C0122", "消息消费出错"),
MESSAGE_SUBSCRIPTION_ERROR("C0123", "消息订阅出错"),
MESSAGE_GROUP_NOT_FOUND("C0124", "消息分组未查到"),
CACHE_SERVICE_ERROR("C0130", "缓存服务出错"),
KEY_LENGTH_EXCEEDS_LIMIT("C0131", "key 长度超过限制"),
VALUE_LENGTH_EXCEEDS_LIMIT("C0132", "value 长度超过限制"),
STORAGE_CAPACITY_FULL("C0133", "存储容量已满"),
UNSUPPORTED_DATA_FORMAT("C0134", "不支持的数据格式"),
CONFIGURATION_SERVICE_ERROR("C0140", "配置服务出错"),
NETWORK_RESOURCE_SERVICE_ERROR("C0150", "网络资源服务出错"),
VPN_SERVICE_ERROR("C0151", "VPN 服务出错"),
CDN_SERVICE_ERROR("C0152", "CDN 服务出错"),
DOMAIN_NAME_RESOLUTION_SERVICE_ERROR("C0153", "域名解析服务出错"),
GATEWAY_SERVICE_ERROR("C0154", "网关服务出错"),
/** 二级宏观错误码 */
THIRD_PARTY_SYSTEM_EXECUTION_TIMEOUT("C0200", "第三方系统执行超时"),
RPC_EXECUTION_TIMEOUT("C0210", "RPC 执行超时"),
MESSAGE_DELIVERY_TIMEOUT("C0220", "消息投递超时"),
CACHE_SERVICE_TIMEOUT("C0230", "缓存服务超时"),
CONFIGURATION_SERVICE_TIMEOUT("C0240", "配置服务超时"),
DATABASE_SERVICE_TIMEOUT("C0250", "数据库服务超时"),
/** 二级宏观错误码 */
DATABASE_SERVICE_ERROR("C0300", "数据库服务出错"),
TABLE_NOT_EXIST("C0311", "表不存在"),
COLUMN_NOT_EXIST("C0312", "列不存在"),
DATABASE_EXECUTION_SYNTAX_ERROR("C0313", "数据库执行语法错误"),
MULTIPLE_SAME_NAME_COLUMNS_IN_MULTI_TABLE_ASSOCIATION("C0321", "多表关联中存在多个相同名称的列"),
DATABASE_DEADLOCK("C0331", "数据库死锁"),
PRIMARY_KEY_CONFLICT("C0341", "主键冲突"),
INTEGRITY_CONSTRAINT_VIOLATION("C0342", "违反了完整性约束"),
DATABASE_ACCESS_DENIED("C0351", "演示环境已禁用数据库写入功能请本地部署修改数据库链接或开启Mock模式进行体验");
DATABASE_ACCESS_DENIED("C0351", "演示环境已禁用数据库写入功能请本地部署修改数据库链接或开启Mock模式进行体验"),
private final String code;
/** 二级宏观错误码 */
THIRD_PARTY_DISASTER_RECOVERY_SYSTEM_TRIGGERED("C0400", "第三方容灾系统被触发"),
THIRD_PARTY_SYSTEM_RATE_LIMITING("C0401", "第三方系统限流"),
THIRD_PARTY_FUNCTION_DEGRADATION("C0402", "第三方功能降级"),
private final String msg;
/** 二级宏观错误码 */
NOTIFICATION_SERVICE_ERROR("C0500", "通知服务出错"),
SMS_REMINDER_SERVICE_FAILED("C0501", "短信提醒服务失败"),
VOICE_REMINDER_SERVICE_FAILED("C0502", "语音提醒服务失败"),
EMAIL_REMINDER_SERVICE_FAILED("C0503", "邮件提醒服务失败");
ResultCode(String code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
@@ -276,10 +135,6 @@ public enum ResultCode implements IResultCode, Serializable {
return msg;
}
private String code;
private String msg;
@Override
public String toString() {
return "{" +

View File

@@ -1,77 +0,0 @@
package com.youlai.boot.core.web;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
/**
* Web响应辅助类
* <p>
* 用于在过滤器、处理器等无法使用 @RestControllerAdvice 的场景中统一处理响应
*
* @author Ray.Hao
* @since 2.0.0
*/
@Slf4j
public class WebResponseHelper {
/**
* 写入错误响应
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode) {
writeError(response, resultCode, null);
}
/**
* 写入错误响应(带自定义消息)
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
* @param message 自定义消息
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode, String message) {
try {
// 设置HTTP状态码
int httpStatus = mapHttpStatus(resultCode);
response.setStatus(httpStatus);
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
// 构建响应对象
Result<?> result = message == null
? Result.failed(resultCode)
: Result.failed(resultCode, message);
// 写入响应
JakartaServletUtil.write(response,
JSONUtil.toJsonStr(result),
MediaType.APPLICATION_JSON_VALUE
);
} catch (Exception e) {
log.error("写入错误响应失败: resultCode={}, message={}", resultCode, message, e);
}
}
/**
* 根据业务结果码映射HTTP状态码
*
* @param resultCode 业务结果码
* @return HTTP状态码
*/
private static int mapHttpStatus(ResultCode resultCode) {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED,
ACCESS_TOKEN_INVALID,
REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}
}

View File

@@ -0,0 +1,122 @@
package com.youlai.boot.core.web;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import java.nio.charset.StandardCharsets;
/**
* Web响应写入器
* <p>
* 用于在过滤器、Security处理器等无法使用 @RestControllerAdvice 的场景中统一写入HTTP响应。
* 支持写入成功响应和错误响应。
* 此类为工具类,所有方法均为静态方法,禁止实例化。
*
* @author Ray.Hao
* @since 2.0.0
*/
@Slf4j
public final class WebResponseWriter {
/**
* 私有构造函数,防止实例化
*/
private WebResponseWriter() {
throw new UnsupportedOperationException("工具类不允许实例化");
}
/**
* 写入成功响应
*
* @param response HttpServletResponse
* @param data 响应数据(可选)
*/
public static void writeSuccess(HttpServletResponse response, Object data) {
writeResult(response, Result.success(data), HttpStatus.OK.value());
}
/**
* 写入成功响应(无数据)
*
* @param response HttpServletResponse
*/
public static void writeSuccess(HttpServletResponse response) {
writeSuccess(response, null);
}
/**
* 写入错误响应
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode) {
writeError(response, resultCode, null);
}
/**
* 写入错误响应(带自定义消息)
*
* @param response HttpServletResponse
* @param resultCode 响应结果码
* @param message 自定义消息(可选,为 null 时使用 resultCode 的默认消息)
*/
public static void writeError(HttpServletResponse response, ResultCode resultCode, String message) {
Result<?> result = message == null
? Result.failed(resultCode)
: Result.failed(resultCode, message);
int httpStatus = mapHttpStatus(resultCode);
writeResult(response, result, httpStatus);
}
/**
* 写入响应结果(通用方法)
*
* @param response HttpServletResponse
* @param result 响应结果对象
* @param httpStatus HTTP状态码
*/
private static void writeResult(HttpServletResponse response, Result<?> result, int httpStatus) {
try {
// 设置HTTP状态码
response.setStatus(httpStatus);
// 设置响应编码和内容类型
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 写入响应
JakartaServletUtil.write(response,
JSONUtil.toJsonStr(result),
MediaType.APPLICATION_JSON_VALUE
);
} catch (Exception e) {
log.error("写入响应时发生未知异常: httpStatus={}, result={}", httpStatus, result, e);
}
}
/**
* 根据业务结果码映射HTTP状态码
*
* @param resultCode 业务结果码
* @return HTTP状态码
*/
private static int mapHttpStatus(ResultCode resultCode) {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED,
ACCESS_TOKEN_INVALID,
REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}
}

View File

@@ -0,0 +1,109 @@
package com.youlai.boot.platform.ai.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.youlai.boot.core.web.PageResult;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDto;
import com.youlai.boot.platform.ai.model.query.AiAssistantPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiAssistantRecordVo;
import com.youlai.boot.platform.ai.service.AiAssistantRecordService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* AI 助手控制器
* <p>
* 负责 AI 命令的解析、执行、记录管理及回滚操作,
* 表示一次 AI 助手完整的指令生命周期。
*
* @author Ray.Hao
* @since 3.0.0
*/
@Tag(name = "AI 助手接口")
@RestController
@RequestMapping("/api/v1/ai/assistant")
@RequiredArgsConstructor
@Slf4j
public class AiAssistantController {
private final AiAssistantRecordService aiAssistantRecordService;
@Operation(summary = "解析自然语言命令")
@PostMapping("/parse")
public Result<AiParseResponseDto> parseCommand(
@RequestBody AiParseRequestDto request,
HttpServletRequest httpRequest
) {
log.info("收到 AI 命令解析请求: {}", request.getCommand());
try {
AiParseResponseDto response = aiAssistantRecordService.parseCommand(request, httpRequest);
return Result.success(response);
} catch (Exception e) {
log.error("命令解析失败", e);
return Result.success(AiParseResponseDto.builder()
.success(false)
.error(e.getMessage())
.build());
}
}
@Operation(summary = "执行已解析的命令")
@PostMapping("/execute")
public Result<Object> executeCommand(
@RequestBody AiExecuteRequestDto request,
HttpServletRequest httpRequest
) {
log.info("收到 AI 命令执行请求: {}", request.getFunctionCall().getName());
try {
Object result = aiAssistantRecordService.executeCommand(request, httpRequest);
return Result.success(result);
} catch (Exception e) {
log.error("命令执行失败", e);
return Result.failed(e.getMessage());
}
}
@Operation(summary = "获取 AI 命令记录分页列表")
@GetMapping("/records")
public PageResult<AiAssistantRecordVo> getRecordPage(AiAssistantPageQuery queryParams) {
IPage<AiAssistantRecordVo> page = aiAssistantRecordService.getRecordPage(queryParams);
return PageResult.success(page);
}
@Operation(summary = "删除 AI 命令记录")
@DeleteMapping("/records/{ids}")
public Result<Void> deleteRecords(
@Parameter(description = "记录ID多个以英文逗号(,)分割")
@PathVariable String ids
) {
List<Long> idList = Arrays.stream(ids.split(","))
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.map(Long::valueOf)
.collect(Collectors.toList());
boolean removed = aiAssistantRecordService.deleteRecords(idList);
return Result.judge(removed);
}
@Operation(summary = "撤销命令执行")
@PostMapping("/records/{recordId}/rollback")
public Result<Void> rollbackCommand(
@Parameter(description = "记录ID")
@PathVariable String recordId
) {
aiAssistantRecordService.rollbackCommand(recordId);
return Result.success();
}
}

View File

@@ -1,93 +0,0 @@
package com.youlai.boot.platform.ai.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.youlai.boot.core.web.PageResult;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO;
import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO;
import com.youlai.boot.platform.ai.service.AiCommandRecordService;
import com.youlai.boot.platform.ai.service.AiCommandService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/**
* AI 命令控制器(基于 Spring AI
*
* @author Ray.Hao
* @since 3.0.0
*/
@Tag(name = "AI命令接口")
@RestController
@RequestMapping("/api/v1/ai/command")
@RequiredArgsConstructor
@Slf4j
public class AiCommandController {
private final AiCommandService aiCommandService;
private final AiCommandRecordService recordService;
@Operation(summary = "解析自然语言命令")
@PostMapping("/parse")
public Result<AiParseResponseDTO> parseCommand(
@RequestBody AiParseRequestDTO request,
HttpServletRequest httpRequest
) {
log.info("收到AI命令解析请求: {}", request.getCommand());
try {
AiParseResponseDTO response = aiCommandService.parseCommand(request, httpRequest);
return Result.success(response);
} catch (Exception e) {
log.error("命令解析失败", e);
return Result.success(AiParseResponseDTO.builder()
.success(false)
.error(e.getMessage())
.build());
}
}
@Operation(summary = "执行已解析的命令")
@PostMapping("/execute")
public Result<Object> executeCommand(
@RequestBody AiExecuteRequestDTO request,
HttpServletRequest httpRequest
) {
log.info("收到AI命令执行请求: {}", request.getFunctionCall().getName());
try {
Object result = aiCommandService.executeCommand(request, httpRequest);
return Result.success(result);
} catch (Exception e) {
log.error("命令执行失败", e);
return Result.failed(e.getMessage());
}
}
@Operation(summary = "获取AI命令记录分页列表")
@GetMapping("/records")
public PageResult<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams) {
IPage<AiCommandRecordVO> page = recordService.getRecordPage(queryParams);
return PageResult.success(page);
}
@Operation(summary = "撤销命令执行")
@PostMapping("/rollback/{recordId}")
public Result<?> rollbackCommand(
@Parameter(description = "记录ID") @PathVariable String recordId
) {
recordService.rollbackCommand(recordId);
return Result.success("撤销成功");
}
}

View File

@@ -0,0 +1,22 @@
package com.youlai.boot.platform.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.platform.ai.model.entity.AiAssistantRecord;
import com.youlai.boot.platform.ai.model.query.AiAssistantPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiAssistantRecordVo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AiAssistantRecordMapper extends BaseMapper<AiAssistantRecord> {
/**
* AI 助手行为记录分页列表
*
* @param page 分页参数
* @param queryParams 查询参数
* @return 分页结果
*/
IPage<AiAssistantRecordVo> getRecordPage(Page<AiAssistantRecordVo> page, AiAssistantPageQuery queryParams);
}

View File

@@ -1,23 +0,0 @@
package com.youlai.boot.platform.ai.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.platform.ai.model.entity.AiCommandRecord;
import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO;
import org.apache.ibatis.annotations.Mapper;
/**
* AI 命令记录 Mapper
*/
@Mapper
public interface AiCommandRecordMapper extends BaseMapper<AiCommandRecord> {
/**
* 获取 AI 命令记录分页列表
*/
IPage<AiCommandRecordVO> getRecordPage(Page<AiCommandRecordVO> page, AiCommandPageQuery queryParams);
}

View File

@@ -3,13 +3,13 @@ package com.youlai.boot.platform.ai.model.dto;
import lombok.Data;
/**
* AI 命令执行请求 DTO
* AI 命令执行请求 Dto
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
public class AiExecuteRequestDTO {
public class AiExecuteRequestDto {
/**
* 关联的解析日志ID
@@ -24,7 +24,7 @@ public class AiExecuteRequestDTO {
/**
* 要执行的函数调用
*/
private AiFunctionCallDTO functionCall;
private AiFunctionCallDto functionCall;
/**
* 确认模式auto=自动执行, manual=需要用户确认
@@ -46,6 +46,3 @@ public class AiExecuteRequestDTO {
*/
private String currentRoute;
}

View File

@@ -6,7 +6,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
/**
* AI 命令执行响应 DTO
* AI 命令执行响应 Dto
*
* @author Ray.Hao
* @since 3.0.0
@@ -15,7 +15,7 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiExecuteResponseDTO {
public class AiExecuteResponseDto {
/**
* 是否执行成功
@@ -57,6 +57,3 @@ public class AiExecuteResponseDTO {
*/
private String confirmationPrompt;
}

View File

@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
import java.util.Map;
/**
* AI 函数调用 DTO
* AI 函数调用 Dto
*
* @author Ray.Hao
* @since 3.0.0
@@ -16,7 +16,7 @@ import java.util.Map;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiFunctionCallDTO {
public class AiFunctionCallDto {
/**
* 函数名称

View File

@@ -4,13 +4,13 @@ import lombok.Data;
import java.util.Map;
/**
* AI 解析请求 DTO
* AI 解析请求 Dto
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
public class AiParseRequestDTO {
public class AiParseRequestDto {
/**
* 用户输入的自然语言命令
@@ -32,4 +32,3 @@ public class AiParseRequestDTO {
*/
private Map<String, Object> context;
}

View File

@@ -7,7 +7,7 @@ import lombok.NoArgsConstructor;
import java.util.List;
/**
* AI 解析响应 DTO
* AI 解析响应 Dto
*
* @author Ray.Hao
* @since 3.0.0
@@ -16,7 +16,7 @@ import java.util.List;
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiParseResponseDTO {
public class AiParseResponseDto {
/**
* 解析日志ID用于关联执行记录
@@ -31,7 +31,7 @@ public class AiParseResponseDTO {
/**
* 解析后的函数调用列表
*/
private List<AiFunctionCallDTO> functionCalls;
private List<AiFunctionCallDto> functionCalls;
/**
* AI 的理解和说明
@@ -53,4 +53,3 @@ public class AiParseResponseDTO {
*/
private String rawResponse;
}

View File

@@ -0,0 +1,80 @@
package com.youlai.boot.platform.ai.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* AI 助手行为记录实体
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("ai_assistant_record")
public class AiAssistantRecord extends BaseEntity {
/** 用户ID */
private Long userId;
/** 用户名 */
private String username;
/** 原始命令 */
private String originalCommand;
// ==================== 解析相关字段 ====================
/** AI 供应商qwen/openai/deepseek等 */
private String aiProvider;
/** AI 模型qwen-plus/qwen-max/gpt-4-turbo等 */
private String aiModel;
/** 解析状态0-失败, 1-成功) */
private Integer parseStatus;
/** 解析出的函数调用列表JSON */
private String functionCalls;
/** AI 的理解说明 */
private String explanation;
/** 置信度0.00-1.00 */
private BigDecimal confidence;
/** 解析错误信息 */
private String parseErrorMessage;
/** 输入 Token 数量 */
private Integer inputTokens;
/** 输出 Token 数量 */
private Integer outputTokens;
/** 解析耗时(毫秒) */
private Integer parseDurationMs;
// ==================== 执行相关字段 ====================
/** 执行的函数名称 */
private String functionName;
/** 函数参数JSON */
private String functionArguments;
/** 执行状态0-待执行, 1-成功, -1-失败) */
private Integer executeStatus;
/** 执行错误信息 */
private String executeErrorMessage;
// ==================== 通用字段 ====================
/** IP 地址 */
private String ipAddress;
}

View File

@@ -1,115 +0,0 @@
package com.youlai.boot.platform.ai.model.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.youlai.boot.common.base.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* AI 命令记录实体(合并解析和执行记录)
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("ai_command_record")
public class AiCommandRecord extends BaseEntity {
/** 用户ID */
private Long userId;
/** 用户名 */
private String username;
/** 原始命令 */
private String originalCommand;
// ==================== 解析相关字段 ====================
/** AI 供应商qwen/openai/deepseek等 */
private String provider;
/** AI 模型qwen-plus/qwen-max/gpt-4-turbo等 */
private String model;
/** 解析是否成功 */
private Boolean parseSuccess;
/** 解析出的函数调用列表JSON */
private String functionCalls;
/** AI 的理解说明 */
private String explanation;
/** 置信度0.00-1.00 */
private BigDecimal confidence;
/** 解析错误信息 */
private String parseErrorMessage;
/** 输入 Token 数量 */
private Integer inputTokens;
/** 输出 Token 数量 */
private Integer outputTokens;
/** 总 Token 数量 */
private Integer totalTokens;
/** 解析耗时(毫秒) */
private Long parseTime;
// ==================== 执行相关字段 ====================
/** 执行的函数名称 */
private String functionName;
/** 函数参数JSON */
private String functionArguments;
/** 执行状态pending, success, failed */
private String executeStatus;
/** 执行结果JSON */
private String executeResult;
/** 执行错误信息 */
private String executeErrorMessage;
/** 影响的记录数 */
private Integer affectedRows;
/** 是否危险操作 */
private Boolean isDangerous;
/** 是否需要确认 */
private Boolean requiresConfirmation;
/** 用户是否确认 */
private Boolean userConfirmed;
/** 幂等性令牌(防止重复执行) */
private String idempotencyKey;
/** 执行耗时(毫秒) */
private Long executionTime;
// ==================== 通用字段 ====================
/** IP 地址 */
private String ipAddress;
/** 用户代理 */
private String userAgent;
/** 当前页面路由 */
private String currentRoute;
/** 备注 */
private String remark;
}

View File

@@ -0,0 +1,44 @@
package com.youlai.boot.platform.ai.model.query;
import com.youlai.boot.common.base.BasePageQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* AI 助手行为记录分页查询对象
*
* @author Ray.Hao
* @since 3.0.0
*/
@Schema(description = "AI 助手行为记录分页查询对象")
@Getter
@Setter
public class AiAssistantPageQuery extends BasePageQuery {
@Schema(description = "关键字(原始命令/函数名称/用户名)")
private String keywords;
@Schema(description = "执行状态(0-待执行, 1-成功, -1-失败)")
private Integer executeStatus;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "解析状态(0-失败, 1-成功)")
private Integer parseStatus;
@Schema(description = "创建时间范围")
private List<String> createTime;
@Schema(description = "函数名称")
private String functionName;
@Schema(description = "AI供应商")
private String aiProvider;
@Schema(description = "AI模型")
private String aiModel;
}

View File

@@ -1,39 +0,0 @@
package com.youlai.boot.platform.ai.model.query;
import com.youlai.boot.common.base.BasePageQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* AI命令记录分页查询对象
*
* @author Ray.Hao
* @since 3.0.0
*/
@Schema(description = "AI命令记录分页查询对象")
@Getter
@Setter
public class AiCommandPageQuery extends BasePageQuery {
@Schema(description = "关键字(原始命令/函数名称/用户名)")
private String keywords;
@Schema(description = "执行状态(pending-待执行, success-成功, failed-失败)")
private String executeStatus;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "是否危险操作")
private Boolean isDangerous;
@Schema(description = "创建时间范围")
private List<String> createTime;
@Schema(description = "函数名称")
private String functionName;
}

View File

@@ -1,39 +0,0 @@
package com.youlai.boot.platform.ai.model.query;
import com.youlai.boot.common.base.BasePageQuery;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* AI命令解析日志分页查询对象
*
* @author Ray.Hao
* @since 3.0.0
*/
@Schema(description = "AI命令解析日志分页查询对象")
@Getter
@Setter
public class AiParseLogPageQuery extends BasePageQuery {
@Schema(description = "关键字(原始命令/用户名)")
private String keywords;
@Schema(description = "解析是否成功(0-失败, 1-成功)")
private Boolean parseSuccess;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "AI提供商(qwen/openai/deepseek/gemini等)")
private String provider;
@Schema(description = "AI模型(qwen-plus/qwen-max/gpt-4-turbo等)")
private String model;
@Schema(description = "创建时间范围")
private List<String> createTime;
}

View File

@@ -0,0 +1,91 @@
package com.youlai.boot.platform.ai.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* AI 助手行为记录Vo
*
* @author Ray.Hao
* @since 3.0.0
*/
@Data
@Schema(description = "AI 助手行为记录Vo")
public class AiAssistantRecordVo implements Serializable {
@Schema(description = "主键ID")
private String id;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "原始命令")
private String originalCommand;
// ==================== 解析相关字段 ====================
@Schema(description = "AI供应商")
private String aiProvider;
@Schema(description = "AI模型")
private String aiModel;
@Schema(description = "解析状态(0-失败, 1-成功)")
private Integer parseStatus;
@Schema(description = "解析出的函数调用列表(JSON)")
private String functionCalls;
@Schema(description = "AI的理解说明")
private String explanation;
@Schema(description = "置信度")
private BigDecimal confidence;
@Schema(description = "解析错误信息")
private String parseErrorMessage;
@Schema(description = "输入Token数量")
private Integer inputTokens;
@Schema(description = "输出Token数量")
private Integer outputTokens;
@Schema(description = "解析耗时(毫秒)")
private Integer parseDurationMs;
// ==================== 执行相关字段 ====================
@Schema(description = "执行的函数名称")
private String functionName;
@Schema(description = "函数参数(JSON)")
private String functionArguments;
@Schema(description = "执行状态(0-待执行, 1-成功, -1-失败)")
private Integer executeStatus;
@Schema(description = "执行错误信息")
private String executeErrorMessage;
// ==================== 通用字段 ====================
@Schema(description = "IP地址")
private String ipAddress;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}

View File

@@ -1,120 +0,0 @@
package com.youlai.boot.platform.ai.model.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* AI命令记录VO合并解析和执行记录
*/
@Data
@Schema(description = "AI命令记录VO")
public class AiCommandRecordVO implements Serializable {
@Schema(description = "主键ID")
private String id;
@Schema(description = "用户ID")
private Long userId;
@Schema(description = "用户名")
private String username;
@Schema(description = "原始命令")
private String originalCommand;
// ==================== 解析相关字段 ====================
@Schema(description = "AI供应商")
private String provider;
@Schema(description = "AI模型")
private String model;
@Schema(description = "解析是否成功")
private Boolean parseSuccess;
@Schema(description = "解析出的函数调用列表(JSON)")
private String functionCalls;
@Schema(description = "AI的理解说明")
private String explanation;
@Schema(description = "置信度")
private BigDecimal confidence;
@Schema(description = "解析错误信息")
private String parseErrorMessage;
@Schema(description = "输入Token数量")
private Integer inputTokens;
@Schema(description = "输出Token数量")
private Integer outputTokens;
@Schema(description = "总Token数量")
private Integer totalTokens;
@Schema(description = "解析耗时(毫秒)")
private Long parseTime;
// ==================== 执行相关字段 ====================
@Schema(description = "执行的函数名称")
private String functionName;
@Schema(description = "函数参数(JSON)")
private String functionArguments;
@Schema(description = "执行状态")
private String executeStatus;
@Schema(description = "执行结果(JSON)")
private String executeResult;
@Schema(description = "执行错误信息")
private String executeErrorMessage;
@Schema(description = "影响的记录数")
private Integer affectedRows;
@Schema(description = "是否危险操作")
private Boolean isDangerous;
@Schema(description = "是否需要确认")
private Boolean requiresConfirmation;
@Schema(description = "用户是否确认")
private Boolean userConfirmed;
@Schema(description = "执行耗时(毫秒)")
private Long executionTime;
// ==================== 通用字段 ====================
@Schema(description = "IP地址")
private String ipAddress;
@Schema(description = "用户代理")
private String userAgent;
@Schema(description = "当前页面路由")
private String currentRoute;
@Schema(description = "创建时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@Schema(description = "更新时间")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
@Schema(description = "备注")
private String remark;
}

View File

@@ -0,0 +1,66 @@
package com.youlai.boot.platform.ai.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDto;
import com.youlai.boot.platform.ai.model.entity.AiAssistantRecord;
import com.youlai.boot.platform.ai.model.query.AiAssistantPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiAssistantRecordVo;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
/**
* AI 助手行为记录服务接口
*
* 负责 AI 助手指令的解析/执行审计记录的分页查询、删除与回滚。
*
* @author Ray.Hao
* @since 3.0.0
*/
public interface AiAssistantRecordService extends IService<AiAssistantRecord> {
/**
* 解析自然语言命令。
*
* @param request 解析请求参数
* @param httpRequest HTTP 请求(用于获取 IP 等上下文)
* @return 解析结果(包含 functionCalls 等信息)
*/
AiParseResponseDto parseCommand(AiParseRequestDto request, HttpServletRequest httpRequest);
/**
* 执行已解析的命令。
*
* @param request 执行请求参数
* @param httpRequest HTTP 请求(用于获取 IP 等上下文)
* @return 执行结果
* @throws Exception 执行异常
*/
Object executeCommand(AiExecuteRequestDto request, HttpServletRequest httpRequest) throws Exception;
/**
* 获取 AI 助手行为记录分页列表
*
* @param queryParams 查询参数
* @return 分页列表
*/
IPage<AiAssistantRecordVo> getRecordPage(AiAssistantPageQuery queryParams);
/**
* 删除 AI 助手行为记录。
*
* @param ids 记录ID列表
* @return 是否删除成功
*/
boolean deleteRecords(List<Long> ids);
/**
* 撤销命令执行
*
* @param logId 记录ID
*/
void rollbackCommand(String logId);
}

View File

@@ -1,30 +0,0 @@
package com.youlai.boot.platform.ai.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.platform.ai.model.entity.AiCommandRecord;
import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO;
/**
* AI 命令记录服务接口
*/
public interface AiCommandRecordService extends IService<AiCommandRecord> {
/**
* 获取命令记录分页列表
*
* @param queryParams 查询参数
* @return 命令记录分页列表
*/
IPage<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams);
/**
* 撤销命令执行
*
* @param recordId 记录ID
*/
void rollbackCommand(String recordId);
}

View File

@@ -1,32 +0,0 @@
package com.youlai.boot.platform.ai.service;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO;
import jakarta.servlet.http.HttpServletRequest;
/**
* AI 命令编排服务:负责对外的解析与执行编排
*/
public interface AiCommandService {
/**
* 解析自然语言命令
*/
AiParseResponseDTO parseCommand(AiParseRequestDTO request, HttpServletRequest httpRequest);
/**
* 执行已解析的命令
*
* @param request 执行请求
* @param httpRequest HTTP 请求
* @return 执行结果数据(成功时返回)
* @throws Exception 执行失败时抛出异常
*/
Object executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) throws Exception;
}

View File

@@ -0,0 +1,321 @@
package com.youlai.boot.platform.ai.service.impl;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.platform.ai.mapper.AiAssistantRecordMapper;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiFunctionCallDto;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDto;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDto;
import com.youlai.boot.platform.ai.model.entity.AiAssistantRecord;
import com.youlai.boot.platform.ai.model.query.AiAssistantPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiAssistantRecordVo;
import com.youlai.boot.platform.ai.service.AiAssistantRecordService;
import com.youlai.boot.platform.ai.tools.UserTools;
import com.youlai.boot.security.util.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* AI 助手行为记录服务实现类
*
* @author Ray.Hao
* @since 3.0.0
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AiAssistantRecordServiceImpl
extends ServiceImpl<AiAssistantRecordMapper, AiAssistantRecord>
implements AiAssistantRecordService {
private static final String SYSTEM_PROMPT = """
你是一个智能的企业操作助手,需要将用户的自然语言命令解析成标准的函数调用。
请返回严格的 JSON 格式,包含字段:
- success: boolean
- explanation: string
- confidence: number (0-1)
- error: string
- provider: string
- model: string
- functionCalls: 数组,每个元素包含 name、description、arguments(对象)
当无法识别命令时success=false并给出 error。
""";
private final UserTools userTools;
private final ChatClient chatClient;
@Override
public AiParseResponseDto parseCommand(AiParseRequestDto request, HttpServletRequest httpRequest) {
long startTime = System.currentTimeMillis();
String command = Optional.ofNullable(request.getCommand()).orElse("").trim();
if (StrUtil.isBlank(command)) {
return AiParseResponseDto.builder()
.success(false)
.error("命令不能为空")
.functionCalls(Collections.emptyList())
.build();
}
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
String ipAddress = JakartaServletUtil.getClientIP(httpRequest);
AiAssistantRecord commandRecord = new AiAssistantRecord();
commandRecord.setUserId(userId);
commandRecord.setUsername(username);
commandRecord.setOriginalCommand(command);
commandRecord.setIpAddress(ipAddress);
commandRecord.setAiProvider("spring-ai");
commandRecord.setAiModel("auto");
String systemPrompt = buildSystemPrompt();
String userPrompt = buildUserPrompt(request);
try {
log.info("📤 发送命令至 AI 模型: {}", command);
ChatResponse chatResponse = chatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call().chatResponse();
String rawContent = Optional.ofNullable(chatResponse.getResult())
.map(result -> result.getOutput().getText())
.orElse("");
ParseResult parseResult = parseAiResponse(rawContent);
commandRecord.setAiProvider(StrUtil.emptyToDefault(parseResult.provider(), "spring-ai"));
commandRecord.setAiModel(StrUtil.emptyToDefault(parseResult.model(), "auto"));
commandRecord.setParseStatus(parseResult.success() ? 1 : 0);
commandRecord.setExplanation(parseResult.explanation());
commandRecord.setFunctionCalls(JSONUtil.toJsonStr(parseResult.functionCalls()));
commandRecord.setConfidence(parseResult.confidence() != null ? BigDecimal.valueOf(parseResult.confidence()) : null);
commandRecord.setParseErrorMessage(parseResult.success() ? null : StrUtil.emptyToDefault(parseResult.error(), "解析失败"));
long duration = System.currentTimeMillis() - startTime;
commandRecord.setParseDurationMs((int) duration);
this.save(commandRecord);
return AiParseResponseDto.builder()
.parseLogId(commandRecord.getId())
.success(parseResult.success())
.functionCalls(parseResult.functionCalls())
.explanation(parseResult.explanation())
.confidence(parseResult.confidence())
.error(parseResult.error())
.rawResponse(rawContent)
.build();
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
commandRecord.setParseStatus(0);
commandRecord.setFunctionCalls(JSONUtil.toJsonStr(Collections.emptyList()));
commandRecord.setParseErrorMessage(e.getMessage());
commandRecord.setParseDurationMs((int) duration);
this.save(commandRecord);
log.error("❌ 解析命令失败: {}", e.getMessage(), e);
throw new RuntimeException("解析命令失败: " + e.getMessage(), e);
}
}
private String buildSystemPrompt() {
return SYSTEM_PROMPT;
}
private String buildUserPrompt(AiParseRequestDto request) {
JSONObject payload = JSONUtil.createObj()
.set("command", request.getCommand())
.set("currentRoute", request.getCurrentRoute())
.set("currentComponent", request.getCurrentComponent())
.set("context", Optional.ofNullable(request.getContext()).orElse(Collections.emptyMap()))
.set("availableFunctions", availableFunctions());
return StrUtil.format("""
请根据以下上下文识别用户意图,并输出符合系统提示要求的 JSON
{}
""", JSONUtil.toJsonPrettyStr(payload));
}
private List<Map<String, Object>> availableFunctions() {
return List.of(
Map.of(
"name", "updateUserNickname",
"description", "根据用户名更新用户昵称",
"requiredParameters", List.of("username", "nickname")
)
);
}
private ParseResult parseAiResponse(String rawContent) {
if (StrUtil.isBlank(rawContent)) {
throw new IllegalStateException("AI 返回内容为空");
}
try {
JSONObject jsonObject = JSONUtil.parseObj(rawContent);
boolean success = jsonObject.getBool("success", false);
String explanation = jsonObject.getStr("explanation");
Double confidence = jsonObject.containsKey("confidence") ? jsonObject.getDouble("confidence") : null;
String error = jsonObject.getStr("error");
String provider = jsonObject.getStr("provider");
String model = jsonObject.getStr("model");
List<AiFunctionCallDto> functionCalls = toFunctionCallList(jsonObject.getJSONArray("functionCalls"));
return new ParseResult(success, explanation, confidence, error, provider, model, functionCalls);
} catch (Exception ex) {
throw new IllegalStateException("无法解析 AI 响应: " + ex.getMessage(), ex);
}
}
private List<AiFunctionCallDto> toFunctionCallList(JSONArray array) {
if (array == null || array.isEmpty()) {
return Collections.emptyList();
}
List<AiFunctionCallDto> result = new ArrayList<>();
for (Object element : array) {
JSONObject functionJson = JSONUtil.parseObj(element);
Map<String, Object> arguments = Optional.ofNullable(functionJson.getJSONObject("arguments"))
.map(obj -> obj.toBean(new TypeReference<Map<String, Object>>() {
}))
.orElse(Collections.emptyMap());
result.add(AiFunctionCallDto.builder()
.name(functionJson.getStr("name"))
.description(functionJson.getStr("description"))
.arguments(arguments)
.build());
}
return result;
}
private record ParseResult(
boolean success,
String explanation,
Double confidence,
String error,
String provider,
String model,
List<AiFunctionCallDto> functionCalls
) {
}
@Override
public Object executeCommand(AiExecuteRequestDto request, HttpServletRequest httpRequest) throws Exception {
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
String ipAddress = JakartaServletUtil.getClientIP(httpRequest);
AiFunctionCallDto functionCall = request.getFunctionCall();
AiAssistantRecord commandRecord;
if (StrUtil.isNotBlank(request.getParseLogId())) {
commandRecord = this.getById(request.getParseLogId());
if (commandRecord == null) {
throw new IllegalStateException("未找到对应的解析记录ID: " + request.getParseLogId());
}
} else {
commandRecord = new AiAssistantRecord();
commandRecord.setUserId(userId);
commandRecord.setUsername(username);
commandRecord.setOriginalCommand(request.getOriginalCommand());
commandRecord.setIpAddress(ipAddress);
this.save(commandRecord);
}
commandRecord.setFunctionName(functionCall.getName());
commandRecord.setFunctionArguments(JSONUtil.toJsonStr(functionCall.getArguments()));
commandRecord.setExecuteStatus(0);
try {
Object result = executeFunctionCall(functionCall);
commandRecord.setExecuteStatus(1);
commandRecord.setExecuteErrorMessage(null);
this.updateById(commandRecord);
log.info("✅ 命令执行成功审计记录ID: {}", commandRecord.getId());
return result;
} catch (Exception e) {
commandRecord.setExecuteStatus(-1);
commandRecord.setExecuteErrorMessage(e.getMessage());
this.updateById(commandRecord);
log.error("❌ 命令执行失败审计记录ID: {}", commandRecord.getId(), e);
throw e;
}
}
private Object executeFunctionCall(AiFunctionCallDto functionCall) {
String functionName = functionCall.getName();
Map<String, Object> arguments = functionCall.getArguments();
log.info("🎯 执行函数: {}, 参数: {}", functionName, arguments);
switch (functionName) {
case "updateUserNickname":
return executeUpdateUserNickname(arguments);
default:
throw new UnsupportedOperationException("不支持的函数: " + functionName);
}
}
private Object executeUpdateUserNickname(Map<String, Object> arguments) {
String username = (String) arguments.get("username");
String nickname = (String) arguments.get("nickname");
log.info("🔧 [Tool] 更新用户昵称: username={}, nickname={}", username, nickname);
String resultMsg = userTools.updateUserNickname(username, nickname);
boolean success = resultMsg != null && resultMsg.contains("成功");
if (!success) {
throw new RuntimeException(resultMsg != null ? resultMsg : "更新用户昵称失败");
}
return Map.of("username", username, "nickname", nickname, "message", resultMsg);
}
@Override
public IPage<AiAssistantRecordVo> getRecordPage(AiAssistantPageQuery queryParams) {
Page<AiAssistantRecordVo> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize());
return this.baseMapper.getRecordPage(page, queryParams);
}
@Override
public boolean deleteRecords(List<Long> ids) {
return this.removeByIds(ids);
}
@Override
public void rollbackCommand(String logId) {
AiAssistantRecord commandRecord = this.getById(logId);
if (commandRecord == null) {
throw new RuntimeException("命令记录不存在");
}
if (commandRecord.getExecuteStatus() == null || commandRecord.getExecuteStatus() != 1) {
throw new RuntimeException("只能撤销成功执行的命令");
}
log.info("撤销命令执行: logId={}, function={}", logId, commandRecord.getFunctionName());
throw new UnsupportedOperationException("回滚功能尚未实现");
}
}

View File

@@ -1,47 +0,0 @@
package com.youlai.boot.platform.ai.service.impl;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.platform.ai.mapper.AiCommandRecordMapper;
import com.youlai.boot.platform.ai.model.entity.AiCommandRecord;
import com.youlai.boot.platform.ai.model.query.AiCommandPageQuery;
import com.youlai.boot.platform.ai.model.vo.AiCommandRecordVO;
import com.youlai.boot.platform.ai.service.AiCommandRecordService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI 命令记录服务实现类
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AiCommandRecordServiceImpl extends ServiceImpl<AiCommandRecordMapper, AiCommandRecord>
implements AiCommandRecordService {
@Override
public IPage<AiCommandRecordVO> getRecordPage(AiCommandPageQuery queryParams) {
Page<AiCommandRecordVO> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize());
return this.baseMapper.getRecordPage(page, queryParams);
}
@Override
public void rollbackCommand(String recordId) {
AiCommandRecord record = this.getById(recordId);
if (record == null) {
throw new RuntimeException("命令记录不存在");
}
if (!"success".equals(record.getExecuteStatus())) {
throw new RuntimeException("只能撤销成功执行的命令");
}
// TODO: 实现具体的回滚逻辑
log.info("撤销命令执行: recordId={}, function={}", recordId, record.getFunctionName());
throw new UnsupportedOperationException("回滚功能尚未实现");
}
}

View File

@@ -1,364 +0,0 @@
package com.youlai.boot.platform.ai.service.impl;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.servlet.JakartaServletUtil;
import cn.hutool.json.JSONUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.youlai.boot.platform.ai.model.dto.AiExecuteRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiFunctionCallDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseRequestDTO;
import com.youlai.boot.platform.ai.model.dto.AiParseResponseDTO;
import com.youlai.boot.platform.ai.model.entity.AiCommandRecord;
import com.youlai.boot.platform.ai.service.AiCommandRecordService;
import com.youlai.boot.platform.ai.service.AiCommandService;
import com.youlai.boot.platform.ai.tools.UserTools;
import com.youlai.boot.security.util.SecurityUtils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* AI 命令编排服务实现
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class AiCommandServiceImpl implements AiCommandService {
private static final String SYSTEM_PROMPT = """
你是一个智能的企业操作助手,需要将用户的自然语言命令解析成标准的函数调用。
请返回严格的 JSON 格式,包含字段:
- success: boolean
- explanation: string
- confidence: number (0-1)
- error: string
- provider: string
- model: string
- functionCalls: 数组,每个元素包含 name、description、arguments(对象)
当无法识别命令时success=false并给出 error。
""";
private final AiCommandRecordService recordService;
private final UserTools userTools;
private final ChatClient chatClient;
@Override
public AiParseResponseDTO parseCommand(AiParseRequestDTO request, HttpServletRequest httpRequest) {
long startTime = System.currentTimeMillis();
String command = Optional.ofNullable(request.getCommand()).orElse("").trim();
if (StrUtil.isBlank(command)) {
return AiParseResponseDTO.builder()
.success(false)
.error("命令不能为空")
.functionCalls(Collections.emptyList())
.build();
}
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
String ipAddress = JakartaServletUtil.getClientIP(httpRequest);
AiCommandRecord record = new AiCommandRecord();
record.setUserId(userId);
record.setUsername(username);
record.setOriginalCommand(command);
record.setIpAddress(ipAddress);
record.setCurrentRoute(request.getCurrentRoute());
record.setProvider("spring-ai");
record.setModel("auto");
String systemPrompt = buildSystemPrompt();
String userPrompt = buildUserPrompt(request);
try {
log.info("📤 发送命令至 AI 模型: {}", command);
ChatResponse chatResponse = chatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call().chatResponse();
String rawContent = Optional.ofNullable(chatResponse.getResult())
.map(result -> result.getOutput().getText())
.orElse("");
ParseResult parseResult = parseAiResponse(rawContent);
record.setProvider(StrUtil.emptyToDefault(parseResult.provider(), "spring-ai"));
record.setModel(StrUtil.emptyToDefault(parseResult.model(), "auto"));
record.setParseSuccess(parseResult.success());
record.setExplanation(parseResult.explanation());
record.setFunctionCalls(JSONUtil.toJsonStr(parseResult.functionCalls()));
record.setConfidence(parseResult.confidence() != null ? BigDecimal.valueOf(parseResult.confidence()) : null);
record.setParseErrorMessage(parseResult.success() ? null : StrUtil.emptyToDefault(parseResult.error(), "解析失败"));
record.setParseTime(System.currentTimeMillis() - startTime);
recordService.save(record);
AiParseResponseDTO response = AiParseResponseDTO.builder()
.parseLogId(record.getId())
.success(parseResult.success())
.functionCalls(parseResult.functionCalls())
.explanation(parseResult.explanation())
.confidence(parseResult.confidence())
.error(parseResult.error())
.rawResponse(rawContent)
.build();
if (!parseResult.success()) {
log.warn("❗️ AI 未能解析命令: {}", parseResult.error());
} else {
log.info("✅ 解析成功审计记录ID: {}", record.getId());
}
return response;
} catch (Exception e) {
long duration = System.currentTimeMillis() - startTime;
record.setParseSuccess(false);
record.setFunctionCalls(JSONUtil.toJsonStr(Collections.emptyList()));
record.setParseErrorMessage(e.getMessage());
record.setParseTime(duration);
recordService.save(record);
log.error("❌ 解析命令失败: {}", e.getMessage(), e);
throw new RuntimeException("解析命令失败: " + e.getMessage(), e);
}
}
private String buildSystemPrompt() {
return SYSTEM_PROMPT;
}
private String buildUserPrompt(AiParseRequestDTO request) {
JSONObject payload = JSONUtil.createObj()
.set("command", request.getCommand())
.set("currentRoute", request.getCurrentRoute())
.set("currentComponent", request.getCurrentComponent())
.set("context", Optional.ofNullable(request.getContext()).orElse(Collections.emptyMap()))
.set("availableFunctions", availableFunctions());
return StrUtil.format("""
请根据以下上下文识别用户意图,并输出符合系统提示要求的 JSON
{}
""", JSONUtil.toJsonPrettyStr(payload));
}
private List<Map<String, Object>> availableFunctions() {
return List.of(
Map.of(
"name", "updateUserNickname",
"description", "根据用户名更新用户昵称",
"requiredParameters", List.of("username", "nickname")
)
);
}
private ParseResult parseAiResponse(String rawContent) {
if (StrUtil.isBlank(rawContent)) {
throw new IllegalStateException("AI 返回内容为空");
}
try {
JSONObject jsonObject = JSONUtil.parseObj(rawContent);
boolean success = jsonObject.getBool("success", false);
String explanation = jsonObject.getStr("explanation");
Double confidence = jsonObject.containsKey("confidence") ? jsonObject.getDouble("confidence") : null;
String error = jsonObject.getStr("error");
String provider = jsonObject.getStr("provider");
String model = jsonObject.getStr("model");
List<AiFunctionCallDTO> functionCalls = toFunctionCallList(jsonObject.getJSONArray("functionCalls"));
return new ParseResult(success, explanation, confidence, error, provider, model, functionCalls);
} catch (Exception ex) {
throw new IllegalStateException("无法解析 AI 响应: " + ex.getMessage(), ex);
}
}
private List<AiFunctionCallDTO> toFunctionCallList(JSONArray array) {
if (array == null || array.isEmpty()) {
return Collections.emptyList();
}
List<AiFunctionCallDTO> result = new ArrayList<>();
for (Object element : array) {
JSONObject functionJson = JSONUtil.parseObj(element);
Map<String, Object> arguments = Optional.ofNullable(functionJson.getJSONObject("arguments"))
.map(obj -> obj.toBean(new TypeReference<Map<String, Object>>() {
}))
.orElse(Collections.emptyMap());
result.add(AiFunctionCallDTO.builder()
.name(functionJson.getStr("name"))
.description(functionJson.getStr("description"))
.arguments(arguments)
.build());
}
return result;
}
private record ParseResult(
boolean success,
String explanation,
Double confidence,
String error,
String provider,
String model,
List<AiFunctionCallDTO> functionCalls
) {
}
@Override
public Object executeCommand(AiExecuteRequestDTO request, HttpServletRequest httpRequest) throws Exception {
long startTime = System.currentTimeMillis();
// 获取用户信息
Long userId = SecurityUtils.getUserId();
String username = SecurityUtils.getUsername();
String ipAddress = JakartaServletUtil.getClientIP(httpRequest);
AiFunctionCallDTO functionCall = request.getFunctionCall();
// 判断是否为危险操作
boolean isDangerous = isDangerousOperation(functionCall.getName());
// 根据解析日志ID获取审计记录如果不存在则创建新记录
AiCommandRecord record;
if (StrUtil.isNotBlank(request.getParseLogId())) {
// 更新已存在的审计记录(解析阶段已创建)
record = recordService.getById(request.getParseLogId());
if (record == null) {
throw new IllegalStateException("未找到对应的解析记录ID: " + request.getParseLogId());
}
} else {
// 如果没有解析日志ID创建新记录兼容直接执行的情况
record = new AiCommandRecord();
record.setUserId(userId);
record.setUsername(username);
record.setOriginalCommand(request.getOriginalCommand());
record.setIpAddress(ipAddress);
record.setCurrentRoute(request.getCurrentRoute());
recordService.save(record);
}
// 更新执行相关字段
record.setFunctionName(functionCall.getName());
record.setFunctionArguments(JSONUtil.toJsonStr(functionCall.getArguments()));
record.setIsDangerous(isDangerous);
record.setRequiresConfirmation(request.getConfirmMode() != null &&
"manual".equals(request.getConfirmMode()));
record.setUserConfirmed(request.getUserConfirmed());
record.setIdempotencyKey(request.getIdempotencyKey());
record.setUserAgent(httpRequest.getHeader("User-Agent"));
record.setExecuteStatus("pending");
try {
// 幂等性检查
if (StrUtil.isNotBlank(request.getIdempotencyKey())) {
AiCommandRecord existing = recordService.getOne(
new LambdaQueryWrapper<AiCommandRecord>()
.eq(AiCommandRecord::getIdempotencyKey, request.getIdempotencyKey())
.ne(AiCommandRecord::getId, record.getId()) // 排除当前记录
);
if (existing != null) {
log.warn("⚠️ 检测到重复执行,幂等性令牌: {}", request.getIdempotencyKey());
throw new IllegalStateException("该操作已执行,请勿重复提交");
}
}
// 🎯 执行具体的函数调用
Object result = executeFunctionCall(functionCall);
// 更新执行成功
record.setExecuteStatus("success");
record.setExecuteResult(JSONUtil.toJsonStr(result));
record.setExecutionTime(System.currentTimeMillis() - startTime);
// 更新审计记录
recordService.updateById(record);
log.info("✅ 命令执行成功审计记录ID: {}", record.getId());
return result;
} catch (Exception e) {
// 更新执行失败
record.setExecuteStatus("failed");
record.setExecuteErrorMessage(e.getMessage());
record.setExecutionTime(System.currentTimeMillis() - startTime);
// 更新审计记录
recordService.updateById(record);
log.error("❌ 命令执行失败审计记录ID: {}", record.getId(), e);
// 抛出异常,由 Controller 统一处理
throw e;
}
}
/**
* 判断是否为危险操作
*/
private boolean isDangerousOperation(String functionName) {
String[] dangerousKeywords = {"delete", "remove", "drop", "truncate", "clear"};
String lowerName = functionName.toLowerCase();
for (String keyword : dangerousKeywords) {
if (lowerName.contains(keyword)) {
return true;
}
}
return false;
}
/**
* 执行具体的函数调用
*/
private Object executeFunctionCall(AiFunctionCallDTO functionCall) {
String functionName = functionCall.getName();
Map<String, Object> arguments = functionCall.getArguments();
log.info("🎯 执行函数: {}, 参数: {}", functionName, arguments);
// 根据函数名称路由到不同的处理器
switch (functionName) {
case "updateUserNickname":
return executeUpdateUserNickname(arguments);
default:
throw new UnsupportedOperationException("不支持的函数: " + functionName);
}
}
/**
* 使用 Tool: 根据用户名更新用户昵称
*/
private Object executeUpdateUserNickname(Map<String, Object> arguments) {
String username = (String) arguments.get("username");
String nickname = (String) arguments.get("nickname");
log.info("🔧 [Tool] 更新用户昵称: username={}, nickname={}", username, nickname);
String resultMsg = userTools.updateUserNickname(username, nickname);
boolean success = resultMsg != null && resultMsg.contains("成功");
if (!success) {
throw new RuntimeException(resultMsg != null ? resultMsg : "更新用户昵称失败");
}
return Map.of("username", username, "nickname", nickname, "message", resultMsg);
}
}

View File

@@ -17,28 +17,28 @@ import org.springframework.ai.tool.annotation.ToolParam;
@RequiredArgsConstructor
public class UserTools {
private final UserService userService;
private final UserService userService;
@Tool(description = "根据关键字在用户列表中筛选用户")
public String queryUser(
@ToolParam(description = "搜索关键字,用于在列表中搜索筛选") String keywords
) {
// 返回搜索关键字,前端会在用户列表页面进行筛选
return "将在用户列表中搜索:" + keywords;
}
@Tool(description = "根据关键字在用户列表中筛选用户")
public String queryUser(
@ToolParam(description = "搜索关键字,用于在列表中搜索筛选") String keywords
) {
// 返回搜索关键字,前端会在用户列表页面进行筛选
return "将在用户列表中搜索:" + keywords;
}
@Tool(description = "根据用户名更新用户昵称")
public String updateUserNickname(
@ToolParam(description = "用户名") String username,
@ToolParam(description = "新的昵称") String nickname
) {
@Tool(description = "根据用户名更新用户昵称")
public String updateUserNickname(
@ToolParam(description = "用户名") String username,
@ToolParam(description = "新的昵称") String nickname
) {
boolean ok = userService.update(new LambdaUpdateWrapper<User>()
.eq(User::getUsername, username)
.set(User::getNickname, nickname)
);
return ok ? "用户昵称更新成功" : "用户昵称更新失败";
}
boolean ok = userService.update(new LambdaUpdateWrapper<User>()
.eq(User::getUsername, username)
.set(User::getNickname, nickname)
);
return ok ? "用户昵称更新成功" : "用户昵称更新失败";
}
}

View File

@@ -8,10 +8,10 @@ import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.platform.codegen.service.CodegenService;
import com.youlai.boot.platform.codegen.model.form.GenConfigForm;
import com.youlai.boot.platform.codegen.model.query.TablePageQuery;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.platform.codegen.model.vo.TablePageVO;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVo;
import com.youlai.boot.platform.codegen.model.vo.TablePageVo;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.platform.codegen.service.GenConfigService;
import com.youlai.boot.platform.codegen.service.GenTableService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -40,25 +40,25 @@ import java.util.List;
public class CodegenController {
private final CodegenService codegenService;
private final GenConfigService genConfigService;
private final GenTableService genTableService;
private final CodegenProperties codegenProperties;
@Operation(summary = "获取数据表分页列表")
@GetMapping("/table/page")
@Log(value = "代码生成分页列表", module = LogModuleEnum.OTHER)
public PageResult<TablePageVO> getTablePage(
public PageResult<TablePageVo> getTablePage(
TablePageQuery queryParams
) {
Page<TablePageVO> result = codegenService.getTablePage(queryParams);
Page<TablePageVo> result = codegenService.getTablePage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "获取代码生成配置")
@GetMapping("/{tableName}/config")
public Result<GenConfigForm> getGenConfigFormData(
public Result<GenConfigForm> getGenTableFormData(
@Parameter(description = "表名", example = "sys_user") @PathVariable String tableName
) {
GenConfigForm formData = genConfigService.getGenConfigFormData(tableName);
GenConfigForm formData = genTableService.getGenTableFormData(tableName);
return Result.success(formData);
}
@@ -66,7 +66,7 @@ public class CodegenController {
@PostMapping("/{tableName}/config")
@Log(value = "生成代码", module = LogModuleEnum.OTHER)
public Result<?> saveGenConfig(@RequestBody GenConfigForm formData) {
genConfigService.saveGenConfig(formData);
genTableService.saveGenConfig(formData);
return Result.success();
}
@@ -75,16 +75,16 @@ public class CodegenController {
public Result<?> deleteGenConfig(
@Parameter(description = "表名", example = "sys_user") @PathVariable String tableName
) {
genConfigService.deleteGenConfig(tableName);
genTableService.deleteGenConfig(tableName);
return Result.success();
}
@Operation(summary = "获取预览生成代码")
@GetMapping("/{tableName}/preview")
@Log(value = "预览生成代码", module = LogModuleEnum.OTHER)
public Result<List<CodegenPreviewVO>> getTablePreviewData(@PathVariable String tableName,
public Result<List<CodegenPreviewVo>> getTablePreviewData(@PathVariable String tableName,
@RequestParam(value = "pageType", required = false, defaultValue = "classic") String pageType) {
List<CodegenPreviewVO> list = codegenService.getCodegenPreviewData(tableName, pageType);
List<CodegenPreviewVo> list = codegenService.getCodegenPreviewData(tableName, pageType);
return Result.success(list);
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.platform.codegen.converter;
import com.youlai.boot.platform.codegen.model.entity.GenConfig;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTable;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
import com.youlai.boot.platform.codegen.model.form.GenConfigForm;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@@ -17,25 +17,25 @@ import java.util.List;
@Mapper(componentModel = "spring")
public interface CodegenConverter {
@Mapping(source = "genConfig.tableName", target = "tableName")
@Mapping(source = "genConfig.businessName", target = "businessName")
@Mapping(source = "genConfig.moduleName", target = "moduleName")
@Mapping(source = "genConfig.packageName", target = "packageName")
@Mapping(source = "genConfig.entityName", target = "entityName")
@Mapping(source = "genConfig.author", target = "author")
@Mapping(source = "genConfig.pageType", target = "pageType")
@Mapping(source = "genConfig.removeTablePrefix", target = "removeTablePrefix")
@Mapping(source = "genTable.tableName", target = "tableName")
@Mapping(source = "genTable.businessName", target = "businessName")
@Mapping(source = "genTable.moduleName", target = "moduleName")
@Mapping(source = "genTable.packageName", target = "packageName")
@Mapping(source = "genTable.entityName", target = "entityName")
@Mapping(source = "genTable.author", target = "author")
@Mapping(source = "genTable.pageType", target = "pageType")
@Mapping(source = "genTable.removeTablePrefix", target = "removeTablePrefix")
@Mapping(source = "fieldConfigs", target = "fieldConfigs")
GenConfigForm toGenConfigForm(GenConfig genConfig, List<GenFieldConfig> fieldConfigs);
GenConfigForm toGenConfigForm(GenTable genTable, List<GenTableColumn> fieldConfigs);
List<GenConfigForm.FieldConfig> toGenFieldConfigForm(List<GenFieldConfig> fieldConfigs);
List<GenConfigForm.FieldConfig> toGenTableColumnForm(List<GenTableColumn> fieldConfigs);
GenConfigForm.FieldConfig toGenFieldConfigForm(GenFieldConfig genFieldConfig);
GenConfigForm.FieldConfig toGenTableColumnForm(GenTableColumn genTableColumn);
GenConfig toGenConfig(GenConfigForm formData);
GenTable toGenTable(GenConfigForm formData);
List<GenFieldConfig> toGenFieldConfig(List<GenConfigForm.FieldConfig> fieldConfigs);
List<GenTableColumn> toGenTableColumn(List<GenConfigForm.FieldConfig> fieldConfigs);
GenFieldConfig toGenFieldConfig(GenConfigForm.FieldConfig fieldConfig);
GenTableColumn toGenTableColumn(GenConfigForm.FieldConfig fieldConfig);
}

View File

@@ -27,9 +27,11 @@ public enum JavaTypeEnum {
FLOAT("float", "Float", "number"),
DOUBLE("double", "Double", "number"),
DECIMAL("decimal", "BigDecimal", "number"),
DATE("date", "LocalDate", "Date"),
DATETIME("datetime", "LocalDateTime", "Date"),
TIMESTAMP("timestamp", "LocalDateTime", "Date");
DATE("date", "LocalDate", "string"),
DATETIME("datetime", "LocalDateTime", "string"),
TIMESTAMP("timestamp", "LocalDateTime", "string"),
BOOLEAN("boolean", "Boolean", "boolean"),
BIT("bit", "Boolean", "boolean");
// 数据库类型
private final String dbType;
@@ -61,11 +63,12 @@ public enum JavaTypeEnum {
* @return 对应的Java类型
*/
public static String getJavaTypeByColumnType(String columnType) {
JavaTypeEnum javaTypeEnum = typeMap.get(columnType);
String normalized = normalizeColumnType(columnType);
JavaTypeEnum javaTypeEnum = typeMap.get(normalized);
if (javaTypeEnum != null) {
return javaTypeEnum.getJavaType();
}
return null;
return "String";
}
/**
@@ -75,11 +78,31 @@ public enum JavaTypeEnum {
* @return 对应的TypeScript类型
*/
public static String getTsTypeByJavaType(String javaType) {
if (javaType == null) {
return "any";
}
for (JavaTypeEnum javaTypeEnum : JavaTypeEnum.values()) {
if (javaTypeEnum.getJavaType().equals(javaType)) {
return javaTypeEnum.getTsType();
}
}
return null;
return "any";
}
private static String normalizeColumnType(String columnType) {
if (columnType == null) {
return "";
}
// Handle values like: varchar(255), bigint unsigned, INT
String normalized = columnType.trim().toLowerCase();
int parenIndex = normalized.indexOf('(');
if (parenIndex > -1) {
normalized = normalized.substring(0, parenIndex);
}
// Remove modifiers
normalized = normalized.replace("unsigned", "").replace("zerofill", "").trim();
// Collapse repeated spaces
normalized = normalized.replaceAll("\\s+", " ");
return normalized;
}
}

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.platform.codegen.model.bo.ColumnMetaData;
import com.youlai.boot.platform.codegen.model.bo.TableMetaData;
import com.youlai.boot.platform.codegen.model.query.TablePageQuery;
import com.youlai.boot.platform.codegen.model.vo.TablePageVO;
import com.youlai.boot.platform.codegen.model.vo.TablePageVo;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@@ -27,7 +27,7 @@ public interface DatabaseMapper extends BaseMapper {
* @param queryParams
* @return
*/
Page<TablePageVO> getTablePage(Page<TablePageVO> page, TablePageQuery queryParams);
Page<TablePageVo> getTablePage(Page<TablePageVo> page, TablePageQuery queryParams);
/**
* 获取表字段列表

View File

@@ -1,17 +1,17 @@
package com.youlai.boot.platform.codegen.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
import org.apache.ibatis.annotations.Mapper;
/**
* 代码生成字段配置访问层
* 代码生成字段配置访问层
*
* @author Ray
* @since 2.10.0
*/
@Mapper
public interface GenFieldConfigMapper extends BaseMapper<GenFieldConfig> {
public interface GenTableColumnMapper extends BaseMapper<GenTableColumn> {
}

View File

@@ -1,17 +1,17 @@
package com.youlai.boot.platform.codegen.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.youlai.boot.platform.codegen.model.entity.GenConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTable;
import org.apache.ibatis.annotations.Mapper;
/**
* 代码生成基础配置访问层
* 代码生成配置访问层
*
* @author Ray
* @since 2.10.0
*/
@Mapper
public interface GenConfigMapper extends BaseMapper<GenConfig> {
public interface GenTableMapper extends BaseMapper<GenTable> {
}

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.platform.codegen.model.bo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "数据表字段VO")
@Schema(description = "数据表字段Vo")
@Data
public class ColumnMetaData {

View File

@@ -7,15 +7,15 @@ import lombok.Getter;
import lombok.Setter;
/**
* 代码生成基础配置
* 代码生成配置
*
* @author Ray
* @since 2.10.0
*/
@TableName(value = "gen_config")
@TableName(value = "gen_table")
@Getter
@Setter
public class GenConfig extends BaseEntity {
public class GenTable extends BaseEntity {
/**
* 表名
@@ -61,4 +61,5 @@ public class GenConfig extends BaseEntity {
* 要移除的表前缀: sys_
*/
private String removeTablePrefix;
}
}

View File

@@ -11,21 +11,21 @@ import lombok.Getter;
import lombok.Setter;
/**
* 字段生成配置实体
* 代码生成表字段配置实体
*
* @author Ray
* @since 2.10.0
*/
@TableName(value = "gen_field_config")
@TableName(value = "gen_table_column")
@Getter
@Setter
public class GenFieldConfig extends BaseEntity {
public class GenTableColumn extends BaseEntity {
/**
* 关联的配置ID
* 关联的配置ID
*/
private Long configId;
private Long tableId;
/**
* 列名
@@ -104,3 +104,4 @@ public class GenFieldConfig extends BaseEntity {
*/
private String dictType;
}

View File

@@ -3,9 +3,9 @@ package com.youlai.boot.platform.codegen.model.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "代码生成代码预览VO")
@Schema(description = "代码生成代码预览Vo")
@Data
public class CodegenPreviewVO {
public class CodegenPreviewVo {
@Schema(description = "生成文件路径")
private String path;

View File

@@ -6,7 +6,7 @@ import lombok.Data;
@Schema(description = "表视图对象")
@Data
public class TablePageVO {
public class TablePageVo {
@Schema(description = "表名称", example = "sys_user")
private String tableName;

View File

@@ -2,8 +2,8 @@ package com.youlai.boot.platform.codegen.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.platform.codegen.model.query.TablePageQuery;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.platform.codegen.model.vo.TablePageVO;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVo;
import com.youlai.boot.platform.codegen.model.vo.TablePageVo;
import java.util.List;
@@ -21,7 +21,7 @@ public interface CodegenService {
* @param queryParams 查询参数
* @return
*/
Page<TablePageVO> getTablePage(TablePageQuery queryParams);
Page<TablePageVo> getTablePage(TablePageQuery queryParams);
/**
* 获取预览生成代码
@@ -29,7 +29,7 @@ public interface CodegenService {
* @param tableName 表名
* @return
*/
List<CodegenPreviewVO> getCodegenPreviewData(String tableName, String pageType);
List<CodegenPreviewVo> getCodegenPreviewData(String tableName, String pageType);
/**
* 下载代码

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.platform.codegen.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
/**
* 代码生成配置接口
@@ -9,6 +9,6 @@ import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
* @author Ray
* @since 2.10.0
*/
public interface GenFieldConfigService extends IService<GenFieldConfig> {
public interface GenTableColumnService extends IService<GenTableColumn> {
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.platform.codegen.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.youlai.boot.platform.codegen.model.entity.GenConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTable;
import com.youlai.boot.platform.codegen.model.form.GenConfigForm;
/**
@@ -10,7 +10,7 @@ import com.youlai.boot.platform.codegen.model.form.GenConfigForm;
* @author Ray
* @since 2.10.0
*/
public interface GenConfigService extends IService<GenConfig> {
public interface GenTableService extends IService<GenTable> {
/**
* 获取代码生成配置
@@ -18,7 +18,7 @@ public interface GenConfigService extends IService<GenConfig> {
* @param tableName 表名
* @return
*/
GenConfigForm getGenConfigFormData(String tableName);
GenConfigForm getGenTableFormData(String tableName);
/**
* 保存代码生成配置

View File

@@ -13,16 +13,16 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.platform.codegen.enums.JavaTypeEnum;
import com.youlai.boot.config.property.CodegenProperties;
import com.youlai.boot.platform.codegen.service.GenConfigService;
import com.youlai.boot.platform.codegen.service.GenFieldConfigService;
import com.youlai.boot.platform.codegen.service.GenTableService;
import com.youlai.boot.platform.codegen.service.GenTableColumnService;
import com.youlai.boot.platform.codegen.service.CodegenService;
import com.youlai.boot.core.exception.BusinessException;
import com.youlai.boot.platform.codegen.mapper.DatabaseMapper;
import com.youlai.boot.platform.codegen.model.entity.GenConfig;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTable;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
import com.youlai.boot.platform.codegen.model.query.TablePageQuery;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVO;
import com.youlai.boot.platform.codegen.model.vo.TablePageVO;
import com.youlai.boot.platform.codegen.model.vo.CodegenPreviewVo;
import com.youlai.boot.platform.codegen.model.vo.TablePageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -36,7 +36,11 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* 数据库服务实现类
* 代码生成服务实现类
*
* <p>
* 根据代码生成配置({@link CodegenProperties})与表/字段元数据,渲染模板并提供预览与下载能力。
* </p>
*
* @author Ray
* @since 2.10.0
@@ -48,8 +52,8 @@ public class CodegenServiceImpl implements CodegenService {
private final DatabaseMapper databaseMapper;
private final CodegenProperties codegenProperties;
private final GenConfigService genConfigService;
private final GenFieldConfigService genFieldConfigService;
private final GenTableService genTableService;
private final GenTableColumnService genTableColumnService;
/**
* 数据表分页列表
@@ -57,8 +61,8 @@ public class CodegenServiceImpl implements CodegenService {
* @param queryParams 查询参数
* @return 分页结果
*/
public Page<TablePageVO> getTablePage(TablePageQuery queryParams) {
Page<TablePageVO> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize());
public Page<TablePageVo> getTablePage(TablePageQuery queryParams) {
Page<TablePageVo> page = new Page<>(queryParams.getPageNum(), queryParams.getPageSize());
// 设置排除的表
List<String> excludeTables = codegenProperties.getExcludeTables();
queryParams.setExcludeTables(excludeTables);
@@ -73,20 +77,20 @@ public class CodegenServiceImpl implements CodegenService {
* @return 预览数据
*/
@Override
public List<CodegenPreviewVO> getCodegenPreviewData(String tableName, String pageType) {
public List<CodegenPreviewVo> getCodegenPreviewData(String tableName, String pageType) {
List<CodegenPreviewVO> list = new ArrayList<>();
List<CodegenPreviewVo> list = new ArrayList<>();
GenConfig genConfig = genConfigService.getOne(new LambdaQueryWrapper<GenConfig>()
.eq(GenConfig::getTableName, tableName)
GenTable genTable = genTableService.getOne(new LambdaQueryWrapper<GenTable>()
.eq(GenTable::getTableName, tableName)
);
if (genConfig == null) {
if (genTable == null) {
throw new BusinessException("未找到表生成配置");
}
List<GenFieldConfig> fieldConfigs = genFieldConfigService.list(new LambdaQueryWrapper<GenFieldConfig>()
.eq(GenFieldConfig::getConfigId, genConfig.getId())
.orderByAsc(GenFieldConfig::getFieldSort)
List<GenTableColumn> fieldConfigs = genTableColumnService.list(new LambdaQueryWrapper<GenTableColumn>()
.eq(GenTableColumn::getTableId, genTable.getId())
.orderByAsc(GenTableColumn::getFieldSort)
);
if (CollectionUtil.isEmpty(fieldConfigs)) {
@@ -96,13 +100,13 @@ public class CodegenServiceImpl implements CodegenService {
// 遍历模板配置
Map<String, CodegenProperties.TemplateConfig> templateConfigs = codegenProperties.getTemplateConfigs();
for (Map.Entry<String, CodegenProperties.TemplateConfig> templateConfigEntry : templateConfigs.entrySet()) {
CodegenPreviewVO previewVO = new CodegenPreviewVO();
CodegenPreviewVo previewVo = new CodegenPreviewVo();
CodegenProperties.TemplateConfig templateConfig = templateConfigEntry.getValue();
/* 1. 生成文件名 UserController */
// User Role Menu Dept
String entityName = genConfig.getEntityName();
String entityName = genTable.getEntityName();
// Controller Service Mapper Entity
String templateName = templateConfigEntry.getKey();
// .java .ts .vue
@@ -110,37 +114,39 @@ public class CodegenServiceImpl implements CodegenService {
// 文件名 UserController.java
String fileName = getFileName(entityName, templateName, extension);
previewVO.setFileName(fileName);
previewVo.setFileName(fileName);
/* 2. 生成文件路径 */
// 包名com.youlai.boot
String packageName = genConfig.getPackageName();
String packageName = genTable.getPackageName();
// 模块名system
String moduleName = genConfig.getModuleName();
String moduleName = genTable.getModuleName();
// 子包名controller
String subpackageName = templateConfig.getSubpackageName();
// 组合成文件路径src/main/java/com/youlai/boot/system/controller
String filePath = getFilePath(templateName, moduleName, packageName, subpackageName, entityName);
previewVO.setPath(filePath);
previewVo.setPath(filePath);
/* 3. 生成文件内容 */
// 将模板文件中的变量替换为具体的值 生成代码内容
// 优先使用保存的 ui没有则使用请求参数
String finalType = StrUtil.blankToDefault(genConfig.getPageType(), pageType);
String content = getCodeContent(templateConfig, genConfig, fieldConfigs, finalType);
previewVO.setContent(content);
String finalType = StrUtil.blankToDefault(genTable.getPageType(), pageType);
String content = getCodeContent(templateConfig, genTable, fieldConfigs, finalType);
previewVo.setContent(content);
list.add(previewVO);
list.add(previewVo);
}
return list;
}
/**
* 生成文件名
* 生成文件名
*
* @param entityName 实体类名 UserController
* @param templateName 模板名 Entity
* @param extension 文件后缀 .java
* <p>部分模板需要使用约定的命名规则(例如前端 API 文件)。</p>
*
* @param entityName 实体名(例如 User
* @param templateName 模板名(例如 Entity、Controller、API
* @param extension 文件后缀(例如 .java、.ts
* @return 文件名
*/
private String getFileName(String entityName, String templateName, String extension) {
@@ -149,8 +155,11 @@ public class CodegenServiceImpl implements CodegenService {
} else if ("MapperXml".equals(templateName)) {
return entityName + "Mapper" + extension;
} else if ("API".equals(templateName)) {
// 生成 user-api.ts 命名
return StrUtil.toSymbolCase(entityName, '-') + "-api" + extension;
// 生成 user.ts 命名
return StrUtil.toSymbolCase(entityName, '-') + extension;
} else if ("API_TYPES".equals(templateName)) {
// 生成 types/api/user.ts
return StrUtil.toSymbolCase(entityName, '-') + extension;
} else if ("VIEW".equals(templateName)) {
return "index.vue";
}
@@ -158,14 +167,14 @@ public class CodegenServiceImpl implements CodegenService {
}
/**
* 生成文件路径
* 生成文件路径
*
* @param templateName 模板名 Entity
* @param moduleName 模块名 system
* @param packageName 包名 com.youlai
* @param subPackageName 子包名 controller
* @param entityName 实体名 UserController
* @return 文件路径 src/main/java/com/youlai/system/controller
* @param templateName 模板名
* @param moduleName 模块名(例如 system
* @param packageName 包名(例如 com.youlai.boot
* @param subPackageName 子包名(例如 controller、service.impl、api、views
* @param entityName 实体名(例如 User
* @return 生成文件路径
*/
private String getFilePath(String templateName, String moduleName, String packageName, String subPackageName, String entityName) {
String path;
@@ -183,6 +192,13 @@ public class CodegenServiceImpl implements CodegenService {
+ File.separator + subPackageName
+ File.separator + moduleName
);
} else if ("API_TYPES".equals(templateName)) {
// path = "src/types/api";
path = (codegenProperties.getFrontendAppName()
+ File.separator + "src"
+ File.separator + "types"
+ File.separator + "api"
);
} else if ("VIEW".equals(templateName)) {
// path = "src/views/system/user";
path = (codegenProperties.getFrontendAppName()
@@ -208,38 +224,47 @@ public class CodegenServiceImpl implements CodegenService {
}
/**
* 生成代码内容
* 渲染模板,生成代码内容
*
* @param templateConfig 模板配置
* @param genConfig 生成配置
* @param genTable 生成配置
* @param fieldConfigs 字段配置
* @return 代码内容
* @param pageType 前端页面类型
* @return 渲染后的代码内容
*/
private String getCodeContent(CodegenProperties.TemplateConfig templateConfig, GenConfig genConfig, List<GenFieldConfig> fieldConfigs, String pageType) {
private String getCodeContent(CodegenProperties.TemplateConfig templateConfig, GenTable genTable, List<GenTableColumn> fieldConfigs, String pageType) {
Map<String, Object> bindMap = new HashMap<>();
String entityName = genConfig.getEntityName();
String entityName = genTable.getEntityName();
bindMap.put("packageName", genConfig.getPackageName());
bindMap.put("moduleName", genConfig.getModuleName());
bindMap.put("packageName", genTable.getPackageName());
bindMap.put("moduleName", genTable.getModuleName());
bindMap.put("subpackageName", templateConfig.getSubpackageName());
bindMap.put("date", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm"));
bindMap.put("entityName", entityName);
bindMap.put("tableName", genConfig.getTableName());
bindMap.put("author", genConfig.getAuthor());
bindMap.put("lowerFirstEntityName", StrUtil.lowerFirst(entityName)); // UserTest → userTest
bindMap.put("kebabCaseEntityName", StrUtil.toSymbolCase(entityName, '-')); // UserTest → user-test
bindMap.put("businessName", genConfig.getBusinessName());
bindMap.put("tableName", genTable.getTableName());
bindMap.put("author", genTable.getAuthor());
String entityLowerCamel = StrUtil.lowerFirst(entityName);
String entityKebab = StrUtil.toSymbolCase(entityName, '-');
String entityUpperSnake = StrUtil.toSymbolCase(entityName, '_').toUpperCase();
bindMap.put("entityLowerCamel", entityLowerCamel);
bindMap.put("entityKebab", entityKebab);
bindMap.put("entityUpperSnake", entityUpperSnake);
bindMap.put("businessName", genTable.getBusinessName());
bindMap.put("fieldConfigs", fieldConfigs);
boolean hasLocalDateTime = false;
boolean hasBigDecimal = false;
boolean hasRequiredField = false;
for (GenFieldConfig fieldConfig : fieldConfigs) {
for (GenTableColumn fieldConfig : fieldConfigs) {
if ("LocalDateTime".equals(fieldConfig.getFieldType())) {
if (StrUtil.isBlank(fieldConfig.getFieldType())) {
fieldConfig.setFieldType(JavaTypeEnum.getJavaTypeByColumnType(fieldConfig.getColumnType()));
}
if ("LocalDateTime".equals(fieldConfig.getFieldType()) || "LocalDate".equals(fieldConfig.getFieldType())) {
hasLocalDateTime = true;
}
if ("BigDecimal".equals(fieldConfig.getFieldType())) {
@@ -267,10 +292,11 @@ public class CodegenServiceImpl implements CodegenService {
}
/**
* 下载代码
* 下载代码
*
* @param tableNames 表名数组,支持多张表
* @return 压缩文件字节数组
* @param tableNames 表名数组,支持多张表
* @param ui 页面类型
* @return zip 压缩文件字节数组
*/
@Override
public byte[] downloadCode(String[] tableNames, String ui) {
@@ -292,15 +318,16 @@ public class CodegenServiceImpl implements CodegenService {
}
/**
* 根据表名生成代码并压缩到zip文件中
* 根据表名生成代码并压缩到 zip 文件中
*
* @param tableName 表名
* @param zip 压缩文件输出流
* @param ui 页面类型
*/
private void generateAndZipCode(String tableName, ZipOutputStream zip, String ui) {
List<CodegenPreviewVO> codePreviewList = getCodegenPreviewData(tableName, ui);
List<CodegenPreviewVo> codePreviewList = getCodegenPreviewData(tableName, ui);
for (CodegenPreviewVO codePreview : codePreviewList) {
for (CodegenPreviewVo codePreview : codePreviewList) {
String fileName = codePreview.getFileName();
String content = codePreview.getContent();
String path = codePreview.getPath();

View File

@@ -1,21 +0,0 @@
package com.youlai.boot.platform.codegen.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.platform.codegen.mapper.GenFieldConfigMapper;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.service.GenFieldConfigService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 代码生成字段配置服务实现类
*
* @author Ray
* @since 2.10.0
*/
@Service
@RequiredArgsConstructor
public class GenFieldConfigServiceImpl extends ServiceImpl<GenFieldConfigMapper, GenFieldConfig> implements GenFieldConfigService {
}

View File

@@ -0,0 +1,21 @@
package com.youlai.boot.platform.codegen.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.youlai.boot.platform.codegen.mapper.GenTableColumnMapper;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
import com.youlai.boot.platform.codegen.service.GenTableColumnService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 代码生成字段配置服务实现类
*
* @author Ray.Hao
* @since 2.10.0
*/
@Service
@RequiredArgsConstructor
public class GenTableColumnServiceImpl extends ServiceImpl<GenTableColumnMapper, GenTableColumn> implements GenTableColumnService {
}

View File

@@ -14,14 +14,14 @@ import com.youlai.boot.core.exception.BusinessException;
import com.youlai.boot.config.property.CodegenProperties;
import com.youlai.boot.platform.codegen.converter.CodegenConverter;
import com.youlai.boot.platform.codegen.mapper.DatabaseMapper;
import com.youlai.boot.platform.codegen.mapper.GenConfigMapper;
import com.youlai.boot.platform.codegen.mapper.GenTableMapper;
import com.youlai.boot.platform.codegen.model.bo.ColumnMetaData;
import com.youlai.boot.platform.codegen.model.bo.TableMetaData;
import com.youlai.boot.platform.codegen.model.entity.GenConfig;
import com.youlai.boot.platform.codegen.model.entity.GenFieldConfig;
import com.youlai.boot.platform.codegen.model.entity.GenTable;
import com.youlai.boot.platform.codegen.model.entity.GenTableColumn;
import com.youlai.boot.platform.codegen.model.form.GenConfigForm;
import com.youlai.boot.platform.codegen.service.GenConfigService;
import com.youlai.boot.platform.codegen.service.GenFieldConfigService;
import com.youlai.boot.platform.codegen.service.GenTableService;
import com.youlai.boot.platform.codegen.service.GenTableColumnService;
import com.youlai.boot.system.service.MenuService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
@@ -40,11 +40,11 @@ import java.util.Objects;
*/
@Service
@RequiredArgsConstructor
public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig> implements GenConfigService {
public class GenTableServiceImpl extends ServiceImpl<GenTableMapper, GenTable> implements GenTableService {
private final DatabaseMapper databaseMapper;
private final CodegenProperties codegenProperties;
private final GenFieldConfigService genFieldConfigService;
private final GenTableColumnService genTableColumnService;
private final CodegenConverter codegenConverter;
@Value("${spring.profiles.active}")
@@ -59,64 +59,64 @@ public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig
* @return 代码生成配置
*/
@Override
public GenConfigForm getGenConfigFormData(String tableName) {
public GenConfigForm getGenTableFormData(String tableName) {
// 查询表生成配置
GenConfig genConfig = this.getOne(
new LambdaQueryWrapper<>(GenConfig.class)
.eq(GenConfig::getTableName, tableName)
GenTable genTable = this.getOne(
new LambdaQueryWrapper<>(GenTable.class)
.eq(GenTable::getTableName, tableName)
.last("LIMIT 1")
);
// 是否有代码生成配置
boolean hasGenConfig = genConfig != null;
boolean hasGenTable = genTable != null;
// 如果没有代码生成配置则根据表的元数据生成默认配置
if (genConfig == null) {
if (genTable == null) {
TableMetaData tableMetadata = databaseMapper.getTableMetadata(tableName);
Assert.isTrue(tableMetadata != null, "未找到表元数据");
genConfig = new GenConfig();
genConfig.setTableName(tableName);
genTable = new GenTable();
genTable.setTableName(tableName);
// 表注释作为业务名称去掉表字 例如用户表 -> 用户
String tableComment = tableMetadata.getTableComment();
if (StrUtil.isNotBlank(tableComment)) {
genConfig.setBusinessName(tableComment.replace("", "").trim());
genTable.setBusinessName(tableComment.replace("", "").trim());
}
// 根据表名生成实体类名支持去除前缀 例如sys_user -> SysUser
String removePrefix = genConfig.getRemoveTablePrefix();
String removePrefix = genTable.getRemoveTablePrefix();
String processedTable = tableName;
if (StrUtil.isNotBlank(removePrefix) && StrUtil.startWith(tableName, removePrefix)) {
processedTable = StrUtil.removePrefix(tableName, removePrefix);
}
genConfig.setEntityName(StrUtil.toCamelCase(StrUtil.upperFirst(StrUtil.toCamelCase(processedTable))));
genTable.setEntityName(StrUtil.toCamelCase(StrUtil.upperFirst(StrUtil.toCamelCase(processedTable))));
genConfig.setPackageName(YouLaiBootApplication.class.getPackageName());
genConfig.setModuleName(codegenProperties.getDefaultConfig().getModuleName()); // 默认模块名
genConfig.setAuthor(codegenProperties.getDefaultConfig().getAuthor());
genTable.setPackageName(YouLaiBootApplication.class.getPackageName());
genTable.setModuleName(codegenProperties.getDefaultConfig().getModuleName()); // 默认模块名
genTable.setAuthor(codegenProperties.getDefaultConfig().getAuthor());
}
// 根据表的列 + 已经存在的字段生成配置 得到 组合后的字段生成配置
List<GenFieldConfig> genFieldConfigs = new ArrayList<>();
List<GenTableColumn> genTableColumns = new ArrayList<>();
// 获取表的列
List<ColumnMetaData> tableColumns = databaseMapper.getTableColumns(tableName);
if (CollectionUtil.isNotEmpty(tableColumns)) {
// 查询字段生成配置
List<GenFieldConfig> fieldConfigList = genFieldConfigService.list(
new LambdaQueryWrapper<GenFieldConfig>()
.eq(GenFieldConfig::getConfigId, genConfig.getId())
.orderByAsc(GenFieldConfig::getFieldSort)
List<GenTableColumn> fieldConfigList = genTableColumnService.list(
new LambdaQueryWrapper<GenTableColumn>()
.eq(GenTableColumn::getTableId, genTable.getId())
.orderByAsc(GenTableColumn::getFieldSort)
);
Integer maxSort = fieldConfigList.stream()
.map(GenFieldConfig::getFieldSort)
.map(GenTableColumn::getFieldSort)
.filter(Objects::nonNull) // 过滤掉空值
.max(Integer::compareTo)
.orElse(0);
for (ColumnMetaData tableColumn : tableColumns) {
// 根据列名获取字段生成配置
String columnName = tableColumn.getColumnName();
GenFieldConfig fieldConfig = fieldConfigList.stream()
GenTableColumn fieldConfig = fieldConfigList.stream()
.filter(item -> StrUtil.equals(item.getColumnName(), columnName))
.findFirst()
.orElseGet(() -> createDefaultFieldConfig(tableColumn));
@@ -130,16 +130,16 @@ public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig
fieldConfig.setFieldType(javaType);
}
// 如果没有代码生成配置则默认展示在列表和表单
if (!hasGenConfig) {
if (!hasGenTable) {
fieldConfig.setIsShowInList(1);
fieldConfig.setIsShowInForm(1);
}
genFieldConfigs.add(fieldConfig);
genTableColumns.add(fieldConfig);
}
}
// genFieldConfigs 按照 fieldSort 排序
genFieldConfigs = genFieldConfigs.stream().sorted(Comparator.comparing(GenFieldConfig::getFieldSort)).toList();
GenConfigForm genConfigForm = codegenConverter.toGenConfigForm(genConfig, genFieldConfigs);
// genTableColumns 按照 fieldSort 排序
genTableColumns = genTableColumns.stream().sorted(Comparator.comparing(GenTableColumn::getFieldSort)).toList();
GenConfigForm genConfigForm = codegenConverter.toGenConfigForm(genTable, genTableColumns);
genConfigForm.setFrontendAppName(codegenProperties.getFrontendAppName());
genConfigForm.setBackendAppName(codegenProperties.getBackendAppName());
@@ -153,17 +153,18 @@ public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig
* @param columnMetaData 表字段元数据
* @return
*/
private GenFieldConfig createDefaultFieldConfig(ColumnMetaData columnMetaData) {
GenFieldConfig fieldConfig = new GenFieldConfig();
private GenTableColumn createDefaultFieldConfig(ColumnMetaData columnMetaData) {
GenTableColumn fieldConfig = new GenTableColumn();
fieldConfig.setColumnName(columnMetaData.getColumnName());
fieldConfig.setColumnType(columnMetaData.getDataType());
fieldConfig.setFieldComment(columnMetaData.getColumnComment());
fieldConfig.setFieldName(StrUtil.toCamelCase(columnMetaData.getColumnName()));
fieldConfig.setIsRequired("YES".equals(columnMetaData.getIsNullable()) ? 0 : 1);
if (fieldConfig.getColumnType().equals("date")) {
String columnType = StrUtil.blankToDefault(fieldConfig.getColumnType(), "").toLowerCase();
if ("date".equals(columnType)) {
fieldConfig.setFormType(FormTypeEnum.DATE);
} else if (fieldConfig.getColumnType().equals("datetime")) {
} else if ("datetime".equals(columnType) || "timestamp".equals(columnType)) {
fieldConfig.setFormType(FormTypeEnum.DATE_TIME);
} else {
fieldConfig.setFormType(FormTypeEnum.INPUT);
@@ -181,24 +182,24 @@ public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig
*/
@Override
public void saveGenConfig(GenConfigForm formData) {
GenConfig genConfig = codegenConverter.toGenConfig(formData);
this.saveOrUpdate(genConfig);
GenTable genTable = codegenConverter.toGenTable(formData);
this.saveOrUpdate(genTable);
// 如果选择上级菜单且当前环境不是生产环境则保存菜单
Long parentMenuId = formData.getParentMenuId();
if (parentMenuId != null && !EnvEnum.PROD.getValue().equals(springProfilesActive)) {
menuService.addMenuForCodegen(parentMenuId, genConfig);
menuService.addMenuForCodegen(parentMenuId, genTable);
}
List<GenFieldConfig> genFieldConfigs = codegenConverter.toGenFieldConfig(formData.getFieldConfigs());
List<GenTableColumn> genTableColumns = codegenConverter.toGenTableColumn(formData.getFieldConfigs());
if (CollectionUtil.isEmpty(genFieldConfigs)) {
if (CollectionUtil.isEmpty(genTableColumns)) {
throw new BusinessException("字段配置不能为空");
}
genFieldConfigs.forEach(genFieldConfig -> {
genFieldConfig.setConfigId(genConfig.getId());
genTableColumns.forEach(genTableColumn -> {
genTableColumn.setTableId(genTable.getId());
});
genFieldConfigService.saveOrUpdateBatch(genFieldConfigs);
genTableColumnService.saveOrUpdateBatch(genTableColumns);
}
/**
@@ -208,15 +209,15 @@ public class GenConfigServiceImpl extends ServiceImpl<GenConfigMapper, GenConfig
*/
@Override
public void deleteGenConfig(String tableName) {
GenConfig genConfig = this.getOne(new LambdaQueryWrapper<GenConfig>()
.eq(GenConfig::getTableName, tableName));
GenTable genTable = this.getOne(new LambdaQueryWrapper<GenTable>()
.eq(GenTable::getTableName, tableName));
boolean result = this.remove(new LambdaQueryWrapper<GenConfig>()
.eq(GenConfig::getTableName, tableName)
boolean result = this.remove(new LambdaQueryWrapper<GenTable>()
.eq(GenTable::getTableName, tableName)
);
if (result) {
genFieldConfigService.remove(new LambdaQueryWrapper<GenFieldConfig>()
.eq(GenFieldConfig::getConfigId, genConfig.getId())
genTableColumnService.remove(new LambdaQueryWrapper<GenTableColumn>()
.eq(GenTableColumn::getTableId, genTable.getId())
);
}
}

View File

@@ -1,12 +1,13 @@
package com.youlai.boot.platform.websocket.controller;
import com.youlai.boot.platform.websocket.model.ChatMessage;
import com.youlai.boot.platform.websocket.dto.TextMessage;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -26,7 +27,7 @@ import java.security.Principal;
@Slf4j
public class WebsocketController {
private final SimpMessagingTemplate messagingTemplate;
private final WebSocketPublisher webSocketPublisher;
/**
@@ -58,7 +59,7 @@ public class WebsocketController {
log.info("发送人:{}; 接收人:{}", sender, receiver);
// 发送消息给指定用户,拼接后路径 /user/{receiver}/queue/greeting
messagingTemplate.convertAndSendToUser(receiver, "/queue/greeting", new ChatMessage(sender, message));
webSocketPublisher.publishToUser(receiver, WebSocketTopics.USER_QUEUE_GREETING, new TextMessage(sender, message, System.currentTimeMillis()));
}
}

View File

@@ -0,0 +1,15 @@
package com.youlai.boot.platform.websocket.dto;
import lombok.Data;
@Data
public class DictChangeEvent {
private String dictCode;
private long timestamp;
public DictChangeEvent(String dictCode) {
this.dictCode = dictCode;
this.timestamp = System.currentTimeMillis();
}
}

View File

@@ -1,25 +1,15 @@
package com.youlai.boot.platform.websocket.model;
package com.youlai.boot.platform.websocket.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 系统消息体
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ChatMessage {
public class TextMessage {
/**
* 发送者
*/
private String sender;
/**
* 消息内容
*/
private String content;
private Long timestamp;
}

View File

@@ -0,0 +1,61 @@
package com.youlai.boot.platform.websocket.publisher;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class WebSocketPublisher {
private SimpMessagingTemplate messagingTemplate;
private final ObjectMapper objectMapper;
@Autowired(required = false)
public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
}
public void publish(String destination, Object payload) {
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送消息: destination={}", destination);
return;
}
try {
Object body = serializeIfNeeded(payload);
messagingTemplate.convertAndSend(destination, body);
} catch (Exception e) {
log.error("发送消息失败: destination={}", destination, e);
}
}
public void publishToUser(String username, String destination, Object payload) {
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送用户消息: username={}, destination={}", username, destination);
return;
}
try {
Object body = serializeIfNeeded(payload);
messagingTemplate.convertAndSendToUser(username, destination, body);
} catch (Exception e) {
log.error("发送用户消息失败: username={}, destination={}", username, destination, e);
}
}
private Object serializeIfNeeded(Object payload) throws JsonProcessingException {
if (payload == null) {
return null;
}
if (payload instanceof String || payload instanceof Number || payload instanceof Boolean) {
return payload;
}
return objectMapper.writeValueAsString(payload);
}
}

View File

@@ -1,22 +1,15 @@
package com.youlai.boot.platform.websocket.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.youlai.boot.system.model.dto.DictEventDTO;
import com.youlai.boot.platform.websocket.dto.DictChangeEvent;
import com.youlai.boot.platform.websocket.dto.TextMessage;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.session.UserSessionRegistry;
import com.youlai.boot.platform.websocket.service.WebSocketService;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* WebSocket 服务实现类
@@ -33,39 +26,12 @@ import java.util.stream.Collectors;
@Slf4j
public class WebSocketServiceImpl implements WebSocketService {
// ==================== 在线用户管理 ====================
/**
* 用户在线会话映射表
* Key: 用户名
* Value: 该用户的所有会话 ID 集合(支持多设备登录)
*/
private final Map<String, Set<String>> userSessionsMap = new ConcurrentHashMap<>();
private final UserSessionRegistry userSessionRegistry;
private final WebSocketPublisher webSocketPublisher;
/**
* 会话详情映射表
* Key: 会话 ID
* Value: 会话详细信息
*/
private final Map<String, SessionInfo> sessionDetailsMap = new ConcurrentHashMap<>();
// ==================== 依赖注入 ====================
private SimpMessagingTemplate messagingTemplate;
private final ObjectMapper objectMapper;
@Autowired
public WebSocketServiceImpl(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 延迟注入 SimpMessagingTemplate避免循环依赖
*/
@Autowired(required = false)
public void setMessagingTemplate(SimpMessagingTemplate messagingTemplate) {
this.messagingTemplate = messagingTemplate;
log.info("✓ WebSocket 消息模板已初始化");
public WebSocketServiceImpl(UserSessionRegistry userSessionRegistry, WebSocketPublisher webSocketPublisher) {
this.userSessionRegistry = userSessionRegistry;
this.webSocketPublisher = webSocketPublisher;
}
// ==================== 用户在线状态管理 ====================
@@ -88,16 +54,10 @@ public class WebSocketServiceImpl implements WebSocketService {
return;
}
// 添加会话到用户的会话集合中(支持多设备登录)
userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet())
.add(sessionId);
userSessionRegistry.userConnected(username, sessionId);
// 保存会话详情
SessionInfo sessionInfo = new SessionInfo(username, sessionId, System.currentTimeMillis());
sessionDetailsMap.put(sessionId, sessionInfo);
int sessionCount = userSessionsMap.get(username).size();
int totalOnlineUsers = userSessionsMap.size();
int sessionCount = userSessionRegistry.getUserSessionCount(username);
int totalOnlineUsers = userSessionRegistry.getOnlineUserCount();
log.info("✓ 用户[{}]会话[{}]上线(该用户共 {} 个会话,系统总在线用户数:{}",
username, sessionId, sessionCount, totalOnlineUsers);
@@ -117,20 +77,9 @@ public class WebSocketServiceImpl implements WebSocketService {
return;
}
// 获取该用户的所有会话
Set<String> sessions = userSessionsMap.get(username);
if (sessions == null || sessions.isEmpty()) {
log.warn("用户[{}]下线:未找到会话记录", username);
return;
}
userSessionRegistry.userDisconnected(username);
// 移除所有会话详情(通常一次只断开一个会话,但这里做全量清理)
sessions.forEach(sessionDetailsMap::remove);
// 移除用户的会话记录
userSessionsMap.remove(username);
int totalOnlineUsers = userSessionsMap.size();
int totalOnlineUsers = userSessionRegistry.getOnlineUserCount();
log.info("✓ 用户[{}]下线(系统总在线用户数:{}", username, totalOnlineUsers);
// 广播在线用户数变更
@@ -143,29 +92,8 @@ public class WebSocketServiceImpl implements WebSocketService {
* @param sessionId 会话 ID
*/
public void removeSession(String sessionId) {
SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId);
if (sessionInfo == null) {
return;
}
String username = sessionInfo.getUsername();
Set<String> sessions = userSessionsMap.get(username);
if (sessions != null) {
sessions.remove(sessionId);
// 如果该用户没有其他会话了,移除用户记录
if (sessions.isEmpty()) {
userSessionsMap.remove(username);
log.info("✓ 用户[{}]最后一个会话[{}]下线", username, sessionId);
} else {
log.info("✓ 用户[{}]会话[{}]下线(还剩 {} 个会话)",
username, sessionId, sessions.size());
}
// 广播在线用户数变更
broadcastOnlineUserCount();
}
userSessionRegistry.removeSession(sessionId);
broadcastOnlineUserCount();
}
/**
@@ -173,23 +101,8 @@ public class WebSocketServiceImpl implements WebSocketService {
*
* @return 在线用户信息列表
*/
public List<OnlineUserDTO> getOnlineUsers() {
return userSessionsMap.entrySet().stream()
.map(entry -> {
String username = entry.getKey();
Set<String> sessions = entry.getValue();
// 获取该用户最早的登录时间
long earliestLoginTime = sessions.stream()
.map(sessionDetailsMap::get)
.filter(info -> info != null)
.mapToLong(SessionInfo::getConnectTime)
.min()
.orElse(System.currentTimeMillis());
return new OnlineUserDTO(username, sessions.size(), earliestLoginTime);
})
.collect(Collectors.toList());
public List<UserSessionRegistry.OnlineUserDto> getOnlineUsers() {
return userSessionRegistry.getOnlineUsers();
}
/**
@@ -198,7 +111,7 @@ public class WebSocketServiceImpl implements WebSocketService {
* @return 在线用户数(不是会话数)
*/
public int getOnlineUserCount() {
return userSessionsMap.size();
return userSessionRegistry.getOnlineUserCount();
}
/**
@@ -207,7 +120,7 @@ public class WebSocketServiceImpl implements WebSocketService {
* @return 所有在线会话的总数
*/
public int getTotalSessionCount() {
return sessionDetailsMap.size();
return userSessionRegistry.getTotalSessionCount();
}
/**
@@ -217,8 +130,7 @@ public class WebSocketServiceImpl implements WebSocketService {
* @return 是否在线
*/
public boolean isUserOnline(String username) {
Set<String> sessions = userSessionsMap.get(username);
return sessions != null && !sessions.isEmpty();
return userSessionRegistry.isUserOnline(username);
}
/**
@@ -228,8 +140,7 @@ public class WebSocketServiceImpl implements WebSocketService {
* @return 会话数量
*/
public int getUserSessionCount(String username) {
Set<String> sessions = userSessionsMap.get(username);
return sessions != null ? sessions.size() : 0;
return userSessionRegistry.getUserSessionCount(username);
}
/**
@@ -246,18 +157,9 @@ public class WebSocketServiceImpl implements WebSocketService {
* 广播在线用户数量变更(内部方法)
*/
private void broadcastOnlineUserCount() {
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送在线用户数量");
return;
}
try {
int count = getOnlineUserCount();
messagingTemplate.convertAndSend("/topic/online-count", count);
log.debug("✓ 已广播在线用户数量: {}", count);
} catch (Exception e) {
log.error("广播在线用户数量失败", e);
}
int count = getOnlineUserCount();
webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count);
log.debug("✓ 已广播在线用户数量: {}", count);
}
// ==================== 消息推送功能 ====================
@@ -274,30 +176,9 @@ public class WebSocketServiceImpl implements WebSocketService {
return;
}
DictEventDTO event = new DictEventDTO(dictCode);
sendDictChangeEvent(event);
}
/**
* 发送字典变更事件
*
* @param event 字典事件
*/
private void sendDictChangeEvent(DictEventDTO event) {
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送字典更新通知");
return;
}
try {
String message = objectMapper.writeValueAsString(event);
messagingTemplate.convertAndSend("/topic/dict", message);
log.info("✓ 已广播字典变更通知: dictCode={}", event.getDictCode());
} catch (JsonProcessingException e) {
log.error("字典事件序列化失败: dictCode={}", event.getDictCode(), e);
} catch (Exception e) {
log.error("发送字典变更通知失败: dictCode={}", event.getDictCode(), e);
}
DictChangeEvent event = new DictChangeEvent(dictCode);
webSocketPublisher.publish(WebSocketTopics.TOPIC_DICT, event);
log.info("✓ 已广播字典变更通知: dictCode={}", dictCode);
}
/**
@@ -318,20 +199,8 @@ public class WebSocketServiceImpl implements WebSocketService {
return;
}
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送用户消息");
return;
}
try {
String messageJson = objectMapper.writeValueAsString(message);
messagingTemplate.convertAndSendToUser(username, "/queue/messages", messageJson);
log.info("✓ 已向用户[{}]发送通知", username);
} catch (JsonProcessingException e) {
log.error("消息序列化失败: username={}", username, e);
} catch (Exception e) {
log.error("向用户[{}]发送通知失败", username, e);
}
webSocketPublisher.publishToUser(username, WebSocketTopics.USER_QUEUE_MESSAGES, message);
log.info("✓ 已向用户[{}]发送通知", username);
}
/**
@@ -345,71 +214,8 @@ public class WebSocketServiceImpl implements WebSocketService {
return;
}
if (messagingTemplate == null) {
log.warn("消息模板尚未初始化,无法发送广播消息");
return;
}
try {
SystemMessage systemMessage = new SystemMessage(
"系统通知",
message,
System.currentTimeMillis()
);
String messageJson = objectMapper.writeValueAsString(systemMessage);
messagingTemplate.convertAndSend("/topic/public", messageJson);
log.info("✓ 已广播系统消息: {}", message);
} catch (JsonProcessingException e) {
log.error("系统消息序列化失败", e);
} catch (Exception e) {
log.error("广播系统消息失败", e);
}
}
// ==================== 内部数据类 ====================
/**
* 会话信息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
private static class SessionInfo {
/** 用户名 */
private String username;
/** 会话 ID */
private String sessionId;
/** 连接时间戳 */
private long connectTime;
}
/**
* 在线用户 DTO
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class OnlineUserDTO {
/** 用户名 */
private String username;
/** 会话数量 */
private int sessionCount;
/** 首次登录时间 */
private long loginTime;
}
/**
* 系统消息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class SystemMessage {
/** 发送者 */
private String sender;
/** 消息内容 */
private String content;
/** 时间戳 */
private long timestamp;
TextMessage systemMessage = new TextMessage("系统通知", message, System.currentTimeMillis());
webSocketPublisher.publish(WebSocketTopics.TOPIC_PUBLIC, systemMessage);
log.info("✓ 已广播系统消息: {}", message);
}
}

View File

@@ -0,0 +1,103 @@
package com.youlai.boot.platform.websocket.session;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Component
public class UserSessionRegistry {
private final Map<String, Set<String>> userSessionsMap = new ConcurrentHashMap<>();
private final Map<String, SessionInfo> sessionDetailsMap = new ConcurrentHashMap<>();
public void userConnected(String username, String sessionId) {
userSessionsMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(sessionId);
sessionDetailsMap.put(sessionId, new SessionInfo(username, sessionId, System.currentTimeMillis()));
}
public void userDisconnected(String username) {
Set<String> sessions = userSessionsMap.remove(username);
if (sessions == null) {
return;
}
sessions.forEach(sessionDetailsMap::remove);
}
public void removeSession(String sessionId) {
SessionInfo sessionInfo = sessionDetailsMap.remove(sessionId);
if (sessionInfo == null) {
return;
}
String username = sessionInfo.getUsername();
Set<String> sessions = userSessionsMap.get(username);
if (sessions == null) {
return;
}
sessions.remove(sessionId);
if (sessions.isEmpty()) {
userSessionsMap.remove(username);
}
}
public int getOnlineUserCount() {
return userSessionsMap.size();
}
public int getUserSessionCount(String username) {
Set<String> sessions = userSessionsMap.get(username);
return sessions != null ? sessions.size() : 0;
}
public int getTotalSessionCount() {
return sessionDetailsMap.size();
}
public boolean isUserOnline(String username) {
Set<String> sessions = userSessionsMap.get(username);
return sessions != null && !sessions.isEmpty();
}
public List<OnlineUserDto> getOnlineUsers() {
return userSessionsMap.entrySet().stream()
.map(entry -> {
String username = entry.getKey();
Set<String> sessions = entry.getValue();
long earliestLoginTime = sessions.stream()
.map(sessionDetailsMap::get)
.filter(info -> info != null)
.mapToLong(SessionInfo::getConnectTime)
.min()
.orElse(System.currentTimeMillis());
return new OnlineUserDto(username, sessions.size(), earliestLoginTime);
})
.collect(Collectors.toList());
}
@Data
@AllArgsConstructor
@NoArgsConstructor
private static class SessionInfo {
private String username;
private String sessionId;
private long connectTime;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class OnlineUserDto {
private String username;
private int sessionCount;
private long loginTime;
}
}

View File

@@ -0,0 +1,15 @@
package com.youlai.boot.platform.websocket.topic;
public final class WebSocketTopics {
private WebSocketTopics() {
}
public static final String TOPIC_DICT = "/topic/dict";
public static final String TOPIC_ONLINE_COUNT = "/topic/online-count";
public static final String TOPIC_PUBLIC = "/topic/public";
public static final String USER_QUEUE_MESSAGES = "/queue/messages";
public static final String USER_QUEUE_MESSAGE = "/queue/message";
public static final String USER_QUEUE_GREETING = "/queue/greeting";
}

View File

@@ -1,22 +1,28 @@
package com.youlai.boot.plugin.mybatis;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/**
* mybatis-plus 字段自动填充
* <p>
* 支持自动填充创建时间、更新时间
* </p>
*
* @author haoxr
* @author Ray.Hao
* @since 2022/10/14
*/
@Component
@RequiredArgsConstructor
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 新增填充创建时间
* 新增填充创建时间、更新时间
*
* @param metaObject 元数据
*/

View File

@@ -2,38 +2,45 @@ package com.youlai.boot.security.filter;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.core.web.WebResponseHelper;
import com.youlai.boot.core.web.WebResponseWriter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StreamUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
* 图形验证码校验过滤器
*
* @author haoxr
* @since 2022/10/1
*/
public class CaptchaValidationFilter extends OncePerRequestFilter {
private static final RequestMatcher LOGIN_PATH_REQUEST_MATCHER = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST,SecurityConstants.LOGIN_PATH);
private static final RequestMatcher LOGIN_PATH_REQUEST_MATCHER = PathPatternRequestMatcher.withDefaults()
.matcher(HttpMethod.POST, SecurityConstants.LOGIN_PATH);
public static final String CAPTCHA_CODE_PARAM_NAME = "captchaCode";
public static final String CAPTCHA_KEY_PARAM_NAME = "captchaKey";
public static final String CAPTCHA_ID_PARAM_NAME = "captchaId";
private final RedisTemplate<String, Object> redisTemplate;
private final CodeGenerator codeGenerator;
public CaptchaValidationFilter(RedisTemplate<String, Object> redisTemplate, CodeGenerator codeGenerator) {
@@ -41,37 +48,111 @@ public class CaptchaValidationFilter extends OncePerRequestFilter {
this.codeGenerator = codeGenerator;
}
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// 检验登录接口的验证码
if (LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
// 请求中的验证码
String captchaCode = request.getParameter(CAPTCHA_CODE_PARAM_NAME);
// TODO 兼容没有验证码的版本(线上请移除这个判断)
if (StrUtil.isBlank(captchaCode)) {
chain.doFilter(request, response);
return;
}
// 缓存中的验证码
String verifyCodeKey = request.getParameter(CAPTCHA_KEY_PARAM_NAME);
String cacheVerifyCode = (String) redisTemplate.opsForValue().get(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, verifyCodeKey)
);
if (cacheVerifyCode == null) {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_EXPIRED);
} else {
// 验证码比对
if (codeGenerator.verify(cacheVerifyCode, captchaCode)) {
chain.doFilter(request, response);
} else {
WebResponseHelper.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
}
}
} else {
// 非登录接口放行
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 非登录接口直接放行
if (!LOGIN_PATH_REQUEST_MATCHER.matches(request)) {
chain.doFilter(request, response);
return;
}
// 仅支持 JSON 登录
String contentType = request.getContentType();
if (contentType == null || !contentType.contains(MediaType.APPLICATION_JSON_VALUE)) {
WebResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
// 包装请求,确保下游还能读取 body
ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
byte[] bodyBytes = StreamUtils.copyToByteArray(requestWrapper.getInputStream());
String body = new String(bodyBytes, StandardCharsets.UTF_8);
String captchaCode = null;
String captchaId = null;
if (StrUtil.isNotBlank(body)) {
JSONObject jsonObject = JSONUtil.parseObj(body);
captchaCode = jsonObject.getStr(CAPTCHA_CODE_PARAM_NAME);
captchaId = jsonObject.getStr(CAPTCHA_ID_PARAM_NAME);
}
if (StrUtil.isBlank(captchaCode) || StrUtil.isBlank(captchaId)) {
WebResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
return;
}
String cacheVerifyCode = (String) redisTemplate.opsForValue().get(
StrUtil.format(RedisConstants.Captcha.IMAGE_CODE, captchaId)
);
if (cacheVerifyCode == null) {
WebResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_EXPIRED);
return;
}
if (codeGenerator.verify(cacheVerifyCode, captchaCode)) {
HttpServletRequest repeatableRequest = new RepeatableReadRequestWrapper(requestWrapper, bodyBytes);
chain.doFilter(repeatableRequest, response);
} else {
WebResponseWriter.writeError(response, ResultCode.USER_VERIFICATION_CODE_ERROR);
}
}
/**
* Simple wrapper to allow repeated reads of the request body after we've parsed it here.
*/
private static class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
private final byte[] cachedBody;
RepeatableReadRequestWrapper(HttpServletRequest request, byte[] cachedBody) {
super(request);
this.cachedBody = cachedBody != null ? cachedBody : new byte[0];
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody);
return new ServletInputStream() {
@Override
public int read() {
return bais.read();
}
@Override
public boolean isFinished() {
return bais.available() == 0;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(jakarta.servlet.ReadListener readListener) {
// no-op
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
@Override
public int getContentLength() {
return cachedBody.length;
}
@Override
public long getContentLengthLong() {
return cachedBody.length;
}
}
}

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.security.filter;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.core.web.WebResponseHelper;
import com.youlai.boot.core.web.WebResponseWriter;
import com.youlai.boot.security.token.TokenManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -52,7 +52,7 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
// 执行令牌有效性检查(包含密码学验签和过期时间验证)
boolean isValidToken = tokenManager.validateToken(rawToken);
if (!isValidToken) {
WebResponseHelper.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
WebResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
}
@@ -63,7 +63,7 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
} catch (Exception ex) {
// 安全上下文清除保障(防止上下文残留)
SecurityContextHolder.clearContext();
WebResponseHelper.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
WebResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
return;
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.security.handler;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.core.web.WebResponseHelper;
import com.youlai.boot.core.web.WebResponseWriter;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
@@ -20,7 +20,7 @@ public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
WebResponseHelper.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
WebResponseWriter.writeError(response, ResultCode.ACCESS_UNAUTHORIZED);
}
}

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.security.handler;
import com.youlai.boot.core.web.ResultCode;
import com.youlai.boot.core.web.WebResponseHelper;
import com.youlai.boot.core.web.WebResponseWriter;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
@@ -32,13 +32,13 @@ public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
if (authException instanceof BadCredentialsException) {
// 用户名或密码错误
WebResponseHelper.writeError(response, ResultCode.USER_PASSWORD_ERROR);
WebResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR);
} else if(authException instanceof InsufficientAuthenticationException){
// 请求头缺失Authorization、Token格式错误、Token过期、签名验证失败
WebResponseHelper.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
WebResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID);
} else {
// 其他未明确处理的认证异常(如账户被锁定、账户禁用等)
WebResponseHelper.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
WebResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage());
}
}
}

View File

@@ -3,6 +3,7 @@ package com.youlai.boot.security.model;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.ObjectUtil;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.security.model.UserAuthInfo;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
@@ -64,9 +65,9 @@ public class SysUserDetails implements UserDetails {
/**
* 构造函数:根据用户认证信息初始化用户详情对象
*
* @param user 用户认证信息对象 {@link UserAuthCredentials}
* @param user 用户认证信息对象 {@link UserAuthInfo}
*/
public SysUserDetails(UserAuthCredentials user) {
public SysUserDetails(UserAuthInfo user) {
this.userId = user.getUserId();
this.username = user.getUsername();
this.password = user.getPassword();

View File

@@ -1,16 +1,20 @@
package com.youlai.boot.security.model;
import lombok.Data;
import java.util.Set;
/**
* 用户认证凭证信息
* 用户认证信息
* <p>
* 用于登录认证过程中的用户信息承载包含用户名密码状态角色等与认证/授权相关的数据
* </p>
*
* @author Ray.Hao
* @since 2022/10/22
* @since 2025/12/16
*/
@Data
public class UserAuthCredentials {
public class UserAuthInfo {
/**
* 用户ID
@@ -33,25 +37,22 @@ public class UserAuthCredentials {
private Long deptId;
/**
* 用户密码
* 密码加密后
*/
private String password;
/**
* 状态1:启用0:禁用
* 状态1:启用 其它:禁用
*/
private Integer status;
/**
* 用户所属的角色集合
* 角色集合
*/
private Set<String> roles;
/**
* 数据权限范围用于控制用户可以访问的数据级别
*
* @see com.youlai.boot.common.enums.DataScopeEnum
* 数据权限范围
*/
private Integer dataScope;
}

View File

@@ -6,7 +6,7 @@ import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.security.exception.CaptchaValidationException;
import com.youlai.boot.security.model.SmsAuthenticationToken;
import com.youlai.boot.security.model.SysUserDetails;
import com.youlai.boot.security.model.UserAuthCredentials;
import com.youlai.boot.security.model.UserAuthInfo;
import com.youlai.boot.system.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
@@ -16,7 +16,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 短信验证码认证 Provider
*
@@ -50,14 +49,14 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
String inputVerifyCode = (String) authentication.getCredentials();
// 根据手机号获取用户信息
UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByMobile(mobile);
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(mobile);
if (userAuthCredentials == null) {
if (userAuthInfo == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthCredentials.getStatus(), 1)) {
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
throw new DisabledException("用户已被禁用");
}
@@ -73,7 +72,7 @@ public class SmsAuthenticationProvider implements AuthenticationProvider {
}
// 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthCredentials);
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
// 创建已认证的 SmsAuthenticationToken
return SmsAuthenticationToken.authenticated(

View File

@@ -5,7 +5,7 @@ import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.security.model.SysUserDetails;
import com.youlai.boot.security.model.UserAuthCredentials;
import com.youlai.boot.security.model.UserAuthInfo;
import com.youlai.boot.security.model.WxMiniAppCodeAuthenticationToken;
import com.youlai.boot.system.service.UserService;
import lombok.extern.slf4j.Slf4j;
@@ -17,7 +17,6 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
/**
* 微信小程序Code认证Provider
*
@@ -30,13 +29,11 @@ public class WxMiniAppCodeAuthenticationProvider implements AuthenticationProvid
private final UserService userService;
private final WxMaService wxMaService;
public WxMiniAppCodeAuthenticationProvider(UserService userService, WxMaService wxMaService) {
this.userService = userService;
this.wxMaService = wxMaService;
}
/**
* 微信认证逻辑,参考 Spring Security 认证密码校验流程
*
@@ -63,26 +60,26 @@ public class WxMiniAppCodeAuthenticationProvider implements AuthenticationProvid
}
// 根据微信 OpenID 查询用户信息
UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByOpenId(openId);
UserAuthInfo userAuthInfo = userService.getAuthInfoByOpenId(openId);
if (userAuthCredentials == null) {
if (userAuthInfo == null) {
// 用户不存在则注册
userService.registerOrBindWechatUser(openId);
// 再次查询用户信息,确保用户注册成功
userAuthCredentials = userService.getAuthCredentialsByOpenId(openId);
if (userAuthCredentials == null) {
userAuthInfo = userService.getAuthInfoByOpenId(openId);
if (userAuthInfo == null) {
throw new UsernameNotFoundException("用户注册失败,请稍后重试");
}
}
// 检查用户状态是否有效
if (ObjectUtil.notEqual(userAuthCredentials.getStatus(), 1)) {
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
throw new DisabledException("用户已被禁用");
}
// 构建认证后的用户详情信息
SysUserDetails userDetails = new SysUserDetails(userAuthCredentials);
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
// 创建已认证的Token
return WxMiniAppCodeAuthenticationToken.authenticated(

View File

@@ -6,7 +6,7 @@ import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.youlai.boot.security.model.SysUserDetails;
import com.youlai.boot.security.model.UserAuthCredentials;
import com.youlai.boot.security.model.UserAuthInfo;
import com.youlai.boot.security.model.WxMiniAppPhoneAuthenticationToken;
import com.youlai.boot.system.service.UserService;
import lombok.extern.slf4j.Slf4j;
@@ -78,28 +78,28 @@ public class WxMiniAppPhoneAuthenticationProvider implements AuthenticationProvi
String phoneNumber = phoneNumberInfo.getPhoneNumber();
// 3. 根据手机号查询用户,不存在则创建新用户
UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByMobile(phoneNumber);
UserAuthInfo userAuthInfo = userService.getAuthInfoByMobile(phoneNumber);
if (userAuthCredentials == null) {
if (userAuthInfo == null) {
// 用户不存在,注册新用户
boolean registered = userService.registerUserByMobileAndOpenId(phoneNumber, openId);
if (!registered) {
throw new UsernameNotFoundException("用户注册失败");
}
// 重新获取用户信息
userAuthCredentials = userService.getAuthCredentialsByMobile(phoneNumber);
userAuthInfo = userService.getAuthInfoByMobile(phoneNumber);
} else {
// 用户存在绑定openId如果未绑定
userService.bindUserOpenId(userAuthCredentials.getUserId(), openId);
userService.bindUserOpenId(userAuthInfo.getUserId(), openId);
}
// 4. 检查用户状态
if (ObjectUtil.notEqual(userAuthCredentials.getStatus(), 1)) {
if (ObjectUtil.notEqual(userAuthInfo.getStatus(), 1)) {
throw new DisabledException("用户已被禁用");
}
// 5. 构建认证后的用户详情
SysUserDetails userDetails = new SysUserDetails(userAuthCredentials);
SysUserDetails userDetails = new SysUserDetails(userAuthInfo);
// 6. 创建已认证的Token
return WxMiniAppPhoneAuthenticationToken.authenticated(

View File

@@ -15,8 +15,8 @@ import java.util.*;
/**
* SpringSecurity 权限校验
*
* @author haoxr
* @since 2022/2/22
* @author Ray.Hao
* @since 0.0.1
*/
@Component("ss")
@RequiredArgsConstructor
@@ -73,15 +73,16 @@ public class PermissionService {
* @return 角色权限列表
*/
public Set<String> getRolePermsFormCache(Set<String> roleCodes) {
// 检查输入是否为空
if (CollectionUtil.isEmpty(roleCodes)) {
return Collections.emptySet();
}
// 构建缓存Key
String cacheKey = RedisConstants.System.ROLE_PERMS;
Set<String> perms = new HashSet<>();
// 从缓存中一次性获取所有角色的权限
Collection<Object> roleCodesAsObjects = new ArrayList<>(roleCodes);
List<Object> rolePermsList = redisTemplate.opsForHash().multiGet(RedisConstants.System.ROLE_PERMS, roleCodesAsObjects);
List<Object> rolePermsList = redisTemplate.opsForHash().multiGet(cacheKey, roleCodesAsObjects);
for (Object rolePermsObj : rolePermsList) {
if (rolePermsObj instanceof Set) {

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.security.service;
import com.youlai.boot.security.model.SysUserDetails;
import com.youlai.boot.security.model.UserAuthCredentials;
import com.youlai.boot.security.model.UserAuthInfo;
import com.youlai.boot.system.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -33,11 +33,11 @@ public class SysUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
try {
UserAuthCredentials userAuthCredentials = userService.getAuthCredentialsByUsername(username);
if (userAuthCredentials == null) {
UserAuthInfo userAuthInfo = userService.getAuthInfoByUsername(username);
if (userAuthInfo == null) {
throw new UsernameNotFoundException(username);
}
return new SysUserDetails(userAuthCredentials);
return new SysUserDetails(userAuthInfo);
} catch (Exception e) {
// 记录异常日志
log.error("认证异常:{}", e.getMessage());

View File

@@ -132,49 +132,46 @@ public class JwtTokenManager implements TokenManager {
* @return 是否有效
*/
private boolean validateToken(String token, boolean validateRefreshToken) {
try {
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
JWT jwt = JWTUtil.parseToken(token);
// 检查 Token 是否有效(验签 + 是否过期)
boolean isValid = jwt.setKey(secretKey).validate(0);
if (isValid) {
JSONObject payloads = jwt.getPayloads();
// 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用)
String jti = payloads.getStr(JWTPayload.JWT_ID);
if (validateRefreshToken) {
//刷新token需要校验token类别
boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE);
if (!isRefreshToken) {
return false;
}
}
// 2. 校验安全版本号(用于按用户维度失效历史 Token
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) {
// 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本
Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION);
int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0;
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0;
// 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效
if (tokenVersion < currentVersion) {
return false;
}
}
// 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效
if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) {
if (isValid) {
JSONObject payloads = jwt.getPayloads();
// 1. 校验刷新令牌类型(仅在校验刷新令牌场景启用)
String jti = payloads.getStr(JWTPayload.JWT_ID);
if (validateRefreshToken) {
//刷新token需要校验token类别
boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE);
if (!isRefreshToken) {
return false;
}
}
return isValid;
} catch (Exception gitignore) {
// token 验证
// 2. 校验安全版本号(用于按用户维度失效历史 Token
// 场景示例:用户修改密码、被管理员强制下线、手动“踢所有端”后,将用户安全版本号 +1旧版本 Token 全部失效
Long userId = payloads.getLong(JwtClaimConstants.USER_ID);
if (userId != null) {
// 老版本 Token 可能没有 SECURITY_VERSION 声明,视为 0 版本
Integer tokenVersionRaw = payloads.getInt(JwtClaimConstants.SECURITY_VERSION);
int tokenVersion = tokenVersionRaw != null ? tokenVersionRaw : 0;
String versionKey = StrUtil.format(RedisConstants.Auth.USER_SECURITY_VERSION, userId);
Integer currentVersionRaw = (Integer) redisTemplate.opsForValue().get(versionKey);
int currentVersion = currentVersionRaw != null ? currentVersionRaw : 0;
// 如果当前版本号比 Token 携带的版本号新,则认为该 Token 已失效
if (tokenVersion < currentVersion) {
return false;
}
}
// 3. 判断是否在黑名单中,如果在,则返回 false 标识Token无效
// 场景示例:单点退出登录、后台手动注销某个会话、封禁账号后立即阻断当前 Token 等
if (Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.BLACKLIST_TOKEN, jti)))) {
return false;
}
}
return false;
return isValid;
}
/**
@@ -210,7 +207,6 @@ public class JwtTokenManager implements TokenManager {
// 永不过期的Token永久加入黑名单
redisTemplate.opsForValue().set(blacklistTokenKey, Boolean.TRUE);
}
;
}
/**

View File

@@ -165,8 +165,8 @@ public class RedisTokenManager implements TokenManager {
*/
@Override
public void invalidateToken(String token) {
OnlineUser onlineUser = (OnlineUser) redisTemplate.opsForValue().get(formatTokenKey(token));
if (onlineUser != null) {
Object value = redisTemplate.opsForValue().get(formatTokenKey(token));
if (value instanceof OnlineUser onlineUser) {
Long userId = onlineUser.getUserId();
invalidateUserSessions(userId);
}
@@ -186,20 +186,18 @@ public class RedisTokenManager implements TokenManager {
// 1. 删除访问令牌相关
String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId);
Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
Optional.of(accessTokenValue)
.map(String.class::cast)
.ifPresent(accessToken -> redisTemplate.delete(formatTokenKey(accessToken)));
if (accessTokenValue instanceof String accessToken) {
redisTemplate.delete(formatTokenKey(accessToken));
}
// 无论是否存在访问令牌映射,都尝试删除 userAccessKey
redisTemplate.delete(userAccessKey);
// 2. 删除刷新令牌相关
String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId);
Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey);
Optional.of(refreshTokenValue)
.map(String.class::cast)
.ifPresent(refreshToken ->
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken))
);
if (refreshTokenValue instanceof String refreshToken) {
redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken));
}
// 同样清理 userRefreshKey 本身
redisTemplate.delete(userRefreshKey);
}
@@ -237,9 +235,9 @@ public class RedisTokenManager implements TokenManager {
// 单设备登录控制,删除旧的访问令牌
if (!allowMultiLogin) {
Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey);
Optional.of(oldAccessTokenValue)
.map(String.class::cast)
.ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken)));
if (oldAccessTokenValue instanceof String oldAccessToken) {
redisTemplate.delete(formatTokenKey(oldAccessToken));
}
}
// 存储访问令牌映射用户ID -> 访问令牌),用于单设备登录控制删除旧的访问令牌和刷新令牌时删除旧令牌
setRedisValue(userAccessKey, accessToken, securityProperties.getSession().getAccessTokenTimeToLive());

View File

@@ -113,7 +113,7 @@ public class SecurityUtils {
*
* @return Token 字符串
*/
public static String getTokenFromRequest() {
public static String getAccessToken() {
ServletRequestAttributes servletRequestAttributes = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes());
if(Objects.isNull(servletRequestAttributes)) {
return null;

View File

@@ -7,7 +7,7 @@ import com.youlai.boot.core.web.Result;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.system.model.form.ConfigForm;
import com.youlai.boot.system.model.query.ConfigPageQuery;
import com.youlai.boot.system.model.vo.ConfigVO;
import com.youlai.boot.system.model.vo.ConfigVo;
import com.youlai.boot.system.service.ConfigService;
import io.swagger.v3.oas.annotations.Parameter;
import jakarta.validation.Valid;
@@ -29,23 +29,23 @@ import org.springframework.security.access.prepost.PreAuthorize;
@RestController
@RequiredArgsConstructor
@Tag(name = "08.系统配置")
@RequestMapping("/api/v1/config")
@RequestMapping("/api/v1/configs")
public class ConfigController {
private final ConfigService configService;
@Operation(summary = "系统配置分页列表")
@GetMapping("/page")
@PreAuthorize("@ss.hasPerm('sys:config:query')")
@PreAuthorize("@ss.hasPerm('sys:config:list')")
@Log( value = "系统配置分页列表",module = LogModuleEnum.SETTING)
public PageResult<ConfigVO> page(@ParameterObject ConfigPageQuery configPageQuery) {
IPage<ConfigVO> result = configService.page(configPageQuery);
public PageResult<ConfigVo> page(@ParameterObject ConfigPageQuery configPageQuery) {
IPage<ConfigVo> result = configService.page(configPageQuery);
return PageResult.success(result);
}
@Operation(summary = "新增系统配置")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:config:add')")
@PreAuthorize("@ss.hasPerm('sys:config:create')")
@Log( value = "新增系统配置",module = LogModuleEnum.SETTING)
public Result<?> save(@RequestBody @Valid ConfigForm configForm) {
return Result.judge(configService.save(configForm));

View File

@@ -6,7 +6,7 @@ import com.youlai.boot.common.model.Option;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.form.DeptForm;
import com.youlai.boot.system.model.query.DeptQuery;
import com.youlai.boot.system.model.vo.DeptVO;
import com.youlai.boot.system.model.vo.DeptVo;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.system.service.DeptService;
import io.swagger.v3.oas.annotations.Parameter;
@@ -27,7 +27,7 @@ import java.util.List;
*/
@Tag(name = "05.部门接口")
@RestController
@RequestMapping("/api/v1/dept")
@RequestMapping("/api/v1/depts")
@RequiredArgsConstructor
public class DeptController {
@@ -36,10 +36,10 @@ public class DeptController {
@Operation(summary = "部门列表")
@GetMapping
@Log( value = "部门列表",module = LogModuleEnum.DEPT)
public Result<List<DeptVO>> getDeptList(
public Result<List<DeptVo>> getDeptList(
DeptQuery queryParams
) {
List<DeptVO> list = deptService.getDeptList(queryParams);
List<DeptVo> list = deptService.getDeptList(queryParams);
return Result.success(list);
}
@@ -52,7 +52,7 @@ public class DeptController {
@Operation(summary = "新增部门")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:dept:add')")
@PreAuthorize("@ss.hasPerm('sys:dept:create')")
@RepeatSubmit
public Result<?> saveDept(
@Valid @RequestBody DeptForm formData
@@ -72,7 +72,7 @@ public class DeptController {
@Operation(summary = "修改部门")
@PutMapping(value = "/{deptId}")
@PreAuthorize("@ss.hasPerm('sys:dept:edit')")
@PreAuthorize("@ss.hasPerm('sys:dept:update')")
public Result<?> updateDept(
@PathVariable Long deptId,
@Valid @RequestBody DeptForm formData

View File

@@ -8,9 +8,9 @@ import com.youlai.boot.common.enums.LogModuleEnum;
import com.youlai.boot.system.model.form.DictItemForm;
import com.youlai.boot.system.model.query.DictItemPageQuery;
import com.youlai.boot.system.model.query.DictPageQuery;
import com.youlai.boot.system.model.vo.DictItemOptionVO;
import com.youlai.boot.system.model.vo.DictItemPageVO;
import com.youlai.boot.system.model.vo.DictPageVO;
import com.youlai.boot.system.model.vo.DictItemOptionVo;
import com.youlai.boot.system.model.vo.DictItemPageVo;
import com.youlai.boot.system.model.vo.DictPageVo;
import com.youlai.boot.common.annotation.RepeatSubmit;
import com.youlai.boot.system.model.form.DictForm;
import com.youlai.boot.common.annotation.Log;
@@ -36,7 +36,6 @@ import java.util.List;
*/
@Tag(name = "06.字典接口")
@RestController
@SuppressWarnings("SpellCheckingInspection")
@RequestMapping("/api/v1/dicts")
@RequiredArgsConstructor
public class DictController {
@@ -51,10 +50,10 @@ public class DictController {
@Operation(summary = "字典分页列表")
@GetMapping("/page")
@Log( value = "字典分页列表",module = LogModuleEnum.DICT)
public PageResult<DictPageVO> getDictPage(
public PageResult<DictPageVo> getDictPage(
DictPageQuery queryParams
) {
Page<DictPageVO> result = dictService.getDictPage(queryParams);
Page<DictPageVo> result = dictService.getDictPage(queryParams);
return PageResult.success(result);
}
@@ -77,7 +76,7 @@ public class DictController {
@Operation(summary = "新增字典")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:dict:add')")
@PreAuthorize("@ss.hasPerm('sys:dict:create')")
@RepeatSubmit
public Result<?> saveDict(@Valid @RequestBody DictForm formData) {
boolean result = dictService.saveDict(formData);
@@ -90,7 +89,7 @@ public class DictController {
@Operation(summary = "修改字典")
@PutMapping("/{id}")
@PreAuthorize("@ss.hasPerm('sys:dict:edit')")
@PreAuthorize("@ss.hasPerm('sys:dict:update')")
public Result<?> updateDict(
@PathVariable Long id,
@RequestBody DictForm dictForm
@@ -128,27 +127,27 @@ public class DictController {
//---------------------------------------------------
@Operation(summary = "字典项分页列表")
@GetMapping("/{dictCode}/items/page")
public PageResult<DictItemPageVO> getDictItemPage(
public PageResult<DictItemPageVo> getDictItemPage(
@PathVariable String dictCode,
DictItemPageQuery queryParams
) {
queryParams.setDictCode(dictCode);
Page<DictItemPageVO> result = dictItemService.getDictItemPage(queryParams);
Page<DictItemPageVo> result = dictItemService.getDictItemPage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "字典项列表")
@GetMapping("/{dictCode}/items")
public Result<List<DictItemOptionVO>> getDictItems(
public Result<List<DictItemOptionVo>> getDictItems(
@Parameter(description = "字典编码") @PathVariable String dictCode
) {
List<DictItemOptionVO> list = dictItemService.getDictItems(dictCode);
List<DictItemOptionVo> list = dictItemService.getDictItems(dictCode);
return Result.success(list);
}
@Operation(summary = "新增字典项")
@PostMapping("/{dictCode}/items")
@PreAuthorize("@ss.hasPerm('sys:dict-item:add')")
@PreAuthorize("@ss.hasPerm('sys:dict-item:create')")
@RepeatSubmit
public Result<Void> saveDictItem(
@PathVariable String dictCode,
@@ -177,7 +176,7 @@ public class DictController {
@Operation(summary = "修改字典项")
@PutMapping("/{dictCode}/items/{itemId}")
@PreAuthorize("@ss.hasPerm('sys:dict-item:edit')")
@PreAuthorize("@ss.hasPerm('sys:dict-item:update')")
@RepeatSubmit
public Result<?> updateDictItem(
@PathVariable String dictCode,

View File

@@ -2,20 +2,14 @@ package com.youlai.boot.system.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.core.web.PageResult;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.query.LogPageQuery;
import com.youlai.boot.system.model.vo.LogPageVO;
import com.youlai.boot.system.model.vo.VisitStatsVO;
import com.youlai.boot.system.model.vo.VisitTrendVO;
import com.youlai.boot.system.model.vo.LogPageVo;
import com.youlai.boot.system.service.LogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* 日志控制层
*
@@ -32,30 +26,11 @@ public class LogController {
@Operation(summary = "日志分页列表")
@GetMapping("/page")
public PageResult<LogPageVO> getLogPage(
public PageResult<LogPageVo> getLogPage(
LogPageQuery queryParams
) {
Page<LogPageVO> result = logService.getLogPage(queryParams);
Page<LogPageVo> result = logService.getLogPage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "获取访问趋势")
@GetMapping("/visit-trend")
public Result<VisitTrendVO> getVisitTrend(
@Parameter(description = "开始时间", example = "yyyy-MM-dd") @RequestParam String startDate,
@Parameter(description = "结束时间", example = "yyyy-MM-dd") @RequestParam String endDate
) {
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
VisitTrendVO data = logService.getVisitTrend(start, end);
return Result.success(data);
}
@Operation(summary = "获取访问统计")
@GetMapping("/visit-stats")
public Result<VisitStatsVO> getVisitStats() {
VisitStatsVO result = logService.getVisitStats();
return Result.success(result);
}
}

View File

@@ -7,8 +7,8 @@ import com.youlai.boot.common.model.Option;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.form.MenuForm;
import com.youlai.boot.system.model.query.MenuQuery;
import com.youlai.boot.system.model.vo.MenuVO;
import com.youlai.boot.system.model.vo.RouteVO;
import com.youlai.boot.system.model.vo.MenuVo;
import com.youlai.boot.system.model.vo.RouteVo;
import com.youlai.boot.system.service.MenuService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -38,8 +38,8 @@ public class MenuController {
@Operation(summary = "菜单列表")
@GetMapping
@Log(value = "菜单列表", module = LogModuleEnum.MENU)
public Result<List<MenuVO>> getMenus(MenuQuery queryParams) {
List<MenuVO> menuList = menuService.listMenus(queryParams);
public Result<List<MenuVo>> getMenus(MenuQuery queryParams) {
List<MenuVo> menuList = menuService.listMenus(queryParams);
return Result.success(menuList);
}
@@ -55,14 +55,14 @@ public class MenuController {
@Operation(summary = "当前用户菜单路由列表")
@GetMapping("/routes")
public Result<List<RouteVO>> getCurrentUserRoutes() {
List<RouteVO> routeList = menuService.listCurrentUserRoutes();
public Result<List<RouteVo>> getCurrentUserRoutes() {
List<RouteVo> routeList = menuService.listCurrentUserRoutes();
return Result.success(routeList);
}
@Operation(summary = "菜单表单数据")
@GetMapping("/{id}/form")
@PreAuthorize("@ss.hasPerm('sys:menu:edit')")
@PreAuthorize("@ss.hasPerm('sys:menu:update')")
public Result<MenuForm> getMenuForm(
@Parameter(description = "菜单ID") @PathVariable Long id
) {
@@ -72,7 +72,7 @@ public class MenuController {
@Operation(summary = "新增菜单")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:menu:add')")
@PreAuthorize("@ss.hasPerm('sys:menu:create')")
@RepeatSubmit
public Result<?> addMenu(@RequestBody MenuForm menuForm) {
boolean result = menuService.saveMenu(menuForm);
@@ -81,7 +81,7 @@ public class MenuController {
@Operation(summary = "修改菜单")
@PutMapping(value = "/{id}")
@PreAuthorize("@ss.hasPerm('sys:menu:edit')")
@PreAuthorize("@ss.hasPerm('sys:menu:update')")
public Result<?> updateMenu(
@RequestBody MenuForm menuForm
) {
@@ -101,7 +101,7 @@ public class MenuController {
@Operation(summary = "修改菜单显示状态")
@PatchMapping("/{menuId}")
@PreAuthorize("@ss.hasPerm('sys:menu:edit')")
@PreAuthorize("@ss.hasPerm('sys:menu:update')")
public Result<?> updateMenuVisible(
@Parameter(description = "菜单ID") @PathVariable Long menuId,
@Parameter(description = "显示状态(1:显示;0:隐藏)") Integer visible

View File

@@ -5,9 +5,9 @@ import com.youlai.boot.core.web.PageResult;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.form.NoticeForm;
import com.youlai.boot.system.model.query.NoticePageQuery;
import com.youlai.boot.system.model.vo.NoticeDetailVO;
import com.youlai.boot.system.model.vo.NoticePageVO;
import com.youlai.boot.system.model.vo.UserNoticePageVO;
import com.youlai.boot.system.model.vo.NoticeDetailVo;
import com.youlai.boot.system.model.vo.NoticePageVo;
import com.youlai.boot.system.model.vo.UserNoticePageVo;
import com.youlai.boot.system.service.NoticeService;
import com.youlai.boot.system.service.UserNoticeService;
import io.swagger.v3.oas.annotations.Operation;
@@ -37,15 +37,15 @@ public class NoticeController {
@Operation(summary = "通知公告分页列表")
@GetMapping("/page")
@PreAuthorize("@ss.hasPerm('sys:notice:query')")
public PageResult<NoticePageVO> getNoticePage(NoticePageQuery queryParams) {
IPage<NoticePageVO> result = noticeService.getNoticePage(queryParams);
@PreAuthorize("@ss.hasPerm('sys:notice:list')")
public PageResult<NoticePageVo> getNoticePage(NoticePageQuery queryParams) {
IPage<NoticePageVo> result = noticeService.getNoticePage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "新增通知公告")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:notice:add')")
@PreAuthorize("@ss.hasPerm('sys:notice:create')")
public Result<?> saveNotice(@RequestBody @Valid NoticeForm formData) {
boolean result = noticeService.saveNotice(formData);
return Result.judge(result);
@@ -53,7 +53,7 @@ public class NoticeController {
@Operation(summary = "获取通知公告表单数据")
@GetMapping("/{id}/form")
@PreAuthorize("@ss.hasPerm('sys:notice:edit')")
@PreAuthorize("@ss.hasPerm('sys:notice:update')")
public Result<NoticeForm> getNoticeForm(
@Parameter(description = "通知公告ID") @PathVariable Long id
) {
@@ -63,16 +63,16 @@ public class NoticeController {
@Operation(summary = "阅读获取通知公告详情")
@GetMapping("/{id}/detail")
public Result<NoticeDetailVO> getNoticeDetail(
public Result<NoticeDetailVo> getNoticeDetail(
@Parameter(description = "通知公告ID") @PathVariable Long id
) {
NoticeDetailVO detailVO = noticeService.getNoticeDetail(id);
return Result.success(detailVO);
NoticeDetailVo detailVo = noticeService.getNoticeDetail(id);
return Result.success(detailVo);
}
@Operation(summary = "修改通知公告")
@PutMapping(value = "/{id}")
@PreAuthorize("@ss.hasPerm('sys:notice:edit')")
@PreAuthorize("@ss.hasPerm('sys:notice:update')")
public Result<Void> updateNotice(
@Parameter(description = "通知公告ID") @PathVariable Long id,
@RequestBody @Validated NoticeForm formData
@@ -120,10 +120,10 @@ public class NoticeController {
@Operation(summary = "获取我的通知公告分页列表")
@GetMapping("/my")
public PageResult<UserNoticePageVO> getMyNoticePage(
public PageResult<UserNoticePageVo> getMyNoticePage(
NoticePageQuery queryParams
) {
IPage<UserNoticePageVO> result = noticeService.getMyNoticePage(queryParams);
IPage<UserNoticePageVo> result = noticeService.getMyNoticePage(queryParams);
return PageResult.success(result);
}
}

View File

@@ -8,7 +8,7 @@ import com.youlai.boot.core.web.PageResult;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.form.RoleForm;
import com.youlai.boot.system.model.query.RolePageQuery;
import com.youlai.boot.system.model.vo.RolePageVO;
import com.youlai.boot.system.model.vo.RolePageVo;
import com.youlai.boot.common.annotation.Log;
import com.youlai.boot.system.service.RoleService;
import io.swagger.v3.oas.annotations.Parameter;
@@ -39,10 +39,10 @@ public class RoleController {
@Operation(summary = "角色分页列表")
@GetMapping("/page")
@Log(value = "角色分页列表", module = LogModuleEnum.ROLE)
public PageResult<RolePageVO> getRolePage(
public PageResult<RolePageVo> getRolePage(
RolePageQuery queryParams
) {
Page<RolePageVO> result = roleService.getRolePage(queryParams);
Page<RolePageVo> result = roleService.getRolePage(queryParams);
return PageResult.success(result);
}
@@ -55,7 +55,7 @@ public class RoleController {
@Operation(summary = "新增角色")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:role:add')")
@PreAuthorize("@ss.hasPerm('sys:role:create')")
@RepeatSubmit
public Result<?> addRole(@Valid @RequestBody RoleForm roleForm) {
boolean result = roleService.saveRole(roleForm);
@@ -64,7 +64,7 @@ public class RoleController {
@Operation(summary = "获取角色表单数据")
@GetMapping("/{roleId}/form")
@PreAuthorize("@ss.hasPerm('sys:role:edit')")
@PreAuthorize("@ss.hasPerm('sys:role:update')")
public Result<RoleForm> getRoleForm(
@Parameter(description = "角色ID") @PathVariable Long roleId
) {
@@ -74,7 +74,7 @@ public class RoleController {
@Operation(summary = "修改角色")
@PutMapping(value = "/{id}")
@PreAuthorize("@ss.hasPerm('sys:role:edit')")
@PreAuthorize("@ss.hasPerm('sys:role:update')")
public Result<?> updateRole(@Valid @RequestBody RoleForm roleForm) {
boolean result = roleService.saveRole(roleForm);
return Result.judge(result);
@@ -92,7 +92,7 @@ public class RoleController {
@Operation(summary = "修改角色状态")
@PutMapping(value = "/{roleId}/status")
@PreAuthorize("@ss.hasPerm('sys:role:edit')")
@PreAuthorize("@ss.hasPerm('sys:role:update')")
public Result<?> updateRoleStatus(
@Parameter(description = "角色ID") @PathVariable Long roleId,
@Parameter(description = "状态(1:启用;0:禁用)") @RequestParam Integer status
@@ -112,6 +112,7 @@ public class RoleController {
@Operation(summary = "角色分配菜单权限")
@PutMapping("/{roleId}/menus")
@PreAuthorize("@ss.hasPerm('sys:role:assign')")
public Result<Void> assignMenusToRole(
@PathVariable Long roleId,
@RequestBody List<Long> menuIds

View File

@@ -0,0 +1,47 @@
package com.youlai.boot.system.controller;
import com.youlai.boot.core.web.Result;
import com.youlai.boot.system.model.vo.VisitStatsVo;
import com.youlai.boot.system.model.vo.VisitTrendVo;
import com.youlai.boot.system.service.LogService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* 统计分析控制层
*
* @author Ray.Hao
* @since 2025-12-15
*/
@Tag(name = "11.统计分析")
@RestController
@RequestMapping("/api/v1/statistics")
@RequiredArgsConstructor
public class StatisticsController {
private final LogService logService;
@Operation(summary = "访问趋势统计")
@GetMapping("/visits/trend")
public Result<VisitTrendVo> getVisitTrend(
@Parameter(description = "开始时间", example = "2024-01-01") @RequestParam String startDate,
@Parameter(description = "结束时间", example = "2024-12-31") @RequestParam String endDate
) {
LocalDate start = LocalDate.parse(startDate);
LocalDate end = LocalDate.parse(endDate);
VisitTrendVo data = logService.getVisitTrend(start, end);
return Result.success(data);
}
@Operation(summary = "访问概览统计")
@GetMapping("/visits/overview")
public Result<VisitStatsVo> getVisitOverview() {
VisitStatsVo result = logService.getVisitStats();
return Result.success(result);
}
}

View File

@@ -14,14 +14,14 @@ import com.youlai.boot.core.web.Result;
import com.youlai.boot.common.util.ExcelUtils;
import com.youlai.boot.security.util.SecurityUtils;
import com.youlai.boot.system.listener.UserImportListener;
import com.youlai.boot.system.model.dto.UserExportDTO;
import com.youlai.boot.system.model.dto.UserImportDTO;
import com.youlai.boot.system.model.dto.UserExportDto;
import com.youlai.boot.system.model.dto.UserImportDto;
import com.youlai.boot.system.model.entity.User;
import com.youlai.boot.system.model.form.*;
import com.youlai.boot.system.model.query.UserPageQuery;
import com.youlai.boot.system.model.dto.CurrentUserDTO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import com.youlai.boot.system.model.dto.CurrentUserDto;
import com.youlai.boot.system.model.vo.UserPageVo;
import com.youlai.boot.system.model.vo.UserProfileVo;
import com.youlai.boot.system.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -59,16 +59,16 @@ public class UserController {
@Operation(summary = "用户分页列表")
@GetMapping("/page")
@Log(value = "用户分页列表", module = LogModuleEnum.USER)
public PageResult<UserPageVO> getUserPage(
public PageResult<UserPageVo> getUserPage(
@Valid UserPageQuery queryParams
) {
IPage<UserPageVO> result = userService.getUserPage(queryParams);
IPage<UserPageVo> result = userService.getUserPage(queryParams);
return PageResult.success(result);
}
@Operation(summary = "新增用户")
@PostMapping
@PreAuthorize("@ss.hasPerm('sys:user:add')")
@PreAuthorize("@ss.hasPerm('sys:user:create')")
@RepeatSubmit
@Log(value = "新增用户", module = LogModuleEnum.USER)
public Result<?> saveUser(
@@ -80,7 +80,7 @@ public class UserController {
@Operation(summary = "获取用户表单数据")
@GetMapping("/{userId}/form")
@PreAuthorize("@ss.hasPerm('sys:user:edit')")
@PreAuthorize("@ss.hasPerm('sys:user:update')")
@Log(value = "用户表单数据", module = LogModuleEnum.USER)
public Result<UserForm> getUserForm(
@Parameter(description = "用户ID") @PathVariable Long userId
@@ -91,7 +91,7 @@ public class UserController {
@Operation(summary = "修改用户")
@PutMapping(value = "/{userId}")
@PreAuthorize("@ss.hasPerm('sys:user:edit')")
@PreAuthorize("@ss.hasPerm('sys:user:update')")
@Log(value = "修改用户", module = LogModuleEnum.USER)
public Result<Void> updateUser(
@Parameter(description = "用户ID") @PathVariable Long userId,
@@ -114,7 +114,7 @@ public class UserController {
@Operation(summary = "修改用户状态")
@PatchMapping(value = "/{userId}/status")
@PreAuthorize("@ss.hasPerm('sys:user:edit')")
@PreAuthorize("@ss.hasPerm('sys:user:update')")
@Log(value = "修改用户状态", module = LogModuleEnum.USER)
public Result<Void> updateUserStatus(
@Parameter(description = "用户ID") @PathVariable Long userId,
@@ -130,9 +130,9 @@ public class UserController {
@Operation(summary = "获取当前登录用户信息")
@GetMapping("/me")
@Log(value = "获取当前登录用户信息", module = LogModuleEnum.USER)
public Result<CurrentUserDTO> getCurrentUser() {
CurrentUserDTO currentUserDTO = userService.getCurrentUserInfo();
return Result.success(currentUserDTO);
public Result<CurrentUserDto> getCurrentUser() {
CurrentUserDto currentUserDto = userService.getCurrentUserInfo();
return Result.success(currentUserDto);
}
@Operation(summary = "用户导入模板下载")
@@ -160,7 +160,7 @@ public class UserController {
@Log(value = "导入用户", module = LogModuleEnum.USER)
public Result<ExcelResult> importUsers(MultipartFile file) throws IOException {
UserImportListener listener = new UserImportListener();
ExcelUtils.importExcel(file.getInputStream(), UserImportDTO.class, listener);
ExcelUtils.importExcel(file.getInputStream(), UserImportDto.class, listener);
return Result.success(listener.getExcelResult());
}
@@ -173,17 +173,17 @@ public class UserController {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
List<UserExportDTO> exportUserList = userService.listExportUsers(queryParams);
EasyExcel.write(response.getOutputStream(), UserExportDTO.class).sheet("用户列表")
List<UserExportDto> exportUserList = userService.listExportUsers(queryParams);
EasyExcel.write(response.getOutputStream(), UserExportDto.class).sheet("用户列表")
.doWrite(exportUserList);
}
@Operation(summary = "获取个人中心用户信息")
@GetMapping("/profile")
@Log(value = "获取个人中心用户信息", module = LogModuleEnum.USER)
public Result<UserProfileVO> getUserProfile() {
public Result<UserProfileVo> getUserProfile() {
Long userId = SecurityUtils.getUserId();
UserProfileVO userProfile = userService.getUserProfile(userId);
UserProfileVo userProfile = userService.getUserProfile(userId);
return Result.success(userProfile);
}

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.Config;
import com.youlai.boot.system.model.vo.ConfigVO;
import com.youlai.boot.system.model.vo.ConfigVo;
import com.youlai.boot.system.model.form.ConfigForm;
import org.mapstruct.Mapper;
@@ -15,7 +15,7 @@ import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface ConfigConverter {
Page<ConfigVO> toPageVo(Page<Config> page);
Page<ConfigVo> toPageVo(Page<Config> page);
Config toEntity(ConfigForm configForm);

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.system.converter;
import com.youlai.boot.system.model.entity.Dept;
import com.youlai.boot.system.model.vo.DeptVO;
import com.youlai.boot.system.model.vo.DeptVo;
import com.youlai.boot.system.model.form.DeptForm;
import org.mapstruct.Mapper;
@@ -16,7 +16,7 @@ public interface DeptConverter {
DeptForm toForm(Dept entity);
DeptVO toVo(Dept entity);
DeptVo toVo(Dept entity);
Dept toEntity(DeptForm deptForm);

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.Dict;
import com.youlai.boot.system.model.vo.DictPageVO;
import com.youlai.boot.system.model.vo.DictPageVo;
import com.youlai.boot.system.model.form.DictForm;
import org.mapstruct.Mapper;
@@ -15,7 +15,7 @@ import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface DictConverter {
Page<DictPageVO> toPageVo(Page<Dict> page);
Page<DictPageVo> toPageVo(Page<Dict> page);
DictForm toForm(Dict entity);

View File

@@ -3,7 +3,7 @@ package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.form.DictItemForm;
import com.youlai.boot.system.model.vo.DictPageVO;
import com.youlai.boot.system.model.vo.DictPageVo;
import com.youlai.boot.common.model.Option;
import org.mapstruct.Mapper;
@@ -18,7 +18,7 @@ import java.util.List;
@Mapper(componentModel = "spring")
public interface DictItemConverter {
Page<DictPageVO> toPageVo(Page<DictItem> page);
Page<DictPageVo> toPageVo(Page<DictItem> page);
DictItemForm toForm(DictItem entity);

View File

@@ -1,7 +1,7 @@
package com.youlai.boot.system.converter;
import com.youlai.boot.system.model.entity.Menu;
import com.youlai.boot.system.model.vo.MenuVO;
import com.youlai.boot.system.model.vo.MenuVo;
import com.youlai.boot.system.model.form.MenuForm;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@@ -15,7 +15,7 @@ import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface MenuConverter {
MenuVO toVo(Menu entity);
MenuVo toVo(Menu entity);
@Mapping(target = "params", ignore = true)
MenuForm toForm(Menu entity);

View File

@@ -1,11 +1,11 @@
package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.bo.NoticeBO;
import com.youlai.boot.system.model.bo.NoticeBo;
import com.youlai.boot.system.model.entity.Notice;
import com.youlai.boot.system.model.form.NoticeForm;
import com.youlai.boot.system.model.vo.NoticeDetailVO;
import com.youlai.boot.system.model.vo.NoticePageVO;
import com.youlai.boot.system.model.vo.NoticeDetailVo;
import com.youlai.boot.system.model.vo.NoticePageVo;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
@@ -30,9 +30,9 @@ public interface NoticeConverter{
})
Notice toEntity(NoticeForm formData);
NoticePageVO toPageVo(NoticeBO bo);
NoticePageVo toPageVo(NoticeBo bo);
Page<NoticePageVO> toPageVo(Page<NoticeBO> noticePage);
Page<NoticePageVo> toPageVo(Page<NoticeBo> noticePage);
NoticeDetailVO toDetailVO(NoticeBO noticeBO);
NoticeDetailVo toDetailVo(NoticeBo noticeBo);
}

View File

@@ -2,7 +2,7 @@ package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.Role;
import com.youlai.boot.system.model.vo.RolePageVO;
import com.youlai.boot.system.model.vo.RolePageVo;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.system.model.form.RoleForm;
import org.mapstruct.Mapper;
@@ -20,7 +20,7 @@ import java.util.List;
@Mapper(componentModel = "spring")
public interface RoleConverter {
Page<RolePageVO> toPageVo(Page<Role> page);
Page<RolePageVo> toPageVo(Page<Role> page);
@Mappings({
@Mapping(target = "value", source = "id"),

View File

@@ -3,12 +3,12 @@ package com.youlai.boot.system.converter;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.common.model.Option;
import com.youlai.boot.system.model.entity.User;
import com.youlai.boot.system.model.dto.CurrentUserDTO;
import com.youlai.boot.system.model.vo.UserPageVO;
import com.youlai.boot.system.model.vo.UserProfileVO;
import com.youlai.boot.system.model.bo.UserBO;
import com.youlai.boot.system.model.dto.CurrentUserDto;
import com.youlai.boot.system.model.vo.UserPageVo;
import com.youlai.boot.system.model.vo.UserProfileVo;
import com.youlai.boot.system.model.bo.UserBo;
import com.youlai.boot.system.model.form.UserForm;
import com.youlai.boot.system.model.dto.UserImportDTO;
import com.youlai.boot.system.model.dto.UserImportDto;
import com.youlai.boot.system.model.form.UserProfileForm;
import org.mapstruct.InheritInverseConfiguration;
import org.mapstruct.Mapper;
@@ -26,9 +26,9 @@ import java.util.List;
@Mapper(componentModel = "spring")
public interface UserConverter {
UserPageVO toPageVo(UserBO bo);
UserPageVo toPageVo(UserBo bo);
Page<UserPageVO> toPageVo(Page<UserBO> bo);
Page<UserPageVo> toPageVo(Page<UserBo> bo);
UserForm toForm(User entity);
@@ -38,12 +38,12 @@ public interface UserConverter {
@Mappings({
@Mapping(target = "userId", source = "id")
})
CurrentUserDTO toCurrentUserDto(User entity);
CurrentUserDto toCurrentUserDto(User entity);
User toEntity(UserImportDTO vo);
User toEntity(UserImportDto vo);
UserProfileVO toProfileVo(UserBO bo);
UserProfileVo toProfileVo(UserBo bo);
User toEntity(UserProfileForm formData);

View File

@@ -5,28 +5,31 @@ import com.youlai.boot.common.base.IBaseEnum;
import lombok.Getter;
/**
* 菜单类型枚举
* 菜单类型枚举char
*
* @author Ray.Hao
* @since 2022/4/23 9:36
* C目录
* M菜单
* B按钮
*/
@Getter
public enum MenuTypeEnum implements IBaseEnum<Integer> {
public enum MenuTypeEnum implements IBaseEnum<String> {
NULL(0, null),
MENU(1, "菜单"),
CATALOG(2, "目录"),
EXTLINK(3, "外链"),
BUTTON(4, "按钮");
CATALOG("C", "目录"),
MENU("M", "菜单"),
BUTTON("B", "按钮");
// Mybatis-Plus 提供注解表示插入数据库时插入该值
/**
* 数据库存储值
*/
@EnumValue
private final Integer value;
private final String value;
// @JsonValue // 表示对枚举序列化时返回此字段
/**
* 友好名称
*/
private final String label;
MenuTypeEnum(Integer value, String label) {
MenuTypeEnum(String value, String label) {
this.value = value;
this.label = label;
}

View File

@@ -16,7 +16,7 @@ public enum NoticePublishStatusEnum implements IBaseEnum<Integer> {
UNPUBLISHED(0, "未发布"),
PUBLISHED(1, "已发布"),
REVOKED(-1, "已撤回");
REVoKED(-1, "已撤回");
private final Integer value;

View File

@@ -2,9 +2,10 @@ package com.youlai.boot.system.handler;
import com.youlai.boot.system.service.UserOnlineService;
import com.youlai.boot.platform.websocket.publisher.WebSocketPublisher;
import com.youlai.boot.platform.websocket.topic.WebSocketTopics;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@@ -20,7 +21,7 @@ import org.springframework.stereotype.Component;
public class OnlineUserJobHandler {
private final UserOnlineService userOnlineService;
private final SimpMessagingTemplate messagingTemplate;
private final WebSocketPublisher webSocketPublisher;
// 每3分钟统计一次在线用户数减少服务器压力
@Scheduled(cron = "0 */3 * * * ?")
@@ -28,7 +29,7 @@ public class OnlineUserJobHandler {
log.info("定时任务:统计在线用户数");
// 推送在线用户数量到新主题
int count = userOnlineService.getOnlineUserCount();
messagingTemplate.convertAndSend("/topic/online-count", count);
webSocketPublisher.publish(WebSocketTopics.TOPIC_ONLINE_COUNT, count);
}
}

View File

@@ -14,7 +14,7 @@ import com.youlai.boot.common.enums.StatusEnum;
import com.youlai.boot.core.web.ExcelResult;
import com.youlai.boot.system.converter.UserConverter;
import com.youlai.boot.system.enums.DictCodeEnum;
import com.youlai.boot.system.model.dto.UserImportDTO;
import com.youlai.boot.system.model.dto.UserImportDto;
import com.youlai.boot.system.model.entity.*;
import com.youlai.boot.system.service.*;
import lombok.Getter;
@@ -35,7 +35,7 @@ import java.util.stream.Collectors;
* @since 2022/4/10
*/
@Slf4j
public class UserImportListener extends AnalysisEventListener<UserImportDTO> {
public class UserImportListener extends AnalysisEventListener<UserImportDto> {
/**
* Excel 导入结果
@@ -82,15 +82,15 @@ public class UserImportListener extends AnalysisEventListener<UserImportDTO> {
* 1. 数据校验;全字段校验
* 2. 数据持久化;
*
* @param userImportDTO 一行数据,类似于 {@link AnalysisContext#readRowHolder()}
* @param userImportDto 一行数据,类似于 {@link AnalysisContext#readRowHolder()}
*/
@Override
public void invoke(UserImportDTO userImportDTO, AnalysisContext analysisContext) {
log.info("解析到一条用户数据:{}", JSONUtil.toJsonStr(userImportDTO));
public void invoke(UserImportDto userImportDto, AnalysisContext analysisContext) {
log.info("解析到一条用户数据:{}", JSONUtil.toJsonStr(userImportDto));
boolean validation = true;
String errorMsg = "" + currentRow + "行数据校验失败:";
String username = userImportDTO.getUsername();
String username = userImportDto.getUsername();
if (StrUtil.isBlank(username)) {
errorMsg += "用户名为空;";
validation = false;
@@ -102,13 +102,13 @@ public class UserImportListener extends AnalysisEventListener<UserImportDTO> {
}
}
String nickname = userImportDTO.getNickname();
String nickname = userImportDto.getNickname();
if (StrUtil.isBlank(nickname)) {
errorMsg += "用户昵称为空;";
validation = false;
}
String mobile = userImportDTO.getMobile();
String mobile = userImportDto.getMobile();
if (StrUtil.isBlank(mobile)) {
errorMsg += "手机号码为空;";
validation = false;
@@ -121,16 +121,16 @@ public class UserImportListener extends AnalysisEventListener<UserImportDTO> {
if (validation) {
// 校验通过,持久化至数据库
User entity = userConverter.toEntity(userImportDTO);
User entity = userConverter.toEntity(userImportDto);
entity.setPassword(passwordEncoder.encode(SystemConstants.DEFAULT_PASSWORD)); // 默认密码
// 性别逆向翻译 根据字典标签得到字典值
String genderLabel = userImportDTO.getGenderLabel();
String genderLabel = userImportDto.getGenderLabel();
entity.setGender(getGenderValue(genderLabel));
// 角色解析
String roleCodes = userImportDTO.getRoleCodes();
String roleCodes = userImportDto.getRoleCodes();
List<Long> roleIds = getRoleIds(roleCodes);
// 部门解析
String deptCode = userImportDTO.getDeptCode();
String deptCode = userImportDto.getDeptCode();
entity.setDeptId(getDeptId(deptCode));
boolean saveResult = userService.save(entity);

View File

@@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.youlai.boot.system.model.entity.DictItem;
import com.youlai.boot.system.model.query.DictItemPageQuery;
import com.youlai.boot.system.model.vo.DictItemPageVO;
import com.youlai.boot.system.model.vo.DictItemPageVo;
import org.apache.ibatis.annotations.Mapper;
/**
@@ -19,7 +19,7 @@ public interface DictItemMapper extends BaseMapper<DictItem> {
/**
* 字典项分页列表
*/
Page<DictItemPageVO> getDictItemPage(Page<DictItemPageVO> page, DictItemPageQuery queryParams);
Page<DictItemPageVo> getDictItemPage(Page<DictItemPageVo> page, DictItemPageQuery queryParams);
}

Some files were not shown because too many files have changed in this diff Show More