This commit is contained in:
jasminexz 2023-08-07 11:50:22 +08:00
parent 8995e2d58f
commit b6c6684ff3
651 changed files with 78491 additions and 1 deletions

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 blossom-editor
Copyright (c) 2023 小贼贼子
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# Blossom 笔记软件
Blossom 是一个**云端存储的笔记软件**,你可以搭建自己的服务器来保存你的所有笔记,图片,个人计划安排,并在任意设备之间实时同步。
![](./doc/imgs/home_ld.jpg "Blossom 首页")
当前版本: `1.0.0`
> 更新日志
---
#### Blossom 具有以下优势:
1. **不再需要为同步设备数量付费**
2. **不再需要为公网访问付费**
3. **不再需要为软件付费**,基于[MIT协议](https://choosealicense.com/licenses/mit/)完全开源
4. **自带截图功能**(仅windows),你不再需要使用其他截图工具截图后保存在本地再上传到文章了。
5. **文章与图片都在你的服务器存储**,不需要再使用任何三方图床,不需要购买任何对象存储。
6. **没有任何私有协议**,基于 Markdown 语法,采用约定格式拓展样式。迁移到其他软件可以正常显示。
7. **所有文章和图片一键打包下载**,你可以一键导出迁移笔记。
8. 多账号权限,可以和你的朋友一起使用。或者将自己的生活/工作笔记存放在不同账号中。
#### Blossom 具有以下功能:
1. **文章编辑**:文章编写,以及文章公网访问权限设置。
2. **双链笔记**:内部文章与外部链接引用形成的双链笔记。
3. **图片管理**:按文章目录分类你的图片,或自定义图片目录。
4. **快捷便签**:方便你记录随意的日常信息。
5. **计划安排**:阶段性的长期计划,或者日历中的短期计划安排。
6. **网站收藏**:常用网站快捷访问,或者说是一个书签功能。
7. 笔记总字数折线图,笔记编辑数量热力图,服务器请求量折线图展示。
更多内容,可前往[源码仓库](https://gitee.com/blossom-projects/blossom)或[文档](https://www.wangyunf.com/blossomdoc)查看。

32
blossom-backend/.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
HELP.md
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**
!**/src/test/**
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
### VS Code ###
.vscode/
/logs/

149
blossom-backend/README.md Normal file
View File

@ -0,0 +1,149 @@
# 一、项目介绍
私密信息:如`Git`账号等不在此列。
---
# 二、系统基本信息
本项目为Web项目除`SpringBoot`使用`2.x`最新版本外,其余不做强制版本要求,只需兼容`JDK8`即可。中间件版本跟随需求变更。
`SpringBoot`版本说明请查阅https://spring.io/projects/spring-boot#support。
简略的注意事项:
1. 接口文档使用`SmartDoc`,不要引入其他三方接口文档框架,如`Swagger`等。用法请查看第【4.1】项。
2. 接口调用需授权时请查看第【4.2】项的系统登录。
3. 项目的模块说明请查看第【三】项。
4. 需遵循第【五】项中说明的开发规范。
5. 本项目依赖其他开源项目,本身为闭源项目。并遵循各类框架的开源协议。
---
# 三、模块说明
```
root
├─ aodc 相关文档
├─ backend-admin 后台应用,当不需要多应用部署时,默认使用该应用对外提供所有服务,该应用只写 Controller 层。
├─ backend-service 业务代码 Service 层与持久层 Mapper。
| ├─ service-base 提供基础的用户登录RBAC文件上传系统字典参数等功能通常所有 Service 模块都依赖该模块。
| ├─ service-biz 业务代码,实现业务需求的模块。
| └─ service-flow 工作流引擎,如果系统需要使用工作流,那么通常所有 Service 模块都依赖该模块。
|
├─ common 项目公共模块
| ├─ common-base 基础对象(如公共响应 R.class工具类异常捕获动态日志级别Caffeine 等。
| ├─ common-cache 缓存封装Redis 封装Redis Cache 封装。
| ├─ common-db 数据库模块,使用 mybatis plusdynamic-datasource慢SQL监控。
| ├─ common-es ElasticSearch 功能封装。
| ├─ common-iaas 云厂商功能的封装如对象存储SMS短信服务IM即时消息等。
| └─ common-wechat 微信相关功能的封装。
|
├─ expand-sentinel 对 Alibaba Sentinel 的封装,拓展了一些功能,实现了一些本地流量查询接口。
├─ expand-tracker 自研的链路追踪核心模块, 源自 tracker-core, 仅提供本地日志记录和日志中插入 Trace 的功能。
└─ smart-doc 本地接口文档,在 Smart-Doc 上修改了样式,使之更符合开发阅读习惯。
```
---
# 四、使用说明
---
## 4.1 SmartDoc 接口文档使用说明
本系统接口文档依赖于`smart-doc`,并修改了`smart-doc`的文档样式,所以需要本地编译`smart-doc`才可使用。<a href="https://smart-doc-group.github.io/#/zh-cn/diy/highlight">官方文档地址</a>
<u>**第一步:**</u>在 smart-doc 目录执行如下命令。
```
mvn clean install -Dmaven.test.skip=true -Dfile.encoding=UTF-8 -Dmaven.javadoc.skip=true
```
<u>**第二步:**</u>搜索 `SmartDocConfig.class` 类,并执行该类生成接口文档。
---
## 4.2 系统登录
登录请访问`/<context-path>/login`
请求体:
```json
{
// 授权客户端的ID, 见配置文件 prohect.auth.clients.client-id
"clientId": "web",
// 授权客户端的登录方式, 见配置文件 prohect.auth.clients.grant-type
"grantType":"password",
"username": "1",
"password": "123"
}
```
登录返回的 token 信息需要放在请求头的 `Authorization` 中, 并以 `Bearer `开头, 例如
```
Authorization:Bearer ac27fd57303d4bddb0229c3b6d71b611
```
> token 的用法遵循 Bearer Token 规范, 可见https://learning.postman.com/docs/sending-requests/authorization/#bearer-token
---
# 五、代码规范
## 5.1 API接口规范
1. **查询请求一律使用`GET`,对数据有变更时一律使用`POST`**
2. `GET`请求如果传入参数`>=5`个时,需使用`@ModelAttribute`接收对象。
3. `GET`不要传入请求体。
4. URL中**不要包含大写字母,不要包含下划线`_`,不要使用驼峰,分割字符串需使用`-`**。
5. API不需要严格按照`Restful`设计,但仍需要遵循一些规则。
1. 尽量不要包含动词,如查询用户:不要使用`GET /getUser`,而是使用`GET /user`
2. 为了协调多种开发习惯,不使用`put`请求,不使用`delete`请求,不使用`head`请求等等。
3. 基础增删改查路径为:列表`list`,分页`page`,详情`info`,新增修改`save`,删除`del`
6. 接口统一返回`Http`响应码`200`,除非强制要求按规范返回响应码。
7. 自定义响应码至少5位数响应码可以是英文+数字的组合,但数字的前三位必须是规范的`Http`响应码。
1. 【正例】:`40001``AUTH-40101``40402``50003`。
2. 【反例】:使用`40400`表示用户未授权。使用`30000`表示服务器处理异常。
8. 对请求参数或请求体有加签需求时,签名一律放在`Header`中。
9. 授权信息一律放在请求头的`Authorization`字段中。
## 5.2 框架规范
### 5.2.1 `Spring`
1. `common`包,`expand`包下的对象要求使用`spring.factories`自动装配。
2. `backend-service`包下的对象要求使用`ServiceXXXScan`指定扫描路径,如`com.blossom.service.base.ServiceBaseScan`。
3. 没有多实现类需求时,不需要创建接口,如各类业务`Service`,多数在整个项目周期内都不会有多个实现类,增加接口徒增工作量。但多个实现类时有必要使用接口(例如:`common.iaas.OSManager.class`
4. 功能为单独模块时,需使用`spring.factories`自动装配,不要在`SpringBootApplication`中指定扫描路径。
5. 不优先使用`spring-security`作为授权框,该框架过于复杂,不同人使用时差异过大,除非有非常非常严格的代码审查与安全扫描。
6. 不强制要求使用`validator`做参数校验,内部调用时,`service`内仍然会做必填等校验。
7. 在保证逻辑清晰且实现唯一的情况下,为了防止相同逻辑在多处实现,允许循环依赖,但仍然需要劲量避免循环依赖。
8. 优先使用`@Autowired`注入对象,不使用`@Resource`。JDK11中移除了`javax.annotation`包,需要手动加入才可使用`@Resource`。
9. 使用自定义配置时,不建议使用`@Value`注解,而是统一使用`ConfigurationProperties`创建配置对象类。为了统一维护以及方便查找。
10. 优先使用`Springboot`默认适配框架。
1. 使用`jackson`作为`json`序列化工具,不要使用`fastjson``gson`或其他工具。
2. 使用`logback`作为日志工具类。
3. 使用`hikari`作为数据库连接池。
### 5.2.2 `MyBatis`
1. 使用驼峰命名转换`mybatis.configuration.map-underscore-to-camel-case: true``mapper.xml`文件中不要使用`resultMap`。
2. `Mapper.java`接口使用`@Mapper`注解,扫描时通过`@MapperScan(annotationClass = Mapper.class)`查找,不要使用路径的方式配置。这样是为了将`Mapper.java`文件于业务service等放在同一目录下。
3. 涉及到多表查询复杂查询使用xml显式编写SQL语句不要使用`MyBatis Plus`,原则上只有单表简单查询时允许使用`MyBatis Plus`。
4. `Entity`类中不需要使用`@TableField`标明数据库字段。
5. `Entity`类中包含非数据库字段时,需要使用`@TableField(exist = false)`标明该字段非数据库字段。
### 5.2.3 其他
1. 使用`hutool`作为工具类,不要使用`common-lang3`,不要使用`guava`,不要使用`spring`自带工具类。
2. 本地缓存工具类使用`caffeine`或基于Java自己实现不要使用`hutool`(存在性能问题),不要使用`guava`。
## 5.3 代码规范
1. 本项目代码根据业务归类。
1. 【正例】:用户`user`相关的`service,mapper,pojo`等都在`com.xx.user`路径下,不要根据类型分类。
2. 【反例】:所有业务的`Service`类都在同一个路径`com.xx.service`下。
3. 由于可能存在多个应用,所以`Controller`在应用所在的目录下,而不根据业务归类。
2. 请求实体类使用`XxxReq.class`
3. 响应实体类使用`XxxRes.class`
4. 数据库实体类使用`XxxEntity.class`
5. 非必要情况下不需要使用DTO多数情况下只是多了一层无必要的转换。
1. 若要求多应用服务且共用模块可以直接使用Entity来代替DTO。
2. 若需要使用 Dubbo 等RPC框则需要对外提供DTO来作为接口参数。
6. 所有实体类需要继承`AbstractPOJO.class`,以便使用`to()`方法转换对象。
7. 实体类中所有对象使用封装类型,尤其注意 Boolean 类型的字段,非封装类型生成的 `getter` 方法为 `is`
8. 由于接口文档依赖于注释,所以`Controller`,实体类的注释必须填写。
9. 配置文件需要写明注释。

View File

@ -0,0 +1,9 @@
#!/dash
# 重启 blossom
pid=`ps aux | grep backend-blossom.jar | grep -v grep | awk '{print $2}'`
echo "进程ID : " $pid
kill -9 $pid
echo "进程" $pid "已被杀死"
echo "开始重启 backend-blossom 服务器"
nohup java -Xms1024m -Xmx1024m -jar /usr/local/jasmine/blossom/backend/backend-blossom.jar &
echo "backend-blossom 正在启动,请查看日志 ↓↓↓↓↓↓↓↓↓↓↓↓↓↓"

View File

@ -0,0 +1,330 @@
/*
Navicat Premium Data Transfer
Source Server : 0_内网root&jasmine888
Source Server Type : MySQL
Source Server Version : 80033
Source Host : 192.168.31.99:3306
Source Schema : blossom
Target Server Type : MySQL
Target Server Version : 80033
File Encoding : 65001
Date: 06/08/2023 22:20:26
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for base_sys_param
-- ----------------------------
DROP TABLE IF EXISTS `base_sys_param`;
CREATE TABLE `base_sys_param` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '参数ID',
`param_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '参数名称',
`param_value` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '参数值',
`param_desc` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '参数说明',
`open_state` int NOT NULL COMMENT '开放状态 [YesNo]',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`upd_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `unq_param_name`(`param_name`) USING BTREE COMMENT '参数名称唯一'
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '系统参数' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of base_sys_param
-- ----------------------------
INSERT INTO `base_sys_param` VALUES (1, 'WEB_ARTICLE_URL', 'https://www.domain.com/blossom/#/articles?articleId=', 'WEB端文章地址,用于PC端直接调往WEB端阅读文章', 1, '2023-04-04 08:20:57', '2023-08-06 22:19:07');
INSERT INTO `base_sys_param` VALUES (3, 'ARTICLE_LOG_EXP_DAYS', '30', '文章修改记录保存天数, 超过该天数将被删除', 1, '2023-08-02 17:46:58', '2023-08-02 18:03:43');
INSERT INTO `base_sys_param` VALUES (11, 'HEFENG_KEY', 'ABC', '和风天气的KEY', 1, '2023-07-31 19:28:54', '2023-08-06 22:19:11');
INSERT INTO `base_sys_param` VALUES (21, 'GITEE_ACCESS_TOKEN', 'ABC', '[过时配置]GITEE API 的访问 token', 1, '2023-07-31 20:12:05', '2023-08-06 22:20:12');
-- ----------------------------
-- Table structure for base_user
-- ----------------------------
DROP TABLE IF EXISTS `base_user`;
CREATE TABLE `base_user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`type` tinyint(1) NOT NULL DEFAULT 2 COMMENT '用户类型: 1:管理员; 2:普通用户; 3:只读用户;',
`username` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '用户名',
`phone` varchar(13) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '用户手机号',
`password` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '用户密码',
`salt` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '密码加盐',
`nick_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '昵称',
`real_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '真实姓名',
`avatar` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '用户头像',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '备注',
`cre_by` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '0,SYS' COMMENT '创建人ID名称',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`upd_by` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '0,SYS' COMMENT '修改人ID名称',
`upd_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`del_by` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '0,SYS' COMMENT '删除人ID名称',
`del_time` bigint NOT NULL DEFAULT 0 COMMENT '删除时间',
`location` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '101100101' COMMENT '和风天气的位置, 官方文档:https://github.com/qwd/LocationList/blob/master/China-City-List-latest.csv',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `unq_user_username`(`username`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10002 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of base_user
-- ----------------------------
INSERT INTO `base_user` VALUES (1, 1, 'blos', '', '$2a$10$SgMx8T/06595PEq3EA9US.ja1oHxpIDG/XnERmBXS.wYS8qbxAGDa', 'UVeESP5NgXwb8JmjCHUK', '用户', 'blos', '', '预设管理员账号, 用户名密码都是 blos', '0,SYS', '2023-08-04 16:48:28', '0,SYS', '2023-08-06 22:17:35', '0', 0, '101100101');
-- ----------------------------
-- Table structure for blossom_article
-- ----------------------------
DROP TABLE IF EXISTS `blossom_article`;
CREATE TABLE `blossom_article` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`pid` bigint NOT NULL COMMENT '文件夹ID',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文章名称',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '文章图标',
`tags` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '标签集合',
`sort` int NOT NULL DEFAULT 1 COMMENT '排序',
`cover` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '封面',
`describes` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '描述',
`star_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT 'star状态',
`open_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '公开状态',
`open_version` int NOT NULL DEFAULT 0 COMMENT '公开版本',
`pv` int NOT NULL DEFAULT 0 COMMENT '页面的查看数',
`uv` int NOT NULL DEFAULT 0 COMMENT '独立的访问次数,每日IP重置',
`likes` int NOT NULL DEFAULT 0 COMMENT '点赞数',
`words` int NOT NULL DEFAULT 0 COMMENT '文章字数',
`version` int NOT NULL DEFAULT 0 COMMENT '版本',
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '颜色',
`toc` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '目录解析',
`markdown` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'Markdown 内容',
`html` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'Html内容',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`upd_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 20153 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '文章Article' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_article
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_article_log
-- ----------------------------
DROP TABLE IF EXISTS `blossom_article_log`;
CREATE TABLE `blossom_article_log` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`article_id` bigint NOT NULL COMMENT '文章ID',
`version` int NOT NULL DEFAULT 0 COMMENT '版本',
`markdown` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文章内容',
`cre_time` datetime NOT NULL COMMENT '修改日期',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 146 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '文章记录ArticleLog' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_article_log
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_article_open
-- ----------------------------
DROP TABLE IF EXISTS `blossom_article_open`;
CREATE TABLE `blossom_article_open` (
`id` bigint NOT NULL COMMENT '文章ID',
`pid` bigint NOT NULL COMMENT '文件夹ID',
`words` int NOT NULL COMMENT '字数',
`open_version` int NOT NULL DEFAULT 1 COMMENT '版本',
`open_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '公开时间',
`sync_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '同步时间',
`toc` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '目录',
`markdown` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'Markdown 内容',
`html` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT 'Html内容',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '公开文章ArticleOpen' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_article_open
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_article_reference
-- ----------------------------
DROP TABLE IF EXISTS `blossom_article_reference`;
CREATE TABLE `blossom_article_reference` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`source_id` bigint NOT NULL COMMENT '文章ID',
`source_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '文章名称',
`target_Id` bigint NOT NULL COMMENT '引用文章ID',
`target_name` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '引用名称',
`target_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '图片链接',
`type` tinyint NOT NULL COMMENT '引用类型: 10:图片; 11:文章; 21:外部文章',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 9148 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_article_reference
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_article_view
-- ----------------------------
DROP TABLE IF EXISTS `blossom_article_view`;
CREATE TABLE `blossom_article_view` (
`id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',
`article_id` bigint NOT NULL COMMENT '文章ID',
`type` char(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '事件类型 1:uv; 2:like',
`ip` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '地址,IPV4',
`user_agent` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '设备',
`province` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '',
`city` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '',
`cre_day` date NOT NULL COMMENT '日期 yyyy-MM-dd',
`cre_time` datetime NOT NULL COMMENT '日期',
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_view_articleid`(`article_id`) USING BTREE COMMENT '文章ID',
INDEX `idx_view_ip`(`ip`) USING BTREE COMMENT 'IP',
INDEX `idx_view_creday`(`cre_day`) USING BTREE COMMENT '日期'
) ENGINE = InnoDB AUTO_INCREMENT = 49 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '文章访问记录ArticleView' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_article_view
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_folder
-- ----------------------------
DROP TABLE IF EXISTS `blossom_folder`;
CREATE TABLE `blossom_folder` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'id',
`pid` bigint UNSIGNED NOT NULL COMMENT '父id',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '文件夹名称',
`icon` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '图标',
`tags` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '标签',
`open_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '开放状态',
`sort` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '排序',
`cover` varchar(300) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '封面图片',
`color` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '颜色',
`describes` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '备注',
`store_path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '/' COMMENT '存储地址',
`subject_words` int NOT NULL DEFAULT 0 COMMENT '专题字数',
`subject_upd_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '专题的最后修改时间',
`type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '1:文章;2:图片',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`upd_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 12035 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '文件夹Folder' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_folder
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_note
-- ----------------------------
DROP TABLE IF EXISTS `blossom_note`;
CREATE TABLE `blossom_note` (
`id` bigint NOT NULL AUTO_INCREMENT,
`content` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL COMMENT '内容',
`top` tinyint(1) NOT NULL DEFAULT 0 COMMENT '置顶',
`top_time` datetime NULL DEFAULT NULL COMMENT '置顶时间',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 18 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_note
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_picture
-- ----------------------------
DROP TABLE IF EXISTS `blossom_picture`;
CREATE TABLE `blossom_picture` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`pid` bigint NOT NULL DEFAULT -1 COMMENT '文件夹ID',
`source_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '原文件名',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文件名',
`path_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '文件路径',
`url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '文件访问url',
`rate` tinyint NOT NULL DEFAULT 0 COMMENT '评分 {0,1,2,3,4,5}',
`star_status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '收藏 0:否,1:是',
`suffix` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '文件后缀',
`size` bigint NOT NULL DEFAULT 0 COMMENT '文件大小',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建日期',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `UNI_FILE_UID_FNAME`(`url`) USING BTREE COMMENT '相同路径的图片不能有两条',
UNIQUE INDEX `unq_pic_pathname`(`path_name`) USING BTREE COMMENT '路径唯一'
) ENGINE = InnoDB AUTO_INCREMENT = 305774931235323969 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '图片Picture' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_picture
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_plan
-- ----------------------------
DROP TABLE IF EXISTS `blossom_plan`;
CREATE TABLE `blossom_plan` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`group_id` bigint NOT NULL COMMENT '分组ID',
`type` tinyint(1) NOT NULL COMMENT '计划类型: daily, day',
`title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '标题',
`content` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '内容',
`plan_month` varchar(7) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '日期所在月份',
`plan_date` date NULL DEFAULT NULL COMMENT '日期: day',
`plan_start_time` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '开始时间: daily, day',
`plan_end_time` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '结束时间',
`color` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '颜色',
`position` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '该计划在该组计划的位置 head, tail, all',
`img` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '图片名称, 或图片地址',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建日期',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 142 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '计划Plan' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_plan
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_stat
-- ----------------------------
DROP TABLE IF EXISTS `blossom_stat`;
CREATE TABLE `blossom_stat` (
`id` bigint NOT NULL AUTO_INCREMENT,
`type` tinyint(1) NOT NULL COMMENT '统计类型: 1:每日编辑文章数; 2:每月总字数;',
`stat_date` date NOT NULL COMMENT '统计日期',
`stat_value` int NOT NULL DEFAULT 0 COMMENT '统计数值',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 218 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_stat
-- ----------------------------
-- ----------------------------
-- Table structure for blossom_web
-- ----------------------------
DROP TABLE IF EXISTS `blossom_web`;
CREATE TABLE `blossom_web` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '网页名称',
`url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '网页url',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '图标',
`img` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '图片, 图片的优先级高于图标',
`type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '' COMMENT '类型 ',
`sort` int NOT NULL DEFAULT 1 COMMENT '排序',
`cre_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`user_id` bigint NOT NULL DEFAULT 1 COMMENT '用户ID',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 292 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '[FS] 网站收藏' ROW_FORMAT = DYNAMIC;
-- ----------------------------
-- Records of blossom_web
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>blossom-backend</artifactId>
<groupId>com.blossom</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>backend</artifactId>
<properties>
<java-jwt.version>4.3.0</java-jwt.version> <!-- JWT -->
</properties>
<dependencies>
<dependency>
<groupId>com.blossom</groupId>
<artifactId>common-base</artifactId>
</dependency>
<dependency>
<groupId>com.blossom</groupId>
<artifactId>common-cache</artifactId>
</dependency>
<dependency>
<groupId>com.blossom</groupId>
<artifactId>common-db</artifactId>
</dependency>
<dependency>
<groupId>com.blossom</groupId>
<artifactId>common-iaas</artifactId>
</dependency>
<!-- 简易链路追踪 -->
<dependency>
<groupId>com.blossom</groupId>
<artifactId>expand-tracker-core</artifactId>
</dependency>
<!-- Sentinel MVC 接口自动拦截 -->
<dependency>
<groupId>com.blossom</groupId>
<artifactId>expand-sentinel-springmvc</artifactId>
</dependency>
<!-- Sentinel 指标查询拓展接口 -->
<dependency>
<groupId>com.blossom</groupId>
<artifactId>expand-sentinel-metric</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>${java-jwt.version}</version>
</dependency>
<!-- 图片处理 -->
<dependency>
<groupId>net.coobird</groupId>
<artifactId>thumbnailator</artifactId>
<version>0.4.20</version>
</dependency>
</dependencies>
<build>
<finalName>backend-blossom</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 解决加载tools.jar的问题 -->
<includeSystemScope>true</includeSystemScope>
<finalName>${project.build.finalName}</finalName>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.1.0</version>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<target>${maven.compiler.target}</target>
<source>${maven.compiler.source}</source>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!--配置运行main函数-->
<mainClass>com.blossom.backend.APP</mainClass>
</configuration>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,28 @@
package com.blossom.backend;
import com.blossom.common.base.BaseConstants;
import com.blossom.expand.tracker.core.Tracker;
import com.blossom.expand.tracker.core.common.TrackerConstants;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* BLOSSOM
*
* @author xzzz
*/
@EnableAsync
@EnableScheduling
@SpringBootApplication
public class APP {
public static void main(String[] args) {
Tracker.start("APPLICATION_START", TrackerConstants.SPAN_TYPE_APPLICATION_RUN);
SpringApplication.run(APP.class, args);
BaseConstants.desc();
Tracker.end();
}
}

View File

@ -0,0 +1,134 @@
package com.blossom.backend.base.auth;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.pojo.LoginDTO;
import com.blossom.backend.base.auth.pojo.LoginEvent;
import com.blossom.backend.base.auth.pojo.LoginReq;
import com.blossom.backend.base.auth.repo.TokenRepository;
import com.blossom.backend.base.auth.security.PasswordEncoder;
import com.blossom.backend.base.auth.token.TokenEncoder;
import com.blossom.backend.base.user.pojo.UserEntity;
import com.blossom.common.base.util.DateUtils;
import com.blossom.common.base.util.ServletUtil;
import com.blossom.common.base.util.json.JsonUtil;
import org.springframework.context.ApplicationContext;
import javax.servlet.http.HttpServletRequest;
import java.util.TreeMap;
/**
* 授权处理抽象类
*
* @author xzzz
*/
public abstract class AbstractAuthService {
protected AuthProperties properties;
protected TokenRepository tokenRepository;
protected TokenEncoder tokenEncoder;
protected PasswordEncoder passwordEncoder;
private ApplicationContext applicationContext;
public AbstractAuthService(AuthProperties properties,
TokenRepository tokenRepository,
TokenEncoder tokenEncoder,
PasswordEncoder passwordEncoder,
ApplicationContext applicationContext) {
this.properties = properties;
this.tokenRepository = tokenRepository;
this.tokenEncoder = tokenEncoder;
this.passwordEncoder = passwordEncoder;
this.applicationContext = applicationContext;
}
/**
* 登录
*
* @param request 请求
* @param req 登录请求参数
* @return 令牌对象
*/
public AccessToken login(HttpServletRequest request, LoginReq req) {
LoginDTO login = req.to(LoginDTO.class);
AccessToken accessToken = req.to(AccessToken.class);
accessToken.setMetadata(new TreeMap<>());
loginByPassword(accessToken, login);
fillConfig(accessToken);
genToken(accessToken);
saveToken(accessToken);
publishEvent(request, accessToken, login);
return accessToken;
}
/**
* 为AccessToken填充配置信息, 如过期日期等
*/
private void fillConfig(AccessToken accessToken) {
AuthProperties.Client client = properties.getClientMap().get(accessToken.getClientId());
if (client == null) {
throw new AuthException(AuthRCode.INVALID_CLIENT_ID);
}
accessToken.setDuration(client.getDuration());
accessToken.setExpire(System.currentTimeMillis() + (accessToken.getDuration() * 1000));
accessToken.setRequestRefresh(client.getRequestRefresh());
accessToken.setMultiPlaceLogin(client.getMultiPlaceLogin());
accessToken.setLoginTime(DateUtils.now());
}
/**
* 登录时生成TOKEN令牌
*/
private void genToken(AccessToken accessToken) {
accessToken.setToken(tokenEncoder.encode(accessToken));
}
/**
* 保存 accessToken 到存储介质中
*/
private void saveToken(AccessToken accessToken) {
tokenRepository.saveToken(accessToken);
tokenRepository.saveUniqueToken(accessToken);
}
/**
* 发布登录事件
*
* @param request request
* @param accessToken token
* @param login 登录信息
*/
private void publishEvent(HttpServletRequest request, AccessToken accessToken, LoginDTO login) {
String ip = ServletUtil.getIP(request);
String userAgent = ServletUtil.getUserAgent(request);
login.setUserId(accessToken.getUserId());
login.setIp(ip);
login.setUserAgent(userAgent);
login.setLoginTime(accessToken.getLoginTime());
login.setAccessTokenSnapshot(JsonUtil.toJson(accessToken));
applicationContext.publishEvent(new LoginEvent(login));
}
/**
* 根据用户名密码登录
*
* @param accessToken token
* @param req 请求参数, 包含用户名和密码
*/
protected abstract void loginByPassword(AccessToken accessToken, LoginDTO req);
/**
* 填充 metadata 内容
*
* @param accessToken token
* @param user 用户信息
*/
protected void fillUserDetail(AccessToken accessToken, UserEntity user) {
if (user == null) {
throw new AuthException(AuthRCode.USER_NOT_EXIST);
}
accessToken.setUserId(user.getId());
accessToken.getMetadata().put("userId", String.valueOf(user.getId()));
accessToken.getMetadata().put("username", user.getUsername());
}
}

View File

@ -0,0 +1,70 @@
package com.blossom.backend.base.auth;
import java.util.ArrayList;
import java.util.List;
/**
* 授权模块静态参数
*
* @author xzzz
* @since 0.0.1
*/
public class AuthConstant {
/**
* token 所在的请求头名称
*/
public static final String HEADER_AUTHORIZATION = "Authorization";
/**
* token 前缀, 遵循 Oauth2.0 规范 (RFC6750: https://tools.ietf.org/html/rfc6750)
* <p>
* 参考文档:
* https://tools.ietf.org/html/RFC6750
* https://learning.postman.com/docs/sending-requests/authorization/
*/
public static final String HEADER_TOKEN_PREFIX = "Bearer ";
/**
* Client 配置不踢出其他前一个 Token , token_unique 中记录的值
*/
public static final String UNIQUE_TOKEN_EVERY_WHERE = "Client允许多处登录";
/**
* 标识请求时白名单, 为后续过滤器判断使用
*/
public static final String WHITE_LIST_ATTRIBUTE_KEY = "IS_WHITE_LIST";
/**
* 默认忽略的请求
*/
public static final List<String> DEFAULT_WHITE_LIST = new ArrayList<String>() {
private static final long serialVersionUID = -1;
{
// 登录接口
this.add("/login");
// 获取图片验证码接口
this.add("/captcha/image");
// 获取手机验证码接口
this.add("/captcha/phone");
// 一些默认的本地静态资源, 一些非前后端分离的静态文件, 如某些框架自带的操作界面( swagger)
this.add("/favicon.ico");
this.add("/**/**.js");
this.add("/**/**.css");
// swagger 默认请求路径
this.add("/doc.html");
this.add("/webjars/**");
this.add("/swagger-resources");
this.add("/v2/**");
}
};
/**
* 请求体包装过滤器的顺序
*/
public static final int AUTH_FILTER_WRAPPER_ORDER = -101;
/**
* 代理过滤器的顺序
*/
public static final int AUTH_FILTER_PROXY = -100;
}

View File

@ -0,0 +1,104 @@
package com.blossom.backend.base.auth;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.redis.RedisTokenValidateFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.util.HashMap;
import java.util.Map;
/**
* 授权上下文
* <p>
* 设置: {@link RedisTokenValidateFilter}
* 删除: {@link RedisTokenAuthFilterProxy#doFilter(ServletRequest, ServletResponse, FilterChain)}
*
* @author xzzz
* @since 0.0.1
*/
public class AuthContext {
private static final ThreadLocal<AccessToken> TOKEN_CONTEXT = new ThreadLocal<>();
// region
/**
* 获取授权上下文
*
* @return 授权信息
*/
public static AccessToken getContext() {
return TOKEN_CONTEXT.get();
}
/**
* 设置上下文信息
*
* @param accessToken 授权信息
*/
public static void setContext(AccessToken accessToken) {
TOKEN_CONTEXT.set(accessToken);
}
/**
* 删除上下文
*/
public static void removeContext() {
TOKEN_CONTEXT.remove();
}
/**
* 获取token 信息
*
* @return token
*/
public static String getToken() {
return TOKEN_CONTEXT.get().getToken();
}
// endregion
/**
* 获取UID
*
* @return 用户ID
*/
public static Long getUserId() {
AccessToken accessToken = getContext();
if (accessToken == null) {
return null;
}
return getContext().getUserId();
}
/**
* 获取BID
*
* @return 商铺 ID
*/
public static Map<String, String> getUserMetadata() {
AccessToken accessToken = getContext();
if (accessToken == null) {
return new HashMap<>(0);
}
return accessToken.getMetadata();
}
/**
* 用户用户ID及用户名
*/
public static String getIdAndName() {
String nameAndId = "0,Null";
try {
AccessToken accessToken = getContext();
if (accessToken != null) {
nameAndId = getUserId() + "," + getUserMetadata().get("username");
}
} catch (Exception ignored) {
// 空的上下文时忽略
}
return nameAndId;
}
}

View File

@ -0,0 +1,70 @@
package com.blossom.backend.base.auth;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.pojo.LoginReq;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.pojo.R;
import lombok.AllArgsConstructor;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
/**
* 登录授权
*
* @author xzzz
*/
@RestController
@AllArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 登录[OP]
*
* @param req 请求对象
* @return token信息
*/
@AuthIgnore
@PostMapping("login")
public R<AccessToken> login(HttpServletRequest request, @Validated @RequestBody LoginReq req) {
XzException400.throwBy(StrUtil.isBlank(req.getClientId()), "客户端ID[ClientId]为必填项");
XzException400.throwBy(StrUtil.isBlank(req.getGrantType()), "授权方式[GrantType]为必填项");
return R.ok(authService.login(request, req));
}
/**
* 用户退出
*
* @apiNote 如果为 JWT 授权方式, 则退出功能无效, 调用该接口后 Token 仍然可以正常使用
* 客户端可以正常调用该接口以校验 Token 是否有效, 然后自行从 Storage Cookie 中删除 Token 即可
*/
@PostMapping("logout")
public R<?> logout() {
authService.logout(AuthContext.getToken());
return R.ok();
}
/**
* 用户主动注册
*/
@PostMapping("register")
public void register() {
}
/**
* 检查 token 状态
*/
@GetMapping("check")
public R<AccessToken> check() {
return R.ok(authService.check());
}
}

View File

@ -0,0 +1,275 @@
package com.blossom.backend.base.auth;
import cn.hutool.core.annotation.AnnotationUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ClassLoaderUtil;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.base.auth.enums.AuthTypeEnum;
import com.blossom.backend.base.auth.enums.GrantTypeEnum;
import com.blossom.backend.base.auth.enums.LogTypeEnum;
import com.blossom.backend.base.auth.enums.PasswordEncoderEnum;
import com.blossom.common.base.exception.XzException500;
import com.blossom.common.base.util.spring.SpringUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeansException;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import javax.annotation.PostConstruct;
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 授权配置项
*
* @author xzzz
*/
@Slf4j
@Data
@Configuration
@ConfigurationProperties(prefix = "project.auth")
public class AuthProperties implements EnvironmentAware, ApplicationContextAware {
/**
* 是否启用授权功能
*/
private Boolean enabled = Boolean.TRUE;
/**
* 使用JWT
*/
private AuthTypeEnum type = AuthTypeEnum.jwt;
/**
* 默认密码
*/
private String defaultPassword = "123456";
/**
* 日志类型
*/
private LogTypeEnum logType = LogTypeEnum.none;
/**
* 默认加密方式
*/
private PasswordEncoderEnum passwordEncoder = PasswordEncoderEnum.bcrypt;
/**
* 授权平台配置,不同的平台类型生成的 token 时效是不同的
* <p>
* 该类创建完成后会检查是否配置平台数据, 未自定义平台配置会创建默认配置
*
* @see AuthProperties#initAfterProcessorClient()
*/
private List<Client> clients;
/**
* platforms 转为 map, 方便查询
*/
private Map<String, Client> clientMap;
/**
* 白名单列表
* 配置时不需要增加context-path,会自动拼接
*/
private List<String> whiteList;
@PostConstruct
public void init() {
// 检查配置的授权类型是否正确
initAfterProcessorTypeCheck();
// 白名单后置处理
initAfterProcessorWhiteList();
// 平台配置后置处理
initAfterProcessorClient();
}
/**
* 白名单后置处理
*/
private void initAfterProcessorWhiteList() {
RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
Map<RequestMappingInfo, HandlerMethod> methodMap = mapping.getHandlerMethods();
methodMap.forEach((key, value) -> {
if (null != AnnotationUtil.getAnnotation(value.getMethod(), AuthIgnore.class)) {
if (key.getPatternsCondition() != null) {
Set<String> urls = key.getPatternsCondition().getPatterns();
whiteList.addAll(urls);
}
}
});
String contentPath = env.getProperty(SpringUtil.SERVLET_CONTEXT_PATH);
// 设置默认登陆页白名单
if (CollUtil.isEmpty(whiteList)) {
this.whiteList = new ArrayList<>();
}
whiteList.addAll(AuthConstant.DEFAULT_WHITE_LIST);
this.whiteList = this.whiteList.stream()
.map(whiteUrl -> Optional.ofNullable(contentPath).orElse("") + whiteUrl)
.collect(Collectors.toList());
}
/**
* 平台配置后置处理,设置默认平台类型
*/
private void initAfterProcessorClient() {
// 初始化客户端列表
if (CollUtil.isEmpty(this.clients)) {
this.clientMap = new HashMap<>(1);
this.clients = new ArrayList<>(1);
}
final Client defaultClient = Client.getDefault();
log.info("[AUTHORIZ] 授权类型:{}, Client:客户端, Expire(h):授权时间(小时), Refresh:请求刷新授权, Unique:用户Token唯一", type);
log.info("[AUTHORIZ] ┌──────────┬───────────┬─────────┬────────────┐");
log.info("[AUTHORIZ] | ClientId | Expire(h) | Refresh | MultiPlace |");
log.info("[AUTHORIZ] ├──────────┼───────────┼─────────┼────────────┤");
for (Client client : this.clients) {
// 配置文件有配置,但配置不全,则未配置的参数使用默认配置
if (client.getDuration() == null || client.getDuration() == 0) {
client.setDuration(defaultClient.getDuration());
}
if (client.getRequestRefresh() == null) {
client.setRequestRefresh(defaultClient.getRequestRefresh());
}
if (client.getMultiPlaceLogin() == null) {
client.setMultiPlaceLogin(defaultClient.getMultiPlaceLogin());
}
if (CollUtil.isEmpty(client.getGrantType())) {
client.setGrantType(defaultClient.getGrantType());
}
if (AuthTypeEnum.jwt.equals(type)) {
client.setRequestRefresh(false);
client.setMultiPlaceLogin(true);
}
log.info("[AUTHORIZ] | {}| {}| {}| {}| {}",
StrUtil.fillAfter(client.getClientId(), StrUtil.C_SPACE, 9),
StrUtil.fillAfter(client.getDuration() / 3600L + "", StrUtil.C_SPACE, 10),
StrUtil.fillAfter(String.valueOf(client.getRequestRefresh()), StrUtil.C_SPACE, 8),
StrUtil.fillAfter(String.valueOf(client.getMultiPlaceLogin()), StrUtil.C_SPACE, 11),
client.getGrantType()
);
}
// 转换为 map, 有相同的 type, 后者会覆盖前者
this.clientMap = this.clients.stream().collect(Collectors.toMap(Client::getClientId, Function.identity(), (key1, key2) -> key2));
log.info("[AUTHORIZ] └──────────┴───────────┴─────────┴────────────┘");
}
private void initAfterProcessorTypeCheck() {
if (type.equals(AuthTypeEnum.redis) && !ClassLoaderUtil.isPresent("org.springframework.data.redis.core.RedisTemplate")) {
String msg = "授权方式 [project.auth.type = redis] 配置错误, 当前项目未使用 Redis, 无法使用 Redis 授权方式.";
log.error(msg);
throw new XzException500(msg);
}
}
/**
* 授权客户端的配置
*/
public static class Client {
/**
* 编码
*/
private String clientId;
/**
* 默认过期时间, 单位为秒
*/
private Integer duration;
/**
* 每次请求刷新
*/
private Boolean requestRefresh;
/**
* 登录时是否踢出上一个登录的 token注意该配置修改后并不会改变在之前下发的 Token 的校验逻辑
* <p> true 则每次登录 token 会替换前一个 token
* <p> false 则每次登录返回的 token 是一样的
*/
private Boolean multiPlaceLogin;
/**
* 该平台允许的登录方式
*/
private List<String> grantType;
/**
* 获取默认平台配置
*
* @return 平台配置
* @see AuthProperties#initAfterProcessorClient
*/
public static Client getDefault() {
Client client = new Client();
client.setDuration(60 * 60 * 4);
client.setRequestRefresh(Boolean.TRUE);
client.setMultiPlaceLogin(Boolean.TRUE);
client.setGrantType(CollUtil.newArrayList(GrantTypeEnum.PASSWORD.getType()));
return client;
}
public Client() {
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public Integer getDuration() {
return duration;
}
public void setDuration(Integer duration) {
this.duration = duration;
}
public Boolean getRequestRefresh() {
return requestRefresh;
}
public void setRequestRefresh(Boolean requestRefresh) {
this.requestRefresh = requestRefresh;
}
public Boolean getMultiPlaceLogin() {
return multiPlaceLogin;
}
public void setMultiPlaceLogin(Boolean multiPlaceLogin) {
this.multiPlaceLogin = multiPlaceLogin;
}
public List<String> getGrantType() {
return grantType;
}
public void setGrantType(List<String> grantType) {
this.grantType = grantType;
}
}
/**
* 环境配置
*/
private Environment env;
@Override
public void setEnvironment(Environment environment) {
this.env = environment;
}
private ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}

View File

@ -0,0 +1,76 @@
package com.blossom.backend.base.auth;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.backend.base.auth.pojo.LoginDTO;
import com.blossom.backend.base.auth.security.PasswordEncoder;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.repo.TokenRepository;
import com.blossom.backend.base.auth.token.TokenEncoder;
import com.blossom.backend.base.user.UserService;
import com.blossom.backend.base.user.pojo.UserEntity;
import com.blossom.common.base.enums.YesNo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
/**
* 授权服务
*
* @author xzzz
*/
@Slf4j
@Service
public class AuthService extends AbstractAuthService {
@Autowired
private UserService userService;
public AuthService(AuthProperties properties,
TokenRepository tokenRepository,
TokenEncoder tokenEncoder,
PasswordEncoder passwordEncoder,
ApplicationContext applicationContext) {
super(properties, tokenRepository, tokenEncoder, passwordEncoder, applicationContext);
}
/**
* 根据用户名密码登录
*/
@Override
protected void loginByPassword(AccessToken accessToken, LoginDTO login) {
AuthException.throwBy(StrUtil.isBlank(login.getPassword()), AuthRCode.USERNAME_OR_PWD_FAULT);
UserEntity user = userService.selectByUsername(login.getUsername());
AuthException.throwBy(ObjUtil.isNull(user), AuthRCode.USERNAME_OR_PWD_FAULT);
AuthException.throwBy(!passwordEncoder.matches(login.getPassword() + user.getSalt(), user.getPassword()), AuthRCode.USERNAME_OR_PWD_FAULT);
fillUserDetail(accessToken, user);
}
/**
* 退出
*
* @param token token 令牌
*/
public void logout(String token) {
tokenRepository.remove(token);
}
/**
* 用户注册
*/
public void register() {
}
/**
* 检查 AccessToken 信息
*
* @return
*/
public AccessToken check() {
return AuthContext.getContext();
}
}

View File

@ -0,0 +1,40 @@
package com.blossom.backend.base.auth;
import cn.hutool.core.util.StrUtil;
import com.blossom.common.base.util.spring.SpringUtil;
/**
* @author xzzz
* @since 0.0.1
*/
public class TokenUtil {
private static final String TOKEN_KEY = "auth:token";
private static final String TOKEN_UNIQUE_KEY = "auth:token_unique";
public static String buildTokenKey(String token) {
return SpringUtil.getAppName() + ":" + TOKEN_KEY + ":" + token;
}
public static String buildUniqueTokenKey(String token) {
return SpringUtil.getAppName() + ":" + TOKEN_UNIQUE_KEY + ":" + token;
}
/**
* 截取 token 前缀(Bearer), 返回 token
*
* @param tokenStr token 字符串
* @return token
*/
public static String cutPrefix(String tokenStr) {
if (tokenStr == null || tokenStr.length() == 0) {
return null;
}
if (StrUtil.startWith(tokenStr, AuthConstant.HEADER_TOKEN_PREFIX)) {
return StrUtil.replace(tokenStr, AuthConstant.HEADER_TOKEN_PREFIX, "");
}
return tokenStr;
}
}

View File

@ -0,0 +1,12 @@
package com.blossom.backend.base.auth.annotation;
import java.lang.annotation.*;
/**
* 拦截器中为接口参数添加用户ID
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AssignUser {
}

View File

@ -0,0 +1,14 @@
package com.blossom.backend.base.auth.annotation;
import java.lang.annotation.*;
/**
* 白名单注解标识
*
* @author xzzz
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthIgnore {
}

View File

@ -0,0 +1,21 @@
package com.blossom.backend.base.auth.annotation;
import java.lang.annotation.*;
/**
* 权限校验
*
* @author xzzz
* @since 0.0.1
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface PermCheck {
/*
所需要的授权
*/
String value();
}

View File

@ -0,0 +1,18 @@
package com.blossom.backend.base.auth.enums;
/**
* 授权类型
*
* @author xzzz
*/
public enum AuthTypeEnum {
/**
* 有状态的授权方式, Token 按自定义逻辑生成, 并存储在 Redis , 该种授权可以退出和主动删除
*/
redis,
/**
* 无状态的授权方式, Token 使用 JWT
*/
jwt
}

View File

@ -0,0 +1,27 @@
package com.blossom.backend.base.auth.enums;
import lombok.Getter;
/**
* 授权方式
*
* @author xzzz
*/
public enum GrantTypeEnum {
/**
* 暂时只支持 password
*/
PASSWORD("password", "密码登录, 需要传入用户名和密码");
@Getter
private final String type;
@Getter
private final String desc;
GrantTypeEnum(String type, String desc) {
this.type = type;
this.desc = desc;
}
}

View File

@ -0,0 +1,21 @@
package com.blossom.backend.base.auth.enums;
/**
* 打印请求日志的类型
*
* @author xzzz
*/
public enum LogTypeEnum {
/**
* 不打印请求日志
*/
none,
/**
* 打印简单的请求日志, 只有请求地址
*/
simple,
/**
* 答应详细的请求日志, 包含请求头, 请求参数, application/json 类型的请求体
*/
detail;
}

View File

@ -0,0 +1,21 @@
package com.blossom.backend.base.auth.enums;
/**
* 密码加密方式
*
* @author xzzz
*/
public enum PasswordEncoderEnum {
/**
* md5 sha256 为匹配 hash .
*/
md5,
/**
* md5 sha256 为匹配 hash .
*/
sha256,
/**
* bcrypt 安全性最高, 但加解密最慢.
*/
bcrypt;
}

View File

@ -0,0 +1,39 @@
package com.blossom.backend.base.auth.exception;
import cn.hutool.core.util.StrUtil;
import com.blossom.common.base.exception.XzAbstractException;
import com.blossom.common.base.pojo.IRCode;
import com.blossom.common.base.pojo.RCode;
/**
* 授权相关异常
*
* @author xzzz
*/
public class AuthException extends XzAbstractException {
public AuthException(AuthRCode authRCode) {
super(authRCode);
}
public AuthException(String code, String message) {
super(code, message);
}
public static void throwBy(boolean expression, IRCode authRCode) {
if (authRCode == null) {
authRCode = RCode.BAD_REQUEST;
}
throwBy(expression, authRCode.getCode(), authRCode.getMsg());
}
public static void throwBy(boolean expression, String code, String msg) {
if (expression) {
if (StrUtil.isBlank(msg)) {
msg = RCode.BAD_REQUEST.getMsg();
}
throw new AuthException(code, msg);
}
}
}

View File

@ -0,0 +1,77 @@
package com.blossom.backend.base.auth.exception;
import com.blossom.common.base.pojo.IRCode;
import lombok.Getter;
/**
* 授权响应码
*
* @author xzzz
*/
public enum AuthRCode implements IRCode {
/**
* 400: 请求路径不合法
*/
REQUEST_REJECTED ("AUTH-40001", "请求不合法","请求不合法:请求路径中可能包含不规范的内容。"),
/**
* 400: 登录参数请求参数不合法
*/
INVALID_GRANT_TYPE ("AUTH-40002", "无效的授权方式","无效的授权方式:[GrantType] 字段错误, 请查看传入值是否在数据字典[GrantType]中。"),
INVALID_CLIENT_ID ("AUTH-40003", "无效的客户端","无效的客户端:[ClientId] 字段错误, 请求的客户端没有在服务器配置。"),
/**
* 400: 登录时发生错误
*/
USERNAME_OR_PWD_FAULT ("AUTH-40004", "用户名或密码错误","用户名或密码错误, 或用户名不存在。"),
CAPTCHA_FAULT ("AUTH-40005", "验证码错误","验证码错误, 或手机号不存在。"),
USER_NOT_ENABLED ("AUTH-40010", "用户已禁用, 暂时无法登录","用户已禁用, 暂时无法登录。"),
/**
* 401: 未经过认证
* <p>指身份验证是必需的, 没有提供身份验证或身份验证失败如果请求已经包含授权凭据, 那么401状态码表示不接受这些凭据
*/
INVALID_TOKEN ("AUTH-40101", "无效的授权信息",
"无效的授权信息\n请求时的令牌错误, 可以通过 AUTH-40101 来判断登录超时来跳转至登录页等。"),
ANOTHER_DEVICE_LOGIN ("AUTH-40102", "账号已在其他设备登录",
"账号已在其他设备登录。\n本账号在其他设备登录时, 本设备下次请求接口时会出现该错误, 该错误出现之后。\n再次使用该令牌访问时会响应 \"AUTH-40101\", 出现该错误通常需要提示用户后跳转至登录页。"),
/**
* 403: 被禁止
* <p>指示尽管请求有效, 但服务器拒绝响应它与401状态码不同, 提供身份验证不会改变结果
*/
PERMISSION_DENIED ("AUTH-40302", "你没有权限访问该资源","你没有权限访问该资源。\n没有权限访问对应 API 接口或服务器资源。"),
/**
* 404:找不到请求
*
*/
USER_NOT_EXIST ("AUTH-40401", "用户不存在","用户不存在。\n通常不会返回该信息, 而是根据 [GrantType] 提示相应参数错误"),
;
@Getter
final String code;
@Getter
final String msg;
@Getter
final String desc;
AuthRCode(String code, String msg, String desc) {
this.code = code;
this.msg = msg;
this.desc = desc;
}
public static AuthRCode getByCode(String code) {
for (AuthRCode value : AuthRCode.values()) {
if (value.getCode().equals(code)) {
return value;
}
}
return null;
}
}

View File

@ -0,0 +1,33 @@
package com.blossom.backend.base.auth.exception;
import com.blossom.common.base.BaseProperties;
import com.blossom.common.base.exception.AbstractExceptionAdvice;
import com.blossom.common.base.exception.XzAbstractException;
import com.blossom.common.base.pojo.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 授权异常处理
*
* <p>并不是所有授权阶段发生的异常都在此捕获, 例如有些异常在过滤器无法通过 Advice 捕获, 该种异常不需要继承 {@link XzAbstractException}
*
* @author xzzz
*/
@Slf4j
@Order(-1)
@RestControllerAdvice
public class ExceptionAdviceByAuth extends AbstractExceptionAdvice {
public ExceptionAdviceByAuth(BaseProperties baseProperties) {
super(baseProperties);
}
@ExceptionHandler(AuthException.class)
public R<?> authExceptionHandler(XzAbstractException exception) {
printExLog(exception, exception.getMessage());
return R.fault(exception.getCode(), exception.getMessage());
}
}

View File

@ -0,0 +1,128 @@
package com.blossom.backend.base.auth.filters;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.AuthProperties;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.common.base.BaseProperties;
import com.blossom.common.base.enums.ExFormat;
import com.blossom.common.base.enums.ExStackTrace;
import com.blossom.common.base.pojo.IRCode;
import com.blossom.common.base.pojo.R;
import com.blossom.common.base.pojo.RCode;
import com.blossom.common.base.util.ExceptionUtil;
import com.blossom.common.base.util.ServletUtil;
import com.blossom.common.base.util.json.JsonUtil;
import com.blossom.common.base.util.spring.SpringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Optional;
/**
* 授权过滤器抽象类, 授权有 JWT 有状态TOKEN 两种实现方式, 所以具体的过滤器逻辑需要
* 子类重写 {@link AuthFilterProxy#doFilterInternal } 方法
*
* @author xzzz
*/
@Slf4j
public abstract class AuthFilterProxy extends GenericFilterBean {
/**
* 授权配置
*/
protected final AuthProperties properties;
public AuthFilterProxy(AuthProperties properties) {
this.properties = properties;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!properties.getEnabled()) {
chain.doFilter(request, response);
} else {
log.debug("[AUTHORIZ] ================================ 代理过滤器 [开始] ================================");
try {
this.doFilterInternal(request, response, chain);
} catch (Exception e) {
log.debug("[AUTHORIZ] **Proxy** >> 代理过滤器执行异常: {}", e.getMessage());
onAuthenticationFailure(request, response, e);
} finally {
// 无论执行成功与否都需要清空上下文
AuthContext.removeContext();
log.debug("[AUTHORIZ] **Proxy** << 代理过滤器: response 清空上下文: {}", JsonUtil.toJson(AuthContext.getContext()));
}
log.debug("[AUTHORIZ] ================================ 代理过滤器 [结束] ================================");
}
}
/**
* 执行过滤器, 由不同的子过滤器实现具体的过滤器内容
*
* @param request
* @param response
* @param chain
* @throws IOException
* @throws ServletException
*/
protected abstract void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
/**
* 执行时出现异常, 在此捕获并统一处理
*
* @param request request
* @param response response
* @param exception ex
*/
protected void onAuthenticationFailure(ServletRequest request, ServletResponse response, Exception exception) throws IOException {
HttpServletResponse resp = (HttpServletResponse) response;
resp.setContentType("application/json;charset=utf-8");
// 要求所有响应都为 200
resp.setStatus(200);
PrintWriter out = resp.getWriter();
// 自定义响应码
IRCode authCode = RCode.INTERNAL_SERVER_ERROR;
if (exception instanceof AuthException) {
authCode = AuthRCode.getByCode(((AuthException) exception).getCode());
}
printStackTrace(authCode, exception);
R<Object> apiResult = R.fault(
authCode.getCode(),
authCode.getMsg(),
exception.getMessage());
String resultJson = JsonUtil.toJson(apiResult);
out.write(resultJson);
out.flush();
out.close();
}
protected void printStackTrace(IRCode authCode, Exception exception) {
boolean filterStackTrace = false;
boolean onLine = false;
try {
BaseProperties properties = SpringUtil.getBean(BaseProperties.class);
filterStackTrace = properties.getEx().getStackTrace().equals(ExStackTrace.project);
onLine = properties.getEx().getFormat().equals(ExFormat.line);
} catch (Exception e) {
e.printStackTrace();
}
log.error("{} | {}({})", ServletUtil.getUri(), exception.getClass().getSimpleName(),
Optional.ofNullable(authCode.getMsg()).orElse(""));
log.error(ExceptionUtil.printStackTrace(exception, filterStackTrace, onLine));
}
}

