提交 be944c68 作者: 方治民

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

上级 611f6b3f
......@@ -33,7 +33,7 @@ build-job:
before_script:
- chmod +x ./gradlew
script:
- ./gradlew app:assemble -Dskip-hooks
- ./gradlew :app:assemble -Dskip-hooks
artifacts:
# 配置构建结果过期时间
expire_in: 1 day
......@@ -54,12 +54,19 @@ deploy-job:
tags:
- YR-CD
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 构建镜像
- 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:
# 设置镜像 tag,使用 git tag 标识作为镜像 tag
TAG: $REGISTRY_REMOTE/basic/$CONTAINER_NAME:$CI_BUILD_REF_NAME
......@@ -26,6 +26,7 @@
- [x] 完成项目构建,开发文档编写
- [x] [conventional-changelog](https://www.cnblogs.com/mengfangui/p/12634845.html)
- [x] 用户及权限模块(目录/菜单/按钮),预览初始化权限配置 [SQL 脚本](./basic-auth/src/main/resources/init-test-mysql.sql)
- [x] 通用文件上传模块
- [x] 通用文件上传模块,支持对图片/PDF/MP3/MP4 等文件进行预处理
- [x] WebSocket 模块
- [ ] 通用字典管理模块
- [ ] XXL-JOB 定时任务模块
......@@ -22,6 +22,9 @@ dependencies {
implementation project(":basic-common:core")
implementation project(":basic-common:util")
// Optional: I18n 消息, 包括参数校验、失败的请求提示等
implementation project(":basic-common:i18n")
// Optional: Redis
implementation project(":basic-common:redis")
......@@ -33,22 +36,29 @@ dependencies {
implementation project(":basic-auth")
implementation "cn.dev33:sa-token-spring-boot-starter:${saTokenVersion}"
// Optional: WebSocket && STOMP 依赖 Auth + Redis 模块
implementation project(":basic-websocket")
// Optional: Minio S3
implementation project(":basic-common:minio")
// FIX: minio dep
implementation "io.minio:minio:${minioVersion}"
implementation "com.squareup.okhttp3:okhttp:${okhttpVersion}"
// Optional: MyBatis Plus
implementation "com.baomidou:mybatis-plus-boot-starter:${mybatisPlusVersion}"
// Optional: 扩展实现在文件上传时对文件进行预处理,依赖 Minio 模块
// https://mvnrepository.com/artifact/org.bytedeco/javacv
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
implementation "com.alibaba:fastjson:${fastJsonVersion}"
implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// hutool
implementation "cn.hutool:hutool-core:${hutoolVersion}"
implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// https://github.com/vladmihalcea/hibernate-types
// hibernate-types-55
implementation "com.vladmihalcea:hibernate-types-55:${hibernateTypesVersion}"
}
/* (C) 2021 YiRing, Inc. */
package com.yiring.app;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@MapperScan(basePackages = Application.BASE_PACKAGES + ".app.mapper")
@EnableJpaRepositories(basePackages = Application.BASE_PACKAGES)
@EntityScan(
basePackageClasses = { Application.class, Jsr310JpaConverters.class },
......
......@@ -4,20 +4,26 @@ package com.yiring.app.config;
import cn.dev33.satoken.exception.NotLoginException;
import com.yiring.app.constant.Code;
import com.yiring.app.exception.CodeException;
import com.yiring.common.core.I18n;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.exception.FailStatusException;
import javax.servlet.http.HttpServletResponse;
import javax.validation.ConstraintViolationException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
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;
@Slf4j
@ControllerAdvice
@ResponseBody
@RequiredArgsConstructor
public class GlobalExceptionHandler {
final I18n i18n;
/**
* 参数校验异常
*
......@@ -40,7 +49,7 @@ public class GlobalExceptionHandler {
value = { BindException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class }
)
public Result<String> bindErrorHandler(Exception e) {
String error = "未知参数校验错误";
String error = null;
if (e instanceof ConstraintViolationException) {
error = ((ConstraintViolationException) e).getConstraintViolations().iterator().next().getMessage();
......@@ -53,7 +62,8 @@ public class GlobalExceptionHandler {
}
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 {
* @param e 异常信息
* @return 异常信息反馈 {@link Status#METHOD_NOT_ALLOWED
*/
@ExceptionHandler(value = HttpRequestMethodNotSupportedException.class)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public Result<String> httpRequestMethodNotSupportedErrorHandler(Exception e) {
return Result.no(Status.METHOD_NOT_ALLOWED, e.getMessage());
}
......@@ -76,7 +86,7 @@ public class GlobalExceptionHandler {
*
* @return 异常信息反馈 {@link Status#UNAUTHORIZED
*/
@ExceptionHandler(value = NotLoginException.class)
@ExceptionHandler(NotLoginException.class)
public Result<String> notLoginErrorHandler() {
return Result.no(Status.UNAUTHORIZED);
}
......@@ -84,7 +94,7 @@ public class GlobalExceptionHandler {
/**
* 自定义业务异常
*/
@ExceptionHandler(value = CodeException.class)
@ExceptionHandler(CodeException.class)
public Result<String> customCodeExceptionHandler(CodeException e) {
Code code = e.getCode();
return Result.no(Status.BAD_REQUEST, code.value(), code.reason(), null);
......@@ -93,15 +103,17 @@ public class GlobalExceptionHandler {
/**
* 失败状态异常
*/
@ExceptionHandler(value = FailStatusException.class)
@ExceptionHandler(FailStatusException.class)
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() {}
/**
......@@ -110,9 +122,9 @@ public class GlobalExceptionHandler {
* @param e 异常信息
* @return 统一的500异常信息 {@link Status#INTERNAL_SERVER_ERROR
*/
@ExceptionHandler(value = Exception.class)
public Result<String> defaultErrorHandler(Exception e, HttpServletResponse response) {
response.setStatus(Status.INTERNAL_SERVER_ERROR.value());
@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(Exception.class)
public Result<String> defaultErrorHandler(Exception e) {
log.error(e.getMessage(), e);
return Result.no(Status.INTERNAL_SERVER_ERROR, e);
}
......
......@@ -5,7 +5,7 @@ import com.yiring.app.exception.CodeException;
import io.swagger.annotations.ApiModel;
/**
* 业务状态码
* 业务状态码(TODO: 结合 spring message i18n)
* eg: <code>throw new CodeException(Code.FAIL)</code>
*
* @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 @@
package com.yiring.app.web.example;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.hutool.extra.spring.SpringUtil;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
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.UserExtensionRepository;
import com.yiring.app.mapper.TestTableMapper;
import com.yiring.app.vo.user.UserExtensionVo;
import com.yiring.auth.annotation.AuthIgnore;
import com.yiring.auth.domain.user.User;
......@@ -25,7 +22,6 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
......@@ -70,8 +66,9 @@ public class ExampleController {
throw Code.FAIL.exception();
}
@SaCheckLogin
@GetMapping("page")
public Result<PageVo<String>> page(@Valid PageParam pageParam) {
public Result<PageVo<String>> page(@Validated PageParam pageParam) {
log.info("PageParam: {}", pageParam);
List<String> data = Arrays.asList(text.split(" "));
......@@ -88,13 +85,6 @@ public class ExampleController {
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
@ApiOperation("查询用户属性")
@GetMapping("findUserExtensionInfo")
......
# 环境变量
env:
host: 192.168.0.156
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring:
datasource:
url: jdbc:mysql://${env.host}:3306/basic_app?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
url: jdbc:postgresql://${env.host}:5432/basic_app
username: ${env.extra.username}
password: ${env.extra.password}
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
database-platform: org.hibernate.dialect.PostgreSQL10Dialect
open-in-view: true
hibernate:
ddl-auto: update
show-sql: false
show-sql: true
properties:
hibernate:
format_sql: true
......@@ -20,32 +24,30 @@ spring:
database: 5
host: ${env.host}
port: 6379
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
password: ${env.extra.password}
# knife4j
knife4j:
enable: true
basic:
enable: false
username: admin
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
setting:
enableOpenApi: false
enableOpenApi: true
enableDebug: true
# minio
minio:
access-key: minioadmin
secret-key: minioadmin
access-key: ${env.extra.username}
secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100"
bucket: public
domain: ${minio.endpoint}
domain: ${minio.end-point}
logging:
level:
# 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:
hibernate:
# 关闭 hibernate-types banner 日志信息
types.print.banner: false
data:
redis:
repositories:
enabled: false
logging:
level:
# 关闭接口扫描 CachingOperationNameGenerator 日志
springfox.documentation.spring.web.readers.operation.CachingOperationNameGenerator: WARN
# 关闭接口扫描 ApiListingReferenceScanner 日志
springfox.documentation.spring.web.scanners.ApiListingReferenceScanner: WARN
# 环境变量
env:
host: 127.0.0.1
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring:
datasource:
url: jdbc:mysql://${env.host}:3306/basic_app?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
open-in-view: true
......@@ -20,33 +24,30 @@ spring:
database: 5
host: ${env.host}
port: 6379
password: 123456
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
password: ${env.extra.password}
# knife4j
knife4j:
enable: true
basic:
enable: false
username: admin
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
setting:
enableOpenApi: false
enableDebug: true
# minio
minio:
access-key: minioadmin
secret-key: minioadmin
access-key: ${env.extra.username}
secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100"
bucket: public
domain: ${minio.endpoint}
domain: ${minio.end-point}
logging:
level:
# sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
# 环境变量
env:
host: 192.168.0.156
prod: false
extra:
username: admin
password: 123456
spring:
datasource:
url: jdbc:postgresql://${env.host}:5432/basic_app
username: admin
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
jpa:
database-platform: org.hibernate.dialect.PostgreSQL10Dialect
open-in-view: true
......@@ -20,20 +24,15 @@ spring:
database: 5
host: ${env.host}
port: 6379
password: 123456
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
password: ${env.extra.password}
# knife4j
knife4j:
enable: true
basic:
enable: false
username: admin
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
setting:
enableOpenApi: true
enableDebug: true
......@@ -44,9 +43,11 @@ minio:
secret-key: minioadmin
end-point: "http://${env.host}:18100"
bucket: public
domain: ${minio.endpoint}
domain: ${minio.end-point}
logging:
level:
# sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
# 环境变量
env:
host: 127.0.0.1
prod: false
extra:
username: admin
password: Hd)XZgtCa&NG~oe@
spring:
datasource:
......@@ -20,34 +24,30 @@ spring:
database: 5
host: ${env.host}
port: 6379
password: 123456
# Optional: MyBatis Plus
mybatis-plus:
global-config:
banner: false
password: ${env.extra.password}
# knife4j
knife4j:
enable: true
basic:
enable: false
username: admin
password: 123456
username: ${env.extra.username}
password: ${env.extra.password}
setting:
enableOpenApi: true
enableDebug: true
# minio
minio:
access-key: minioadmin
secret-key: minioadmin
access-key: ${env.extra.username}
secret-key: ${env.extra.password}
end-point: "http://${env.host}:18100"
bucket: public
domain: ${minio.endpoint}
domain: ${minio.end-point}
logging:
level:
# sql bind parameter
org.hibernate.type.descriptor.sql.BasicBinder: trace
# request log
com.yiring.common.aspect.RequestAspect: info
......@@ -2,15 +2,18 @@ server:
port: 8081
servlet:
context-path: /api
tomcat:
max-http-form-post-size: 20MB
spring:
servlet:
# 文件上传大小限制
multipart:
max-file-size: 10MB
max-request-size: 30MB
application:
name: "basic-api-app"
messages:
basename: i18n/messages
servlet:
multipart:
max-file-size: 50MB
max-request-size: 100MB
profiles:
include: auth, conf-patch
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 {
implementation "cn.dev33:sa-token-dao-redis-jackson:${saTokenVersion}"
// fastjson
implementation "com.alibaba:fastjson:${fastJsonVersion}"
implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// hutool-core
implementation "cn.hutool:hutool-core:${hutoolVersion}"
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.auth.annotation;
import cn.dev33.satoken.annotation.SaIgnore;
import java.lang.annotation.*;
/**
......@@ -16,5 +17,6 @@ import java.lang.annotation.*;
@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@SaIgnore
public @interface AuthIgnore {
}
/* (C) 2022 YiRing, Inc. */
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.SaRouter;
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.web.method.HandlerMethod;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
......@@ -24,26 +23,23 @@ public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册Sa-Token的路由拦截器
// 注册 Sa-Token 路由拦截器
registry
.addInterceptor(
new SaRouteInterceptor((req, res, handler) -> {
new SaInterceptor(handle -> {
// 登录认证 -- 拦截所有路由,并排除 /auth/** 用于开放授权相关, 以及 swagger 相关
SaRouter
.match("/**")
.notMatchMethod(SaHttpMethod.OPTIONS.name())
// 实现用户权限相关后应移除下行代码
// TODO
// .notMatch("/**")
// 示例接口
// .notMatch("/example/**")
// 授权相关接口(登录、登出、注册等)
.notMatch("/example/**")
.notMatch("/auth/**")
.notMatch("/favicon.ico", "/**/*.html", "/**/*.js", "/**/*.css")
.notMatch("/v2/api-docs/**", "/v3/api-docs/**", "/swagger-resources/**")
// 自定义注解忽略接口鉴权
.notMatch(r -> ((HandlerMethod) handler).hasMethodAnnotation(AuthIgnore.class))
.check(r -> StpUtil.checkLogin());
// 管理员权限才可访问的路由地址
SaRouter.match("/sys/**", r -> StpUtil.checkRoleOr(Auths.ADMIN_ROLES.toArray(new String[0])));
})
)
.addPathPatterns("/**");
......
......@@ -6,6 +6,7 @@ import com.yiring.auth.domain.permission.Permission;
import com.yiring.auth.domain.role.Role;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository;
import com.yiring.common.core.Status;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
......@@ -36,6 +37,7 @@ public class StpInterfaceImpl implements StpInterface {
.stream()
.map(Role::getPermissions)
.flatMap(Set::stream)
.distinct()
.map(Permission::getUid)
.collect(Collectors.toList());
}
......@@ -55,7 +57,7 @@ public class StpInterfaceImpl implements StpInterface {
String id = Objects.toString(loginId);
Optional<User> optional = userRepository.findById(id);
if (optional.isEmpty()) {
throw new RuntimeException("用户不存在");
throw Status.NOT_FOUND.exception("用户不存在");
}
return optional.get();
......
......@@ -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.TABLE_NAME;
import com.alibaba.fastjson.JSONObject;
import com.vladmihalcea.hibernate.type.json.JsonType;
import com.alibaba.fastjson2.JSONObject;
import com.yiring.common.domain.BasicEntity;
import java.io.Serial;
import java.io.Serializable;
import javax.persistence.*;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import lombok.*;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
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.*;
@SQLDeleteAll(sql = DELETE_SQL)
@Where(clause = BasicEntity.Where.EXIST)
@Entity
@TypeDef(name = "json", typeClass = JsonType.class)
@Table(
name = TABLE_NAME,
indexes = {
......@@ -128,7 +126,7 @@ public class Permission extends BasicEntity implements Serializable {
* @return JSON 格式 Meta 元数据
*/
public JSONObject getMetaJson() {
JSONObject meta = new JSONObject(true);
JSONObject meta = new JSONObject();
meta.put("title", this.name);
meta.put("icon", this.icon);
meta.put("orderNo", this.serial);
......
......@@ -62,6 +62,9 @@ public class Role extends BasicEntity implements Serializable {
@Column(nullable = false)
String name;
@Comment("是否启用")
Boolean enable;
@JsonIgnore
@Builder.Default
@Comment("权限集合")
......
......@@ -3,6 +3,7 @@ package com.yiring.auth.domain.role;
import java.io.Serializable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* 角色接口
......@@ -10,4 +11,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
* @author ifzm
* 2018/9/4 8:49
*/
@Repository
public interface RoleRepository extends JpaRepository<Role, Serializable> {}
......@@ -6,6 +6,7 @@ import cn.dev33.satoken.stp.StpUtil;
import com.yiring.auth.domain.user.User;
import com.yiring.auth.domain.user.UserRepository;
import com.yiring.common.core.Status;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import javax.annotation.Resource;
......@@ -28,6 +29,11 @@ public class Auths {
UserRepository userRepository;
/**
* 管理员角色标识
*/
public static final List<String> ADMIN_ROLES = List.of("admin", "super-admin", "platform-admin", "data-admin");
/**
* 根据 Token 获取用户信息
* 如果用户未登录或校验失败会抛出 NotLoginException {@link Status#UNAUTHORIZED}
* @param token token
......@@ -61,4 +67,46 @@ public class Auths {
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 {
}
});
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 {
.stream()
.map(Role::getPermissions)
.flatMap(Set::stream)
.distinct()
.sorted(Comparator.comparing(Permission::getTree, Comparator.comparingInt(String::length)))
.collect(Collectors.toList());
}
......
/* (C) 2022 YiRing, Inc. */
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.JsonInclude;
import io.swagger.annotations.ApiModel;
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.auth.vo.permission;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.yiring.auth.domain.permission.Permission;
import io.swagger.annotations.ApiModel;
......
......@@ -31,6 +31,9 @@ public class UserInfoVo implements Serializable {
@ApiModelProperty(value = "主键", example = "1")
String userId;
@ApiModelProperty(value = "手机号", example = "15616260195")
String mobile;
@ApiModelProperty(value = "真实姓名", example = "超级用户")
String realName;
......
......@@ -126,6 +126,12 @@ public class AuthController {
return Result.ok(vo);
}
@ApiOperation(value = "检查登录状态")
@GetMapping("valid")
public Result<Boolean> valid() {
return Result.ok(StpUtil.isLogin());
}
@ApiOperation(value = "登出")
@GetMapping("logout")
public Result<String> logout() {
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.auth.web.permission;
package com.yiring.auth.web.sys.permission;
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.yiring.auth.domain.permission.Permission;
import com.yiring.auth.domain.permission.PermissionRepository;
......@@ -46,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
@ApiSupport(order = -97)
@Api(tags = "权限管理", description = "Permission")
@RestController
@RequestMapping("/manage/permission/")
@RequestMapping("/sys/permission/")
@RequiredArgsConstructor
public class PermissionController {
......@@ -60,10 +61,7 @@ public class PermissionController {
}
Permission entity = new Permission();
BeanUtils.copyProperties(param, entity);
entity.setTree(getTreeNode(param.getPid()));
entity.setMeta(JSON.parseObject(param.getMeta()));
permissionRepository.saveAndFlush(entity);
save(entity, param);
return Result.ok();
}
......@@ -83,10 +81,7 @@ public class PermissionController {
}
}
BeanUtils.copyProperties(param, entity);
entity.setTree(getTreeNode(param.getPid()));
entity.setMeta(JSON.parseObject(param.getMeta()));
permissionRepository.saveAndFlush(entity);
save(entity, param);
return Result.ok();
}
......@@ -111,8 +106,10 @@ public class PermissionController {
return Result.no(Status.NOT_FOUND);
}
Permission permission = optional.get();
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);
}
......@@ -159,4 +156,19 @@ public class PermissionController {
Permission entity = Permission.builder().uid(uid).build();
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. */
package com.yiring.auth.web.role;
package com.yiring.auth.web.sys.role;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.auth.domain.permission.Permission;
......@@ -17,8 +17,8 @@ import com.yiring.common.param.PageParam;
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;
......@@ -45,7 +45,7 @@ import org.springframework.web.bind.annotation.RestController;
@ApiSupport(order = -96)
@Api(tags = "角色管理", description = "Role")
@RestController
@RequestMapping("/manage/role/")
@RequestMapping("/sys/role/")
@RequiredArgsConstructor
public class RoleController {
......@@ -95,12 +95,8 @@ public class RoleController {
}
// 查询权限集合
Set<String> ids = idsParam.toIds();
Set<Permission> permissions = permissionRepository
.findAll()
.stream()
.filter(permission -> ids.contains(permission.getId()))
.collect(Collectors.toSet());
Collection<Serializable> ids = idsParam.toIds();
Set<Permission> permissions = new HashSet<>(permissionRepository.findAllById(ids));
Role entity = optional.get();
entity.setPermissions(permissions);
......@@ -110,14 +106,9 @@ public class RoleController {
@ApiOperation(value = "删除")
@PostMapping("deleted")
public Result<String> deleted(@Valid IdParam param) {
Optional<Role> optional = roleRepository.findById(param.getId());
if (optional.isEmpty()) {
return Result.no(Status.NOT_FOUND);
}
Role entity = optional.get();
roleRepository.delete(entity);
public Result<String> deleted(@Valid IdsParam param) {
List<Role> roles = roleRepository.findAllById(param.toIds());
roleRepository.deleteAll(roles);
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;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
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.UserRepository;
import com.yiring.auth.util.Auths;
import com.yiring.auth.util.Permissions;
import com.yiring.auth.vo.permission.MenuVo;
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.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.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
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;
......@@ -48,15 +33,13 @@ import org.springframework.web.bind.annotation.RestController;
@Validated
@SuppressWarnings({ "deprecation" })
@ApiSupport(order = -95)
@Api(tags = "用户管理", description = "User")
@Api(tags = "用户信息", description = "UserView")
@RestController
@RequestMapping("/user/")
@RequiredArgsConstructor
public class UserController {
public class UserViewController {
final Auths auths;
final UserRepository userRepository;
final RoleRepository roleRepository;
@ApiOperation(value = "获取登录用户信息")
@GetMapping("getUserInfo")
......@@ -65,6 +48,7 @@ public class UserController {
UserInfoVo userInfoVo = UserInfoVo
.builder()
.userId(user.getId())
.mobile(user.getMobile())
.username(user.getUsername())
.realName(user.getRealName())
.avatar(user.getAvatar())
......@@ -95,36 +79,4 @@ public class UserController {
List<String> codes = permissions.stream().map(Permission::getUid).collect(Collectors.toList());
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 {
implementation "cn.hutool:hutool-extra:${hutoolVersion}"
// fastjson
implementation "com.alibaba:fastjson:${fastJsonVersion}"
implementation "com.alibaba.fastjson2:fastjson2:${fastJsonVersion}"
// JTS 几何对象操作库
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
implementation("org.n52.jackson:jackson-datatype-jts:1.2.10") {
exclude group: 'com.fasterxml.jackson.core'
......
......@@ -2,8 +2,8 @@
package com.yiring.common.aspect;
import cn.hutool.extra.servlet.ServletUtil;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.JSONWriter;
import com.yiring.common.constant.DateFormatter;
import com.yiring.common.core.Result;
import com.yiring.common.util.Commons;
......@@ -55,8 +55,11 @@ public class RequestAspect {
// 获取接口请求扩展信息,Header, Params
String extra = "";
if (Boolean.TRUE.equals(debug)) {
String headers = JSONObject.toJSONString(ServletUtil.getHeaderMap(request), SerializerFeature.PrettyFormat);
String params = JSONObject.toJSONString(ServletUtil.getParamMap(request), SerializerFeature.PrettyFormat);
String headers = JSONObject.toJSONString(
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("\nParams: %s", params);
if (result instanceof Result) {
......
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.config;
import java.io.Serial;
import java.io.Serializable;
import lombok.AccessLevel;
import lombok.Data;
import lombok.experimental.FieldDefaults;
......@@ -17,12 +19,48 @@ import org.springframework.context.annotation.Configuration;
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Configuration
@Configuration("env.config")
@ConfigurationProperties(prefix = "env")
public class EnvConfig {
public class EnvConfig implements Serializable {
@Serial
private static final long serialVersionUID = 1017213697767634790L;
/**
* 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. */
package com.yiring.common.domain;
import com.vladmihalcea.hibernate.type.json.JsonBinaryType;
import com.vladmihalcea.hibernate.type.json.JsonType;
import java.time.LocalDateTime;
import javax.persistence.*;
import lombok.*;
import lombok.experimental.FieldDefaults;
import lombok.experimental.FieldNameConstants;
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.GenericGenerator;
import org.hibernate.annotations.UpdateTimestamp;
import org.hibernate.annotations.*;
import org.hibernate.snowflake.SnowflakeId;
/**
......@@ -29,6 +28,11 @@ import org.hibernate.snowflake.SnowflakeId;
@FieldNameConstants
@FieldDefaults(level = AccessLevel.PRIVATE)
@SuperBuilder(toBuilder = true)
@TypeDefs(
value = {
@TypeDef(name = "json", typeClass = JsonType.class), @TypeDef(name = "jsonb", typeClass = JsonBinaryType.class),
}
)
@MappedSuperclass
public abstract class BasicEntity {
......
......@@ -6,7 +6,7 @@ import io.swagger.annotations.ApiModelProperty;
import java.io.Serial;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Set;
import java.util.Collection;
import java.util.stream.Collectors;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
......@@ -38,9 +38,10 @@ public class IdsParam implements Serializable {
/**
* 获取 String 类型的 ID 集合
*
* @return ID 集合
*/
public Set<String> toIds() {
public Collection<Serializable> toIds() {
return Arrays.stream(this.ids.split(",")).collect(Collectors.toSet());
}
}
......@@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.io.Serial;
import java.io.Serializable;
import java.util.Objects;
import javax.validation.constraints.DecimalMin;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
......@@ -12,6 +13,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.FieldDefaults;
import lombok.experimental.SuperBuilder;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
......@@ -58,6 +60,10 @@ public class OptionalPageParam implements Serializable {
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. */
package com.yiring.common.vo;
import com.yiring.common.util.Commons;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.io.Serial;
......@@ -10,6 +11,7 @@ import java.util.Collections;
import java.util.List;
import lombok.*;
import lombok.experimental.FieldDefaults;
import org.springframework.data.domain.Page;
/**
* 分页查询响应公共类
......@@ -41,6 +43,18 @@ public class PageVo<T extends Serializable> implements Serializable {
/**
* 构建一个 PageVo
*
* @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 总数据量
* @return PageVo
......@@ -52,6 +66,7 @@ public class PageVo<T extends Serializable> implements Serializable {
/**
* 构建一个 PageVo
*
* @param data 数据
* @param total 总数据量
* @param latest 数据最新时间
......@@ -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
*/
@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")
public static <T extends Serializable> PageVo<T> empty() {
return PageVo.build(Collections.emptyList(), 0);
......
......@@ -60,23 +60,28 @@ public class SwaggerConfig implements CommandLineRunner {
OpenApiExtensionResolver openApiExtensionResolver;
@Bean(name = "api.all")
public Docket api() {
return api("@All", List.of(""), PathSelectors.any());
public Docket all() {
return api("① 全部", List.of(""), PathSelectors.any());
}
@Bean(name = "api.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")
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")
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) {
......
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;
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
/**
* 通常是内网地址
*/
String endpoint;
/**
* 账户/访问标识
*/
String accessKey;
/**
* 密码/认证密钥
*/
String secretKey;
/**
* 默认储存桶名称
*/
String bucket;
String domain;
/**
* 外部地址
* 通常是和 endpoint 相对的外网地址
*/
String externalPoint;
String domain;
@Bean
public MinioClient getClient() {
......
......@@ -9,6 +9,10 @@ import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
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.web.multipart.MultipartFile;
......@@ -32,6 +36,7 @@ public record Minio(MinioConfig config, MinioClient client) {
* 获取文件访问地址
*
* @param object 文件相对地址(含路径)
* @param bucket 存储桶名称
* @return URI
*/
public String getURI(String object, String bucket) {
......@@ -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 文件流
......@@ -146,9 +180,7 @@ public record Minio(MinioConfig config, MinioClient client) {
*/
public void copyObject(String bucket, String source, String target) throws Exception {
CopySource copySource = CopySource.builder().bucket(bucket).object(source).build();
CopyObjectArgs args = CopyObjectArgs.builder().bucket(bucket).object(target).source(copySource).build();
client.copyObject(args);
}
......
......@@ -35,6 +35,6 @@ public class DownloadParam implements Serializable {
String bucket;
@ApiModelProperty(value = "object", example = "cat.jpg", required = true)
@NotEmpty(message = "文件对象不能为空")
@NotEmpty
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 @@
package com.yiring.common.web;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.util.IdUtil;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import com.yiring.common.config.MinioConfig;
import com.yiring.common.core.Minio;
import com.yiring.common.core.Result;
import com.yiring.common.core.Status;
import com.yiring.common.exception.FailStatusException;
import com.yiring.common.param.DownloadParam;
import com.yiring.common.service.UploadProcessService;
import com.yiring.common.util.FileUtils;
import com.yiring.common.vo.ImageInfo;
import io.minio.GetObjectResponse;
import io.minio.ObjectWriteResponse;
import io.minio.StatObjectResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
......@@ -47,7 +45,7 @@ import org.springframework.web.multipart.MultipartFile;
public class MinioController {
final Minio minio;
final MinioConfig minioConfig;
final UploadProcessService service;
/**
* minio 上传文件,成功返回文件 url
......@@ -56,15 +54,52 @@ public class MinioController {
@PostMapping(value = "upload", headers = HttpHeaders.CONTENT_TYPE + "=" + MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<String> upload(@ApiParam(value = "文件", required = true) @RequestPart("file") MultipartFile file) {
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 uuid = IdUtil.fastSimpleUUID();
String object = minio.buildUploadPath(filename, "", uuid);
// 预处理(默认不做任何处理,具体逻辑需自行在外部实现)
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()
);
// 文件上传
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/M/d"));
String folder = "upload/" + date + "/" + uuid;
ObjectWriteResponse response = minio.putObject(file, folder);
String uri = minio.getURI(response.object(), minioConfig.getBucket());
return Result.ok(uri);
// 上传原文件
minio.putObject(image.getStream(), image.getContentType(), object);
return Result.ok(minio.getDefaultURI(object));
} catch (Exception e) {
log.error(e.getMessage(), e);
return Result.no(Status.BAD_REQUEST, "上传失败");
......@@ -92,7 +127,7 @@ public class MinioController {
lastModified
);
} catch (Exception e) {
throw new FailStatusException(Status.BAD_REQUEST, e.getMessage());
throw Status.BAD_REQUEST.exception(e.getMessage());
}
}
}
/* (C) 2022 YiRing, Inc. */
package com.yiring.common.util;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.io.FileUtil;
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.IOException;
import java.io.InputStream;
......@@ -10,7 +14,9 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.BasicFileAttributes;
import javax.imageio.ImageIO;
import javax.servlet.http.HttpServletResponse;
import lombok.SneakyThrows;
import lombok.experimental.UtilityClass;
import org.apache.tomcat.util.http.fileupload.IOUtils;
import org.springframework.http.HttpHeaders;
......@@ -69,4 +75,36 @@ public class FileUtils {
IOUtils.copy(object, response.getOutputStream());
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 {
id 'java'
// 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
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
id "com.diffplug.spotless" version "6.3.0"
}
......@@ -17,7 +17,7 @@ ext {
// SpringCloud
// https://start.spring.io/
springCloudVersion = '2021.0.3'
springCloudVersion = '2021.0.4'
// Dependencies
// https://mvnrepository.com/artifact/com.github.xiaoymin/knife4j-spring-boot-starter
......@@ -25,27 +25,25 @@ ext {
// https://mvnrepository.com/artifact/io.swagger/swagger-annotations
swaggerAnnotationsVersion = '1.6.6'
// 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
hutoolVersion = '5.8.4'
// https://mvnrepository.com/artifact/com.alibaba/fastjson
fastJsonVersion = '2.0.10'
hutoolVersion = '5.8.7'
// https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2
fastJsonVersion = '2.0.14'
// https://mvnrepository.com/artifact/com.xuxueli/xxl-job-core
xxlJobVersion = '2.3.1'
// https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp
okhttpVersion = '4.9.3'
okhttpVersion = '4.10.0'
// https://mvnrepository.com/artifact/io.minio/minio
minioVersion = '8.4.3'
minioVersion = '8.4.4'
// 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
hibernateSpatialVersion = '5.6.10.Final'
hibernateSpatialVersion = '5.6.11.Final'
// https://mvnrepository.com/artifact/org.locationtech.jts/jts-core
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
myexcelVersion = '4.2.1'
myexcelVersion = '4.2.2'
}
allprojects {
......
distributionBase=GRADLE_USER_HOME
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
zipStorePath=wrapper/dists
......@@ -4,12 +4,15 @@ pluginManagement {
gradlePluginPortal()
}
}
rootProject.name = 'basic-api'
include 'app'
include 'basic-auth'
include 'basic-websocket'
include 'basic-common:core'
include 'basic-common:util'
include 'basic-common:doc'
include 'basic-common:minio'
include 'basic-common:redis'
include 'basic-common:i18n'
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论