提交 be944c68 作者: 方治民

feat: 依赖升级、大量代码重构及优化、新增图片预处理、WebSocket、I18n 模块等

上级 611f6b3f
...@@ -33,7 +33,7 @@ build-job: ...@@ -33,7 +33,7 @@ build-job:
before_script: before_script:
- chmod +x ./gradlew - chmod +x ./gradlew
script: script:
- ./gradlew app:assemble -Dskip-hooks - ./gradlew :app:assemble -Dskip-hooks
artifacts: artifacts:
# 配置构建结果过期时间 # 配置构建结果过期时间
expire_in: 1 day expire_in: 1 day
...@@ -54,12 +54,19 @@ deploy-job: ...@@ -54,12 +54,19 @@ deploy-job:
tags: tags:
- YR-CD - YR-CD
script: script:
# 容器名
- NAME=$CONTAINER_NAME-$CI_BUILD_REF_NAME
# 尝试停止并删除上一个容器
- id=$(docker ps -aqf name=$NAME) && [ "$id" ] && docker stop $id && docker rm -f $id || true
# 尝试删除镜像(先删除再构建,避免产生 <none> image)
- iid=$(docker images -aq $TAG) && [ "$iid" ] && docker rmi -f $iid || true
# 基于 Dockerfile 构建镜像 # 基于 Dockerfile 构建镜像
- docker build -t $TAG . - docker build -t $TAG .
# 尝试删除上一个容器 # 条件判断
- id=$(docker ps -aqf name=$CONTAINER_NAME) && [ "$id" ] && docker rm -f $id || true - PORT=$EXPOSE_PORT
- echo "Branch:" $CI_BUILD_REF_NAME "PORT:" $PORT "Container:" $NAME
# 在本地运行构建好的镜像 # 在本地运行构建好的镜像
- docker run -d --name $CONTAINER_NAME -p $EXPOSE_PORT:8081 -e TZ="Asia/Shanghai" $TAG - docker run -d --name $NAME -p $PORT:8081 -e TZ="Asia/Shanghai" $TAG
variables: variables:
# 设置镜像 tag,使用 git tag 标识作为镜像 tag # 设置镜像 tag,使用 git tag 标识作为镜像 tag
TAG: $REGISTRY_REMOTE/basic/$CONTAINER_NAME:$CI_BUILD_REF_NAME TAG: $REGISTRY_REMOTE/basic/$CONTAINER_NAME:$CI_BUILD_REF_NAME
...@@ -26,6 +26,7 @@ ...@@ -26,6 +26,7 @@
- [x] 完成项目构建,开发文档编写 - [x] 完成项目构建,开发文档编写
- [x] [conventional-changelog](https://www.cnblogs.com/mengfangui/p/12634845.html) - [x] [conventional-changelog](https://www.cnblogs.com/mengfangui/p/12634845.html)
- [x] 用户及权限模块(目录/菜单/按钮),预览初始化权限配置 [SQL 脚本](./basic-auth/src/main/resources/init-test-mysql.sql) - [x] 用户及权限模块(目录/菜单/按钮),预览初始化权限配置 [SQL 脚本](./basic-auth/src/main/resources/init-test-mysql.sql)
- [x] 通用文件上传模块 - [x] 通用文件上传模块,支持对图片/PDF/MP3/MP4 等文件进行预处理
- [x] WebSocket 模块
- [ ] 通用字典管理模块 - [ ] 通用字典管理模块
- [ ] XXL-JOB 定时任务模块 - [ ] XXL-JOB 定时任务模块
...@@ -22,6 +22,9 @@ dependencies { ...@@ -22,6 +22,9 @@ dependencies {
implementation project(":basic-common:core") implementation project(":basic-common:core")
implementation project(":basic-common:util") implementation project(":basic-common:util")
// Optional: I18n 消息, 包括参数校验、失败的请求提示等
implementation project(":basic-common:i18n")
// Optional: Redis // Optional: Redis
implementation project(":basic-common:redis") implementation project(":basic-common:redis")
...@@ -33,22 +36,29 @@ dependencies { ...@@ -33,22 +36,29 @@ dependencies {
implementation project(":basic-auth") implementation project(":basic-auth")
implementation "cn.dev33:sa-token-spring-boot-starter:${saTokenVersion}" implementation "cn.dev33:sa-token-spring-boot-starter:${saTokenVersion}"
// Optional: WebSocket && STOMP 依赖 Auth + Redis 模块
implementation project(":basic-websocket")
// Optional: Minio S3 // Optional: Minio S3
implementation project(":basic-common:minio") implementation project(":basic-common:minio")
// FIX: minio dep // FIX: minio dep
implementation "io.minio:minio:${minioVersion}"
implementation "com.squareup.okhttp3:okhttp:${okhttpVersion}" implementation "com.squareup.okhttp3:okhttp:${okhttpVersion}"
// Optional: 扩展实现在文件上传时对文件进行预处理,依赖 Minio 模块
// Optional: MyBatis Plus // https://mvnrepository.com/artifact/org.bytedeco/javacv
implementation "com.baomidou:mybatis-plus-boot-starter:${mybatisPlusVersion}" implementation 'org.bytedeco:javacv:1.5.7'
// https://mvnrepository.com/artifact/org.bytedeco/ffmpeg-platform
implementation 'org.bytedeco:ffmpeg-platform:5.0-1.5.7'
// https://mvnrepository.com/artifact/org.apache.pdfbox/pdfbox
implementation 'org.apache.pdfbox:pdfbox:2.0.26'
// fastjson // fastjson
implementation "com.alibaba:fastjson:${fastJsonVersion}" implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// hutool // hutool
implementation "cn.hutool:hutool-core:${hutoolVersion}" implementation "cn.hutool:hutool-core:${hutoolVersion}"
implementation "cn.hutool:hutool-extra:${hutoolVersion}" implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// https://github.com/vladmihalcea/hibernate-types // https://github.com/vladmihalcea/hibernate-types
// hibernate-types-55
implementation "com.vladmihalcea:hibernate-types-55:${hibernateTypesVersion}" implementation "com.vladmihalcea:hibernate-types-55:${hibernateTypesVersion}"
} }
/* (C) 2021 YiRing, Inc. */ /* (C) 2021 YiRing, Inc. */
package com.yiring.app; package com.yiring.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters; import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@MapperScan(basePackages = Application.BASE_PACKAGES + ".app.mapper")
@EnableJpaRepositories(basePackages = Application.BASE_PACKAGES) @EnableJpaRepositories(basePackages = Application.BASE_PACKAGES)
@EntityScan( @EntityScan(
basePackageClasses = { Application.class, Jsr310JpaConverters.class }, basePackageClasses = { Application.class, Jsr310JpaConverters.class },
......
...@@ -4,20 +4,26 @@ package com.yiring.app.config; ...@@ -4,20 +4,26 @@ package com.yiring.app.config;
import cn.dev33.satoken.exception.NotLoginException; import cn.dev33.satoken.exception.NotLoginException;
import com.yiring.app.constant.Code; import com.yiring.app.constant.Code;
import com.yiring.app.exception.CodeException; import com.yiring.app.exception.CodeException;
import com.yiring.common.core.I18n;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.core.Status; import com.yiring.common.core.Status;
import com.yiring.common.exception.FailStatusException; import com.yiring.common.exception.FailStatusException;
import javax.servlet.http.HttpServletResponse;
import javax.validation.ConstraintViolationException; import javax.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.ClientAbortException; import org.apache.catalina.connector.ClientAbortException;
import org.aspectj.bridge.AbortException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.validation.BindException; import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult; import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
/** /**
* 全局错误处理 * 全局错误处理
...@@ -28,8 +34,11 @@ import org.springframework.web.bind.annotation.ResponseBody; ...@@ -28,8 +34,11 @@ import org.springframework.web.bind.annotation.ResponseBody;
@Slf4j @Slf4j
@ControllerAdvice @ControllerAdvice
@ResponseBody @ResponseBody
@RequiredArgsConstructor
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
final I18n i18n;
/** /**
* 参数校验异常 * 参数校验异常
* *
...@@ -40,7 +49,7 @@ public class GlobalExceptionHandler { ...@@ -40,7 +49,7 @@ public class GlobalExceptionHandler {
value = { BindException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class } value = { BindException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class }
) )
public Result<String> bindErrorHandler(Exception e) { public Result<String> bindErrorHandler(Exception e) {
String error = "未知参数校验错误"; String error = null;
if (e instanceof ConstraintViolationException) { if (e instanceof ConstraintViolationException) {
error = ((ConstraintViolationException) e).getConstraintViolations().iterator().next().getMessage(); error = ((ConstraintViolationException) e).getConstraintViolations().iterator().next().getMessage();
...@@ -53,7 +62,8 @@ public class GlobalExceptionHandler { ...@@ -53,7 +62,8 @@ public class GlobalExceptionHandler {
} }
if (result != null) { if (result != null) {
error = result.getAllErrors().iterator().next().getDefaultMessage(); ObjectError next = result.getAllErrors().iterator().next();
error = i18n.get(next);
} }
} }
...@@ -66,7 +76,7 @@ public class GlobalExceptionHandler { ...@@ -66,7 +76,7 @@ public class GlobalExceptionHandler {
* @param e 异常信息 * @param e 异常信息
* @return 异常信息反馈 {@link Status#METHOD_NOT_ALLOWED * @return 异常信息反馈 {@link Status#METHOD_NOT_ALLOWED
*/ */
@ExceptionHandler(value = HttpRequestMethodNotSupportedException.class) @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public Result<String> httpRequestMethodNotSupportedErrorHandler(Exception e) { public Result<String> httpRequestMethodNotSupportedErrorHandler(Exception e) {
return Result.no(Status.METHOD_NOT_ALLOWED, e.getMessage()); return Result.no(Status.METHOD_NOT_ALLOWED, e.getMessage());
} }
...@@ -76,7 +86,7 @@ public class GlobalExceptionHandler { ...@@ -76,7 +86,7 @@ public class GlobalExceptionHandler {
* *
* @return 异常信息反馈 {@link Status#UNAUTHORIZED * @return 异常信息反馈 {@link Status#UNAUTHORIZED
*/ */
@ExceptionHandler(value = NotLoginException.class) @ExceptionHandler(NotLoginException.class)
public Result<String> notLoginErrorHandler() { public Result<String> notLoginErrorHandler() {
return Result.no(Status.UNAUTHORIZED); return Result.no(Status.UNAUTHORIZED);
} }
...@@ -84,7 +94,7 @@ public class GlobalExceptionHandler { ...@@ -84,7 +94,7 @@ public class GlobalExceptionHandler {
/** /**
* 自定义业务异常 * 自定义业务异常
*/ */
@ExceptionHandler(value = CodeException.class) @ExceptionHandler(CodeException.class)
public Result<String> customCodeExceptionHandler(CodeException e) { public Result<String> customCodeExceptionHandler(CodeException e) {
Code code = e.getCode(); Code code = e.getCode();
return Result.no(Status.BAD_REQUEST, code.value(), code.reason(), null); return Result.no(Status.BAD_REQUEST, code.value(), code.reason(), null);
...@@ -93,15 +103,17 @@ public class GlobalExceptionHandler { ...@@ -93,15 +103,17 @@ public class GlobalExceptionHandler {
/** /**
* 失败状态异常 * 失败状态异常
*/ */
@ExceptionHandler(value = FailStatusException.class) @ExceptionHandler(FailStatusException.class)
public Result<String> failStatusExceptionHandler(FailStatusException e) { public Result<String> failStatusExceptionHandler(FailStatusException e) {
return Result.no(e.getStatus(), e.getMessage()); return Result.no(e.getStatus(), i18n.get(e.getMessage(), e.getStatus().getReasonPhrase()));
} }
/** /**
* 取消请求异常(忽略) * 取消请求异常(忽略)
*/ */
@ExceptionHandler(value = ClientAbortException.class) @ExceptionHandler(
value = { ClientAbortException.class, AbortException.class, HttpMessageNotWritableException.class }
)
public void clientAbortExceptionHandler() {} public void clientAbortExceptionHandler() {}
/** /**
...@@ -110,9 +122,9 @@ public class GlobalExceptionHandler { ...@@ -110,9 +122,9 @@ public class GlobalExceptionHandler {
* @param e 异常信息 * @param e 异常信息
* @return 统一的500异常信息 {@link Status#INTERNAL_SERVER_ERROR * @return 统一的500异常信息 {@link Status#INTERNAL_SERVER_ERROR
*/ */
@ExceptionHandler(value = Exception.class) @ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
public Result<String> defaultErrorHandler(Exception e, HttpServletResponse response) { @ExceptionHandler(Exception.class)
response.setStatus(Status.INTERNAL_SERVER_ERROR.value()); public Result<String> defaultErrorHandler(Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
return Result.no(Status.INTERNAL_SERVER_ERROR, e); return Result.no(Status.INTERNAL_SERVER_ERROR, e);
} }
......
...@@ -5,7 +5,7 @@ import com.yiring.app.exception.CodeException; ...@@ -5,7 +5,7 @@ import com.yiring.app.exception.CodeException;
import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModel;
/** /**
* 业务状态码 * 业务状态码(TODO: 结合 spring message i18n)
* eg: <code>throw new CodeException(Code.FAIL)</code> * eg: <code>throw new CodeException(Code.FAIL)</code>
* *
* @author Jim * @author Jim
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.app.domain;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serial;
import java.io.Serializable;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
import lombok.*;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import org.hibernate.annotations.Comment;
/**
* 测试表
*
* @author Jim
* @version 0.1
* 2022/4/15 18:34
*/
@Getter
@Setter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@Entity
@TableName("TEST_TABLE")
@Table(name = "TEST_TABLE")
@Comment("测试表")
public class TestTable implements Serializable {
@Serial
private static final long serialVersionUID = -6168070383092874608L;
@Comment("主键")
@Id
String id;
@Comment("姓名")
String name;
@Comment("年龄")
Integer age;
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.app.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yiring.app.domain.TestTable;
/**
* @author Jim
* @version 0.1
* 2022/4/15 17:25
*/
public interface TestTableMapper extends BaseMapper<TestTable> {}
/**
* @author Jim
* @version 0.1
* 2022/4/5 16:57
*/
package com.yiring.app.mapper;
// MyBatis Mapper/XML 目录
/* (C) 2022 YiRing, Inc. */
package com.yiring.app.service.upload;
import cn.hutool.core.io.FileUtil;
import com.yiring.common.core.Minio;
import com.yiring.common.service.UploadProcessService;
import java.awt.image.BufferedImage;
import java.awt.image.RenderedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.util.List;
import javax.imageio.ImageIO;
import lombok.Cleanup;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.Frame;
import org.bytedeco.javacv.Java2DFrameConverter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
/**
* @author Jim
* @version 0.1
* 2022/9/23 16:44
*/
@ConditionalOnClass({ PDDocument.class, FFmpegFrameGrabber.class })
@Component
@RequiredArgsConstructor
public class UploadProcessServiceImpl implements UploadProcessService {
final Minio minio;
@Override
public String handle(String object, InputStream is) {
String suffix = FileUtil.getSuffix(object);
// Image: 在文件名上追加图片物理像素
if (isSupportiveImage(suffix)) {
object = handleImage(object, is);
}
// PDF: 在文件名上追加页数,同时在同目录生成 PDF 每一页的图片
if (isPdf(suffix)) {
object = handlePdf(object, is);
}
// Video/Audio: 在文件名上追加时长,视频生成封面图
if (isSupportiveMedia(suffix)) {
object = handleMedia(object, suffix, is);
}
return object;
}
@SneakyThrows
public String handleImage(String object, InputStream is) {
BufferedImage image = ImageIO.read(is);
return fillSuffix(object, image.getWidth() + "x" + image.getHeight());
}
@SneakyThrows
public String handlePdf(String object, InputStream is) {
PDDocument doc = PDDocument.load(is);
int pages = doc.getNumberOfPages();
// 构建具有 PDF 页数标记的存储地址
String filepath = fillSuffix(object, "P" + pages);
// 将 PDF 解析成图片上传
String format = "jpg";
PDFRenderer renderer = new PDFRenderer(doc);
for (int i = 0; i < pages; i++) {
@Cleanup
ByteArrayOutputStream os = new ByteArrayOutputStream();
BufferedImage image = renderer.renderImageWithDPI(i, 144);
ImageIO.write(image, format, os);
@Cleanup
InputStream io = new ByteArrayInputStream(os.toByteArray());
minio.putObject(io, MediaType.IMAGE_JPEG_VALUE, filepath + "." + (i + 1) + "." + format);
}
return filepath;
}
@SneakyThrows
public String handleMedia(String object, String suffix, InputStream is) {
// 判断是否为视频
@Cleanup
FFmpegFrameGrabber ff = new FFmpegFrameGrabber(is);
ff.start();
// 构建具有时长(秒)标记的存储地址
String filepath = fillSuffix(object, "T" + (ff.getLengthInTime() / (1000 * 1000)));
// 视频截取首帧可见画面作为封面
if (isSupportiveVideo(suffix)) {
// 获取视频可见画面帧
Frame frame = getPictureFrame(ff);
// 将截取的封面并存储
String format = "jpg";
@Cleanup
ByteArrayOutputStream os = new ByteArrayOutputStream();
ImageIO.write(frameToBufferedImage(frame), format, os);
@Cleanup
InputStream io = new ByteArrayInputStream(os.toByteArray());
minio.putObject(io, MediaType.IMAGE_JPEG_VALUE, filepath + "." + format);
}
ff.stop();
return filepath;
}
public static Frame getPictureFrame(FFmpegFrameGrabber grabber) throws FFmpegFrameGrabber.Exception {
int ftp = grabber.getLengthInFrames();
int flag = 0;
Frame frame = null;
while (flag <= ftp) {
//获取帧
frame = grabber.grabImage();
//过滤前3帧,避免出现全黑图片
if ((flag > 3) && (frame != null)) {
break;
}
flag++;
}
return frame;
}
public static RenderedImage frameToBufferedImage(Frame frame) {
@Cleanup
Java2DFrameConverter converter = new Java2DFrameConverter();
return converter.getBufferedImage(frame);
}
public static boolean isSupportiveMedia(String suffix) {
return isSupportiveVideo(suffix) || isSupportiveAudio(suffix);
}
public static boolean isSupportiveVideo(String suffix) {
return List.of("mp4", "flv", "avi", "rmvb", "rm", "wmv", "mkv", "mpg", "mpeg").contains(suffix.toLowerCase());
}
public static boolean isSupportiveAudio(String suffix) {
return List.of("mp3", "wav").contains(suffix.toLowerCase());
}
public static boolean isSupportiveImage(String suffix) {
return List.of("png", "jpg", "webp", "gif", "tif", "svg", "bmp").contains(suffix.toLowerCase());
}
public static boolean isPdf(String suffix) {
return "pdf".equalsIgnoreCase(suffix);
}
public static String fillSuffix(String object, String fill) {
String suffix = FileUtil.getSuffix(object);
String regex = "^(.*)\\." + suffix + "$";
return object.replaceAll(regex, "$1." + fill + "." + suffix);
}
}
...@@ -2,13 +2,10 @@ ...@@ -2,13 +2,10 @@
package com.yiring.app.web.example; package com.yiring.app.web.example;
import cn.dev33.satoken.annotation.SaCheckLogin; import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.hutool.extra.spring.SpringUtil;
import com.github.xiaoymin.knife4j.annotations.ApiSupport; import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.app.constant.Code; import com.yiring.app.constant.Code;
import com.yiring.app.domain.TestTable;
import com.yiring.app.domain.user.UserExtension; import com.yiring.app.domain.user.UserExtension;
import com.yiring.app.domain.user.UserExtensionRepository; import com.yiring.app.domain.user.UserExtensionRepository;
import com.yiring.app.mapper.TestTableMapper;
import com.yiring.app.vo.user.UserExtensionVo; import com.yiring.app.vo.user.UserExtensionVo;
import com.yiring.auth.annotation.AuthIgnore; import com.yiring.auth.annotation.AuthIgnore;
import com.yiring.auth.domain.user.User; import com.yiring.auth.domain.user.User;
...@@ -25,7 +22,6 @@ import java.util.Arrays; ...@@ -25,7 +22,6 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
...@@ -70,8 +66,9 @@ public class ExampleController { ...@@ -70,8 +66,9 @@ public class ExampleController {
throw Code.FAIL.exception(); throw Code.FAIL.exception();
} }
@SaCheckLogin
@GetMapping("page") @GetMapping("page")
public Result<PageVo<String>> page(@Valid PageParam pageParam) { public Result<PageVo<String>> page(@Validated PageParam pageParam) {
log.info("PageParam: {}", pageParam); log.info("PageParam: {}", pageParam);
List<String> data = Arrays.asList(text.split(" ")); List<String> data = Arrays.asList(text.split(" "));
...@@ -88,13 +85,6 @@ public class ExampleController { ...@@ -88,13 +85,6 @@ public class ExampleController {
FileUtils.download(response, resource.getFile()); FileUtils.download(response, resource.getFile());
} }
@ApiOperation("测试 MyBatis Plus 查询")
@GetMapping("test")
public Result<String> test() {
List<TestTable> tests = SpringUtil.getBean(TestTableMapper.class).selectList(null);
return Result.ok(tests.stream().map(TestTable::getName).reduce((a, b) -> a + "," + b).orElse(""));
}
@SaCheckLogin @SaCheckLogin
@ApiOperation("查询用户属性") @ApiOperation("查询用户属性")
@GetMapping("findUserExtensionInfo") @GetMapping("findUserExtensionInfo")
......
# 环境变量 # 环境变量
env: env:
host: 192.168.0.156 host: 192.168.0.156
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring: spring:
datasource: datasource:
url: jdbc:mysql://${env.host}:3306/basic_app?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai url: jdbc:postgresql://${env.host}:5432/basic_app
username: root username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
jpa: jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect database-platform: org.hibernate.dialect.PostgreSQL10Dialect
open-in-view: true open-in-view: true
hibernate: hibernate:
ddl-auto: update ddl-auto: update
show-sql: false show-sql: true
properties: properties:
hibernate: hibernate:
format_sql: true format_sql: true
...@@ -20,32 +24,30 @@ spring: ...@@ -20,32 +24,30 @@ spring:
database: 5 database: 5
host: ${env.host} host: ${env.host}
port: 6379 port: 6379
password: ${env.extra.password}
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
# knife4j # knife4j
knife4j: knife4j:
enable: true enable: true
basic: basic:
enable: false enable: false
username: admin username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
setting: setting:
enableOpenApi: false enableOpenApi: true
enableDebug: true enableDebug: true
# minio # minio
minio: minio:
access-key: minioadmin access-key: ${env.extra.username}
secret-key: minioadmin secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100" end-point: "http://${env.host}:18100"
bucket: public bucket: public
domain: ${minio.endpoint} domain: ${minio.end-point}
logging: logging:
level: level:
# sql bind parameter # sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: fatal org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
...@@ -8,8 +8,14 @@ spring: ...@@ -8,8 +8,14 @@ spring:
hibernate: hibernate:
# 关闭 hibernate-types banner 日志信息 # 关闭 hibernate-types banner 日志信息
types.print.banner: false types.print.banner: false
data:
redis:
repositories:
enabled: false
logging: logging:
level: level:
# 关闭接口扫描 CachingOperationNameGenerator 日志 # 关闭接口扫描 CachingOperationNameGenerator 日志
springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator: WARN springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator: WARN
# 关闭接口扫描 ApiListingReferenceScanner 日志
springfox.documentation.spring.web.scanners.ApiListingReferenceScanner: WARN
# 环境变量 # 环境变量
env: env:
host: 127.0.0.1 host: 127.0.0.1
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring: spring:
datasource: datasource:
url: jdbc:mysql://${env.host}:3306/basic_app?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai url: jdbc:mysql://${env.host}:3306/basic_app?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
jpa: jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect database-platform: org.hibernate.dialect.MySQL8Dialect
open-in-view: true open-in-view: true
...@@ -20,33 +24,30 @@ spring: ...@@ -20,33 +24,30 @@ spring:
database: 5 database: 5
host: ${env.host} host: ${env.host}
port: 6379 port: 6379
password: 123456 password: ${env.extra.password}
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
# knife4j # knife4j
knife4j: knife4j:
enable: true enable: true
basic: basic:
enable: false enable: false
username: admin username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
setting: setting:
enableOpenApi: false enableOpenApi: false
enableDebug: true enableDebug: true
# minio # minio
minio: minio:
access-key: minioadmin access-key: ${env.extra.username}
secret-key: minioadmin secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100" end-point: "http://${env.host}:18100"
bucket: public bucket: public
domain: ${minio.endpoint} domain: ${minio.end-point}
logging: logging:
level: level:
# sql bind parameter # sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
# 环境变量 # 环境变量
env: env:
host: 192.168.0.156 host: 192.168.0.156
prod: false
extra:
username: admin
password: 123456
spring: spring:
datasource: datasource:
url: jdbc:postgresql://${env.host}:5432/basic_app url: jdbc:postgresql://${env.host}:5432/basic_app
username: admin username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
jpa: jpa:
database-platform: org.hibernate.dialect.PostgreSQL10Dialect database-platform: org.hibernate.dialect.PostgreSQL10Dialect
open-in-view: true open-in-view: true
...@@ -20,20 +24,15 @@ spring: ...@@ -20,20 +24,15 @@ spring:
database: 5 database: 5
host: ${env.host} host: ${env.host}
port: 6379 port: 6379
password: 123456 password: ${env.extra.password}
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
# knife4j # knife4j
knife4j: knife4j:
enable: true enable: true
basic: basic:
enable: false enable: false
username: admin username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
setting: setting:
enableOpenApi: true enableOpenApi: true
enableDebug: true enableDebug: true
...@@ -44,9 +43,11 @@ minio: ...@@ -44,9 +43,11 @@ minio:
secret-key: minioadmin secret-key: minioadmin
end-point: "http://${env.host}:18100" end-point: "http://${env.host}:18100"
bucket: public bucket: public
domain: ${minio.endpoint} domain: ${minio.end-point}
logging: logging:
level: level:
# sql bind parameter # sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
# 环境变量 # 环境变量
env: env:
host: 127.0.0.1 host: 127.0.0.1
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring: spring:
datasource: datasource:
...@@ -20,34 +24,30 @@ spring: ...@@ -20,34 +24,30 @@ spring:
database: 5 database: 5
host: ${env.host} host: ${env.host}
port: 6379 port: 6379
password: 123456 password: ${env.extra.password}
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
# knife4j # knife4j
knife4j: knife4j:
enable: true enable: true
basic: basic:
enable: false enable: false
username: admin username: ${env.extra.username}
password: 123456 password: ${env.extra.password}
setting: setting:
enableOpenApi: true enableOpenApi: true
enableDebug: true enableDebug: true
# minio # minio
minio: minio:
access-key: minioadmin access-key: ${env.extra.username}
secret-key: minioadmin secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100" end-point: "http://${env.host}:18100"
bucket: public bucket: public
domain: ${minio.endpoint} domain: ${minio.end-point}
logging: logging:
level: level:
# sql bind parameter # sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
...@@ -2,15 +2,18 @@ server: ...@@ -2,15 +2,18 @@ server:
port: 8081 port: 8081
servlet: servlet:
context-path: /api context-path: /api
tomcat:
max-http-form-post-size: 20MB
spring: spring:
servlet:
# 文件上传大小限制
multipart:
max-file-size: 10MB
max-request-size: 30MB
application: application:
name: "basic-api-app" name: "basic-api-app"
messages:
basename: i18n/messages
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
profiles: profiles:
include: auth, conf-patch include: auth, conf-patch
active: dev-postgresql active: dev-postgresql
......
common.result.success = \u6210\u529F
upload.filename.null = \u4E0A\u4F20\u6587\u4EF6\u6CA1\u6709\u6587\u4EF6\u540D
NotEmpty.downloadParam.object = \u6587\u4EF6\u5BF9\u8C61\u4E0D\u80FD\u4E3A\u7A7A
/* (C) 2022 YiRing, Inc. */
package com.yiring;
import com.yiring.app.domain.TestTable;
import com.yiring.app.mapper.TestTableMapper;
import java.util.List;
import javax.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.Assert;
/**
* @author Jim
* @version 0.1
* 2022/4/15 17:25
*/
@SpringBootTest
public class MapperSampleTest {
@Resource
TestTableMapper testTableMapper;
@Test
public void testSelect() {
List<TestTable> tests = testTableMapper.selectList(null);
Assert.isTrue(tests.size() > 0, "查询失败");
}
}
...@@ -16,7 +16,7 @@ dependencies { ...@@ -16,7 +16,7 @@ dependencies {
implementation "cn.dev33:sa-token-dao-redis-jackson:${saTokenVersion}" implementation "cn.dev33:sa-token-dao-redis-jackson:${saTokenVersion}"
// fastjson // fastjson
implementation "com.alibaba:fastjson:${fastJsonVersion}" implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// hutool-core // hutool-core
implementation "cn.hutool:hutool-core:${hutoolVersion}" implementation "cn.hutool:hutool-core:${hutoolVersion}"
......
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.annotation; package com.yiring.auth.annotation;
import cn.dev33.satoken.annotation.SaIgnore;
import java.lang.annotation.*; import java.lang.annotation.*;
/** /**
...@@ -16,5 +17,6 @@ import java.lang.annotation.*; ...@@ -16,5 +17,6 @@ import java.lang.annotation.*;
@Target({ ElementType.METHOD }) @Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Documented @Documented
@SaIgnore
public @interface AuthIgnore { public @interface AuthIgnore {
} }
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.config; package com.yiring.auth.config;
import cn.dev33.satoken.interceptor.SaRouteInterceptor; import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaHttpMethod; import cn.dev33.satoken.router.SaHttpMethod;
import cn.dev33.satoken.router.SaRouter; import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil; import cn.dev33.satoken.stp.StpUtil;
import com.yiring.auth.annotation.AuthIgnore; import com.yiring.auth.util.Auths;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
...@@ -24,26 +23,23 @@ public class SaTokenConfigure implements WebMvcConfigurer { ...@@ -24,26 +23,23 @@ public class SaTokenConfigure implements WebMvcConfigurer {
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
// 注册Sa-Token的路由拦截器 // 注册 Sa-Token 路由拦截器
registry registry
.addInterceptor( .addInterceptor(
new SaRouteInterceptor((req, res, handler) -> { new SaInterceptor(handle -> {
// 登录认证 -- 拦截所有路由,并排除 /auth/** 用于开放授权相关, 以及 swagger 相关 // 登录认证 -- 拦截所有路由,并排除 /auth/** 用于开放授权相关, 以及 swagger 相关
SaRouter SaRouter
.match("/**") .match("/**")
.notMatchMethod(SaHttpMethod.OPTIONS.name()) .notMatchMethod(SaHttpMethod.OPTIONS.name())
// 实现用户权限相关后应移除下行代码
// TODO
// .notMatch("/**") // .notMatch("/**")
// 示例接口 .notMatch("/example/**")
// .notMatch("/example/**")
// 授权相关接口(登录、登出、注册等)
.notMatch("/auth/**") .notMatch("/auth/**")
.notMatch("/favicon.ico", "/**/*.html", "/**/*.js", "/**/*.css") .notMatch("/favicon.ico", "/**/*.html", "/**/*.js", "/**/*.css")
.notMatch("/v2/api-docs/**", "/v3/api-docs/**", "/swagger-resources/**") .notMatch("/v2/api-docs/**", "/v3/api-docs/**", "/swagger-resources/**")
// 自定义注解忽略接口鉴权
.notMatch(r -> ((HandlerMethod) handler).hasMethodAnnotation(AuthIgnore.class))
.check(r -> StpUtil.checkLogin()); .check(r -> StpUtil.checkLogin());
// 管理员权限才可访问的路由地址
SaRouter.match("/sys/**", r -> StpUtil.checkRoleOr(Auths.ADMIN_ROLES.toArray(new String[0])));
}) })
) )
.addPathPatterns("/**"); .addPathPatterns("/**");
......
...@@ -6,6 +6,7 @@ import com.yiring.auth.domain.permission.Permission; ...@@ -6,6 +6,7 @@ import com.yiring.auth.domain.permission.Permission;
import com.yiring.auth.domain.role.Role; import com.yiring.auth.domain.role.Role;
import com.yiring.auth.domain.user.User; import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository; import com.yiring.auth.domain.user.UserRepository;
import com.yiring.common.core.Status;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
...@@ -36,6 +37,7 @@ public class StpInterfaceImpl implements StpInterface { ...@@ -36,6 +37,7 @@ public class StpInterfaceImpl implements StpInterface {
.stream() .stream()
.map(Role::getPermissions) .map(Role::getPermissions)
.flatMap(Set::stream) .flatMap(Set::stream)
.distinct()
.map(Permission::getUid) .map(Permission::getUid)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
...@@ -55,7 +57,7 @@ public class StpInterfaceImpl implements StpInterface { ...@@ -55,7 +57,7 @@ public class StpInterfaceImpl implements StpInterface {
String id = Objects.toString(loginId); String id = Objects.toString(loginId);
Optional<User> optional = userRepository.findById(id); Optional<User> optional = userRepository.findById(id);
if (optional.isEmpty()) { if (optional.isEmpty()) {
throw new RuntimeException("用户不存在"); throw Status.NOT_FOUND.exception("用户不存在");
} }
return optional.get(); return optional.get();
......
...@@ -4,20 +4,19 @@ package com.yiring.auth.domain.permission; ...@@ -4,20 +4,19 @@ package com.yiring.auth.domain.permission;
import static com.yiring.auth.domain.permission.Permission.DELETE_SQL; import static com.yiring.auth.domain.permission.Permission.DELETE_SQL;
import static com.yiring.auth.domain.permission.Permission.TABLE_NAME; import static com.yiring.auth.domain.permission.Permission.TABLE_NAME;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.vladmihalcea.hibernate.type.json.JsonType;
import com.yiring.common.domain.BasicEntity; import com.yiring.common.domain.BasicEntity;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import javax.persistence.*; import javax.persistence.*;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import lombok.*; import lombok.*;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants; import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder; import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.*; import org.hibernate.annotations.Comment;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLDeleteAll;
import org.hibernate.annotations.Where;
/** /**
* 权限 * 权限
...@@ -38,7 +37,6 @@ import org.hibernate.annotations.*; ...@@ -38,7 +37,6 @@ import org.hibernate.annotations.*;
@SQLDeleteAll(sql = DELETE_SQL) @SQLDeleteAll(sql = DELETE_SQL)
@Where(clause = BasicEntity.Where.EXIST) @Where(clause = BasicEntity.Where.EXIST)
@Entity @Entity
@TypeDef(name = "json", typeClass = JsonType.class)
@Table( @Table(
name = TABLE_NAME, name = TABLE_NAME,
indexes = { indexes = {
...@@ -128,7 +126,7 @@ public class Permission extends BasicEntity implements Serializable { ...@@ -128,7 +126,7 @@ public class Permission extends BasicEntity implements Serializable {
* @return JSON 格式 Meta 元数据 * @return JSON 格式 Meta 元数据
*/ */
public JSONObject getMetaJson() { public JSONObject getMetaJson() {
JSONObject meta = new JSONObject(true); JSONObject meta = new JSONObject();
meta.put("title", this.name); meta.put("title", this.name);
meta.put("icon", this.icon); meta.put("icon", this.icon);
meta.put("orderNo", this.serial); meta.put("orderNo", this.serial);
......
...@@ -62,6 +62,9 @@ public class Role extends BasicEntity implements Serializable { ...@@ -62,6 +62,9 @@ public class Role extends BasicEntity implements Serializable {
@Column(nullable = false) @Column(nullable = false)
String name; String name;
@Comment("是否启用")
Boolean enable;
@JsonIgnore @JsonIgnore
@Builder.Default @Builder.Default
@Comment("权限集合") @Comment("权限集合")
......
...@@ -3,6 +3,7 @@ package com.yiring.auth.domain.role; ...@@ -3,6 +3,7 @@ package com.yiring.auth.domain.role;
import java.io.Serializable; import java.io.Serializable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/** /**
* 角色接口 * 角色接口
...@@ -10,4 +11,6 @@ import org.springframework.data.jpa.repository.JpaRepository; ...@@ -10,4 +11,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
* @author ifzm * @author ifzm
* 2018/9/4 8:49 * 2018/9/4 8:49
*/ */
@Repository
public interface RoleRepository extends JpaRepository<Role, Serializable> {} public interface RoleRepository extends JpaRepository<Role, Serializable> {}
...@@ -6,6 +6,7 @@ import cn.dev33.satoken.stp.StpUtil; ...@@ -6,6 +6,7 @@ import cn.dev33.satoken.stp.StpUtil;
import com.yiring.auth.domain.user.User; import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository; import com.yiring.auth.domain.user.UserRepository;
import com.yiring.common.core.Status; import com.yiring.common.core.Status;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import javax.annotation.Resource; import javax.annotation.Resource;
...@@ -28,6 +29,11 @@ public class Auths { ...@@ -28,6 +29,11 @@ public class Auths {
UserRepository userRepository; UserRepository userRepository;
/** /**
* 管理员角色标识
*/
public static final List<String> ADMIN_ROLES = List.of("admin", "super-admin", "platform-admin", "data-admin");
/**
* 根据 Token 获取用户信息 * 根据 Token 获取用户信息
* 如果用户未登录或校验失败会抛出 NotLoginException {@link Status#UNAUTHORIZED} * 如果用户未登录或校验失败会抛出 NotLoginException {@link Status#UNAUTHORIZED}
* @param token token * @param token token
...@@ -61,4 +67,46 @@ public class Auths { ...@@ -61,4 +67,46 @@ public class Auths {
return getUserByToken(token); return getUserByToken(token);
} }
/**
* 踢出这个用户 id 所有登录状态(可能有多人重复登录了一个账号的情况)
* @param userId 用户 id
*/
public void logoutAll(String userId) {
List<String> tokens = StpUtil.getTokenValueListByLoginId(userId);
for (String token : tokens) {
StpUtil.logoutByTokenValue(token);
}
}
/**
* 判断用户是否为超级管理员
* @param userId 用户 ID
* @return 是否为管理员
*/
public boolean isAdmin(String userId) {
Optional<User> optional = userRepository.findById(userId);
return optional.filter(this::isAdmin).isPresent();
}
/**
* 检查用户是否为管理员(检查用户是否拥有包含 admin 字符的角色)
* @param user 用户
* @return 是否为管理员
*/
public boolean isAdmin(User user) {
return user
.getRoles()
.stream()
.anyMatch(role -> Boolean.TRUE.equals(role.getEnable()) && ADMIN_ROLES.contains(role.getUid()));
}
/**
* 检查当前登录用户是否为管理员
* {@link this.isAdmin}
* @return 是否为管理员
*/
public boolean checkLoginUserIsAdmin() {
return isAdmin(getLoginUser());
}
} }
...@@ -70,7 +70,29 @@ public class Permissions { ...@@ -70,7 +70,29 @@ public class Permissions {
} }
}); });
return roots; return new ArrayList<>(sortMenuTreeVo(roots));
}
/**
* 菜单树递归排序
* @param menus 菜单集合
* @return 排序后的菜单集合
*/
public static List<MenuVo> sortMenuTreeVo(@Nullable List<MenuVo> menus) {
return menus
.stream()
.sorted(
Comparator.comparing(
item -> item.getMeta().getIntValue("orderNo"),
Comparator.nullsFirst(Comparator.naturalOrder())
)
)
.peek(item -> {
if (!Commons.isNullOrEmpty(item.getChildren())) {
item.setChildren(sortMenuTreeVo(item.getChildren()));
}
})
.toList();
} }
/** /**
...@@ -101,6 +123,7 @@ public class Permissions { ...@@ -101,6 +123,7 @@ public class Permissions {
.stream() .stream()
.map(Role::getPermissions) .map(Role::getPermissions)
.flatMap(Set::stream) .flatMap(Set::stream)
.distinct()
.sorted(Comparator.comparing(Permission::getTree, Comparator.comparingInt(String::length))) .sorted(Comparator.comparing(Permission::getTree, Comparator.comparingInt(String::length)))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
......
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.vo.permission; package com.yiring.auth.vo.permission;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModel;
......
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.vo.permission; package com.yiring.auth.vo.permission;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.yiring.auth.domain.permission.Permission; import com.yiring.auth.domain.permission.Permission;
import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModel;
......
...@@ -31,6 +31,9 @@ public class UserInfoVo implements Serializable { ...@@ -31,6 +31,9 @@ public class UserInfoVo implements Serializable {
@ApiModelProperty(value = "主键", example = "1") @ApiModelProperty(value = "主键", example = "1")
String userId; String userId;
@ApiModelProperty(value = "手机号", example = "15616260195")
String mobile;
@ApiModelProperty(value = "真实姓名", example = "超级用户") @ApiModelProperty(value = "真实姓名", example = "超级用户")
String realName; String realName;
......
...@@ -126,6 +126,12 @@ public class AuthController { ...@@ -126,6 +126,12 @@ public class AuthController {
return Result.ok(vo); return Result.ok(vo);
} }
@ApiOperation(value = "检查登录状态")
@GetMapping("valid")
public Result<Boolean> valid() {
return Result.ok(StpUtil.isLogin());
}
@ApiOperation(value = "登出") @ApiOperation(value = "登出")
@GetMapping("logout") @GetMapping("logout")
public Result<String> logout() { public Result<String> logout() {
......
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.web.permission; package com.yiring.auth.web.sys.permission;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONValidator;
import com.github.xiaoymin.knife4j.annotations.ApiSupport; import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.permission.Permission; import com.yiring.auth.domain.permission.Permission;
import com.yiring.auth.domain.permission.PermissionRepository; import com.yiring.auth.domain.permission.PermissionRepository;
...@@ -46,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -46,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
@ApiSupport(order = -97) @ApiSupport(order = -97)
@Api(tags = "权限管理", description = "Permission") @Api(tags = "权限管理", description = "Permission")
@RestController @RestController
@RequestMapping("/manage/permission/") @RequestMapping("/sys/permission/")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PermissionController { public class PermissionController {
...@@ -60,10 +61,7 @@ public class PermissionController { ...@@ -60,10 +61,7 @@ public class PermissionController {
} }
Permission entity = new Permission(); Permission entity = new Permission();
BeanUtils.copyProperties(param, entity); save(entity, param);
entity.setTree(getTreeNode(param.getPid()));
entity.setMeta(JSON.parseObject(param.getMeta()));
permissionRepository.saveAndFlush(entity);
return Result.ok(); return Result.ok();
} }
...@@ -83,10 +81,7 @@ public class PermissionController { ...@@ -83,10 +81,7 @@ public class PermissionController {
} }
} }
BeanUtils.copyProperties(param, entity); save(entity, param);
entity.setTree(getTreeNode(param.getPid()));
entity.setMeta(JSON.parseObject(param.getMeta()));
permissionRepository.saveAndFlush(entity);
return Result.ok(); return Result.ok();
} }
...@@ -111,8 +106,10 @@ public class PermissionController { ...@@ -111,8 +106,10 @@ public class PermissionController {
return Result.no(Status.NOT_FOUND); return Result.no(Status.NOT_FOUND);
} }
Permission permission = optional.get();
PermissionVo vo = new PermissionVo(); PermissionVo vo = new PermissionVo();
BeanUtils.copyProperties(optional.get(), vo); BeanUtils.copyProperties(optional.get(), vo, Permission.Fields.meta);
vo.setMeta(permission.getMetaJson());
return Result.ok(vo); return Result.ok(vo);
} }
...@@ -159,4 +156,19 @@ public class PermissionController { ...@@ -159,4 +156,19 @@ public class PermissionController {
Permission entity = Permission.builder().uid(uid).build(); Permission entity = Permission.builder().uid(uid).build();
return permissionRepository.count(Example.of(entity)) > 0; return permissionRepository.count(Example.of(entity)) > 0;
} }
/**
* 新增或修改权限菜单
*
* @param entity 实体对象
* @param param 参数
*/
private void save(Permission entity, PermissionParam param) {
BeanUtils.copyProperties(param, entity, Permission.Fields.meta);
entity.setTree(getTreeNode(param.getPid()));
if (JSONValidator.from(param.getMeta()).validate()) {
entity.setMeta(JSON.parseObject(param.getMeta()));
}
permissionRepository.saveAndFlush(entity);
}
} }
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.auth.web.role; package com.yiring.auth.web.sys.role;
import com.github.xiaoymin.knife4j.annotations.ApiSupport; import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.permission.Permission; import com.yiring.auth.domain.permission.Permission;
...@@ -17,8 +17,8 @@ import com.yiring.common.param.PageParam; ...@@ -17,8 +17,8 @@ import com.yiring.common.param.PageParam;
import com.yiring.common.vo.PageVo; import com.yiring.common.vo.PageVo;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import java.io.Serializable;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
import javax.validation.Valid; import javax.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
...@@ -45,7 +45,7 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -45,7 +45,7 @@ import org.springframework.web.bind.annotation.RestController;
@ApiSupport(order = -96) @ApiSupport(order = -96)
@Api(tags = "角色管理", description = "Role") @Api(tags = "角色管理", description = "Role")
@RestController @RestController
@RequestMapping("/manage/role/") @RequestMapping("/sys/role/")
@RequiredArgsConstructor @RequiredArgsConstructor
public class RoleController { public class RoleController {
...@@ -95,12 +95,8 @@ public class RoleController { ...@@ -95,12 +95,8 @@ public class RoleController {
} }
// 查询权限集合 // 查询权限集合
Set<String> ids = idsParam.toIds(); Collection<Serializable> ids = idsParam.toIds();
Set<Permission> permissions = permissionRepository Set<Permission> permissions = new HashSet<>(permissionRepository.findAllById(ids));
.findAll()
.stream()
.filter(permission -> ids.contains(permission.getId()))
.collect(Collectors.toSet());
Role entity = optional.get(); Role entity = optional.get();
entity.setPermissions(permissions); entity.setPermissions(permissions);
...@@ -110,14 +106,9 @@ public class RoleController { ...@@ -110,14 +106,9 @@ public class RoleController {
@ApiOperation(value = "删除") @ApiOperation(value = "删除")
@PostMapping("deleted") @PostMapping("deleted")
public Result<String> deleted(@Valid IdParam param) { public Result<String> deleted(@Valid IdsParam param) {
Optional<Role> optional = roleRepository.findById(param.getId()); List<Role> roles = roleRepository.findAllById(param.toIds());
if (optional.isEmpty()) { roleRepository.deleteAll(roles);
return Result.no(Status.NOT_FOUND);
}
Role entity = optional.get();
roleRepository.delete(entity);
return Result.ok(); return Result.ok();
} }
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.auth.web.sys.user;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.role.Role;
import com.yiring.auth.domain.role.RoleRepository;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository;
import com.yiring.auth.vo.user.UserVo;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.param.IdParam;
import com.yiring.common.param.IdsParam;
import com.yiring.common.param.PageParam;
import com.yiring.common.util.Commons;
import com.yiring.common.vo.PageVo;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import java.io.Serializable;
import java.util.*;
import java.util.stream.Collectors;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 系统用户控制器
*
* @author Jim
* @version 0.1
* 2022/1/24 14:13
*/
@Slf4j
@Validated
@SuppressWarnings({ "deprecation" })
@ApiSupport(order = -95)
@Api(tags = "用户管理", description = "User")
@RestController
@RequestMapping("/sys/user/")
@RequiredArgsConstructor
public class UserController {
final UserRepository userRepository;
final RoleRepository roleRepository;
@ApiOperation(value = "分配角色")
@PostMapping("assign")
public Result<String> assign(@Valid IdParam idParam, @Valid IdsParam idsParam) {
Optional<User> optional = userRepository.findById(idParam.getId());
if (optional.isEmpty()) {
return Result.no(Status.NOT_FOUND);
}
// 查询角色集合
Collection<Serializable> ids = idsParam.toIds();
Set<Role> roles = new HashSet<>(roleRepository.findAllById(ids));
User entity = optional.get();
entity.setRoles(roles);
userRepository.saveAndFlush(entity);
return Result.ok();
}
@ApiOperation(value = "分页查询")
@GetMapping("page")
public Result<PageVo<UserVo>> page(@Valid PageParam param) {
Page<User> page = userRepository.findAll(PageParam.toPageable(param));
List<UserVo> data = page.get().map(user -> Commons.transform(user, UserVo.class)).collect(Collectors.toList());
PageVo<UserVo> vo = PageVo.build(data, page.getTotalElements());
return Result.ok(vo);
}
}
...@@ -3,36 +3,21 @@ package com.yiring.auth.web.user; ...@@ -3,36 +3,21 @@ package com.yiring.auth.web.user;
import com.github.xiaoymin.knife4j.annotations.ApiSupport; import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.permission.Permission; import com.yiring.auth.domain.permission.Permission;
import com.yiring.auth.domain.role.Role;
import com.yiring.auth.domain.role.RoleRepository;
import com.yiring.auth.domain.user.User; import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository;
import com.yiring.auth.util.Auths; import com.yiring.auth.util.Auths;
import com.yiring.auth.util.Permissions; import com.yiring.auth.util.Permissions;
import com.yiring.auth.vo.permission.MenuVo; import com.yiring.auth.vo.permission.MenuVo;
import com.yiring.auth.vo.user.UserInfoVo; import com.yiring.auth.vo.user.UserInfoVo;
import com.yiring.auth.vo.user.UserVo;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.param.IdParam;
import com.yiring.common.param.IdsParam;
import com.yiring.common.param.PageParam;
import com.yiring.common.util.Commons;
import com.yiring.common.vo.PageVo;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
...@@ -48,15 +33,13 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -48,15 +33,13 @@ import org.springframework.web.bind.annotation.RestController;
@Validated @Validated
@SuppressWarnings({ "deprecation" }) @SuppressWarnings({ "deprecation" })
@ApiSupport(order = -95) @ApiSupport(order = -95)
@Api(tags = "用户管理", description = "User") @Api(tags = "用户信息", description = "UserView")
@RestController @RestController
@RequestMapping("/user/") @RequestMapping("/user/")
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserController { public class UserViewController {
final Auths auths; final Auths auths;
final UserRepository userRepository;
final RoleRepository roleRepository;
@ApiOperation(value = "获取登录用户信息") @ApiOperation(value = "获取登录用户信息")
@GetMapping("getUserInfo") @GetMapping("getUserInfo")
...@@ -65,6 +48,7 @@ public class UserController { ...@@ -65,6 +48,7 @@ public class UserController {
UserInfoVo userInfoVo = UserInfoVo UserInfoVo userInfoVo = UserInfoVo
.builder() .builder()
.userId(user.getId()) .userId(user.getId())
.mobile(user.getMobile())
.username(user.getUsername()) .username(user.getUsername())
.realName(user.getRealName()) .realName(user.getRealName())
.avatar(user.getAvatar()) .avatar(user.getAvatar())
...@@ -95,36 +79,4 @@ public class UserController { ...@@ -95,36 +79,4 @@ public class UserController {
List<String> codes = permissions.stream().map(Permission::getUid).collect(Collectors.toList()); List<String> codes = permissions.stream().map(Permission::getUid).collect(Collectors.toList());
return Result.ok((ArrayList<String>) codes); return Result.ok((ArrayList<String>) codes);
} }
@ApiOperation(value = "分配角色")
@PostMapping("/manage/assign")
public Result<String> assign(@Valid IdParam idParam, @Valid IdsParam idsParam) {
Optional<User> optional = userRepository.findById(idParam.getId());
if (optional.isEmpty()) {
return Result.no(Status.NOT_FOUND);
}
// 查询权限集合
Set<String> ids = idsParam.toIds();
Set<Role> roles = roleRepository
.findAll()
.stream()
.filter(role -> ids.contains(role.getId()))
.collect(Collectors.toSet());
User entity = optional.get();
entity.setRoles(roles);
userRepository.saveAndFlush(entity);
return Result.ok();
}
@ApiOperation(value = "分页查询")
@GetMapping("/manage/page")
public Result<PageVo<UserVo>> page(@Valid PageParam param) {
Page<User> page = userRepository.findAll(PageParam.toPageable(param));
List<UserVo> data = page.get().map(user -> Commons.transform(user, UserVo.class)).collect(Collectors.toList());
PageVo<UserVo> vo = PageVo.build(data, page.getTotalElements());
return Result.ok(vo);
}
} }
...@@ -16,11 +16,15 @@ dependencies { ...@@ -16,11 +16,15 @@ dependencies {
implementation "cn.hutool:hutool-extra:${hutoolVersion}" implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// fastjson // fastjson
implementation "com.alibaba:fastjson:${fastJsonVersion}" implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// JTS 几何对象操作库 // JTS 几何对象操作库
implementation "org.locationtech.jts:jts-core:${jtsVersion}" implementation "org.locationtech.jts:jts-core:${jtsVersion}"
// https://github.com/vladmihalcea/hibernate-types
// hibernate-types-55
implementation "com.vladmihalcea:hibernate-types-55:${hibernateTypesVersion}"
// https://mvnrepository.com/artifact/org.n52.jackson/jackson-datatype-jts/1.2.10 // https://mvnrepository.com/artifact/org.n52.jackson/jackson-datatype-jts/1.2.10
implementation("org.n52.jackson:jackson-datatype-jts:1.2.10") { implementation("org.n52.jackson:jackson-datatype-jts:1.2.10") {
exclude group: 'com.fasterxml.jackson.core' exclude group: 'com.fasterxml.jackson.core'
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
package com.yiring.common.aspect; package com.yiring.common.aspect;
import cn.hutool.extra.servlet.ServletUtil; import cn.hutool.extra.servlet.ServletUtil;
import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson.serializer.SerializerFeature; import com.alibaba.fastjson2.JSONWriter;
import com.yiring.common.constant.DateFormatter; import com.yiring.common.constant.DateFormatter;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.util.Commons; import com.yiring.common.util.Commons;
...@@ -55,8 +55,11 @@ public class RequestAspect { ...@@ -55,8 +55,11 @@ public class RequestAspect {
// 获取接口请求扩展信息,Header, Params // 获取接口请求扩展信息,Header, Params
String extra = ""; String extra = "";
if (Boolean.TRUE.equals(debug)) { if (Boolean.TRUE.equals(debug)) {
String headers = JSONObject.toJSONString(ServletUtil.getHeaderMap(request), SerializerFeature.PrettyFormat); String headers = JSONObject.toJSONString(
String params = JSONObject.toJSONString(ServletUtil.getParamMap(request), SerializerFeature.PrettyFormat); ServletUtil.getHeaderMap(request),
JSONWriter.Feature.PrettyFormat
);
String params = JSONObject.toJSONString(ServletUtil.getParamMap(request), JSONWriter.Feature.PrettyFormat);
extra += String.format("\nHeaders: %s", headers); extra += String.format("\nHeaders: %s", headers);
extra += String.format("\nParams: %s", params); extra += String.format("\nParams: %s", params);
if (result instanceof Result) { if (result instanceof Result) {
......
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.common.config; package com.yiring.common.config;
import java.io.Serial;
import java.io.Serializable;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.Data; import lombok.Data;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
...@@ -17,12 +19,48 @@ import org.springframework.context.annotation.Configuration; ...@@ -17,12 +19,48 @@ import org.springframework.context.annotation.Configuration;
@Data @Data
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@Configuration @Configuration("env.config")
@ConfigurationProperties(prefix = "env") @ConfigurationProperties(prefix = "env")
public class EnvConfig { public class EnvConfig implements Serializable {
@Serial
private static final long serialVersionUID = 1017213697767634790L;
/** /**
* host,用来共享一些资源(如:数据库、文件存储等相关的依赖源) * host,用来共享一些资源(如:数据库、文件存储等相关的依赖源)
*/ */
String host; String host;
/**
* 是否为生产环境
*/
boolean prod;
/**
* 扩展配置
*/
Extra extra;
/**
* 扩展环境配置变量
*/
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Configuration("env.config.extra")
@ConfigurationProperties(prefix = "env.extra")
public static class Extra implements Serializable {
@Serial
private static final long serialVersionUID = -521508901960998020L;
/**
* 公共用户名
*/
String username;
/**
* 公共密码
*/
String password;
}
} }
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.common.domain; package com.yiring.common.domain;
import com.vladmihalcea.hibernate.type.json.JsonBinaryType;
import com.vladmihalcea.hibernate.type.json.JsonType;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import javax.persistence.*; import javax.persistence.*;
import lombok.*; import lombok.*;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants; import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder; import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.Comment; import org.hibernate.annotations.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.snowflake.SnowflakeId; import org.hibernate.snowflake.SnowflakeId;
/** /**
...@@ -29,6 +28,11 @@ import org.hibernate.snowflake.SnowflakeId; ...@@ -29,6 +28,11 @@ import org.hibernate.snowflake.SnowflakeId;
@FieldNameConstants @FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE) @FieldDefaults(level = AccessLevel.PRIVATE)
@SuperBuilder(toBuilder = true) @SuperBuilder(toBuilder = true)
@TypeDefs(
value = {
@TypeDef(name = "json", typeClass = JsonType.class), @TypeDef(name = "jsonb", typeClass = JsonBinaryType.class),
}
)
@MappedSuperclass @MappedSuperclass
public abstract class BasicEntity { public abstract class BasicEntity {
......
...@@ -6,7 +6,7 @@ import io.swagger.annotations.ApiModelProperty; ...@@ -6,7 +6,7 @@ import io.swagger.annotations.ApiModelProperty;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.Arrays; import java.util.Arrays;
import java.util.Set; import java.util.Collection;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotEmpty;
...@@ -38,9 +38,10 @@ public class IdsParam implements Serializable { ...@@ -38,9 +38,10 @@ public class IdsParam implements Serializable {
/** /**
* 获取 String 类型的 ID 集合 * 获取 String 类型的 ID 集合
*
* @return ID 集合 * @return ID 集合
*/ */
public Set<String> toIds() { public Collection<Serializable> toIds() {
return Arrays.stream(this.ids.split(",")).collect(Collectors.toSet()); return Arrays.stream(this.ids.split(",")).collect(Collectors.toSet());
} }
} }
...@@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModel; ...@@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty; import io.swagger.annotations.ApiModelProperty;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.Objects;
import javax.validation.constraints.DecimalMin; import javax.validation.constraints.DecimalMin;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
...@@ -12,6 +13,7 @@ import lombok.Data; ...@@ -12,6 +13,7 @@ import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import lombok.experimental.SuperBuilder; import lombok.experimental.SuperBuilder;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
...@@ -58,6 +60,10 @@ public class OptionalPageParam implements Serializable { ...@@ -58,6 +60,10 @@ public class OptionalPageParam implements Serializable {
return Pageable.unpaged(); return Pageable.unpaged();
} }
return PageParam.toPageable(param.getSortField(), param.getSortOrder(), param.pageSize, param.getPageNo()); Sort sort = Sort.unsorted();
if (Objects.nonNull(param.getSortField())) {
sort = Sort.by(new Sort.Order(param.getSortOrder(), param.getSortField()));
}
return PageRequest.of(param.getPageNo() - 1, param.getPageSize(), sort);
} }
} }
/* (C) 2021 YiRing, Inc. */ /* (C) 2021 YiRing, Inc. */
package com.yiring.common.vo; package com.yiring.common.vo;
import com.yiring.common.util.Commons;
import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty; import io.swagger.annotations.ApiModelProperty;
import java.io.Serial; import java.io.Serial;
...@@ -10,6 +11,7 @@ import java.util.Collections; ...@@ -10,6 +11,7 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import lombok.*; import lombok.*;
import lombok.experimental.FieldDefaults; import lombok.experimental.FieldDefaults;
import org.springframework.data.domain.Page;
/** /**
* 分页查询响应公共类 * 分页查询响应公共类
...@@ -41,7 +43,19 @@ public class PageVo<T extends Serializable> implements Serializable { ...@@ -41,7 +43,19 @@ public class PageVo<T extends Serializable> implements Serializable {
/** /**
* 构建一个 PageVo * 构建一个 PageVo
*
* @param data 数据 * @param data 数据
* @return PageVo
*/
@SuppressWarnings({ "unused" })
public static <R extends Serializable> PageVo<R> build(List<R> data) {
return build(data, data.size());
}
/**
* 构建一个 PageVo
*
* @param data 数据
* @param total 总数据量 * @param total 总数据量
* @return PageVo * @return PageVo
*/ */
...@@ -52,8 +66,9 @@ public class PageVo<T extends Serializable> implements Serializable { ...@@ -52,8 +66,9 @@ public class PageVo<T extends Serializable> implements Serializable {
/** /**
* 构建一个 PageVo * 构建一个 PageVo
* @param data 数据 *
* @param total 总数据量 * @param data 数据
* @param total 总数据量
* @param latest 数据最新时间 * @param latest 数据最新时间
* @return PageVo * @return PageVo
*/ */
...@@ -67,9 +82,23 @@ public class PageVo<T extends Serializable> implements Serializable { ...@@ -67,9 +82,23 @@ public class PageVo<T extends Serializable> implements Serializable {
} }
/** /**
* 构建一个空的分页查询结果 * 提取 Page 查询结果并转换成 Vo
*
* @param page Page 查询结果
* @param type Vo 类
* @param <S> 数据实体表
* @param <T> Vo 表
* @return PageVo * @return PageVo
*/ */
@SuppressWarnings({ "unused" })
public static <S, T extends Serializable> PageVo<T> toPageVo(Page<S> page, Class<T> type) {
List<T> data = Commons.transform(page.toList(), type);
return build(data, page.getTotalElements());
}
/**
* @return 空的分页查询结果
*/
@SuppressWarnings("unused") @SuppressWarnings("unused")
public static <T extends Serializable> PageVo<T> empty() { public static <T extends Serializable> PageVo<T> empty() {
return PageVo.build(Collections.emptyList(), 0); return PageVo.build(Collections.emptyList(), 0);
......
...@@ -60,23 +60,28 @@ public class SwaggerConfig implements CommandLineRunner { ...@@ -60,23 +60,28 @@ public class SwaggerConfig implements CommandLineRunner {
OpenApiExtensionResolver openApiExtensionResolver; OpenApiExtensionResolver openApiExtensionResolver;
@Bean(name = "api.all") @Bean(name = "api.all")
public Docket api() { public Docket all() {
return api("@All", List.of(""), PathSelectors.any()); return api("① 全部", List.of(""), PathSelectors.any());
} }
@Bean(name = "api.auth") @Bean(name = "api.auth")
public Docket auth() { public Docket auth() {
return api("Auth", List.of("com.yiring.auth.web.auth"), PathSelectors.any()); return api("② Auth", List.of("com.yiring.auth.web"), Predicate.not(PathSelectors.ant(path + "/sys/**")));
} }
@Bean(name = "api.common") @Bean(name = "api.common")
public Docket common() { public Docket common() {
return api("公共", List.of("com.yiring.common.web", "com.yiring.app.web.common"), PathSelectors.any()); return api("③ 公共", List.of("com.yiring.common.web", "com.yiring.app.web.common"), PathSelectors.any());
}
@Bean(name = "api.manage")
public Docket manage() {
return api("④ 系统管理", List.of("com.yiring.auth.web.sys"), PathSelectors.any());
} }
@Bean(name = "api.example") @Bean(name = "api.example")
public Docket example() { public Docket example() {
return api("示例", List.of("com.yiring.app.web.example"), PathSelectors.any()); return api("示例", List.of("com.yiring.app.web.example"), PathSelectors.any());
} }
private Docket api(String group, List<String> basePackages, Predicate<String> paths) { private Docket api(String group, List<String> basePackages, Predicate<String> paths) {
......
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.config;
import java.util.Locale;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
/**
* 国际化配置
*
* @author Jim
* @version 0.1
* 2022/8/17 10:33
*/
@Configuration
public class I18nConfig {
@Bean
public LocaleResolver localeResolver() {
// header accept-language
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.SIMPLIFIED_CHINESE);
return resolver;
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.core;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
/**
* 国际化
*
* @author Jim
* @version 0.1
* 2022/8/17 10:40
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class I18n {
final MessageSource messageSource;
/**
* 根据 MessageSourceResolvable 获取 I18n 消息
* @param resolvable MessageSourceResolvable
* @return 消息内容
*/
@SuppressWarnings("unused")
public String get(MessageSourceResolvable resolvable) {
return messageSource.getMessage(resolvable, LocaleContextHolder.getLocale());
}
/**
* 根据 code 和注入参数获取 I18n 消息
* eg:
* default.nonnull = {0}不能为空
* message.username.not-empty = 用户姓名不能为空
* I18n.get("default.nonnull", "用户姓名")
* I18n.get("message.username.not-empty")
* @param code 消息标识
* @param args 注入参数
* @return 消息内容
*/
@SuppressWarnings("unused")
public String get(String code, Object... args) {
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
/**
* 根据 code 和注入参数获取 I18n 消息
* eg:
* default.nonnull = {0}不能为空
* I18n.get("default.nonnull", "用户姓名")
* I18n.get("message.username.not-empty", "用户姓名不能为空")
* @param code 消息标识
* @param defaultMessage 默认消息
* @param args 注入参数
* @return 消息内容
*/
@SuppressWarnings("unused")
public String get(String code, String defaultMessage, Object... args) {
return messageSource.getMessage(code, args, defaultMessage, LocaleContextHolder.getLocale());
}
}
...@@ -23,15 +23,26 @@ import org.springframework.context.annotation.Configuration; ...@@ -23,15 +23,26 @@ import org.springframework.context.annotation.Configuration;
@ConfigurationProperties(prefix = "minio") @ConfigurationProperties(prefix = "minio")
public class MinioConfig { public class MinioConfig {
/**
* 通常是内网地址
*/
String endpoint; String endpoint;
/**
* 账户/访问标识
*/
String accessKey; String accessKey;
/**
* 密码/认证密钥
*/
String secretKey; String secretKey;
/**
* 默认储存桶名称
*/
String bucket; String bucket;
String domain;
/** /**
* 外部地址 * 通常是和 endpoint 相对的外网地址
*/ */
String externalPoint; String domain;
@Bean @Bean
public MinioClient getClient() { public MinioClient getClient() {
......
...@@ -9,6 +9,10 @@ import java.io.File; ...@@ -9,6 +9,10 @@ import java.io.File;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import lombok.NonNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
...@@ -32,6 +36,7 @@ public record Minio(MinioConfig config, MinioClient client) { ...@@ -32,6 +36,7 @@ public record Minio(MinioConfig config, MinioClient client) {
* 获取文件访问地址 * 获取文件访问地址
* *
* @param object 文件相对地址(含路径) * @param object 文件相对地址(含路径)
* @param bucket 存储桶名称
* @return URI * @return URI
*/ */
public String getURI(String object, String bucket) { public String getURI(String object, String bucket) {
...@@ -39,6 +44,35 @@ public record Minio(MinioConfig config, MinioClient client) { ...@@ -39,6 +44,35 @@ public record Minio(MinioConfig config, MinioClient client) {
} }
/** /**
* 获取文件访问地址(默认桶)
*
* @param object 文件相对地址(含路径)
* @return URI
*/
public String getDefaultURI(String object) {
return String.join("/", config.getDomain(), config.getBucket(), object);
}
/**
* 构建文件上传地址
*
* @param name 名称
* @param suffix 后缀,可以是空字符串
* @param folder 前缀目录,支持多层级
* @return 文件 object 相对地址
*/
public String buildUploadPath(@NonNull String name, @NonNull String suffix, String... folder) {
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/M/d"));
String prefix = "upload/" + date;
if (Objects.nonNull(folder) && folder.length > 0) {
prefix += "/" + String.join("/", folder);
}
return prefix + "/" + name + suffix;
}
/**
* 上传文件流 * 上传文件流
* *
* @param is 文件流 * @param is 文件流
...@@ -146,9 +180,7 @@ public record Minio(MinioConfig config, MinioClient client) { ...@@ -146,9 +180,7 @@ public record Minio(MinioConfig config, MinioClient client) {
*/ */
public void copyObject(String bucket, String source, String target) throws Exception { public void copyObject(String bucket, String source, String target) throws Exception {
CopySource copySource = CopySource.builder().bucket(bucket).object(source).build(); CopySource copySource = CopySource.builder().bucket(bucket).object(source).build();
CopyObjectArgs args = CopyObjectArgs.builder().bucket(bucket).object(target).source(copySource).build(); CopyObjectArgs args = CopyObjectArgs.builder().bucket(bucket).object(target).source(copySource).build();
client.copyObject(args); client.copyObject(args);
} }
......
...@@ -35,6 +35,6 @@ public class DownloadParam implements Serializable { ...@@ -35,6 +35,6 @@ public class DownloadParam implements Serializable {
String bucket; String bucket;
@ApiModelProperty(value = "object", example = "cat.jpg", required = true) @ApiModelProperty(value = "object", example = "cat.jpg", required = true)
@NotEmpty(message = "文件对象不能为空") @NotEmpty
String object; String object;
} }
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.service;
import java.io.InputStream;
/**
* 文件上传媒体文件预处理服务
*
* @author Jim
* @version 0.1
* 2022/9/23 16:31
*/
public interface UploadProcessService {
/**
* 对文件进行预处理,例如:
* 图片:haha.png -> haha.89x120.png 记录图片宽高,宽 = 89px,高 = 120px
* PDF:haha.pdf -> haha.P12.pdf 记录 PDF 文件总页数,P12 即代表 PDF 总共 12 页,同时可通过 haha.P12.pdf.1.jpg... 按序号读取到每一页 PDF 对应的图片
* 音视频:haha.mp3/4 -> haha.T12.mp3/4 记录音视频的时长(秒),T12 即代表音视频时长为 12 秒
* 视频封面:haha.mp4 -> haha.mp4.jpg 截取视频封面
* @param object 上传文件存储地址
* @param is 文件流
* @return 预处理后的文件地址(可能对文件名追加了时长、页数、分辨率等标识)
*/
@SuppressWarnings("unused")
default String handle(String object, InputStream is) {
return object;
}
}
...@@ -2,27 +2,25 @@ ...@@ -2,27 +2,25 @@
package com.yiring.common.web; package com.yiring.common.web;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.IdUtil;
import com.github.xiaoymin.knife4j.annotations.ApiSupport; import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.common.config.MinioConfig;
import com.yiring.common.core.Minio; import com.yiring.common.core.Minio;
import com.yiring.common.core.Result; import com.yiring.common.core.Result;
import com.yiring.common.core.Status; import com.yiring.common.core.Status;
import com.yiring.common.exception.FailStatusException;
import com.yiring.common.param.DownloadParam; import com.yiring.common.param.DownloadParam;
import com.yiring.common.service.UploadProcessService;
import com.yiring.common.util.FileUtils; import com.yiring.common.util.FileUtils;
import com.yiring.common.vo.ImageInfo;
import io.minio.GetObjectResponse; import io.minio.GetObjectResponse;
import io.minio.ObjectWriteResponse;
import io.minio.StatObjectResponse; import io.minio.StatObjectResponse;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam; import io.swagger.annotations.ApiParam;
import java.sql.Timestamp; import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid; import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
...@@ -47,7 +45,7 @@ import org.springframework.web.multipart.MultipartFile; ...@@ -47,7 +45,7 @@ import org.springframework.web.multipart.MultipartFile;
public class MinioController { public class MinioController {
final Minio minio; final Minio minio;
final MinioConfig minioConfig; final UploadProcessService service;
/** /**
* minio 上传文件,成功返回文件 url * minio 上传文件,成功返回文件 url
...@@ -56,15 +54,52 @@ public class MinioController { ...@@ -56,15 +54,52 @@ public class MinioController {
@PostMapping(value = "upload", headers = HttpHeaders.CONTENT_TYPE + "=" + MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "upload", headers = HttpHeaders.CONTENT_TYPE + "=" + MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<String> upload(@ApiParam(value = "文件", required = true) @RequestPart("file") MultipartFile file) { public Result<String> upload(@ApiParam(value = "文件", required = true) @RequestPart("file") MultipartFile file) {
try { try {
Snowflake snowflake = IdUtil.getSnowflake(1, 1); // 获取文件名信息
long uuid = snowflake.nextId(); String filename = file.getOriginalFilename();
if (filename == null) {
throw Status.BAD_REQUEST.exception("upload.filename.null");
}
// 文件上传 // 获取文件信息以及默认存储地址
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/M/d")); String uuid = IdUtil.fastSimpleUUID();
String folder = "upload/" + date + "/" + uuid; String object = minio.buildUploadPath(filename, "", uuid);
ObjectWriteResponse response = minio.putObject(file, folder);
String uri = minio.getURI(response.object(), minioConfig.getBucket()); // 预处理(默认不做任何处理,具体逻辑需自行在外部实现)
return Result.ok(uri); object = service.handle(object, file.getInputStream());
// 上传原文件
minio.putObject(file.getInputStream(), file.getContentType(), object);
return Result.ok(minio.getDefaultURI(object));
} catch (Exception e) {
log.error(e.getMessage(), e);
return Result.no(Status.BAD_REQUEST, "上传失败");
}
}
@ApiOperation(value = "Base64 图片上传")
@ApiImplicitParam(
name = "base64Image",
value = "Base64 图片信息",
example = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAALCAYAAABYpyyrAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAATSURBVBhXYzAwNP6PjIeKgPF/ABj+RUX4hZfVAAAAAElFTkSuQmCC",
required = true,
paramType = "query"
)
@PostMapping(value = "uploadBase64Image")
public Result<String> uploadBase64Image(@NotBlank(message = "图片 Base64 信息不能为空") String base64Image) {
try {
// 解析 Base64 图片信息
ImageInfo image = FileUtils.parseBase64ImageText(base64Image);
// 获取文件信息以及默认存储地址
String uuid = IdUtil.getSnowflakeNextIdStr();
String object = minio.buildUploadPath(
uuid,
"." + image.getWidth() + "x" + image.getHeight() + "." + image.getSuffix()
);
// 上传原文件
minio.putObject(image.getStream(), image.getContentType(), object);
return Result.ok(minio.getDefaultURI(object));
} catch (Exception e) { } catch (Exception e) {
log.error(e.getMessage(), e); log.error(e.getMessage(), e);
return Result.no(Status.BAD_REQUEST, "上传失败"); return Result.no(Status.BAD_REQUEST, "上传失败");
...@@ -92,7 +127,7 @@ public class MinioController { ...@@ -92,7 +127,7 @@ public class MinioController {
lastModified lastModified
); );
} catch (Exception e) { } catch (Exception e) {
throw new FailStatusException(Status.BAD_REQUEST, e.getMessage()); throw Status.BAD_REQUEST.exception(e.getMessage());
} }
} }
} }
/* (C) 2022 YiRing, Inc. */ /* (C) 2022 YiRing, Inc. */
package com.yiring.common.util; package com.yiring.common.util;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.file.FileReader; import cn.hutool.core.io.file.FileReader;
import com.yiring.common.vo.ImageInfo;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
...@@ -10,7 +14,9 @@ import java.net.URLEncoder; ...@@ -10,7 +14,9 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass; import lombok.experimental.UtilityClass;
import org.apache.tomcat.util.http.fileupload.IOUtils; import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
...@@ -69,4 +75,36 @@ public class FileUtils { ...@@ -69,4 +75,36 @@ public class FileUtils {
IOUtils.copy(object, response.getOutputStream()); IOUtils.copy(object, response.getOutputStream());
object.close(); object.close();
} }
/**
* 解析 Base64 Image 文内容
*
* @param base64 图片 Base64 编码内容,含 data:image/xxx;base64, 前缀
* @return 图片信息
*/
@SneakyThrows
public ImageInfo parseBase64ImageText(String base64) {
if (!base64.startsWith("data:image")) {
throw new RuntimeException("Base64 Image 格式错误, 前缀应是 data:image");
}
// 解析数据
String type = base64.replaceAll("data:image/(.*);.*", "$1");
String contentType = "image/" + type;
String content = base64.substring(base64.indexOf(",") + 1);
byte[] bytes = Base64.decode(content);
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
// 构建结果
return ImageInfo
.builder()
.stream(new ByteArrayInputStream(bytes))
.image(image)
.width(image.getWidth())
.height(image.getHeight())
.suffix(type)
.size(bytes.length)
.contentType(contentType)
.build();
}
} }
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.vo;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.io.Serial;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Image 信息
*
* @author Jim
* @version 0.1
* 2022/8/15 10:02
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ImageInfo implements Serializable {
@Serial
private static final long serialVersionUID = 4042804283860857802L;
/**
*
*/
BufferedImage image;
/**
* IO
*/
InputStream stream;
/**
* 内容大小
*/
long size;
/**
* 类型,eg: image/png
*/
String contentType;
/**
* 后缀,eg: png
*/
String suffix;
/**
* 图片宽度
*/
int width;
/**
* 图片高度
*/
int height;
}
dependencies {
implementation project(':basic-auth')
implementation project(':basic-common:core')
implementation project(':basic-common:redis')
implementation "org.springframework.boot:spring-boot-starter-websocket"
implementation "org.springframework.boot:spring-boot-starter-reactor-netty"
// hutool
implementation "cn.hutool:hutool-core:${hutoolVersion}"
implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// fastjson
implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.config;
import cn.hutool.core.convert.Convert;
import cn.hutool.extra.spring.SpringUtil;
import com.yiring.common.core.Redis;
import com.yiring.websocket.constant.RedisKey;
import com.yiring.websocket.interceptor.ClientInboundChannelInterceptor;
import com.yiring.websocket.interceptor.ClientOutboundChannelInterceptor;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.amqp.RabbitProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
/**
* WebSocketStompConfig
*
* @author ifzm
* @version 0.1
* 2019/9/25 20:12
*/
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
final Redis redis;
final ClientInboundChannelInterceptor clientInboundChannelInterceptor;
final ClientOutboundChannelInterceptor clientOutboundChannelInterceptor;
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/stomp/sock-js")
.setAllowedOriginPatterns("*")
.addInterceptors(new HttpSessionHandshakeInterceptor())
.withSockJS();
registry
.addEndpoint("/stomp/ws")
.setAllowedOriginPatterns("*")
.addInterceptors(new HttpSessionHandshakeInterceptor());
log.info("Init STOMP Endpoints Success.");
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 启动前先删除掉可能存在的残留STOMP连接缓存数据
redis.del(RedisKey.STOMP_ONLINE_USERS);
log.info("Clear STOMP online user info cache of redis.");
registry.setPreservePublishOrder(true);
registry.setUserDestinationPrefix("/user");
registry.setApplicationDestinationPrefixes("/app");
String stompPort = SpringUtil.getProperty("spring.rabbitmq.stomp-port");
if (Objects.isNull(stompPort)) {
// 1. 使用内存方式处理消息
registry.enableSimpleBroker("/topic", "/queue");
} else {
// 2. 使用 RabbitMQ 处理消息(需要安装 STOMP 插件)
RabbitProperties rabbitProperties = SpringUtil.getBean(RabbitProperties.class);
registry
.enableStompBrokerRelay("/topic", "/queue")
.setRelayPort(Convert.toInt(stompPort))
.setRelayHost(rabbitProperties.getHost())
.setVirtualHost(rabbitProperties.getVirtualHost())
.setClientLogin(rabbitProperties.getUsername())
.setClientPasscode(rabbitProperties.getPassword())
.setSystemLogin(rabbitProperties.getUsername())
.setSystemPasscode(rabbitProperties.getPassword());
}
log.info("Init RabbitMQ STOMP MessageBroker Success.");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(clientInboundChannelInterceptor);
}
@Override
public void configureClientOutboundChannel(ChannelRegistration registration) {
registration.interceptors(clientOutboundChannelInterceptor);
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.constant;
/**
* Redis Key 常量类
*
* @author fangzhimin
* 2018/9/4 15:51
*/
public interface RedisKey {
/**
* STOMP 在线用户关键数据
*/
String STOMP_ONLINE_USERS = "STOMP_ONLINE_USERS";
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.domain;
import com.alibaba.fastjson2.JSONObject;
import java.io.Serial;
import java.io.Serializable;
import java.security.Principal;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
/**
* StompPrincipal
*
* @author ifzm
* @version 0.1
* 2019/9/28 21:28
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
public class StompPrincipal implements Principal, Serializable {
@Serial
private static final long serialVersionUID = 5351052642945180737L;
Type type;
String user;
String session;
JSONObject options;
@Override
public String getName() {
return this.session;
}
public enum Type {
/**
* 游客用户
*/
GUEST_USER,
/**
* 登录用户
*/
LOGIN_USER,
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.interceptor;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.util.Auths;
import com.yiring.common.core.Redis;
import com.yiring.websocket.constant.RedisKey;
import com.yiring.websocket.domain.StompPrincipal;
import java.util.LinkedList;
import java.util.Map;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.messaging.support.NativeMessageHeaderAccessor;
import org.springframework.stereotype.Component;
/**
* ClientInboundChannelInterceptor
* 接收客户端消息的拦截器
*
* @author ifzm
* @version 0.1
* 2019/9/28 20:58
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ClientInboundChannelInterceptor implements ChannelInterceptor {
final Redis redis;
final Auths auths;
private final Object lock = new Object();
@Override
public Message<?> preSend(@NonNull Message<?> message, @NonNull MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
assert accessor != null;
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
Object raw = message.getHeaders().get(NativeMessageHeaderAccessor.NATIVE_HEADERS);
if (raw instanceof Map) {
StompPrincipal principal = new StompPrincipal();
principal.setSession(accessor.getSessionId());
Object tokens = ((Map<?, ?>) raw).get("token");
if (tokens instanceof LinkedList) {
String token = ((LinkedList<?>) tokens).getFirst().toString();
User user = auths.getUserByToken(token);
principal.setUser(user.getUsername());
principal.setType(StompPrincipal.Type.LOGIN_USER);
} else {
principal.setUser("Guest." + principal.getSession());
principal.setType(StompPrincipal.Type.GUEST_USER);
}
accessor.setUser(principal);
synchronized (lock) {
redis.hset(RedisKey.STOMP_ONLINE_USERS, principal.getSession(), principal);
log.info(
"STOMP Online Users: {} (incr: +1, session: {}, user: `{}`)",
redis.hsize(RedisKey.STOMP_ONLINE_USERS),
principal.getSession(),
principal.getUser()
);
}
}
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
StompPrincipal principal = (StompPrincipal) accessor.getUser();
if (principal != null && !message.getHeaders().containsKey(SimpMessageHeaderAccessor.HEART_BEAT_HEADER)) {
synchronized (lock) {
redis.hdel(RedisKey.STOMP_ONLINE_USERS, principal.getSession());
log.info(
"STOMP Online Users: {} (incr: -1, session: {}, user: `{}`)",
redis.hsize(RedisKey.STOMP_ONLINE_USERS),
principal.getSession(),
principal.getUser()
);
}
}
}
return message;
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.interceptor;
import com.alibaba.fastjson2.JSON;
import com.yiring.websocket.domain.StompPrincipal;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
/**
* ClientOutboundChannelInterceptor
* 向客户端输出消息的拦截器
*
* @author ifzm
* @version 0.1
* 2019/10/12 11:05
*/
@Slf4j
@Component
public class ClientOutboundChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(@NonNull Message<?> message, @NonNull MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
assert accessor != null;
if (StompCommand.CONNECTED.equals(accessor.getCommand())) {
StompPrincipal principal = (StompPrincipal) accessor.getUser();
return MessageBuilder.createMessage(JSON.toJSONBytes(principal), message.getHeaders());
}
return message;
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.registry;
import com.yiring.websocket.domain.StompPrincipal;
import java.security.Principal;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import lombok.NonNull;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.event.SmartApplicationListener;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.socket.messaging.AbstractSubProtocolEvent;
import org.springframework.web.socket.messaging.SessionConnectedEvent;
import org.springframework.web.socket.messaging.SessionDisconnectEvent;
/**
* 自定义STOMP在线用户信息统计与操作
*
* @author ifzm
* @version 0.1
* 2019/10/10 21:19
*/
@Component
public class CustomStompUserRegistry implements StompUserRegistry, SmartApplicationListener {
/**
* sessionId, Principal
*/
private final Map<String, StompPrincipal> users = new ConcurrentHashMap<>();
private final Object lock = new Object();
@Override
public boolean supportsEventType(@NonNull Class<? extends ApplicationEvent> eventType) {
return AbstractSubProtocolEvent.class.isAssignableFrom(eventType);
}
@Override
public void onApplicationEvent(@NonNull ApplicationEvent event) {
AbstractSubProtocolEvent subProtocolEvent = (AbstractSubProtocolEvent) event;
Message<?> message = subProtocolEvent.getMessage();
SimpMessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message,
SimpMessageHeaderAccessor.class
);
Assert.state(accessor != null, "No SimpMessageHeaderAccessor");
String sessionId = accessor.getSessionId();
Assert.state(sessionId != null, "No session id");
if (event instanceof SessionConnectedEvent) {
Principal user = subProtocolEvent.getUser();
synchronized (lock) {
this.users.put(sessionId, (StompPrincipal) user);
}
} else if (event instanceof SessionDisconnectEvent) {
synchronized (lock) {
this.users.remove(sessionId);
}
}
}
@Override
public Set<StompPrincipal> getUsers() {
return new HashSet<>(this.users.values());
}
@Override
public int getUserCount() {
return this.users.size();
}
@Override
public StompPrincipal getUser(String sessionId) {
return this.users.get(sessionId);
}
@Override
public void updateUser(String sessionId, StompPrincipal principal) {
synchronized (lock) {
if (this.users.containsKey(sessionId)) {
this.users.put(sessionId, principal);
}
}
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.registry;
import com.yiring.websocket.domain.StompPrincipal;
import java.util.Set;
/**
* STOMP 用户注册器
*
* @author ifzm
* @version 0.1
* 2019/10/10 21:57
*/
public interface StompUserRegistry {
/**
* 获取所有在线的用户信息
*
* @return 用户信息集合
*/
@SuppressWarnings("unused")
Set<StompPrincipal> getUsers();
/**
* 获取所有在线用户的数量
*
* @return 在线用户的数量
*/
@SuppressWarnings("unused")
int getUserCount();
/**
* 根据SessionId获取用户信息
*
* @param sessionId sessionId
* @return StompPrincipal
*/
@SuppressWarnings("unused")
StompPrincipal getUser(String sessionId);
/**
* 更新用户信息
*
* @param sessionId sessionId
* @param principal StompPrincipal
*/
void updateUser(String sessionId, StompPrincipal principal);
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.websocket.web;
import cn.hutool.core.lang.UUID;
import com.alibaba.fastjson2.JSON;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.util.Auths;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.websocket.domain.StompPrincipal;
import com.yiring.websocket.registry.StompUserRegistry;
import java.util.Objects;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.simp.user.SimpUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Controller;
/**
* STOMP Receiver Controller
*
* @author ifzm
* @version 0.1
* 2019/9/28 23:13
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class StompReceiver {
final Auths auths;
final SimpMessagingTemplate simpMessagingTemplate;
final SimpUserRegistry simpUserRegistry;
final StompUserRegistry stompUserRegistry;
/**
* 登录
*
* @param accessor StompHeaderAccessor
*/
@MessageMapping("/login")
public void login(StompHeaderAccessor accessor, String token) {
try {
User user = auths.getUserByToken(token);
StompPrincipal principal = (StompPrincipal) accessor.getUser();
assert principal != null;
principal.setType(StompPrincipal.Type.LOGIN_USER);
principal.setUser(user.getUsername());
accessor.setUser(principal);
stompUserRegistry.updateUser(accessor.getSessionId(), principal);
log.info("STOMP user `{}` login success.", principal.getUser());
} catch (Exception e) {
simpMessagingTemplate.convertAndSendToUser(
Objects.requireNonNull(accessor.getSessionId()),
"/topic/notice",
Result.no(Status.UNAUTHORIZED)
);
}
}
/**
* 更新用户状态
*
* @param accessor 访问器
*/
@MessageMapping("/state")
public void state(StompHeaderAccessor accessor, String message) {
log.info("收到来自 STOMP Client `/app/state` 消息:{}", message);
StompPrincipal principal = (StompPrincipal) accessor.getUser();
assert principal != null;
principal.setOptions(JSON.parseObject(message));
accessor.setUser(principal);
log.info("principal info: {}", principal);
stompUserRegistry.updateUser(accessor.getSessionId(), principal);
}
@MessageMapping("/test")
public void test(StompHeaderAccessor accessor, String message) {
log.info("收到来自 STOMP Client `/app/test` 消息:{}", message);
Set<SimpUser> users = simpUserRegistry.getUsers();
log.info("{}", users);
simpMessagingTemplate.convertAndSendToUser(
Objects.requireNonNull(accessor.getSessionId()),
"/topic/reply",
Result.ok(UUID.fastUUID().toString(true))
);
}
}
plugins { plugins {
id 'java' id 'java'
// https://start.spring.io // https://start.spring.io
id 'org.springframework.boot' version '2.6.9' id 'org.springframework.boot' version '2.6.12'
// https://plugins.gradle.org/plugin/io.spring.dependency-management // https://plugins.gradle.org/plugin/io.spring.dependency-management
id 'io.spring.dependency-management' version '1.0.12.RELEASE' id 'io.spring.dependency-management' version '1.0.13.RELEASE'
// https://plugins.gradle.org/plugin/com.diffplug.spotless // https://plugins.gradle.org/plugin/com.diffplug.spotless
id "com.diffplug.spotless" version "6.3.0" id "com.diffplug.spotless" version "6.3.0"
} }
...@@ -17,7 +17,7 @@ ext { ...@@ -17,7 +17,7 @@ ext {
// SpringCloud // SpringCloud
// https://start.spring.io/ // https://start.spring.io/
springCloudVersion = '2021.0.3' springCloudVersion = '2021.0.4'
// Dependencies // Dependencies
// https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-spring-boot-starter // https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-spring-boot-starter
...@@ -25,27 +25,25 @@ ext { ...@@ -25,27 +25,25 @@ ext {
// https://mvnrepository.com/artifact/io.swagger/swagger-annotations // https://mvnrepository.com/artifact/io.swagger/swagger-annotations
swaggerAnnotationsVersion = '1.6.6' swaggerAnnotationsVersion = '1.6.6'
// https://mvnrepository.com/artifact/cn.dev33/sa-token-spring-boot-starter // https://mvnrepository.com/artifact/cn.dev33/sa-token-spring-boot-starter
saTokenVersion = '1.30.0' saTokenVersion = '1.31.0'
// https://mvnrepository.com/artifact/cn.hutool/hutool-all // https://mvnrepository.com/artifact/cn.hutool/hutool-all
hutoolVersion = '5.8.4' hutoolVersion = '5.8.7'
// https://mvnrepository.com/artifact/com.alibaba/fastjson // https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2
fastJsonVersion = '2.0.10' fastJsonVersion = '2.0.14'
// https://mvnrepository.com/artifact/com.xuxueli/xxl-job-core // https://mvnrepository.com/artifact/com.xuxueli/xxl-job-core
xxlJobVersion = '2.3.1' xxlJobVersion = '2.3.1'
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp // https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
okhttpVersion = '4.9.3' okhttpVersion = '4.10.0'
// https://mvnrepository.com/artifact/io.minio/minio // https://mvnrepository.com/artifact/io.minio/minio
minioVersion = '8.4.3' minioVersion = '8.4.4'
// https://mvnrepository.com/artifact/com.vladmihalcea/hibernate-types-55 // https://mvnrepository.com/artifact/com.vladmihalcea/hibernate-types-55
hibernateTypesVersion = '2.16.3' hibernateTypesVersion = '2.19.2'
// https://mvnrepository.com/artifact/org.hibernate/hibernate-spatial // https://mvnrepository.com/artifact/org.hibernate/hibernate-spatial
hibernateSpatialVersion = '5.6.10.Final' hibernateSpatialVersion = '5.6.11.Final'
// https://mvnrepository.com/artifact/org.locationtech.jts/jts-core // https://mvnrepository.com/artifact/org.locationtech.jts/jts-core
jtsVersion = '1.19.0' jtsVersion = '1.19.0'
// https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter
mybatisPlusVersion = '3.5.2'
// https://mvnrepository.com/artifact/com.github.liaochong/myexcel // https://mvnrepository.com/artifact/com.github.liaochong/myexcel
myexcelVersion = '4.2.1' myexcelVersion = '4.2.2'
} }
allprojects { allprojects {
......
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
...@@ -4,12 +4,15 @@ pluginManagement { ...@@ -4,12 +4,15 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
rootProject.name = 'basic-api' rootProject.name = 'basic-api'
include 'app' include 'app'
include 'basic-auth' include 'basic-auth'
include 'basic-websocket'
include 'basic-common:core' include 'basic-common:core'
include 'basic-common:util' include 'basic-common:util'
include 'basic-common:doc' include 'basic-common:doc'
include 'basic-common:minio' include 'basic-common:minio'
include 'basic-common:redis' include 'basic-common:redis'
include 'basic-common:i18n'
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论