View File

@ -0,0 +1,251 @@
package com.blossom.backend.base.auth.filters;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import org.springframework.http.HttpMethod;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import java.util.*;
/**
* 请求链接防火墙
*
* @author xzzz
* @since 0.0.1
*/
public class HttpFirewall {
/**
* 请求地址中不允许出现. URL 编码 %2e
*/
private static final List<String> FORBIDDEN_ENCODED_PERIOD =
Collections.unmodifiableList(Arrays.asList("%2e", "%2E"));
/**
* 请求地址中不允许出现;以及; URL 编码 %3b
*/
private static final List<String> FORBIDDEN_SEMICOLON =
Collections.unmodifiableList(Arrays.asList(";", "%3b", "%3B"));
/**
* 请求地址中不允许出现/ URL 编码 %2f
*/
private static final List<String> FORBIDDEN_FORWARDSLASH =
Collections.unmodifiableList(Arrays.asList("%2f", "%2F"));
/**
* 请求地址中不允许出现//以及// URL 编码 %2f%2f
*/
private static final List<String> FORBIDDEN_DOUBLE_FORWARDSLASH =
Collections.unmodifiableList(Arrays.asList("//", "%2f%2f", "%2f%2F", "%2F%2f", "%2F%2F"));
/**
* 请求地址中不允许出现\以及\ URL 编码 %5c
*/
private static final List<String> FORBIDDEN_BACKSLASH =
Collections.unmodifiableList(Arrays.asList("\\", "%5c", "%5C"));
/**
* 请求地址中不允许出现以及 URL 编码 %00
* (不是 null 而是空) 可转码 %00 查看, 转码后什么都没有
*/
private static final List<String> FORBIDDEN_NULL = Collections.unmodifiableList(Arrays.asList("\0", "%00"));
/**
* 请求地址中不允许出现%以及% URL 编码 %25
*/
private static final String PERCENT = "%";
private static final String ENCODED_PERCENT = "%25";
private final Set<String> encodedUrlBlockList = new HashSet<>();
private final Set<String> decodedUrlBlockList = new HashSet<>();
public HttpFirewall() {
// 请求地址中不允许出现. URL 编码 %2e
this.encodedUrlBlockList.addAll(FORBIDDEN_ENCODED_PERIOD);
this.decodedUrlBlockList.addAll(FORBIDDEN_ENCODED_PERIOD);
// 请求地址中不允许出现;以及; URL 编码 %3b
this.encodedUrlBlockList.addAll(FORBIDDEN_SEMICOLON);
this.decodedUrlBlockList.addAll(FORBIDDEN_SEMICOLON);
// 请求地址中不允许出现/ URL 编码 %2f
this.encodedUrlBlockList.addAll(FORBIDDEN_FORWARDSLASH);
this.decodedUrlBlockList.addAll(FORBIDDEN_FORWARDSLASH);
// 请求地址中不允许出现//以及// URL 编码 %2f%2f
this.encodedUrlBlockList.addAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
this.decodedUrlBlockList.addAll(FORBIDDEN_DOUBLE_FORWARDSLASH);
// 请求地址中不允许出现\以及\ URL 编码 %5c
this.encodedUrlBlockList.addAll(FORBIDDEN_BACKSLASH);
this.decodedUrlBlockList.addAll(FORBIDDEN_BACKSLASH);
// 请求地址中不允许出现以及 URL 编码 %00
this.encodedUrlBlockList.addAll(FORBIDDEN_NULL);
this.decodedUrlBlockList.addAll(FORBIDDEN_NULL);
// 请求地址中不允许出现 %, 以及 % URL 编码 %25
this.encodedUrlBlockList.add(ENCODED_PERCENT);
this.decodedUrlBlockList.add(PERCENT);
}
/**
* 允许的请求方式
*/
private final Set<String> allowedHttpMethods = createDefaultAllowedHttpMethods();
private static Set<String> createDefaultAllowedHttpMethods() {
Set<String> result = new HashSet<>();
result.add(HttpMethod.DELETE.name());
result.add(HttpMethod.GET.name());
result.add(HttpMethod.HEAD.name());
result.add(HttpMethod.OPTIONS.name());
result.add(HttpMethod.PATCH.name());
result.add(HttpMethod.POST.name());
result.add(HttpMethod.PUT.name());
return result;
}
/**
* 执行防火墙
* @param servletRequest request
*/
public void wall(ServletRequest servletRequest) {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 判断请求方式
boolean isForbiddenMethod = rejectForbiddenHttpMethod(request);
if (!isForbiddenMethod) {
throw new AuthException(AuthRCode.REQUEST_REJECTED.getCode(), "请求方式错误 [" + request.getMethod() + "], 只允许如下请求: " + this.allowedHttpMethods);
}
// 是否是无效的 url 请求
boolean isNormalized = isNormalized(request);
if (!isNormalized) {
throw new AuthException(AuthRCode.REQUEST_REJECTED.getCode(), "请求链接格式错误, 请检查!");
}
// 判断请求连接中的非法字符
rejectedBlockListedUrls(request);
// 如果请求地址中包含不可打印的 ASCII 字符请求则会被拒绝
boolean isContainsOnlyPrintableAsciiCharacters = containsOnlyPrintableAsciiCharacters(request);
if (!isContainsOnlyPrintableAsciiCharacters) {
throw new AuthException(AuthRCode.REQUEST_REJECTED.getCode(), "请求链接包含非法字符, 请检查!");
}
}
/**
* 判断请求方式
* @param request request
*/
private boolean rejectForbiddenHttpMethod(HttpServletRequest request) {
return this.allowedHttpMethods.contains(request.getMethod());
}
/**
* 标准化 URL 请求, 路径中不能包含
* ./
* /../
* /.
*
* @param request 请求
* @return 是否
*/
private boolean isNormalized(HttpServletRequest request) {
if (!isNormalized(request.getRequestURI())) {
return false;
}
if (!isNormalized(request.getContextPath())) {
return false;
}
if (!isNormalized(request.getServletPath())) {
return false;
}
if (!isNormalized(request.getPathInfo())) {
return false;
}
return true;
}
/**
* 标准化 URL 请求, 路径中不能包含
* ./
* /../
* /.
*
* @param path 请求路径
* @return 是否
*/
private boolean isNormalized(String path) {
if (path == null) {
return true;
}
for (int i = path.length(); i > 0;) {
int slashIndex = path.lastIndexOf('/', i - 1);
int gap = i - slashIndex;
if (gap == 2 && path.charAt(slashIndex + 1) == '.') {
// ".", "/./" or "/."
return false;
}
if (gap == 3 && path.charAt(slashIndex + 1) == '.' && path.charAt(slashIndex + 2) == '.') {
return false;
}
i = slashIndex;
}
return true;
}
/**
* 如果请求地址中包含不可打印的 ASCII 字符请求则会被拒绝
* @param request request
* @return 是否
*/
private static boolean containsOnlyPrintableAsciiCharacters(HttpServletRequest request) {
String requestUri = request.getRequestURI();
int length = requestUri.length();
for (int i = 0; i < length; i++) {
char c = requestUri.charAt(i);
if (c < '\u0020' || c > '\u007e') {
return false;
}
}
return true;
}
/**
* 请求中不能包含 // 双斜杠
* @param request request
*/
private void rejectedBlockListedUrls(HttpServletRequest request) {
for (String forbidden : this.encodedUrlBlockList) {
if (encodedUrlContains(request, forbidden)) {
throw new AuthException(AuthRCode.REQUEST_REJECTED.getCode(), "请求被拒绝, 请求路径中包含非法字符 [" + forbidden + "]");
}
}
for (String forbidden : this.decodedUrlBlockList) {
if (decodedUrlContains(request, forbidden)) {
throw new AuthException(AuthRCode.REQUEST_REJECTED.getCode(), "请求被拒绝, 请求路径中包含非法字符 [" + forbidden + "]");
}
}
}
private boolean encodedUrlContains(HttpServletRequest request, String value) {
if (valueContains(request.getContextPath(), value)) {
return true;
}
return valueContains(request.getRequestURI(), value);
}
private boolean decodedUrlContains(HttpServletRequest request, String value) {
if (valueContains(request.getServletPath(), value)) {
return true;
}
if (valueContains(request.getPathInfo(), value)) {
return true;
}
return false;
}
private boolean valueContains(String value, String contains) {
return value != null && value.contains(contains);
}
}

View File

@ -0,0 +1,67 @@
package com.blossom.backend.base.auth.filters;
import cn.hutool.core.io.IoUtil;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* request 封装类,用于实现多次读取 requet body
*
* @author xzzz
* @since 0.0.1
*/
public class RequestBodyReaderWrapper extends HttpServletRequestWrapper {
private byte[] body;
public RequestBodyReaderWrapper(HttpServletRequest request) throws IOException {
super(request);
body = IoUtil.readBytes(request.getInputStream(), false);
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream inputStream = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() throws IOException {
return inputStream.read();
}
@Override
public boolean isFinished() {
return true;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
public void setInputStream(byte[] body) {
this.body = body;
}
}

View File

@ -0,0 +1,84 @@
package com.blossom.backend.base.auth.filters;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import com.blossom.backend.base.auth.AuthProperties;
import com.blossom.backend.base.auth.enums.LogTypeEnum;
import com.blossom.common.base.util.SystemUtil;
import com.blossom.common.base.util.json.JsonUtil;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author wangyf
* @since 0.0.1
*/
@Slf4j
public class RequestLogFilter {
/**
* 配置文件内容
*/
private AuthProperties properties;
public RequestLogFilter(AuthProperties properties) {
this.properties = properties;
}
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
if (properties.getLogType().equals(LogTypeEnum.none)) {
return;
}
// 显示请求路径
try {
if (properties.getLogType().equals(LogTypeEnum.simple)) {
HttpServletRequest req = ((HttpServletRequest) request);
String uri = req.getRequestURI();
log.info("[AUTHORIZ] IP:{} > {}", StrUtil.fillAfter(SystemUtil.getRemoteIp(req), ' ', 15), uri);
}
// 显示请求详情,此配置会覆盖显示请求路径
if (properties.getLogType().equals(LogTypeEnum.detail)) {
HttpServletRequest req = ((HttpServletRequest) request);
Enumeration<String> headerNames = req.getHeaderNames();
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String key = headerNames.nextElement();
String value = req.getHeader(key);
headers.put(key, value);
}
String requestBody = "";
if (request.getContentType() != null && request.getContentType().contains("application/json")) {
try {
requestBody = req.getReader().lines().collect(Collectors.joining(System.lineSeparator()));
} catch (Exception e) {
log.warn("请求体转换错误");
}
}
log.warn("[AUTHORIZ] 请求详情 [{}:{}] From [{}]" +
"\n请求参数: [{}]" +
"\n请求体 : [{}]" +
"\n请求头 : [{}]",
req.getMethod(), req.getRequestURL().toString(), SystemUtil.getRemoteIp(req),
StrUtil.isBlank(req.getQueryString()) ? "" : URLUtil.decode(req.getQueryString()),
requestBody,
JsonUtil.toPrettyJson(headers)
);
}
} catch (Exception e) {
log.warn("输出日志错误:" + e.getMessage());
}
}
}

View File

@ -0,0 +1,38 @@
package com.blossom.backend.base.auth.filters;
import com.blossom.backend.base.auth.AuthConstant;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 包装request, 提供多次读取请求体的机会
*
* @author wangyf
* @since 0.0.1
*/
@Slf4j
@Component
@Order(AuthConstant.AUTH_FILTER_WRAPPER_ORDER)
public class RequestWrapperFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// ContentType "application/json" 格式才使用包装类
// 这样是为了过滤上传文件等类型的请求
if (request.getContentType() != null && request.getContentType().contains("application/json")) {
ServletRequest requestWrapper = new RequestBodyReaderWrapper(request);
filterChain.doFilter(requestWrapper, response);
} else {
filterChain.doFilter(request, response);
}
}
}

View File

@ -0,0 +1,57 @@
package com.blossom.backend.base.auth.filters;
import com.blossom.backend.base.auth.AuthConstant;
import com.blossom.backend.base.auth.AuthProperties;
import com.blossom.common.base.util.spring.AntPathMatcherUtil;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* [0]
*
* 白名单拦截器, 不一定作为拦截器链的入口
* 通过获取配置文件中的白名单列表, 对请求(URI)进行判断
* 支持 Ant 风格, 使用 {@link org.springframework.util.AntPathMatcher} 类进行匹配
* 在白名单中的请求不会执行后续过滤器链, 即无法获取授权上下文
*
* @author xzzz
* @since 0.0.1
*/
@Slf4j
public class WhiteListFilter {
/**
* 配置文件内容
*/
private final AuthProperties properties;
public WhiteListFilter(AuthProperties properties) {
this.properties = properties;
}
/**
* 如果不在白名单 isWhiteRequest = false, 则需要走授权校验逻辑, 即继续代理过滤器链流程
* 如果在白名单中 isWhiteRequest = true, 则中断过滤器链, 代理过滤器会调用后续原生过滤器
*
* @param request request
* @param response response
*/
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
log.debug("[AUTHORIZ] filter(000) 白名单校验");
String uri = ((HttpServletRequest)request).getRequestURI();
// 如果请求路径在白名单中, 则允许略过请求
for (String pattern : properties.getWhiteList()) {
if (AntPathMatcherUtil.match(pattern, uri)) {
request.setAttribute(AuthConstant.WHITE_LIST_ATTRIBUTE_KEY,true);
break;
}
}
}
}

View File

@ -0,0 +1,56 @@
package com.blossom.backend.base.auth.interceptor;
import cn.hutool.core.util.ObjUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.base.user.UserService;
import com.blossom.backend.base.user.UserTypeEnum;
import com.blossom.backend.base.user.pojo.UserEntity;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.exception.XzException404;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Slf4j
public class UserTypeInterceptor implements HandlerInterceptor {
private UserService userService;
@Autowired
public void setUserService(UserService userService) {
this.userService = userService;
}
/**
* @param request request
* @param response response
* @return 通过与否
* @throws Exception 异常处理
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//必须强转为HandlerMethod
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 白名单不校验
boolean isIgnore = handlerMethod.hasMethodAnnotation(AuthIgnore.class);
if (isIgnore) {
return true;
}
// GET请求校验
if (HttpMethod.GET.name().equals(request.getMethod())) {
return true;
}
UserEntity user = userService.selectById(AuthContext.getUserId());
XzException404.throwBy(ObjUtil.isNull(user), "未查询到你的账户信息");
XzException400.throwBy(UserTypeEnum.READONLY.getType().equals(user.getType()), "您的账号为只读账号, 无法使用该功能");
return true;
}
}

View File

@ -0,0 +1,87 @@
package com.blossom.backend.base.auth.jwt;
import com.blossom.backend.base.auth.AuthConstant;
import com.blossom.backend.base.auth.AuthProperties;
import com.blossom.backend.base.auth.filters.AuthFilterProxy;
import com.blossom.backend.base.auth.filters.HttpFirewall;
import com.blossom.backend.base.auth.filters.RequestLogFilter;
import com.blossom.backend.base.auth.filters.WhiteListFilter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.IOException;
/**
* JWT 过滤器实现
*
* @author xzzz
*/
@Slf4j
@Component
@Order(AuthConstant.AUTH_FILTER_PROXY)
@ConditionalOnProperty(value = "project.auth.type", havingValue = "jwt", matchIfMissing = true)
public class JWTAuthFilterProxy extends AuthFilterProxy {
/**
* 防火墙对象
*/
private final HttpFirewall httpFirewall = new HttpFirewall();
/**
* 日志过滤器
*/
private final RequestLogFilter logFilter;
/**
* 白名单过滤器
*/
private final WhiteListFilter whiteListFilter;
/**
* JWT校验过滤器
*/
private final JWTValidateFilter validateFilter;
public JWTAuthFilterProxy(AuthProperties properties, JWTTokenEncoder tokenEncoder) {
super(properties);
this.logFilter = new RequestLogFilter(properties);
this.whiteListFilter = new WhiteListFilter(properties);
this.validateFilter = new JWTValidateFilter(tokenEncoder);
}
/**
* <p>1. 防火墙校验
* <p>2. 授权校验
* <ol>
* <li>日志</li>
* <li>白名单</li>
* <li>授权校验</li>
* </ol>
*
* @param request request
* @param response response
* @param chain chain
*/
@Override
protected void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
/*
1. 执行防火墙
*/
httpFirewall.wall(request);
/*
2. 授权校验
*/
// 2.1 日志
logFilter.doFilter(request, response);
// 2.2 白名单
whiteListFilter.doFilter(request, response);
// 2.3 token 校验
validateFilter.doFilter(request, response);
chain.doFilter(request, response);
}
}

View File

@ -0,0 +1,128 @@
package com.blossom.backend.base.auth.jwt;
import cn.hutool.core.util.StrUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.blossom.backend.base.auth.token.TokenEncoder;
import com.blossom.common.base.exception.XzException500;
import com.blossom.common.base.util.DateUtils;
import com.blossom.common.base.util.json.JsonUtil;
import com.blossom.common.base.util.spring.SpringUtil;
import com.blossom.backend.base.auth.pojo.AccessToken;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
/**
* jwt 在线解密解码https://www.box3.cn/tools/jwt.html
*
* @author xzzz
*/
@Slf4j
@Component
@ConditionalOnProperty(value = "project.auth.type", havingValue = "jwt")
public class JWTTokenEncoder implements TokenEncoder, EnvironmentAware {
/**
* 加密字符串
*/
private final String secret = "T1h22WstOUaStOiUnAuCYif3Kw7DeLUciE8iWVAReqdnJTnJ7n4WYtE6x0UW";
/**
* 生成签名
*/
private final Algorithm signer = Algorithm.HMAC256(secret);
private Environment environment;
private String appName;
@Override
public void setEnvironment(@NotNull Environment environment) {
this.environment = environment;
}
@PostConstruct
public void init() {
this.appName = environment.getProperty(SpringUtil.APP_NAME);
}
/**
* token 对象转为 jwt
*
* @param accessToken token
* @return jwt 字符串
*/
@Override
public String encode(AccessToken accessToken) {
try {
return JWT.create()
.withIssuer(appName)
// 主题科目
.withSubject(accessToken.getClientId())
// 自定义字段
.withClaim("expire", accessToken.getExpire())
.withClaim("duration", accessToken.getDuration())
.withClaim("loginTime", accessToken.getLoginTime())
.withClaim("userId", accessToken.getUserId())
.withClaim("metadata", JsonUtil.toJson(accessToken.getMetadata()))
// 过期时间
.withExpiresAt(DateUtils.date(accessToken.getExpire()))
.sign(signer);
} catch (Exception e) {
log.error("授权发生异常:", e);
throw new XzException500("JWT授权异常");
}
}
/**
* 解码 jwt
*
* @param token jwt
* @return token 对象
*/
@Override
public AccessToken decode(String token) {
try {
JWTVerifier verifier = JWT.require(signer).build();
DecodedJWT jwt = verifier.verify(token);
AccessToken accessToken = new AccessToken();
accessToken.setToken(token);
Long userId = jwt.getClaim("userId").asLong();
if (userId == null || StrUtil.isBlank(jwt.getClaim("metadata").asString())) {
return null;
}
accessToken.setExpire(jwt.getClaim("expire").asLong());
accessToken.setDuration(jwt.getClaim("duration").asInt());
accessToken.setLoginTime(jwt.getClaim("loginTime").asString());
accessToken.setUserId(jwt.getClaim("userId").asLong());
accessToken.setMetadata(JsonUtil.toMap(jwt.getClaim("metadata").asString()));
return accessToken;
} catch (SignatureVerificationException e) {
log.error("token 解码时 Signature 错误");
throw e;
} catch (JWTDecodeException e) {
log.error("token 解码错误");
throw e;
} catch (TokenExpiredException e) {
log.error("token 解码超时");
throw e;
} catch (IllegalArgumentException e) {
log.error("token 解码参数错误");
throw e;
} catch (JWTVerificationException e) {
log.error("token 解码失败");
throw e;
}
}
}

View File

@ -0,0 +1,43 @@
package com.blossom.backend.base.auth.jwt;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.repo.TokenRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* JWT 存储, JWT 无状态无需存储
*
* @author xzzz
*/
@Slf4j
@Component
@ConditionalOnProperty(value = "project.auth.type", havingValue = "jwt")
public class JWTTokenRepository implements TokenRepository {
@Override
public void saveToken(AccessToken accessToken) {
}
@Override
public AccessToken getToken(String token) {
return null;
}
@Override
public void remove(String token) {
}
@Override
public void saveUniqueToken(AccessToken accessToken) {
}
@Override
public String getUniqueToken(String userId) {
return null;
}
}

View File

@ -0,0 +1,153 @@
package com.blossom.backend.base.auth.jwt;
import cn.hutool.core.util.StrUtil;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.blossom.backend.base.auth.AuthConstant;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.TokenUtil;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.common.base.util.ServletUtil;
import com.blossom.backend.base.auth.pojo.AccessToken;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 请求验证, 校验请求是否携带 token, 未携带的进行拦截跑出错误
*
* @author xzzz
* @since 0.0.1
*/
@Slf4j
public class JWTValidateFilter {
private final JWTTokenEncoder jwtTokenEncoder;
public JWTValidateFilter(JWTTokenEncoder jwtTokenEncoder) {
this.jwtTokenEncoder = jwtTokenEncoder;
}
/**
* 执行过滤器
*
* @param request request
* @param response response
* @throws IOException io
* @throws ServletException servlet
*/
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
log.debug("[AUTHORIZ] filter(100) 授权校验过滤器");
boolean isWhiteList = Boolean.TRUE.equals(request.getAttribute(AuthConstant.WHITE_LIST_ATTRIBUTE_KEY));
// 解析请求头中的 token
String token = getHeaderToken(request);
// 如果白名单且无token, 则直接放行
if (isWhiteList && (StrUtil.isBlank(token) || AuthConstant.HEADER_TOKEN_PREFIX.trim().equals(token))) {
return;
}
AccessToken accessToken = null;
try {
try {
accessToken = jwtTokenEncoder.decode(token);
} catch (SignatureVerificationException e) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
} catch (JWTDecodeException e) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
} catch (TokenExpiredException e) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
} catch (IllegalArgumentException e) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
} catch (JWTVerificationException e) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
}
} catch (AuthException e) {
if (!isWhiteList) {
throw e;
}
}
// 如果是白名单请求没有授权信息,则构造空的授权信息到上下文,异常由调用方捕获
if (isWhiteList && null == accessToken) {
AccessToken accessTokenNull = new AccessToken();
accessTokenNull.setToken("");
accessTokenNull.setUserId(0L);
setAuthContext(accessTokenNull);
return;
}
// 如果白名单请求,且获得了token,则说明是登录状态请求白名单的接口,正常执行下一过滤器
if (isWhiteList && null != accessToken) {
// 将授权数据存放到上下文中,不执行后续过滤器
setAuthContext(accessToken);
return;
}
// 如果不是白名单请求,则抛出错误
if (!isWhiteList && null == accessToken) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
}
// 将授权数据存放到上下文中,并执行下一过滤器
setAuthContext(accessToken);
}
/**
* 1. 从请求头中获取 Token
*
* @param servletRequest request
* @return Token
*/
private String getHeaderToken(ServletRequest servletRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
// 从请求头获取
String token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION);
// 从请求头获取全小写
if (StrUtil.isBlank(token)) {
token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION.toLowerCase());
}
// 从请求头获取全大写
if (StrUtil.isBlank(token)) {
token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION.toUpperCase());
}
// cookie 获取
if (StrUtil.isBlank(token)) {
Cookie cookie = ServletUtil.getCookie(httpServletRequest, AuthConstant.HEADER_AUTHORIZATION);
if (cookie != null) {
token = cookie.getValue();
}
}
// Bearer 开头 && 除此外长度 > 0
token = TokenUtil.cutPrefix(token);
if (null != token && token.length() > 0) {
return token;
}
return null;
}
/**
* 3. 保存到上下文
*
* @param accessToken 授权主体
*/
private void setAuthContext(AccessToken accessToken) {
AuthContext.setContext(accessToken);
}
}

View File

@ -0,0 +1,76 @@
package com.blossom.backend.base.auth.pojo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
/**
* token 对象
*
* @author xzzz
*/
@Data
public class AccessToken implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 登录令牌
*/
private String token;
/**
* 到期时间
*/
private Long expire;
/**
* 授权方式
*/
private String grantType;
/**
* 登录平台
*/
private String clientId;
/**
* 请求是否刷新Token
*/
private Boolean requestRefresh;
/**
* 用户 Token 唯一:
* <p> false 则每次登录返回的 token 是一样的;
* <p> true 则每次登录 token 会替换前一个 token
*/
private Boolean multiPlaceLogin;
/**
* 授权时长, 单位为秒
*/
private Integer duration;
/**
* 用户ID
*/
private Long userId;
/**
* 登录日期
*/
private String loginTime;
/**
* 权限列表
*/
private List<String> permissions;
/**
* 用户元信息, 由用户自定义各类信息
*/
private Map<String, String> metadata;
}

View File

@ -0,0 +1,35 @@
package com.blossom.backend.base.auth.pojo;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 登录信息
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class LoginDTO extends LoginReq {
/**
* 用户ID
*/
private Long userId;
/**
* userAgent
*/
private String userAgent;
/**
* ip
*/
private String ip;
/**
* 登录时间
*/
private String loginTime;
/**
* token 快照
*/
private String accessTokenSnapshot;
}

View File

@ -0,0 +1,15 @@
package com.blossom.backend.base.auth.pojo;
import org.springframework.context.ApplicationEvent;
/**
* 登录事件
*
* @author xzzz
*/
public class LoginEvent extends ApplicationEvent {
public LoginEvent(LoginDTO source) {
super(source);
}
}

View File

@ -0,0 +1,40 @@
package com.blossom.backend.base.auth.pojo;
import com.blossom.backend.base.auth.enums.GrantTypeEnum;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotEmpty;
/**
* 登录请求
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class LoginReq extends AbstractPOJO {
/**
* 登录方式可见数据字典中[GrantType]部分, 必须为小写
*
* @see GrantTypeEnum
*/
@NotEmpty(message = "授权方式[GrantType]为必填项")
private String grantType;
/**
* 客户端ID
*/
@NotEmpty(message = "客户端ID[ClientId]为必填项")
private String clientId;
/**
* 用户名, grantType = password 时必填
*/
private String username;
/**
* 密码, grantType = password 时必填
*/
private String password;
}

View File

@ -0,0 +1,111 @@
//package com.blossom.backend.base.auth.redis;
//
//import com.blossom.backend.base.auth.AuthConstant;
//import com.blossom.backend.base.auth.AuthProperties;
//import com.blossom.backend.base.auth.repo.TokenRepository;
//import com.blossom.backend.base.auth.filters.AuthFilterProxy;
//import com.blossom.backend.base.auth.filters.HttpFirewall;
//import com.blossom.backend.base.auth.filters.RequestLogFilter;
//import com.blossom.backend.base.auth.filters.WhiteListFilter;
//import lombok.extern.slf4j.Slf4j;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
//import org.springframework.core.annotation.Order;
//import org.springframework.data.redis.core.RedisTemplate;
//import org.springframework.stereotype.Component;
//
//import javax.servlet.FilterChain;
//import javax.servlet.ServletException;
//import javax.servlet.ServletRequest;
//import javax.servlet.ServletResponse;
//import java.io.IOException;
//
///**
// * redis token 拦截器实现
// *
// * @author xzzz
// */
//@Slf4j
//@Component
//@Order(AuthConstant.AUTH_FILTER_PROXY)
//@ConditionalOnClass(RedisTemplate.class)
//@ConditionalOnProperty(value = "project.auth.type", havingValue = "redis")
//public class RedisTokenAuthFilterProxy extends AuthFilterProxy {
// /**
// * 防火墙对象
// */
// protected final HttpFirewall httpFirewall = new HttpFirewall();
// /**
// * 日期过滤器
// */
// protected final RequestLogFilter logFilter;
// /**
// * 白名单过滤器
// */
// protected final WhiteListFilter whiteListFilter;
// /**
// * 授权检查过滤器
// */
// private final RedisTokenValidateFilter redisTokenValidateFilter;
// /**
// * 唯一授权检查过滤器
// */
// private final RedisTokenUniqueFilter redisTokenUniqueFilter;
// /**
// * 授权重置过滤器
// */
// private final RedisTokenExpireResetFilter redisTokenExpireResetFilter;
//
// public RedisTokenAuthFilterProxy(AuthProperties properties, TokenRepository tokenRepository) {
// super(properties);
// this.logFilter = new RequestLogFilter(properties);
// this.whiteListFilter = new WhiteListFilter(properties);
// this.redisTokenValidateFilter = new RedisTokenValidateFilter(tokenRepository);
// this.redisTokenUniqueFilter = new RedisTokenUniqueFilter(tokenRepository);
// this.redisTokenExpireResetFilter = new RedisTokenExpireResetFilter(tokenRepository);
// }
//
// /**
// * <p>1. 防火墙校验
// * <p>2. 授权校验
// * <ol>
// * <li>日志</li>
// * <li>白名单</li>
// * <li>授权校验</li>
// * <li>授权续期</li>
// * </ol>
// *
// * @param request request
// * @param response response
// * @param chain chain
// */
// @Override
// protected void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// /*
// 1. 执行防火墙
// */
// httpFirewall.wall(request);
//
// /*
// 2. 授权校验
// */
// // 2.1 日志
// logFilter.doFilter(request, response);
// // 2.2 白名单
// whiteListFilter.doFilter(request, response);
// // 2.3 token 校验
// redisTokenValidateFilter.doFilter(request, response);
// // 2.4 token 唯一校验
// redisTokenUniqueFilter.doFilter(request, response);
// // 2.5 过期时间刷新
// redisTokenExpireResetFilter.doFilter(request, response);
//
// /*
// 虚拟过滤器链执行完毕后交由原生过滤器继续执行
// */
// chain.doFilter(request, response);
//
// log.debug("[AUTHORIZ] **Proxy** >> 原生过滤器: request 请求结束");
// }
//
//}

View File

@ -0,0 +1,31 @@
//package com.blossom.backend.base.auth.redis;
//
//import cn.hutool.core.lang.UUID;
//import com.blossom.backend.base.auth.pojo.AccessToken;
//import com.blossom.backend.base.auth.token.TokenEncoder;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
//import org.springframework.data.redis.core.RedisTemplate;
//import org.springframework.stereotype.Component;
//
///**
// * token 编解码器, 简单生成一个 UUID token, token 信息无加解密, 编解码关系
// *
// * @author xzzz
// */
//@Component
//@ConditionalOnClass(RedisTemplate.class)
//@ConditionalOnProperty(value = "project.auth.type", havingValue = "redis")
//public class RedisTokenEncoder implements TokenEncoder {
//
// /**
// * 生成一个 uuid 作为 token
// *
// * @param accessToken token
// * @return uuid
// */
// @Override
// public String encode(AccessToken accessToken) {
// return UUID.randomUUID().toString(true);
// }
//}

View File

@ -0,0 +1,60 @@
package com.blossom.backend.base.auth.redis;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.repo.TokenRepository;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.IOException;
/**
* [200]
*
* 令牌刷新, 对令牌进行刷新.
* 不同类型的令牌刷新不同
*
* @author xzzz
* @since 0.0.1
*/
@Slf4j
public class RedisTokenExpireResetFilter {
/**
* 配置文件内容
*/
private final TokenRepository tokenRepository;
public RedisTokenExpireResetFilter(TokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
/**
* 执行过滤器
* @param request request
* @param response response
* @throws IOException io
* @throws ServletException servlet
*/
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
log.debug("[AUTHORIZ] filter(200) 令牌续期");
// 1. 上下文获取授权主体
AccessToken accessToken = AuthContext.getContext();
// 2. 如果配置客户端有效 && 客户端允许刷新令牌
if (accessToken != null && StrUtil.isNotBlank(accessToken.getToken()) && accessToken.getRequestRefresh()) {
// 3.1 刷新存储中的授权信息
accessToken.setExpire(System.currentTimeMillis() + (accessToken.getDuration() * 1000));
tokenRepository.saveToken(accessToken);
tokenRepository.saveUniqueToken(accessToken);
// 3.2 刷新上下文授权信息
AuthContext.setContext(accessToken);
} else {
}
}
}

View File

@ -0,0 +1,85 @@
//package com.blossom.backend.base.auth.redis;
//
//import cn.hutool.core.util.StrUtil;
//import com.blossom.backend.base.auth.TokenUtil;
//import com.blossom.backend.base.auth.pojo.AccessToken;
//import com.blossom.backend.base.auth.repo.TokenRepository;
//import com.blossom.common.base.exception.XzException500;
//import com.blossom.common.base.util.json.JsonUtil;
//import lombok.extern.slf4j.Slf4j;
//import org.springframework.beans.factory.annotation.Autowired;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
//import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
//import org.springframework.data.redis.core.RedisTemplate;
//import org.springframework.data.redis.core.StringRedisTemplate;
//import org.springframework.scheduling.annotation.Async;
//import org.springframework.stereotype.Component;
//
//import java.util.concurrent.TimeUnit;
//
///**
// * redis token 存储
// *
// * @author xzzz
// */
//@Slf4j
//@Component
//@ConditionalOnClass(RedisTemplate.class)
//@ConditionalOnProperty(value = "project.auth.type", havingValue = "redis")
//public class RedisTokenRepository implements TokenRepository {
//
// @Autowired
// private StringRedisTemplate redisTemplate;
//
// @Override
// public void saveToken(AccessToken accessToken) {
// if (accessToken == null || StrUtil.isBlank(accessToken.getToken())) {
// throw new XzException500("无法保存空的AccessToken");
// }
// redisTemplate.opsForValue().set(
// TokenUtil.buildTokenKey(accessToken.getToken()),
// JsonUtil.toJson(accessToken),
// accessToken.getDuration(),
// TimeUnit.SECONDS);
// }
//
// @Override
// public AccessToken getToken(String token) {
// if (StrUtil.isBlank(token)) {
// return null;
// }
// return JsonUtil.toObj(redisTemplate.opsForValue().get(TokenUtil.buildTokenKey(token)), AccessToken.class);
// }
//
// @Async
// @Override
// public void remove(String token) {
// log.info("异步删除 token");
// redisTemplate.delete(TokenUtil.buildTokenKey(token));
// }
//
// @Override
// public void saveUniqueToken(AccessToken accessToken) {
// if (accessToken == null || StrUtil.isBlank(accessToken.getToken())) {
// throw new XzException500("无法保存空的AccessToken");
// }
//
// if (accessToken.getMultiPlaceLogin()) {
// return;
// }
//
// redisTemplate.opsForValue().set(
// TokenUtil.buildUniqueTokenKey(String.valueOf(accessToken.getUserId())),
// accessToken.getToken(),
// accessToken.getDuration(),
// TimeUnit.SECONDS);
// }
//
// @Override
// public String getUniqueToken(String userId) {
// if (StrUtil.isBlank(userId)) {
// return null;
// }
// return redisTemplate.opsForValue().get(TokenUtil.buildUniqueTokenKey(userId));
// }
//}

View File

@ -0,0 +1,63 @@
package com.blossom.backend.base.auth.redis;
import com.blossom.backend.base.auth.AuthConstant;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.repo.TokenRepository;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.io.IOException;
/**
* redis 唯一token 拦截器, 用于处理用户多设备登录时的 唯一有效token 判断逻辑
*
* @author xzzz
*/
@Slf4j
public class RedisTokenUniqueFilter {
/**
* 配置文件内容
*/
private final TokenRepository tokenRepository;
public RedisTokenUniqueFilter(TokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
/**
* 执行过滤器
*
* @param request request
* @param response response
* @throws IOException io
* @throws ServletException servlet
*/
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
boolean isWhiteList = Boolean.TRUE.equals(request.getAttribute(AuthConstant.WHITE_LIST_ATTRIBUTE_KEY));
if (isWhiteList) {
return;
}
AccessToken accessToken = AuthContext.getContext();
/*
* 如果不允许多地登录, 则要判断当前用户是否是唯一的用户
*/
if (!accessToken.getMultiPlaceLogin()) {
String uniqueToken = tokenRepository.getUniqueToken(String.valueOf(accessToken.getUserId()));
if (!accessToken.getToken().equals(uniqueToken)) {
tokenRepository.remove(accessToken.getToken());
throw new AuthException(AuthRCode.ANOTHER_DEVICE_LOGIN);
}
}
}
}

View File

@ -0,0 +1,131 @@
package com.blossom.backend.base.auth.redis;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.AuthConstant;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.TokenUtil;
import com.blossom.backend.base.auth.exception.AuthException;
import com.blossom.backend.base.auth.exception.AuthRCode;
import com.blossom.backend.base.auth.pojo.AccessToken;
import com.blossom.backend.base.auth.repo.TokenRepository;
import com.blossom.common.base.util.ServletUtil;
import lombok.extern.slf4j.Slf4j;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
* 请求验证, 校验请求是否携带 token, 未携带的进行拦截跑出错误
*
* @author xzzz
* @since 0.0.1
*/
@Slf4j
public class RedisTokenValidateFilter {
private final TokenRepository tokenRepository;
public RedisTokenValidateFilter(TokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
/**
* 执行过滤器
*
* @param request request
* @param response response
* @throws IOException io
* @throws ServletException servlet
*/
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
log.debug("[AUTHORIZ] filter(100) 授权校验过滤器");
boolean isWhiteList = Boolean.TRUE.equals(request.getAttribute(AuthConstant.WHITE_LIST_ATTRIBUTE_KEY));
// 解析请求头中的 token
String token = getHeaderToken(request);
// 如果白名单且无token, 则直接放行
if (isWhiteList && StrUtil.isBlank(token)) {
return;
}
// 根据 token 取出 Redis 中的授权数据
AccessToken accessToken = tokenRepository.getToken(token);
// 如果是白名单请求没有授权信息,则构造空的授权信息到上下文,异常由调用方捕获
if (isWhiteList && null == accessToken) {
AccessToken accessTokenNull = new AccessToken();
accessTokenNull.setToken("");
accessTokenNull.setUserId(0L);
setAuthContext(accessTokenNull);
return;
}
// 如果白名单请求,且获得了token,则说明是登录状态请求白名单的接口,正常执行下一过滤器
if (isWhiteList && null != accessToken) {
// 将授权数据存放到上下文中,不执行后续过滤器
setAuthContext(accessToken);
return;
}
// 如果不是白名单请求,则抛出错误
if (!isWhiteList && null == accessToken) {
throw new AuthException(AuthRCode.INVALID_TOKEN);
}
// 将授权数据存放到上下文中,并执行下一过滤器
setAuthContext(accessToken);
}
/**
* 1. 从请求头中获取 Token
*
* @param servletRequest request
* @return Token
*/
private String getHeaderToken(ServletRequest servletRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
// 从请求头获取
String token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION);
// 从请求头获取全小写
if (StrUtil.isBlank(token)) {
token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION.toLowerCase());
}
// 从请求头获取全大写
if (StrUtil.isBlank(token)) {
token = httpServletRequest.getHeader(AuthConstant.HEADER_AUTHORIZATION.toUpperCase());
}
// cookie 获取
if (StrUtil.isBlank(token)) {
Cookie cookie = ServletUtil.getCookie(httpServletRequest, AuthConstant.HEADER_AUTHORIZATION);
if (cookie != null) {
token = cookie.getValue();
}
}
// Bearer 开头 && 除此外长度 > 0
token = TokenUtil.cutPrefix(token);
if (null != token && token.length() > 0) {
return token;
}
return null;
}
/**
* 3. 保存到上下文
*
* @param accessToken 授权主体
*/
private void setAuthContext(AccessToken accessToken) {
AuthContext.setContext(accessToken);
}
}

View File

@ -0,0 +1,49 @@
package com.blossom.backend.base.auth.repo;
import com.blossom.backend.base.auth.pojo.AccessToken;
/**
* token 存储
*
* @author xzzz
*/
public interface TokenRepository {
/**
* 保存 token
*
* @param accessToken token 对象信息
*/
void saveToken(AccessToken accessToken);
/**
* 获取 token 对象信息
*
* @param token token 令牌
* @return token
*/
AccessToken getToken(String token);
/**
* 删除 Token 信息
*
* @param token token 令牌
*/
void remove(String token);
/**
* 保存唯一生效的 token 对象
*
* @param accessToken 唯一生效的 token 信息
*/
void saveUniqueToken(AccessToken accessToken);
/**
* 获取唯一生效的 token 令牌
*
* @param userId 用户ID
* @return 该用户有效的 token
*/
String getUniqueToken(String userId);
}

View File

@ -0,0 +1,42 @@
package com.blossom.backend.base.auth.security;
/**
* 密码加密方式接口
*
* @author xzzz
* @since 0.0.1
*/
public interface PasswordEncoder {
/**
* 明文加密
* <p>
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
*
* @param rawPassword 密码原文
* @return 密文
*/
String encode(CharSequence rawPassword);
/**
* 密码判断用未加密的密码与已加密的密码进行判断
*
* @param rawPassword 原始密码未加密的密码
* @param encodedPassword 已经加密过的密码
* @return true 为密码正确
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* Returns true if the encoded password should be encoded again for better security,
* else false. The default implementation always returns false.
*
* @param encodedPassword the encoded password to check
* @return true if the encoded password should be encoded again for better security,
* else false.
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}

View File

@ -0,0 +1,32 @@
package com.blossom.backend.base.auth.security;
import com.blossom.common.base.util.security.BCryptUtil;
import com.blossom.common.base.util.security.SaltUtil;
/**
* bcrypt 加解密
*
* @author xzzz
*/
public class PasswordEncoderBCrypt implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return BCryptUtil.encode(String.valueOf(rawPassword));
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return BCryptUtil.matches(rawPassword, encodedPassword);
}
public static void main(String[] args) {
System.out.println(SaltUtil.randomSalt());
String salt = "UVeESP5NgXwb8JmjCHUK";
String password = "blos";
PasswordEncoderBCrypt uuid = new PasswordEncoderBCrypt();
System.out.println(uuid.encode(password + salt));
}
}

View File

@ -0,0 +1,21 @@
package com.blossom.backend.base.auth.security;
import cn.hutool.crypto.SecureUtil;
/**
* md5 加解密匹配
*
* @author xzzz
*/
public class PasswordEncoderMD5 implements PasswordEncoder {
@Override
public String encode(CharSequence rawPassword) {
return SecureUtil.md5(rawPassword.toString());
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return SecureUtil.md5(rawPassword.toString()).equals(encodedPassword);
}
}

View File

@ -0,0 +1,21 @@
package com.blossom.backend.base.auth.security;
import com.blossom.common.base.util.security.SHA256Util;
/**
* sha256 加解密匹配
*
* @author xzzz
*/
public class PasswordEncoderSHA256 implements PasswordEncoder{
@Override
public String encode(CharSequence rawPassword) {
return SHA256Util.encode(String.valueOf(rawPassword));
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return SHA256Util.encode(String.valueOf(rawPassword)).equals(encodedPassword);
}
}

View File

@ -0,0 +1,57 @@
package com.blossom.backend.base.auth.security;
import com.blossom.backend.base.auth.AuthProperties;
import com.blossom.backend.base.auth.enums.PasswordEncoderEnum;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
/**
* 密码处理
*
* @author xzzz
*/
@Primary
@Component
public class PasswordManager implements PasswordEncoder {
private final AuthProperties authProperties;
private final PasswordEncoder passwordEncoder;
public PasswordManager(AuthProperties authProperties) {
this.authProperties = authProperties;
if (authProperties.getPasswordEncoder() == PasswordEncoderEnum.md5) {
passwordEncoder = new PasswordEncoderMD5();
} else if (authProperties.getPasswordEncoder() == PasswordEncoderEnum.sha256) {
passwordEncoder = new PasswordEncoderSHA256();
} else {
passwordEncoder = new PasswordEncoderBCrypt();
}
}
public String getDefaultPassword() {
return passwordEncoder.encode(authProperties.getDefaultPassword());
}
/**
* 明文加密, 如果密码需要加盐, 需要加盐后传入
*
* @param rawPassword 密码明文
* @return 密码密文
*/
@Override
public String encode(CharSequence rawPassword) {
return passwordEncoder.encode(rawPassword);
}
/**
* 密码判断用未加密的密码与已加密的密码进行判断
*
* @param rawPassword 原始密码未加密的密码
* @param encodedPassword 已经加密过的密码
* @return true 为密码正确
*/
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return passwordEncoder.matches(rawPassword, encodedPassword);
}
}

View File

@ -0,0 +1,29 @@
package com.blossom.backend.base.auth.token;
import com.blossom.backend.base.auth.pojo.AccessToken;
/**
* token 编解码器
*
* @author xzzz
*/
public interface TokenEncoder {
/**
* token 编码成字符串
*
* @param accessToken token
* @return 字符串
*/
String encode(AccessToken accessToken);
/**
* token 解码
*
* @param string token 字符串
* @return token
*/
default AccessToken decode(String string) {
return null;
}
}

View File

@ -0,0 +1,42 @@
package com.blossom.backend.base.param;
import lombok.Getter;
/**
* 参数枚举
*
* @author xzzz
*/
public enum ParamEnum {
/**
* 文章的 web 端访问路径
*/
WEB_ARTICLE_URL(false),
/**
* 文章日志过期天数
*/
ARTICLE_LOG_EXP_DAYS(false),
/**
* 和风天气KEY
*/
HEFENG_KEY(true),
/**
* GITEE key
*/
GITEE_ACCESS_TOKEN(true),
;
/**
* 是否脱敏
*/
@Getter
private final Boolean masking;
ParamEnum(Boolean masking) {
this.masking = masking;
}
}

View File

@ -0,0 +1,17 @@
package com.blossom.backend.base.param;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blossom.backend.base.param.pojo.ParamEntity;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
* 系统参数信息的持久化层
*
* @author xzzz
*/
@Mapper
public interface ParamMapper extends BaseMapper<ParamEntity> {
}

View File

@ -0,0 +1,79 @@
package com.blossom.backend.base.param;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.base.param.pojo.ParamEntity;
import com.blossom.backend.base.param.pojo.ParamReq;
import com.blossom.common.base.exception.XzException404;
import com.blossom.common.base.util.BeanUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.PostConstruct;
import java.util.*;
/**
* 系统参数信息业务接口
*
* @author xzzz
*/
@Slf4j
@Service
@AllArgsConstructor
public class ParamService extends ServiceImpl<ParamMapper, ParamEntity> {
private static final Map<String, ParamEntity> CACHE = new HashMap<>(20);
@PostConstruct
public void refresh() {
log.info("[ BASE] 初始化系统参数缓存");
CACHE.clear();
List<ParamEntity> params = baseMapper.selectList(new QueryWrapper<>());
if (CollUtil.isEmpty(params)) {
return;
}
for (ParamEntity param : params) {
CACHE.put(param.getParamName(), param);
}
}
public ParamEntity getValue(ParamEnum name) {
return CACHE.get(name.name());
}
/**
* 根据多个名称查询
*
* @param masking 返回数据是否脱敏
* @param names 参数名称
* @return 返回参数 map
*/
public Map<String, String> selectMap(boolean masking, ParamEnum... names) {
if (ArrayUtil.isEmpty(names)) {
return new HashMap<>(0);
}
Map<String, String> result = new HashMap<>(names.length);
for (ParamEnum name : names) {
ParamEntity param = BeanUtil.toObj(CACHE.get(name.name()), ParamEntity.class);
if (masking && name.getMasking()) {
param.setParamValue(StrUtil.hide(param.getParamValue(), 0, Math.min(param.getParamValue().length(), 20)));
}
result.put(name.name(), param.getParamValue());
}
return result;
}
/**
* 修改
*/
@Transactional(rollbackFor = Exception.class)
public Long update(ParamReq req) {
XzException404.throwBy(req.getId() == null, "ID不得为空");
return req.getId();
}
}

View File

@ -0,0 +1,37 @@
package com.blossom.backend.base.param.pojo;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* 系统参数信息的实体类
*
* @author xzzz
*/
@Data
@TableName("base_sys_param")
@EqualsAndHashCode(callSuper = true)
public class ParamEntity extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/** 参数ID */
@TableId
private Long id;
/** 参数名称 */
private String paramName;
/** 参数值 */
private String paramValue;
/** 参数说明 */
private String paramDesc;
/** 创建时间 */
private Date creTime;
/** 修改时间 */
private Date updTime;
}

View File

@ -0,0 +1,33 @@
package com.blossom.backend.base.param.pojo;
import com.blossom.common.db.pojo.PageReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* 系统参数分页请求类
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ParamPageReq extends PageReq implements Serializable {
private static final long serialVersionUID = 1L;
/** 参数ID */
private Long id;
/** 参数名称 */
private String paramName;
/** 参数值 */
private String paramValue;
/** 参数说明 */
private String paramDesc;
/** 创建时间 */
private Date creTime;
/** 修改时间 */
private Date updTime;
}

View File

@ -0,0 +1,33 @@
package com.blossom.backend.base.param.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* 系统参数请求实体
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ParamReq extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/** 参数ID */
private Long id;
/** 参数名称 */
private String paramName;
/** 参数值 */
private String paramValue;
/** 参数说明 */
private String paramDesc;
/** 创建时间 */
private Date creTime;
/** 修改时间 */
private Date updTime;
}

View File

@ -0,0 +1,24 @@
package com.blossom.backend.base.param.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
/**
* 系统参数响应实体会排除敏感字段
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ParamRes extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/** 参数名称 */
private String paramName;
/** 参数值 */
private String paramValue;
}

View File

@ -0,0 +1,41 @@
package com.blossom.backend.base.sys;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.base.sys.os.OSRes;
import com.blossom.common.base.pojo.R;
import com.blossom.common.base.util.spring.SpringUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 系统功能
*
* @apiNote 服务在线检查, 文件上传
* @author xzzz
*/
@Slf4j
@RestController
@RequestMapping("/sys")
public class SysController {
@Autowired
private SysService sysService;
/**
* 服务在线检查
*/
@AuthIgnore
@GetMapping("/alive")
public R<String> checkAlive() {
return R.ok(String.format("这里是 [%s] 服务器 [%s] 环境", SpringUtil.getAppName(), SpringUtil.getProfileAction()));
}
/**
* 对象存储的配置信息
*/
@GetMapping("/osconfig")
public R<OSRes> getOsConfig() {
return R.ok(sysService.getOsConfig());
}
}

View File

@ -0,0 +1,33 @@
package com.blossom.backend.base.sys;
import com.blossom.backend.base.sys.os.OSRes;
import com.blossom.common.iaas.IaasProperties;
import com.blossom.common.iaas.OSManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
/**
* 系统
*
* @author xzzz
*/
@Service
public class SysService {
@Autowired
private OSManager osManager;
/**
* 对象存储的配置信息
*/
public OSRes getOsConfig() {
IaasProperties props = osManager.getProp();
OSRes res = new OSRes();
res.setOsType(props.getOsType());
// TODO 目前只有 BLOS 一种
res.setBucketName("");
res.setDefaultPath(props.getBlos().getDefaultPath());
res.setDomain(props.getBlos().getDomain());
return res;
}
}

View File

@ -0,0 +1,29 @@
package com.blossom.backend.base.sys.os;
import lombok.Data;
/**
* 对象存储配置
*
* @author xzzz
*/
@Data
public class OSRes {
/**
* 对象存储类型
*/
private String osType;
/**
* bucket 名称
*/
private String bucketName;
/**
* 请求域名
*/
private String domain;
/**
* 保存路径
*/
private String defaultPath;
}

View File

@ -0,0 +1,96 @@
package com.blossom.backend.base.user;
import com.blossom.backend.base.param.ParamEnum;
import com.blossom.backend.base.param.ParamService;
import com.blossom.backend.base.sys.SysService;
import com.blossom.backend.base.user.pojo.*;
import com.blossom.backend.config.BlConstants;
import com.blossom.backend.server.article.draft.pojo.ArticleStatRes;
import com.blossom.backend.server.article.stat.ArticleStatService;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.pojo.R;
import lombok.AllArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Map;
/**
* 用户 [User]
*
* @author xzzz
*/
@RestController
@RequestMapping("/user")
@AllArgsConstructor
public class UserController {
private final UserService userService;
private final ArticleStatService articleService;
private final SysService sysService;
private final ParamService paramService;
/**
* 查询 blossom 用户
*
* @return 用户信息
* @apiNote blossom 当前只支持一个用户登录, 数据并为做区分. 当登录状态调用该接口时, 会返回服务器相关配置信息.
*/
@GetMapping("/info")
public R<BlossomUserRes> user() {
BlossomUserRes user = userService.selectById(AuthContext.getUserId()).to(BlossomUserRes.class);
user.setOsRes(sysService.getOsConfig());
Map<String, String> paramMap = paramService.selectMap(true, ParamEnum.values());
user.setParams(paramMap);
return R.ok(user);
}
@AuthIgnore
@GetMapping("/info/open")
public R<BlossomUserRes> userOpen(@RequestHeader(BlConstants.REQ_HEADER_USERID) Long userId) {
if (userId == null) {
return R.ok(new BlossomUserRes());
}
BlossomUserRes user = userService.selectById(userId).to(BlossomUserRes.class);
ArticleStatRes stat = articleService.statCount(null, null, userId);
user.setArticleWords(stat.getArticleWords());
user.setArticleCount(stat.getArticleCount());
return R.ok(user);
}
/**
* 修改用户
*/
@PostMapping("/upd")
public R<?> update(@Validated @RequestBody UserUpdReq req) {
UserEntity user = req.to(UserEntity.class);
user.setId(AuthContext.getUserId());
userService.updById(user);
return R.ok();
}
/**
* 修改密码
*/
@PostMapping("/upd/pwd")
public R<?> updatePassword(@Validated @RequestBody UserUpdPwdReq req) {
req.setUserId(AuthContext.getUserId());
userService.updPassword(req);
return R.ok();
}
/**
* 新增用户
*/
@PostMapping("/add")
public R<?> add(@Validated @RequestBody UserAddReq req) {
UserEntity curUser = userService.getById(AuthContext.getUserId());
if (curUser == null || !UserTypeEnum.ADMIN.getType().equals(curUser.getType())) {
throw new XzException400("您没有权限添加用户");
}
userService.insert(req);
return R.ok();
}
}

View File

@ -0,0 +1,28 @@
package com.blossom.backend.base.user;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blossom.backend.base.user.pojo.UserEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 用户
*
* @author xzzz
*/
@Mapper
public interface UserMapper extends BaseMapper<UserEntity> {
/**
* 修改用户信息
*/
void updById(UserEntity user);
/**
* 修改密码
*
* @param userId 用户ID
* @param password 新密码
*/
void updPwd(@Param("userId") Long userId, @Param("password") String password);
}

View File

@ -0,0 +1,100 @@
package com.blossom.backend.base.user;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.base.auth.security.PasswordEncoder;
import com.blossom.backend.base.user.pojo.UserAddReq;
import com.blossom.backend.base.user.pojo.UserEntity;
import com.blossom.backend.base.user.pojo.UserUpdPwdReq;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.exception.XzException404;
import com.blossom.common.base.util.security.SaltUtil;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.omg.PortableInterceptor.USER_EXCEPTION;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 用户
*
* @author xzzz
*/
@Slf4j
@Service
@AllArgsConstructor
public class UserService extends ServiceImpl<UserMapper, UserEntity> {
private static final Map<String, UserEntity> userCache = new HashMap<>();
private final PasswordEncoder passwordEncoder;
/**
* 查询全部用户
*/
public List<UserEntity> listAll() {
return baseMapper.selectList(new QueryWrapper<>());
}
/**
* 根据ID查询
*/
public UserEntity selectById(Long id) {
return baseMapper.selectOne(new LambdaQueryWrapper<UserEntity>().eq(UserEntity::getId, id));
}
/**
* 根据用户名查询用户
*
* @param username 用户名
* @return 用户信息
*/
public UserEntity selectByUsername(String username) {
return baseMapper.selectOne(new LambdaQueryWrapper<UserEntity>().eq(UserEntity::getUsername, username));
}
/**
* 新增用户
*/
@Transactional(rollbackFor = Exception.class)
public void insert(UserAddReq req) {
UserEntity user = req.to(UserEntity.class);
user.setNickName(req.getUsername());
user.setRealName(req.getUsername());
user.setType(req.getType());
user.setSalt(SaltUtil.randomSalt());
user.setPassword(passwordEncoder.encode(req.getPassword() + user.getSalt()));
baseMapper.insert(user);
}
/**
* 修改用户信息
*/
@Transactional(rollbackFor = Exception.class)
public void updById(UserEntity user) {
baseMapper.updById(user);
}
/**
* 修改密码
*/
@Transactional(rollbackFor = Exception.class)
public void updPassword(UserUpdPwdReq req) {
XzException400.throwBy(!req.getNewPassword().equals(req.getConfirmPassword()), "两次输入密码不匹配");
UserEntity user = selectById(req.getUserId());
if (!passwordEncoder.matches(req.getPassword() + user.getSalt(), user.getPassword())) {
throw new XzException400("密码错误");
}
String newPwd = passwordEncoder.encode(req.getNewPassword() + user.getSalt());
baseMapper.updPwd(req.getUserId(), newPwd);
}
}

View File

@ -0,0 +1,31 @@
package com.blossom.backend.base.user;
import lombok.Getter;
/**
* 用户类型
*
* @author xzzz
*/
public enum UserTypeEnum {
/**
* 管理员
*/
ADMIN(1),
/**
* 普通用户
*/
NORMAL(2),
/**
* 只读用户
*/
READONLY(3),
;
@Getter
private final Integer type;
UserTypeEnum(Integer type) {
this.type = type;
}
}

View File

@ -0,0 +1,66 @@
package com.blossom.backend.base.user.pojo;
import com.blossom.backend.base.sys.os.OSRes;
import com.blossom.common.base.pojo.AbstractPOJO;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Map;
/**
* 用户响应
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class BlossomUserRes extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
private Long id;
/**
* 用户名
*/
private String username;
/**
* 昵称
*/
private String nickName;
/**
* 用户头像
*/
private String avatar;
/**
* 备注
*/
private String remark;
/**
* 位置
*/
private String location;
/**
* 文章数
*/
private Integer articleCount;
/**
* 文章字数
*/
private Integer articleWords;
/**
* 对象存储信息, 非登录状态不返回该字段
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
private OSRes osRes;
/**
* 系统参数, paramName: paramValue
*/
private Map<String, String> params;
}

View File

@ -0,0 +1,22 @@
package com.blossom.backend.base.user.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
@EqualsAndHashCode(callSuper = true)
public class UserAddReq extends AbstractPOJO {
@NotBlank(message = "用户名为必填项")
private String username;
@NotBlank(message = "密码为必填项")
private String password;
@NotNull(message = "用户类型为必填项")
private Integer type;
}

View File

@ -0,0 +1,69 @@
package com.blossom.backend.base.user.pojo;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
/**
* 用户
*
* @author xzzz
*/
@Data
@TableName("base_user")
@EqualsAndHashCode(callSuper = true)
public class UserEntity extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*/
@TableId
private Long id;
/**
* 用户类型
*/
private Integer type;
/**
* 用户名
*/
private String username;
/**
* 用户手机号
*/
private String phone;
/**
* 用户密码
*/
private String password;
/**
* 密码加盐
*/
private String salt;
/**
* 昵称
*/
private String nickName;
/**
* 真实姓名
*/
private String realName;
/**
* 用户头像
*/
private String avatar;
/**
* 备注
*/
private String remark;
/**
* 用户的位置
*/
private String location;
}

View File

@ -0,0 +1,28 @@
package com.blossom.backend.base.user.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
/**
* 修改密码
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class UserUpdPwdReq extends AbstractPOJO {
private Long userId;
@NotBlank(message = "旧密码为必填项")
private String password;
@NotBlank(message = "新密码为必填项")
private String newPassword;
@NotBlank(message = "确认密码为必填项")
private String confirmPassword;
}

View File

@ -0,0 +1,43 @@
package com.blossom.backend.base.user.pojo;
import com.blossom.common.base.pojo.AbstractEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* 用户请求
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class UserUpdReq extends AbstractEntity {
/**
* 用户名
*/
@NotBlank(message = "用户名为必填项")
private String username;
/**
* 昵称
*/
@NotBlank(message = "昵称为必填项")
private String nickName;
/**
* 说明
*/
@NotBlank(message = "说明为必填项")
private String remark;
/**
* 位置
*/
private String location;
/**
* 用户头像
*/
@NotBlank(message = "用户头像为必填项")
private String avatar;
}

View File

@ -0,0 +1,6 @@
package com.blossom.backend.config;
public class BlConstants {
public static final String REQ_HEADER_USERID = "Blossom-User-Id";
}

View File

@ -0,0 +1,78 @@
package com.blossom.backend.config;
import cn.hutool.core.thread.ThreadFactoryBuilder;
import com.blossom.common.base.util.spring.SpringUtil;
import com.blossom.expand.tracker.core.adapter.spring.TrackerTaskDecorator;
import com.blossom.expand.tracker.core.common.TrackerConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
/**
* @author xzzz
* @since 0.0.1
*/
@Slf4j
@Configuration
public class SpringTaskExecutorConfig {
@Bean("taskExecutor")
public Executor taskExecutor(Environment env) {
log.info("[TRACKERS] 已经适配框架 : Spring Task");
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程池10
executor.setCorePoolSize(10);
// 最大线程池20
executor.setMaxPoolSize(20);
// 队列容量
executor.setQueueCapacity(200);
// 当线程空闲时间达到keepAliveTime该线程会退出直到线程数量等于corePoolSize
executor.setKeepAliveSeconds(60);
// 线程名称前缀
executor.setThreadNamePrefix(env.getProperty(SpringUtil.APP_NAME) + "-task-");
// 用来设置线程池关闭的时候等待所有任务都完成再继续销毁其他的Bean
executor.setWaitForTasksToCompleteOnShutdown(true);
// 等待时间
executor.setAwaitTerminationSeconds(60);
// 增加 TaskDecorator 属性的配置
executor.setTaskDecorator(new TrackerTaskDecorator(
"SPRING_ASYNC_TASK", TrackerConstants.SPAN_TYPE_SPRING_ASYNC));
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 处理未捕获异常日志打印
final Thread.UncaughtExceptionHandler uncaughtExceptionHandler = (t, e) ->
log.error("异步线程执行失败。异常信息 => {} : ", e.getMessage(), e);
ThreadFactoryBuilder threadFactoryBuilder = new ThreadFactoryBuilder();
threadFactoryBuilder.setNamePrefix(env.getProperty(SpringUtil.APP_NAME) + "-task-");
threadFactoryBuilder.setUncaughtExceptionHandler(uncaughtExceptionHandler);
executor.setThreadFactory(threadFactoryBuilder.build());
executor.initialize();
return executor;
}
/**
* 定时任务线程池
*/
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(10);
taskScheduler.setThreadNamePrefix("bl-sche-");
final Thread.UncaughtExceptionHandler uncaughtExceptionHandler = (t, e) -> log.error("定时任务执行失败。异常信息 => {} : ", e.getMessage(), e);
ThreadFactoryBuilder threadFactoryBuilder = new ThreadFactoryBuilder();
threadFactoryBuilder.setNamePrefix("bl-sche-");
threadFactoryBuilder.setUncaughtExceptionHandler(uncaughtExceptionHandler);
taskScheduler.setThreadFactory(threadFactoryBuilder.build());
taskScheduler.initialize();
return taskScheduler;
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,29 @@
package com.blossom.backend.config;
import com.blossom.backend.base.auth.interceptor.UserTypeInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* @author jasmineXz
*/
@Slf4j
@Configuration
public class WebConfigurer implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 拦截所有请求通过判断是否有 @myInterceptor 注解 决定是否需要登录
registry.addInterceptor(myInterceptor()).addPathPatterns("/**");
}
@Bean
public UserTypeInterceptor myInterceptor() {
log.info("123123213");
return new UserTypeInterceptor();
}
}

View File

@ -0,0 +1,27 @@
package com.blossom.backend.server;
import lombok.Getter;
/**
* 文件夹类型, 不同的文件夹具有不同的字段要素, 例如图片文件夹不需要公开
*
* @author xzzz
*/
public enum FolderTypeEnum {
/**
* 文章文件夹
*/
ARTICLE(1),
/**
* 图片文件夹
*/
PICTURE(2);
@Getter
private final Integer type;
FolderTypeEnum(Integer type) {
this.type = type;
}
}

View File

@ -0,0 +1,34 @@
package com.blossom.backend.server;
import cn.hutool.core.util.StrUtil;
/**
* 文档中有特殊意义的标签
*
* @author xzzz
*/
public enum TagEnum {
/**
* 标识该文件夹是个专题
*/
subject,
/**
* 标识该文章是个目录
*/
toc,
;
public static boolean isSubject(String tags) {
if (StrUtil.isBlank(tags)) {
return false;
}
return tags.toLowerCase().contains(TagEnum.subject.name());
}
public static boolean isToc(String tags) {
if (StrUtil.isBlank(tags)) {
return false;
}
return tags.toLowerCase().contains(TagEnum.toc.name());
}
}

View File

@ -0,0 +1,171 @@
package com.blossom.backend.server.article.draft;
import cn.hutool.core.net.URLEncodeUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.server.article.draft.pojo.*;
import com.blossom.backend.server.article.open.ArticleOpenService;
import com.blossom.backend.server.article.open.pojo.ArticleOpenEntity;
import com.blossom.backend.server.doc.DocTypeEnum;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.enums.YesNo;
import com.blossom.common.base.exception.XzException400;
import com.blossom.common.base.pojo.R;
import com.blossom.common.base.util.DateUtils;
import lombok.AllArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 文章 [Article]
*
* @author xzzz
*/
@RestController
@AllArgsConstructor
@RequestMapping("/article")
public class ArticleController {
private final ArticleService baseService;
private final ArticleOpenService openService;
/**
* 查询列表
*
* @apiNote 返回简单字段
*/
@GetMapping("/list")
public R<List<ArticleInfoSimpleRes>> listAll(ArticleQueryReq req) {
req.setUserId(AuthContext.getUserId());
return R.ok(baseService.listAll(req), ArticleInfoSimpleRes.class);
}
/**
* 通过ID查询文章
*
* @param id 文章ID
* @param showToc 返回目录
* @param showMarkdown 返回 markdown 内容
* @param showHtml 返回 html 内容
* @return 文章信息
*/
@GetMapping("/info")
public R<ArticleInfoRes> info(@RequestParam("id") Long id,
@RequestParam("showToc") Boolean showToc,
@RequestParam("showMarkdown") Boolean showMarkdown,
@RequestParam("showHtml") Boolean showHtml) {
if (showToc == null) {
showToc = false;
}
if (showMarkdown == null) {
showMarkdown = false;
}
if (showHtml == null) {
showHtml = false;
}
ArticleEntity article = baseService.selectById(id, showToc, showMarkdown, showHtml);
XzException400.throwBy(ObjUtil.isNull(article), "文章不存在");
ArticleInfoRes res = article.to(ArticleInfoRes.class);
res.setTags(DocUtil.toTagList(article.getTags()));
res.setType(DocTypeEnum.A.getType());
if (YesNo.YES.getValue().equals(article.getOpenStatus())) {
ArticleOpenEntity open = openService.selectById(id, false, false, false);
if (open != null) {
res.setOpenTime(open.getOpenTime());
res.setOpenVersion(open.getOpenVersion());
res.setSyncTime(open.getSyncTime());
}
}
return R.ok(res);
}
/**
* 新增文章
*
* @param req 文章对象
* @return 保存结果
*/
@PostMapping("/add")
public R<Long> insert(@Validated @RequestBody ArticleAddReq req) {
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(req.getTags()));
article.setUserId(AuthContext.getUserId());
return R.ok(baseService.insert(article));
}
/**
* 修改文章
*
* @param req 文章对象
* @apiNote 该接口只能修改文章的基本信息, 正文及版本修改请使用 {@link ArticleService#updateContentById(ArticleEntity)}
*/
@PostMapping("/upd")
public R<Long> insert(@Validated @RequestBody ArticleUpdReq req) {
ArticleEntity article = req.to(ArticleEntity.class);
article.setTags(DocUtil.toTagStr(req.getTags()));
return R.ok(baseService.update(article));
}
/**
* 保存正文内容
*/
@PostMapping("/upd/content")
public R<ArticleUpdContentRes> updateContent(@Validated @RequestBody ArticleUpdContentReq content) {
ArticleEntity upd = content.to(ArticleEntity.class);
upd.setReferences(content.getReferences());
upd.setUserId(AuthContext.getUserId());
int words = baseService.updateContentById(upd);
ArticleUpdContentRes res = new ArticleUpdContentRes();
res.setWords(words);
res.setUpdTime(DateUtils.date());
return R.ok(res);
}
/**
* 星标文章
*
* @param req 文章对象
*/
@PostMapping("/star")
public R<Long> star(@Validated @RequestBody ArticleStarReq req) {
return R.ok(baseService.update(req.to(ArticleEntity.class)));
}
/**
* 下载文章
*
* @param id 文章ID
* @param response 文章流
*/
@GetMapping("/download")
public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
ArticleEntity article = baseService.selectById(id, false, true, false);
if (StrUtil.isBlank(article.getMarkdown())) {
throw new IllegalArgumentException("文章内容为空,无法下载");
}
try (InputStream is = new ByteArrayInputStream(article.getMarkdown().getBytes(StandardCharsets.UTF_8)); BufferedInputStream bis = new BufferedInputStream(is)) {
OutputStream os = response.getOutputStream();
String filename = URLEncodeUtil.encode(article.getName() + ".md");
// 设置强制下载不打开
response.setContentType("application/force-download");
// 将请求头暴露给前端
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
response.addHeader("Content-Disposition", "attachment;filename=" + filename);
byte[] buffer = new byte[1024];
int i = bis.read(buffer);
while (i != -1) {
os.write(buffer, 0, i);
i = bis.read(buffer);
}
}
}
}

View File

@ -0,0 +1,60 @@
package com.blossom.backend.server.article.draft;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleStatRes;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 文章
*
* @author xzzz
*/
@Mapper
public interface ArticleMapper extends BaseMapper<ArticleEntity> {
/**
* 查询全部
*/
List<ArticleEntity> listAll(ArticleEntity entity);
/**
* 根据ID修改
*/
void updById(ArticleEntity entity);
/**
* 修改文章内容
*/
void updContentById(ArticleEntity entity);
/**
* 修改某段日期内修改的文章数据
*
* @param beginUpdTime 开始修改日期
* @param endUpdTime 结束修改日期
*/
ArticleStatRes statCount(@Param("beginUpdTime") String beginUpdTime,
@Param("endUpdTime") String endUpdTime,
@Param("userId") Long userId);
/**
* 同步版本号, 将文章的 version 同步到 openVersion, 只有 open_status 1 才会修改成功
*
* @param articleId 文章ID
*/
void sync(@Param("articleId") Long articleId);
/**
* 递增 UV, PV
*
* @param article 文章ID
* @param pv pv
* @param uv uv
*/
void uvAndPv(@Param("articleId") Long article, @Param("pv") Integer pv, @Param("uv") Integer uv);
}

View File

@ -0,0 +1,148 @@
package com.blossom.backend.server.article.draft;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.server.TagEnum;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleQueryReq;
import com.blossom.backend.server.article.log.ArticleLogService;
import com.blossom.backend.server.article.reference.ArticleReferenceService;
import com.blossom.backend.server.article.view.ArticleViewService;
import com.blossom.backend.server.doc.pojo.DocTreeRes;
import com.blossom.backend.server.utils.ArticleUtil;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.exception.XzException404;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* 文章
*
* @author xzzz
*/
@Slf4j
@Service
@AllArgsConstructor
public class ArticleService extends ServiceImpl<ArticleMapper, ArticleEntity> {
private final ArticleReferenceService referenceService;
private final ArticleViewService viewService;
private final ArticleLogService logService;
/**
* 查询列表
*/
public List<ArticleEntity> listAll(ArticleQueryReq req) {
List<ArticleEntity> articles = baseMapper.listAll(req.to(ArticleEntity.class));
if (CollUtil.isEmpty(articles)) {
return new ArrayList<>();
}
return articles;
}
/**
* 树状列表
*
* @return DocTreeRes
*/
public List<DocTreeRes> listTree(ArticleQueryReq req) {
List<ArticleEntity> articles = baseMapper.listAll(req.to(ArticleEntity.class));
List<DocTreeRes> articleTrees = new ArrayList<>(articles.size());
for (ArticleEntity article : articles) {
articleTrees.add(DocUtil.toDocTree(article));
}
return articleTrees;
}
/**
* 根据ID查询
*/
public ArticleEntity selectById(Long id, boolean showToc, boolean showMarkdown, boolean showHtml) {
QueryWrapper<ArticleEntity> where = new QueryWrapper<>();
List<String> column = CollUtil.newArrayList("id", "pid", "name", "icon", "tags", "sort", "cover", "describes", "star_status",
"open_status", "pv", "uv", "likes", "words", "version", "cre_time", "upd_time");
if (showToc) {
column.add(TagEnum.toc.name());
}
if (showMarkdown) {
column.add("markdown");
}
if (showHtml) {
column.add("html");
}
where.select(column);
where.eq("id", id).last("limit 1");
return baseMapper.selectOne(where);
}
/**
* 新增
*/
@Transactional(rollbackFor = Exception.class)
public Long insert(ArticleEntity req) {
baseMapper.insert(req);
return req.getId();
}
/**
* 修改, 该接口只能修改文章的基本信息, 正文及版本修改请使用 {@link ArticleService#updateContentById(ArticleEntity)}
*/
@Transactional(rollbackFor = Exception.class)
public Long update(ArticleEntity req) {
XzException404.throwBy(req.getId() == null, "ID不得为空");
baseMapper.updById(req);
return req.getId();
}
/**
* 修改文章内容, 并统计字数
*
* @return 返回文章字数
*/
@Transactional(rollbackFor = Exception.class)
public Integer updateContentById(ArticleEntity req) {
XzException404.throwBy(req.getId() == null, "ID不得为空");
if (req.getMarkdown() != null) {
req.setWords(ArticleUtil.statWords(req.getMarkdown()));
}
if (req.getHtml() != null) {
req.setHtml(req.getHtml().replaceAll("<p><br></p>", ""));
}
baseMapper.updContentById(req);
referenceService.bind(req.getUserId(), req.getId(), req.getName(), req.getReferences());
logService.insert(req.getId(), 0, req.getMarkdown());
return req.getWords();
}
/**
* 同步版本号, 将文章的 version 同步到 openVersion, 只有 open_status 1 才会修改成功
*/
public void sync(Long id) {
baseMapper.sync(id);
}
/**
* 递增UV和PV数据
* PV 接口每调用一次都会递增, UV数据每个IP每天只会递增一次
*
* @param ip ip
* @param userAgent userAgent
* @param id 文章ID
*/
@Async
@Transactional(rollbackFor = Exception.class)
public void uvAndPv(String ip, String userAgent, Long id) {
int uv = 0;
if (viewService.uv(ip, userAgent, id)) {
uv = 1;
}
baseMapper.uvAndPv(id, 1, uv);
}
}

View File

@ -0,0 +1,59 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.util.List;
/**
* 文章新增请求
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleAddReq extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 文件夹ID
*/
@Min(value = 0, message = "[上级菜单ID] 不能小于0")
@NotNull(message = "[上级菜单] 为必填项")
private Long pid;
/**
* 文章名称
*/
@NotBlank(message = "[文章名称] 为必填项")
private String name;
/**
* 文章图标
*/
private String icon;
/**
* 标签集合
*/
private List<String> tags;
/**
* 排序
*/
private Integer sort;
/**
* 封面
*/
private String cover;
/**
* 描述
*/
private String describes;
/**
* 颜色
*/
private String color;
}

View File

@ -0,0 +1,135 @@
package com.blossom.backend.server.article.draft.pojo;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.backend.server.article.reference.pojo.ArticleReferenceReq;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 文章
*
* @author xzzz
*/
@Data
@TableName("blossom_article")
@EqualsAndHashCode(callSuper = true)
public class ArticleEntity extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
@TableId
private Long id;
/**
* 文件夹ID
*/
private Long pid;
/**
* 文章名称
*/
private String name;
/**
* 文章图标
*/
private String icon;
/**
* 标签集合
*/
private String tags;
/**
* 排序
*/
private Integer sort;
/**
* 封面
*/
private String cover;
/**
* 描述
*/
private String describes;
/**
* star状态
*/
private Integer starStatus;
/**
* 公开状态
*/
private Integer openStatus;
/**
* 公开版本
*/
private Integer openVersion;
/**
* 页面的查看数
*/
private Integer pv;
/**
* 独立的访问次数,每日IP重置
*/
private Integer uv;
/**
* 点赞数
*/
private Integer likes;
/**
* 文章字数
*/
private Integer words;
/**
* 文章字数
*/
private Integer version;
/**
* 颜色
*/
private String color;
/**
* 目录
*/
private String toc;
/**
* Markdown 内容
*/
private String markdown;
/**
* Html 内容
*/
private String html;
/**
* 版本
*/
private Date creTime;
/**
* 修改时间
*/
private Date updTime;
/**
* 用户ID
*/
private Long userId;
//region ============================== 非数据库字段 ==============================
/**
* 引用集合
*/
@TableField(exist = false)
private List<ArticleReferenceReq> references;
/**
* 父ID集合
*/
@TableField(exist = false)
private List<Long> pids;
//endregion
}

View File

@ -0,0 +1,122 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 文章响应
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleInfoRes extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
private Long id;
/**
* 文件夹ID
*/
private Long pid;
/**
* 文章名称
*/
private String name;
/**
* 文章图标
*/
private String icon;
/**
* 标签集合
*/
private List<String> tags;
/**
* 排序
*/
private Integer sort;
/**
* 封面
*/
private String cover;
/**
* 描述
*/
private String describes;
/**
* star状态
*/
private Integer starStatus;
/**
* 页面的查看数
*/
private Integer pv;
/**
* 独立的访问次数,每日IP重置
*/
private Integer uv;
/**
* 点赞数
*/
private Integer likes;
/**
* 文章字数
*/
private Integer words;
/**
* 版本
*/
private Integer version;
/**
* Markdown 内容
*/
private String markdown;
/**
* Html 内容
*/
private String html;
/**
* 版本
*/
private Date creTime;
/**
* 修改时间
*/
private Date updTime;
/**
* 颜色
*/
private String color;
/**
* 目录
*/
private String toc;
/**
* 类型
*/
private Integer type;
/**
* 开放状态
*/
private Integer openStatus;
/**
* 公开同步时间
*/
private Date syncTime;
/**
* 公开时间
*/
private Date openTime;
/**
* 公开版本
*/
private Integer openVersion;
}

View File

@ -0,0 +1,81 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.List;
/**
* 文章响应
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleInfoSimpleRes extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
private Long id;
/**
* 文件夹ID
*/
private Long pid;
/**
* 文章名称
*/
private String name;
/**
* 文章图标
*/
private String icon;
/**
* 标签集合
*/
private List<String> tags;
/**
* 封面
*/
private String cover;
/**
* 描述
*/
private String describes;
/**
* star状态
*/
private Integer starStatus;
/**
* 开放状态
*/
private Integer openStatus;
/**
* 页面的查看数
*/
private Integer pv;
/**
* 独立的访问次数,每日IP重置
*/
private Integer uv;
/**
* 点赞数
*/
private Integer likes;
/**
* 文章字数
*/
private Integer words;
/**
* 版本
*/
private Integer version;
/**
* 颜色
*/
private String color;
}

View File

@ -0,0 +1,37 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.db.pojo.PageReq;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.List;
/**
* 文章查询请求
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleQueryReq extends PageReq implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 上级ID集合
*/
private List<Long> pids;
/**
* star状态, YesNoEnum
*/
private Integer starStatus;
/**
* 公开状态
*/
private Integer openStatus;
/**
* 用户ID
*/
private Long userId;
}

View File

@ -0,0 +1,35 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
/**
* 文章 star
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleStarReq extends AbstractPOJO {
/**
* 文章ID
*/
@Min(value = 0, message = "[文章ID] 不能小于0")
@NotNull(message = "[文章ID] 为必填项")
private Long id;
/**
* star 状态
*/
@Min(value = 0, message = "[star 状态] 不能小于0")
@Max(value = 1, message = "[star 状态] 不能大于1")
@NotNull(message = "[star 状态] 为必填项")
private Integer starStatus;
}

View File

@ -0,0 +1,25 @@
package com.blossom.backend.server.article.draft.pojo;
import lombok.Data;
import java.io.Serializable;
/**
* 文章统计对象
*
* @author xzzz
*/
@Data
public class ArticleStatRes implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 文章数
*/
private Integer articleCount;
/**
* 字数
*/
private Integer articleWords;
}

View File

@ -0,0 +1,53 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.backend.server.article.draft.ArticleController;
import com.blossom.backend.server.article.reference.pojo.ArticleReferenceReq;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 修改文章内容
*
* <p>
* {@link ArticleController#updateContent(ArticleUpdContentReq)}
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleUpdContentReq extends AbstractPOJO {
/**
* ID
*/
@Min(value = 0, message = "[文章ID] 不能小于0")
@NotNull(message = "[文章ID] 为必填项")
private Long id;
/**
* 名称, 用于引用关系表中的名称冗余
*/
@NotBlank(message = "文章名称为必填项")
private String name;
/**
* markdown 内容
*/
private String markdown;
/**
* html 内容
*/
private String html;
/**
* 目录
*/
private String toc;
/**
* 引用链接集合
*/
private List<ArticleReferenceReq> references;
}

View File

@ -0,0 +1,40 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.backend.server.article.draft.ArticleController;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* 修改文章内容
* <p>
* {@link ArticleController#updateContent(ArticleUpdContentReq)}
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleUpdContentRes extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 文章ID
*/
private Long id;
/**
* 文章版本
*/
private Integer version;
/**
* 字数
*/
private Integer words;
/**
* 修改时间
*/
private Date updTime;
}

View File

@ -0,0 +1,63 @@
package com.blossom.backend.server.article.draft.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.util.List;
/**
* 文章修改请求
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleUpdReq extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
@Min(value = 0, message = "[文章ID] 不能小于0")
@NotNull(message = "[文章ID] 为必填项")
private Long id;
/**
* 文件夹ID
*/
@Min(value = 0, message = "[上级菜单ID] 不能小于0")
@NotNull(message = "[上级菜单] 为必填项")
private Long pid;
/**
* 文章名称
*/
@NotBlank(message = "[文章名称] 为必填项")
private String name;
/**
* 文章图标
*/
private String icon;
/**
* 标签集合
*/
private List<String> tags;
/**
* 排序
*/
private Integer sort;
/**
* 封面
*/
private String cover;
/**
* 描述
*/
private String describes;
/** 颜色 */
private String color;
}

View File

@ -0,0 +1,14 @@
package com.blossom.backend.server.article.log;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blossom.backend.server.article.log.pojo.ArticleLogEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 文章记录
*/
@Mapper
public interface ArticleLogMapper extends BaseMapper<ArticleLogEntity> {
}

View File

@ -0,0 +1,66 @@
package com.blossom.backend.server.article.log;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.base.param.ParamEnum;
import com.blossom.backend.base.param.ParamService;
import com.blossom.backend.base.param.pojo.ParamEntity;
import com.blossom.backend.server.article.log.pojo.ArticleLogEntity;
import com.blossom.common.base.util.DateUtils;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
/**
* 文章记录
*/
@Slf4j
@Service
@AllArgsConstructor
public class ArticleLogService extends ServiceImpl<ArticleLogMapper, ArticleLogEntity> {
private final ParamService paramService;
/**
* 新增记录
*/
@Async
@Transactional(rollbackFor = Exception.class)
public void insert(Long articleId, Integer version, String markdown) {
ArticleLogEntity log = new ArticleLogEntity();
log.setArticleId(articleId);
log.setVersion(version);
log.setMarkdown(markdown);
log.setCreTime(DateUtils.date());
baseMapper.insert(log);
}
/**
* 每天凌晨5点执行
*/
@Scheduled(cron = "0 0 05 * * ?")
@Transactional(rollbackFor = Exception.class)
public void refreshWeather() {
ParamEntity param = paramService.getValue(ParamEnum.ARTICLE_LOG_EXP_DAYS);
int expireDay = -60;
if (param != null) {
expireDay = Integer.parseInt(param.getParamValue());
}
if (expireDay > 0) {
expireDay = expireDay * -1;
}
log.info("[BLOSSOM] 删除{}日前的编辑记录", Math.abs(expireDay));
Date expireDate = DateUtils.offsetDay(DateUtils.date(), expireDay);
LambdaQueryWrapper<ArticleLogEntity> where = new LambdaQueryWrapper<>();
where.lt(ArticleLogEntity::getCreTime, DateUtils.toYMD(expireDate));
baseMapper.delete(where);
}
}

View File

@ -0,0 +1,33 @@
package com.blossom.backend.server.article.log.pojo;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.*;
import java.io.Serializable;
/**
* 文章记录
*/
@Data
@TableName("blossom_article_log")
@EqualsAndHashCode(callSuper = true)
public class ArticleLogEntity extends AbstractPOJO implements Serializable {
private static final long serialVersionUID = 1L;
/** ID */
@TableId
private Long id;
/** 文章ID */
private Long articleId;
/** 版本 */
private Integer version;
/** 文章内容 */
private String markdown;
/** 修改日期 */
private Date creTime;
}

View File

@ -0,0 +1,97 @@
package com.blossom.backend.server.article.open;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.ObjectUtil;
import com.blossom.backend.base.auth.AuthContext;
import com.blossom.backend.server.article.draft.ArticleService;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.draft.pojo.ArticleInfoRes;
import com.blossom.backend.server.article.open.pojo.ArticleOpenEntity;
import com.blossom.backend.server.article.open.pojo.ArticleOpenReq;
import com.blossom.backend.server.article.open.pojo.ArticleOpenRes;
import com.blossom.backend.server.article.open.pojo.ArticleOpenSyncReq;
import com.blossom.backend.base.auth.annotation.AuthIgnore;
import com.blossom.backend.server.article.view.ArticleViewService;
import com.blossom.backend.server.article.view.pojo.ArticleViewEntity;
import com.blossom.backend.server.doc.DocTypeEnum;
import com.blossom.backend.server.utils.DocUtil;
import com.blossom.common.base.exception.XzException404;
import com.blossom.common.base.pojo.R;
import com.blossom.common.base.util.ServletUtil;
import lombok.AllArgsConstructor;
import org.checkerframework.checker.units.qual.A;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
/**
* 公开文章 [Article]
*
* @author xzzz
*/
@RestController
@AllArgsConstructor
@RequestMapping("/article/open")
public class ArticleOpenController {
private final ArticleService articleService;
private final ArticleOpenService openService;
/**
* 查询公开文章, 只返回 html 内容
*
* @param id 文章ID
* @return
*/
@AuthIgnore
@GetMapping("/info")
public R<ArticleInfoRes> infoOpen(@RequestParam("id") Long id, HttpServletRequest request) {
ArticleOpenEntity open = openService.selectById(id, true, false, true);
XzException404.throwBy(ObjUtil.isNull(open), "文章不存在");
ArticleInfoRes res = open.to(ArticleInfoRes.class);
res.setOpenTime(open.getOpenTime());
res.setOpenVersion(open.getOpenVersion());
res.setSyncTime(open.getSyncTime());
res.setType(DocTypeEnum.A.getType());
ArticleEntity article = articleService.selectById(id, false, false, false);
if (article != null) {
res.setTags(DocUtil.toTagList(article.getTags()));
res.setName(article.getName());
res.setUv(article.getUv());
res.setLikes(article.getLikes());
}
articleService.uvAndPv(ServletUtil.getIP(request), ServletUtil.getUserAgent(request), id);
return R.ok(res);
}
/**
* 公开文章
*
* @param req 文章对象
*/
@PostMapping
public R<Long> open(@Validated @RequestBody ArticleOpenReq req) {
req.setUserId(AuthContext.getUserId());
return R.ok(openService.open(req));
}
/**
* 同步公开文章
*
* @param req 文章对象
*/
@PostMapping("/sync")
public R<ArticleOpenRes> sync(@Validated @RequestBody ArticleOpenSyncReq req) {
openService.sync(req.getId());
ArticleOpenEntity openEntity = openService.selectById(req.getId(), false, false, false);
return R.ok(openEntity.to(ArticleOpenRes.class));
}
}

View File

@ -0,0 +1,35 @@
package com.blossom.backend.server.article.open;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.blossom.backend.server.article.open.pojo.ArticleOpenEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 公开文章
*
* @author xzzz
*/
@Mapper
public interface ArticleOpenMapper extends BaseMapper<ArticleOpenEntity> {
/**
* 公开文章
*
* @param id 公开文章的ID
*/
void open(Long id);
/**
* 关闭公开
*
* @param id 关闭公开文章的ID
*/
void close(Long id);
/**
* 同步公开
*
* @param id 公开文章的ID
*/
void sync(Long id);
}

View File

@ -0,0 +1,93 @@
package com.blossom.backend.server.article.open;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.blossom.backend.server.TagEnum;
import com.blossom.backend.server.article.draft.ArticleService;
import com.blossom.backend.server.article.draft.pojo.ArticleEntity;
import com.blossom.backend.server.article.open.pojo.ArticleOpenEntity;
import com.blossom.backend.server.article.open.pojo.ArticleOpenReq;
import com.blossom.common.base.enums.YesNo;
import com.blossom.common.base.exception.XzException400;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 公开文章
*
* @author xzzz
*/
@Slf4j
@Service
@AllArgsConstructor
public class ArticleOpenService extends ServiceImpl<ArticleOpenMapper, ArticleOpenEntity> {
private final ArticleService articleService;
/**
* 根据ID查询
*/
public ArticleOpenEntity selectById(Long id, boolean showToc, boolean showMarkdown, boolean showHtml) {
QueryWrapper<ArticleOpenEntity> where = new QueryWrapper<>();
List<String> column = CollUtil.newArrayList("id", "pid", "words", "open_version", "open_time", "sync_time");
if (showToc) {
column.add(TagEnum.toc.name());
}
if (showMarkdown) {
column.add("markdown");
}
if (showHtml) {
column.add("html");
}
where.select(column);
where.eq("id", id).last("limit 1");
return baseMapper.selectOne(where);
}
/**
* 公开或关闭公开访问
*/
@Transactional(rollbackFor = Exception.class)
public Long open(ArticleOpenReq req) {
ArticleEntity article = articleService.getById(req.getId());
ArticleEntity entity = req.to(ArticleEntity.class);
/*
* 公开文章 article 表插入到 article_open
*/
if (YesNo.YES.getValue().equals(req.getOpenStatus())) {
XzException400.throwBy(article.getOpenStatus().equals(YesNo.YES.getValue()), "文章已[" + req.getId() + "]已允许公开访问, 若要同步最新文章内容, 请使用同步");
entity.setOpenVersion(article.getVersion());
baseMapper.open(req.getId());
}
/*
* 取消公开 删除 article_open 表数据
*/
else if (YesNo.NO.getValue().equals(req.getOpenStatus())) {
entity.setOpenVersion(0);
XzException400.throwBy(article.getOpenStatus().equals(YesNo.NO.getValue()), "文章[" + req.getId() + "]未公开, 无法取消公开访问");
baseMapper.close(req.getId());
}
articleService.update(entity);
return req.getId();
}
/**
* 同步公开访问的文章正文等信息
*/
@Transactional(rollbackFor = Exception.class)
public void sync(Long id) {
ArticleOpenEntity article = baseMapper.selectById(id);
XzException400.throwBy(ObjUtil.isNull(article), "文章[" + id + "]未公开, 无法同步");
baseMapper.sync(id);
articleService.sync(id);
}
}

View File

@ -0,0 +1,58 @@
package com.blossom.backend.server.article.open.pojo;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.Date;
/**
* 公开文章
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("blossom_article_open")
public class ArticleOpenEntity extends AbstractPOJO {
/**
* ID
*/
@TableId
private Long id;
/**
* 文件夹ID
*/
private Long pid;
/**
* 公开版本的字数
*/
private Integer words;
/**
* 公开版本
*/
private Integer openVersion;
/**
* 公开时间
*/
private Date openTime;
/**
* 同步时间
*/
private Date syncTime;
/**
* 目录
*/
private String toc;
/**
* markdown 内容
*/
private String markdown;
/**
* html 内容
*/
private String html;
}

View File

@ -0,0 +1,40 @@
package com.blossom.backend.server.article.open.pojo;
import com.blossom.common.base.pojo.AbstractPOJO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
/**
* 文章公开
*
* @author xzzz
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ArticleOpenReq extends AbstractPOJO {
/**
* 文章ID
*/
@Min(value = 0, message = "[文章ID] 不能小于0")
@NotNull(message = "[文章ID] 为必填项")
private Long id;
/**
* 公开状态
*/
@Min(value = 0, message = "[open 状态] 不能小于0")
@Max(value = 1, message = "[open 状态] 不能大于1")
@NotNull(message = "[open 状态] 为必填项")
private Integer openStatus;
/**
* 用户ID
*/
private Long userId;
}

View File

@ -0,0 +1,52 @@
package com.blossom.backend.server.article.open.pojo;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 文章公开响应
*
* @author xzzz
*/
@Data
public class ArticleOpenRes implements Serializable {
private static final long serialVersionUID = 1L;
/**
* ID
*/
@TableId
private Long id;
/**
* 文件夹ID
*/
private Long pid;
/**
* 字数
*/
private Integer words;
/**
* 公开文章版本
*/
private Integer openVersion;
/**
* 初次公开时间
*/
private Date openTime;
/**
* 同步时间
*/
private Date syncTime;
/**
* markdown 正文
*/
private String markdown;
/**
* html 正文
*/
private String html;
}

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