【ts + java】古玩系统开发总结
src别名的配置
开发中文件和文件的关系会比较复杂,我们需要给src文件夹一个别名@吧
vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve("./src") // 相对路径别名配置,使用@代替src
}
}
})
typescript配置:
//tsconfig.app.json
{
"compilerOptions":{
"baseUrl":"./" // 解析非相对模块的基地址,默认是当前目录
"paths":{ // 路径映射,相对于baseUrl
"@/*":["src/*"]
}
}
}
环境变量的配置
开发会经历开发,测试,生产环境三个阶段,不同阶段请求的状态不同,于是环境变量的配置需求就有了。只要做简单的配置,这样就可以将环境切换的配置交给代码。(一般一个环境对应一台服务器)
在项目根目录下添加生产,开发,测试环境文件:
.env.deveoplment
.env.production
.env.test
文件内容如下:变量必须以VITE_为前缀
# 变量必须以VITE_为前缀才能暴露给外部读取
# 开发阶段可以获取到的开发环境变量
NODE_ENV = 'development'
VITE_APP_TITLE = '古玩售卖后台系统'
VITE_APP_BASE_API = '/dev-api'
VITE_serve = 'http://xxx.com'
NODE_ENV = 'production'
VITE_APP_TITLE = '古玩售卖后台系统'
VITE_APP_BASE_API = '/prod-api'
VITE_serve = 'http://yyy.com'
NODE_ENV = 'test'
VITE_APP_TITLE = '古玩售卖后台系统'
VITE_APP_BASE_API = '/test-api'
VITE_serve = 'http://zzz.com'
配置运行命令:package.json
"scripts": {
"dev": "vite",
"build:test":"vue-tsc && vite build --mode test",
"build:pro":"vue-tsc && vite build -- mode production"
}
通过import.meta.env获取环境变量。例如在main.js中打印:可以发现在配置文件中配置的开发环境存储变量显示如下,可以在开发阶段使用。
SVG图标配置
安装:
pnpm install vite-plugin-svg-icons -D
cnpm i fast-glob -D
vite.config.ts中配置:
// 引入svg插件
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
export default defineConfig({
plugins: [
vue(),
createSvgIconsPlugin({
// 将svg矢量图标放在项目的src/assets/icons目录下
iconDirs:[path.resolve(process.cwd(),'src/assets/icons')],
symbolId:'icon-[dir]-[name]'
})
]
})
在main.ts 中添加:
// svg插件需要的配置代码
import 'virtual:svg-icons-register'
按照配置文件在src/assets/icons下保存svg图片的代码,常用网站iconfont-阿里巴巴矢量图标库
随后可以在项目中使用该目录下svg图片,使用方式如下,注意需要将svg标签和use标签结合使用,xlink:href指定需要使用哪个图标,其命名方式遵循配置文件#icon-svg文件名,fill属性设置图标的颜色。
<template>
<div>
<!-- svg是图标外层的容器节点,内部需要和use标签结合使用 -->
<svg>
<!-- xlink:href指定需要使用哪个图标 -->
<use xlink:href="#icon-phone" fill="red"></use>
</svg>
</div>
</template>
由于在项目中会经常使用到svg图标,可以将其封装成一个组件(src/conpoments/SvgIcons/index.vue)
template>
<svg :style="{width,height}">
<!-- xlink:href指定需要使用哪个图标 -->
<use :xlink:href="prefix + name" :fill="color"></use>
</svg>
</template>
<script setup lang="ts">
defineProps({
// xlink:href属性的前缀
prefix:{
type:String,
default:'#icon-'
},
// 提供使用图标的名字
name:String,
color:{
type:String,
default:''
},
width:{
type:String,
defaule:'16px'
},
height:{
type:String,
defaule:'16px'
}
})
</script>
这样就可以在其他组件中使用,但使用时需要import引入,还可以将其注册为全局组件。Vue3中使用app.component()注册全局组件。main.js
import SvgIcon from '@/components/SvgIcon/index.vue'
app.component('SvgIcon',SvgIcon);
但项目中会存在许多组件都需要注册为全局组件,在main.ts中一一引入太过于繁琐。可以创建自定义插件对象,在其中注册整个项目的全局组件。在component目录下创建index.ts:
import SvgIcon from './SvgIcon/index.vue'
// 将所有要注册的全局组件放在一个对象中
const allGlobalComponent: { [key: string]: any } = {SvgIcon}
// 对外暴露一个插件对象
export default {
// 务必叫做install方法,会将app应用实例传递给我们
install(app:any) {
// 注册项目所有的全局组件
Object.keys(allGlobalComponent).forEach(key => {
// 注册为全局组件
app.component(key,allGlobalComponent[key])
})
}
}
插件对象必须有install方法。将所有注册的全局组件放在allGlobalComponent对象中,{ [key: string]: any }
表示该对象可以使用任何字符串作为索引,并且可以返回任何类型的属性值。不这样写会报错。
main.ts:安装自定义插件
// 引入自定义插件对象,注册整个项目的全局组件
import globalComponent from '@/components'
// 安装自定义插件,此时会触发install方法
app.use(globalComponent);
这样在项目中就可以直接引入SvgIcon组件了
集成sass
项目中使用sass,需要在对应的style标签上添加属性lang="scss"
我们需要给项目添加一些全局样式。在src/style目录下创建一个index.scss(存放所有的全局样式),在里面引入reset.scss(清除默认样式),代码可以在scss-reset - npm上获取。
在main.ts中引入
// 引入模板的全局样式
import '@/styles/index.scss'
但是index.scss中没有办法使用$变量,需要给项目引入全局变量$(sass中变量使用$符开头),在style/variable.scss创建一个variable.scss文件(存放项目的scss全局变量),并在vite.config.ts文件配置如下:
export default defineConfig({
// scss样式全局变量的配置
css:{
preprocessorOptions:{
scss:{
javascriptEnabled:true,
additionalData:'@import "./src/styles/variable.scss";'
}
}
}
})
在variable.scss中定义scss全局变量
$color:blue;
这样在项目中就可以使用例如:h1{ color:$color }
mock接口
vite安装:cnpm i vite-plugin-mock mockjs -D
在vite.config.ts中配置文件启用插件
export default defineConfig(({command}) => {
return {
plugins: [
viteMockServe ({
localEnabled:command === 'serve', // 保证开发阶段能使用mock接口
})
],
}
})
在项目的根目录下创建mock文件夹,里面存放接口,例如,user.ts:
function createUserList () {
return [
{
userId:1,
avator:'',
username:'admin',
password:'11111',
desc:'平台管理员',
roles:['平台管理员'],
buttons:['user.detail'],
routes:['home'],
token:'Admin1 Token'
},
{
userId:2,
avator:'',
username:'system',
password:'11111',
desc:'平台管理员',
roles:['平台管理员'],
buttons:['user.detail','cuser.user'],
routes:['home'],
token:'Admin2 Token'
}
]
}
export default [
// 用户登录接口
{
url:'/api/user/login',
method:'post',
response:({body}) => {
const { username, password} = body;
const checkUser = createUserList().find(
(item) => item.username === username && item.password === password
)
if(!checkUser) {
return {code:201,data:{message:'账号或密码不正确'}}
}
const {token} = checkUser;
return { code:200,data:{token}}
}
},
// 获取用户信息
{
url:'/api/user/info',
method:'get',
response:(request) => {
// 获取请求头携带的token
const token = request.herder.token;
const checkUser = createUserList().find((item) => item.token === token)
if(!checkUser) {
return {code:201,data:{message:'获取用户信息失败'}}
}
return {code:200,data:checkUser}
}
}
]
vite脚手架跨域设置
vite提供一个方法loadEnv加载对应环境下的变量,loadEnv()方法一执行就会返回当前开发环境对象,包含项目.env.xxx文件下配置的变量。
此外defineConfig函数的回调会注入一个mode变量,默认是开发环境
还需要获取环境文件的位置,即传入项目根目录,用process.cwd()获取
即 let env = loadEnv(mode,process.cwd()) 表示要加载哪个环境对应的哪个文件,随后就可使用文件中配置的变量
import { defineConfig,loadEnv } from 'vite'
// command获取当前的运行环境
export default defineConfig(({command,mode}) => {
// 获取各种环境下对应的变量
let env = loadEnv(mode,process.cwd())
return {
// 代理跨域
server:{
proxy:{
// 获取对应环境代理跨域需要带的关键字 例如开发环境需要配置/api才能从指定服务器拿数据
[env.VITE_APP_BASE_API]: {
// 获取数据服务器的地址
target:env.VITE_serve,
// 是否代理跨域
changeOrigin:true,
// 路径重写 真实的接口路径前面是没有/api的,需要将'/api'其替换为''
rewrite:(path) => path.replace(/^\/api/,''),
}
}
}
}
})
Java实体类在项目中的分类
1)封装请求参数的实体类:定义的时候会携带到dto,如:数据传输对象 Data Transfer Object,会定义在dto包中
2)与数据库对应的实体类:和数据库表名一致,定义在domain,entity,pojo包中
3)封装响应结果的实体类:定义的时候会携带到vo(视图对象)字样,定义在vo包中
统一结果实体类
让项目中所有的后端接口返回相同的数据格式。项目中所有的controller接口返回的都是Result格式的数据,code是状态码,message是响应信息,data是响应的数据
@Data
@Schema(description = "响应结果实体类")
public class Result<T> {
//返回码
@Schema(description = "业务状态码")
private Integer code;
//返回消息
@Schema(description = "响应消息")
private String message;
//返回数据
@Schema(description = "业务数据")
private T data;
// 私有化构造
private Result() {}
// 通过它返回统一的数据
public static <T> Result<T> build(T body, Integer code, String message) {
Result<T> result = new Result<>();
result.setData(body);
result.setCode(code);
result.setMessage(message);
return result;
}
// 通过枚举构造Result对象
public static <T> Result build(T body , ResultCodeEnum resultCodeEnum) {
return build(body , resultCodeEnum.getCode() , resultCodeEnum.getMessage()) ;
}
}
枚举类中配置好所有的响应状态码对应的响应信息,可以直接调用 public static <T> Result build(T body , ResultCodeEnum resultCodeEnum) 方法
@Getter // 提供获取属性值的getter方法
public enum ResultCodeEnum {
SUCCESS(200 , "操作成功") ,
LOGIN_ERROR(201 , "用户名或者密码错误"),
VALIDATECODE_ERROR(202 , "验证码错误") ,
LOGIN_AUTH(208 , "用户未登录"),
USER_NAME_IS_EXISTS(209 , "用户名已经存在"),
SYSTEM_ERROR(9999 , "您的网络有问题请稍后重试"),
NODE_ERROR( 217, "该节点下有子节点,不可以删除"),
DATA_ERROR(204, "数据异常"),
ACCOUNT_STOP( 216, "账号已停用"),
STOCK_LESS( 219, "库存不足"),
;
private Integer code ; // 业务状态码
private String message ; // 响应消息
private ResultCodeEnum(Integer code , String message) {
this.code = code ;
this.message = message ;
}
}
如下图,项目中所有的controller接口返回的数据都是Result类型的,调用Result.build方法获取一个Result对象返回前端:
后端项目统一的异常处理
注解一:@ControllerAdvice Controller增强器,给controller增加统一的操作和处理
注解二:@ExceptionHandler 捕获controller抛出的指定类型异常
1)创建一个统一异常处理类;
2)在这个类上添加注解@ControllerAdvice;
3)在该类上创建异常处理的方法,方法上加@ExceptionHandler注解,指定异常类型,当出现异常该方法就会执行;
4)在异常方法中返回统一结果实体类Result格式;
以下的代码就是给controller增加一个对于异常的额外处理操作,当出现ExceptionHandle参数中配置的Exception类的异常时候,就会执行error方法,返回一个统一异常返回格式,即Result.build方法的返回值。
@ControllerAdvice
public class GlobalExceptionHandler {
// 全局异常处理
@ExceptionHandler(Exception.class)
@ResponseBody
public Result error() {
return Result.build(null, ResultCodeEnum.SYSTEM_ERROR);
}
// 自定义异常处理
@ExceptionHandler(FrankException.class)
@ResponseBody
public Result error(FrankException e) {
return Result.build(null,e.getResultCodeEnum());
}
}
此外还可以自定义异常,自定义异常类需要继承RuntimeException类,在类中加上需要的属性,并在上述统一异常处理类中定义,注意此时ExceptionHandle的参数类名就为自定义异常类。
自定义异常需要手动抛出 throw new FrankException (ResultCodeEnum.Xxx)
@Data
public class FrankException extends RuntimeException {
private Integer code;
private String message;
private ResultCodeEnum resultCodeEnum;
public FrankException (ResultCodeEnum resultCodeEnum) {
this.resultCodeEnum = resultCodeEnum;
this.code = resultCodeEnum.getCode();
this.message = resultCodeEnum.getMessage();
}
}
后端解决跨域
使用如下配置类,在项目目录下创建config包,其下创建如下java类
@Component
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 设置允许跨域访问
registry.addMapping("/**")
.allowCredentials(true) // 是否在允许跨域的情况下传递Cookie
.allowedOriginPatterns("*")
.allowedMethods("*")
.allowedHeaders("*");
}
}
后端图片验证码实现思路
后端需要实现两件事:
1)进入登录页面后,要生成图片验证码,将其存储在redis中,设置验证码的有效时间
2)用户提交登录表单后,需要验证验证码的正确性
推荐通过工具hutool生成验证码。
需要返回给前端的:1)验证码的key(使用UUID生成的随机字符串,作为key存入redis)2)验证码的base64编码,需要加上前缀 data:image/png;base64, ,这样前端才能将其显示。
放如redis中的:1)验证码的key(由UUID随机生成)和对应的验证码的值
hutool常用的方法:
class CaptchaUtil
1)public static CircleCaptcha createCircleCaptcha(验证码的宽度, 高度, 验证码的位数, 干扰线的数量)
class CircleCaptcha
2) public String getCode() 获取验证码的值
3)public String getImageBase64() 获取验证码base64图片
public ValidateCodeVo generateValidateCode() {
// 通过工具hutool生成图片验证码
// int width, int height : 图片验证码的宽度和高度
// int codeCount 图片验证码的位数
// int circleCount 干扰线的数量
CircleCaptcha circleCaptcha = CaptchaUtil.createCircleCaptcha(150, 48, 4, 2);
String code = circleCaptcha.getCode();// 获取验证码的值
String imageBase64 = circleCaptcha.getImageBase64();// 验证码图片 做了base64编码
// 把验证码存储在redis中,设置key:UUID value:验证码的值
String key = UUID.randomUUID().toString().replaceAll("-","");
redisTemplate.opsForValue().set("user:validate" + key,code,5, TimeUnit.MINUTES);
// 返回ValidateCodeVo对象
ValidateCodeVo validateCodeVo = new ValidateCodeVo();
validateCodeVo.setCodeKey(key); // redis中存储数据的key
validateCodeVo.setCodeValue("data:image/png;base64," + imageBase64);
return validateCodeVo;
}
}
用户登录校验(后端)
使用拦截器。项目下除了经过登录接口和图片验证码接口之外的所有接口都需要经过拦截器拦截。判断当前接口是否需要校验登录,拦截器中具体做法如下:
0) 判断请求的类型,将option请求直接放行
1)从请求头中获取token,根据token查询redis,获取用户信息
2)没有登录信息,直接返回提示信息
3)有登录信息,获取用户信息存储到Threadlocal中。这是jdk提供的线程工具类(线程变量),Threadlocal充当的变量属于当前线程,对于其他线程是隔离的(线程私有的),可以实现在同一个线程进行数据的共享。即在当前这次请求中可以方便的获取其中的数据。
4)更新redis数据过期时间,增加xx分钟。防止当前请求时数据即将过期,即经过一个请求和响应的时间后数据过期了,用户又要重新登录。
创建工具类AuthContextUtil,实现对ThreadLocal的操作:
public class AuthContextUtil {
// 创建Threadlocal对象
private static final ThreadLocal<SysUser> threadlocal = new ThreadLocal<>();
// 添加数据
public static void set(SysUser sysUser) {
threadlocal.set(sysUser);
}
// 获取数据
public static SysUser get() {
return threadlocal.get();
}
// 删除数据
public static void remove() {
threadlocal.remove();
}
}
创建拦截器类,实现拦截的核心步骤:
@Component
public class LoginAuthInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取请求方法
// 如果请求方法是option(预检请求)比如查看一个当前是否支持跨域,支持再发正式请求 ,直接放行
String method = request.getMethod();
if ("OPTIONS".equals(method)) {
return true;
}
// 请求头获取token
String token = request.getHeader("token");
// token为空返回错误信息
if(StrUtil.isEmpty(token)) {
responseNoLoginInfo(response);
return false;
}
// 如果token不为空,拿着token查询redis
String userInfoString = redisTemplate.opsForValue().get("user:login" + token);
// redis查不到数据,返回错误提示
if(StrUtil.isEmpty(userInfoString)) {
responseNoLoginInfo(response);
return false;
}
// redis查到错误信息,把用户的信息放到Threadlocal中
SysUser sysUser = JSON.parseObject(userInfoString, SysUser.class);
AuthContextUtil.set(sysUser);
// 把redis用户信息数据更新过期时间
redisTemplate.expire("user:login" + token,30, TimeUnit.MINUTES);
// 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 删除ThreadLocal中的数据
AuthContextUtil.remove();
}
// 响应208状态码给前端
private void responseNoLoginInfo(HttpServletResponse response) {
Result<Object> result = Result.build(null, ResultCodeEnum.LOGIN_AUTH);
PrintWriter writer = null;
response.setCharacterEncoding("UTF-8");
response.setContentType("text/html;charset=utf-8");
try {
writer = response.getWriter();
writer.println(JSON.toJSONString(result));
} catch(IOException e) {
e.printStackTrace();
} finally {
if(writer != null) writer.close();
}
}
}
在配置类中注册拦截器:
@Component
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private LoginAuthInterceptor loginAuthInterceptor;
@Autowired
private UserProperties userProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginAuthInterceptor)
.excludePathPatterns(userProperties.getNoAuthUrls())
// .excludePathPatterns("/admin/system/index/generateValidateCode","/admin/system/index/login") // 除去登录和验证码接口不拦截
.addPathPatterns("/**"); // 所有路径
}
}
excludePathPatterns中配置不需要拦截的路径,可以使用参数的形式传递这些接口路径,但路径一多不方便维护,需要将这些路径配置在application.yml中:其中的属性名可以自定义
antique:
auth:
noAuthUrls:
- /admin/system/index/generateValidateCode
- /admin/system/index/login
随后需要添加一个Properties去读取配置文件中的路径,在项目src目录下创建一个文件夹为properties,创建类UserProperties,添加@ConfigurationProperties 获取配置文件中的属性前缀,类中添加属性存放配置文件中的路径,属性名必须和配置文件中的名字保持一致
@Data
@ConfigurationProperties(prefix = "antique.auth")
public class UserProperties {
// 变量名一定要和配置文件中的属性名一致
private List<String> noAuthUrls;
}
随后在启动类上添加注解,让UserProperties类生效
@EnableConfigurationProperties(value = {UserProperties.class})
这样就可以在 WebMvcConfiguration 中使用properties获取配置文件中的路径名了
权限管理表结构设计
权限管理需求基本都遵循以下一套方案:
1)存在三个实体:用户,角色,菜单
2)用户表和角色表,角色表和菜单表都是多对多的关系。即一个用户拥有多个角色,一个角色包含多个用户。
3)为了表示这三者之间的多对多的关系,需要创建一张关系表,来表示角色和用户的关系,角色和菜单的关系。这张表至少包括两个字段,分别指向两个表的主键id。
常见的需求的sql语句:
1)根据用户id查询用户具有的角色数据
select sys_role.* from
sys_role INNER JOIN sys_user_role
ON sys_role.id = sys_user_role.role_id
WHERE sys_user_role.user_id = 11
需要查询的是 角色用户关系表 和 角色表 将角色用户关系表的角色id字段和角色表的角色id字段做关联,连接两表
角色表sys_role:
角色用户表sys_user_role:
查询结果:
根据用户id查询用户具有的菜单数据
select distinct sys_menu.* from sys_menu
inner join sys_role_menu on sys_menu.id = sys_role_menu.menu_id
inner join sys_user_role on sys_role_menu.role_id = sys_user_role.role_id
where sys_user_role.user_id = 11
需要查询的是角色用户关系表,角色菜单关系表,菜单表。前两表之间用角色id做关联,后面两张表之间用菜单id做关联。有时一个用户具有多个角色,二这些角色具有的权限往往都会重复,所以需要对查询结果做去重处理使用distinct关键字
菜单表 sys_menu:
角色菜单表sys_role_menu:
用户角色表 sys_user_role:
Minio的使用
下载地址:https://dl.min.io/server/minio/release/windows-amd64/ 随后点击其中的minio.exe下载。下载成功后在任意目录下新建一个minio的文件夹,将该启动文件放下其下,并创建一个data文件夹用于存放数据。
随后在minio文件下使用cmd进行启动,在命令行中输入:minio.exe server 数据文件的路径 D:\Program Files\minio\data
出现如下图片即启动成功:
注意:尽量不要放在C盘programme file目录下,命令行启动后可能没有权限访问data目录;也不要下载最新版本的minio.exe会无法启动,并报错。
访问minio.io。命令行启动后会看到有两个端口号,一个是将资源上传的端口号,另一个是管理控制台的端口号。
使用系统默认的用户名和密码进行登录控制台,随后需要创建bucket才能使用minio存储文件
创建bucket
随后将access policy设置bucket的访问策略为public
访问官网:https://min.io/docs/minio/linux/developers/java/minio-java.html查看具体使用。
随后创建接口。接口的方法fileupload需要接收传输的文件对象,类型为MultipartFile,可以使用注解@RequestParam("file") ,也可以不使用如下。前端Element-plus默认的name属性的value就是file,即:<input type="file" name="file">。故注解参数file不能随便写。
@PostMapping("/fileupload")
public Result fileupload(MultipartFile file) {
// 获取上传的文件
// 调用service方法上传,返回minio路径
String url = fileUploadService.upload(file);
return Result.build(url, ResultCodeEnum.SUCCESS);
}
创建FileUploadServiceImpl,在官网首页中就有示例代码,
@Service
public class FileUploadServiceImpl implements FileUploadService {
@Autowired
private MinioProperties minioProperties;
@Override
public String upload(MultipartFile file) {
try {
// 创建MinioClient对象
MinioClient minioClient =
MinioClient.builder()
.endpoint(minioProperties.getEndpointUrl())
.credentials(minioProperties.getAccessKey(), minioProperties.getSecreKey())
.build();
// 创建bucket
boolean found =
minioClient.bucketExists(BucketExistsArgs.builder().bucket(minioProperties.getBucketName()).build());
if (!found) {
// bucket不存在则进行创建
minioClient.makeBucket(MakeBucketArgs.builder().bucket(minioProperties.getBucketName()).build());
} else {
System.out.println("Bucket 'antique-bucket' already exists.");
}
// 每个上传文件名称唯一 根据当前日期对上传的文件进行分组
String dateToday = DateUtil.format(new Date(), "yyyyMMdd");
String uuid = UUID.randomUUID().toString().replaceAll("-","");
// 获取上传的文件名称
String filename = dateToday + "/" + uuid + file.getOriginalFilename();
// 文件上传操作
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioProperties.getBucketName())
.object(filename) // 文件的名称
.stream(file.getInputStream(),file.getSize(),-1)
.build());
String url = minioProperties.getEndpointUrl() + "/" + minioProperties.getBucketName() + "/" + filename;
return url;
} catch (Exception e) {
e.printStackTrace();
throw new FrankException(ResultCodeEnum.SYSTEM_ERROR);
}
}
}
上述代码中首先创建一个MinioClient对象,需要在endpoint和credentials中添加连接的服务器地址和用户名及密码。
随后创建bucket,随后进行文件的上传,但官网中使用的是对象的方式上传。需要在官方文档中查找流的方式上传的方法
在里面找到MinioClient,需要在里面找用流的方式上传文件的方法putObject
使用的是知道传输文件大小的代码
此外,为了保证上传的文件不重名,我们使用UUID.randomUUID随机数为文件名加前缀,再给上传的文件按上传日期存在不同的文件中。即date/filename.xxx的形式做为文件名,传递给PutObjectArgs.object()方法,那么minio就会将/前面的部分为文件名创建文件夹,/后面的部分为文件名存入创建的文件夹中。
String dateToday = DateUtil.format(new Date(), "yyyyMMdd");
String uuid = UUID.randomUUID().toString().replaceAll("-","");
// 获取上传的文件名称
String filename = dateToday + "/" + uuid + file.getOriginalFilename();
前端使用element-plus上传文件,需要给出文件的上传地址action。文件上传不是使用ajax,使用普通方式进行的,所以请求过程中不会自动携带token,需要在headers中手动传递token.
文件上传后执行on-success的方法,后端会返回上传文件在服务器的存储路径,将上传的地址赋值给sysUser对象,在提交表单信息给服务器时将地址存放到数据库中。
<el-upload class="avatar-uploader"
action="http://localhost:8501/admin/system/fileupload"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:headers="headers"
>
<img :src="sysUser.avatar" v-if="sysUser.avatar" class="avatar">
<el-icon v-else class="avatar-uploader-icon">
<Plus></Plus>
</el-icon>
</el-upload>
....
// 文件上传
const headers = ref({
token:useApp().authorization.token
})
const handleAvatarSuccess = (response,uploadFile) => {
console.log(response)
if(response.code === 200) {
sysUser.value.avatar = response.data
} else {
ElMessage.error(`文件上传失败,${response.message}`)
}
}
后台菜单管理
需要以这样的形式显示加载后台所有的菜单,进行管理。但菜单是多级嵌套的结构,需要以这样的方式显示,要在后端进行一些处理。
前端使用element-plus的树形结构表格展示
需要使用el-table显示树形结构的表格,需要准备的数据类型如下:
若是子菜单,则以children属性表示。数据库为每一个菜单分配一个唯一的id,并给每一个菜单一个parent_id字段,为0代表没有上级菜单,非0即表示上级菜单的id值。
所以要在后端使用递归的方式将数据库中查询得到的菜单List集合转换为前端需要的格式。
在entity sysMenu中设置好children属性,用来对应前端存放子菜单节点。
现在数据库中查询好所有的菜单,存放在List集合中,传入buildTree方法,先找到一级菜单即parentId为0的菜单。随后使用递归查找对应一级菜单的子菜单,即parentId为当前一级菜单的id的菜单,依此类推。
public class MenuHelper {
// 递归实现封装的过程
public static List<SysMenu> buildTree(List<SysMenu> sysMenuList) {
// 创建一个list集合封装最终的数据
List<SysMenu> trees = new ArrayList<>();
for (SysMenu sysMenu:sysMenuList) {
// 找到递归操作的入口 找到第一层菜单 parent_id = 0
if (sysMenu.getParentId().longValue() == 0) {
// 根据第一层找下层数据,使用递归 (第一层菜单,所有菜单集合)
trees.add(findChildren(sysMenu,sysMenuList));
}
}
return trees;
}
// 递归查找下层菜单
public static SysMenu findChildren(SysMenu sysMenu, List<SysMenu> sysMenuList) {
sysMenu.setChildren(new ArrayList<>());
// 找下一层数据
for(SysMenu it : sysMenuList) {
// 判断id和parentId值是否相同
if(sysMenu.getId().longValue() == it.getParentId().longValue()) {
sysMenu.getChildren().add(findChildren(it,sysMenuList));
}
}
return sysMenu;
}
}
serviceImpl:
public List<SysMenu> findNodes() {
// 查询所有菜单 返回List集合
List<SysMenu> sysMenuList = sysMenuMapper.findAll();
// 调用工具类中的方法,把返回list集合封装要求数据格式
if (CollectionUtils.isEmpty(sysMenuList)) {
return null;
}
List<SysMenu> treeList = MenuHelper.buildTree(sysMenuList);
return treeList;
}
EasyExcel导入导出数据
导出数据:导出数据,即文件的下载。需要查询数据库得到需要写入的内容,再把内容写到excel表格中返回给前端。
controller部分:不需要写返回值。文件下载需要使用HttpServletResponse对象
// 导出分类
@GetMapping(value="/exportData")
public void exportData(HttpServletResponse response) {
categoryService.exportData(response);
}
service部分:
设置响应头信息Content-disposition让文件以下载方式打开,若不设置则文件不能下载,只有这个内容是不可或缺的。
response.setHeader("Content-disposition","attachment;filename=" + filename + ".xlsx");
最终浏览器查看到的响应头信息如下:
随后查询数据库将要写入到excel中的内容查出来,得到categoryList。随后使用EasyExcel的write(OutputStream输出流,类型Class)方法实现数据的写入。
EasyExcel.write(response.getOutputStream(), CategoryExcelVo.class).sheet("分类数据").doWrite(categoryExcelVoList);
其中sheet('')传入的参数表示工作表的名称,最终下载得到的内容显示如下:
其中doWrite()中传入的是和CategoryExcelVo.class类型一致的数据集合,即需要写入到excel中的内容。但mapper返回的list集合元素是Category类型的,需要将结果转化为CategoryExcelVo类型。
这里使用foreach循环遍历集合List<Category>,转化为List<CategoryExcelVo>类型。转化的过程哦就是将两个集合类型共有的属性先get后set到新的CategoryExcelVo中去,一个个转换比较繁琐,可以使用org.springframework.beans.BeanUtils下的copyProperties(from,to)方法直接拷贝
@Service
public class CategoryServiceImpl implements CategoryService {
@Autowired
private CategoryMapper categoryMapper;
@Override
public void exportData(HttpServletResponse response) {
try {
// 设置响应的头信息和其他信息
response.setContentType("application/vnd,ms-excel"); // 设置内容的类型 微软的excel表格
response.setCharacterEncoding("utf-8"); //
// 防止中文乱码
String filename = URLEncoder.encode("分类数据", "UTF-8");
// 设置响应头信息 Content-disposition 作用是将文件以下载的方式打开
response.setHeader("Content-disposition","attachment;filename=" + filename + ".xlsx");
// 调用mapper方法查询所有的分类 返回list集合
List<Category> categoryList = categoryMapper.findAll();
// List<category> => List<CategoryExcelVo>
List<CategoryExcelVo> categoryExcelVoList = new ArrayList<>();
for(Category category : categoryList) {
CategoryExcelVo categoryExcelVo = new CategoryExcelVo();
// 把category值获取出来,设置到categoryExcelVo中去
// categoryExcelVo.setId(category.getId());
BeanUtils.copyProperties(category,categoryExcelVo); // 把一个对象值复制到另一个里面去
categoryExcelVoList.add(categoryExcelVo);
}
// 调用EasyExecl的write方法完成写操作
EasyExcel.write(response.getOutputStream(), CategoryExcelVo.class).sheet("分类数据").doWrite(categoryExcelVoList);
} catch (Exception e) {
e.printStackTrace();
throw new FrankException(ResultCodeEnum.DATA_ERROR);
}
}
}
其中传入的实体类类型CategoryExcelVo是与excel表格的列对应的java实体类
该实体类的属性上需要添加@ExcelProperty(value='',index=) 注解,其中value表示表头,index表示该属性在excel表中是第几列,从0开始。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CategoryExcelVo {
@ExcelProperty(value = "id" ,index = 0)
private Long id;
@ExcelProperty(value = "名称" ,index = 1)
private String name;
@ExcelProperty(value = "图片url" ,index = 2)
private String imageUrl ;
@ExcelProperty(value = "上级id" ,index = 3)
private Long parentId;
@ExcelProperty(value = "状态" ,index = 4)
private Integer status;
@ExcelProperty(value = "排序" ,index = 5)
private Integer orderNum;
}
mapper部分:
<mapper namespace="com.frank.spzx.manager.mapper.CategoryMapper">
<resultMap id="categoryMap" type="com.atguigu.spzx.model.entity.product.Category" autoMapping="true"></resultMap>
<sql id="columns">
id,name,image_url,parent_id,status,order_num,create_time,update_time,is_deleted
</sql>
<select id="findAll" resultMap="categoryMap">
select <include refid="columns"/>from category where is_deleted=0 order by id
</select>
</mapper>
前端部分:使用bolb类型作为响应类型,使用 URL.createObjectURL()方法会根据传入的参数创建一个指向该参数对象的URL,这里传递的就是从后端请求得到的需要下载的excel文件的二进制数据,最后创建一个超链接标签,绑定其连接路径,自动执行其click事件完成自动下载。
category.js:
// 指定响应类型为bolb类型,即二进制数据类型用于表示大量的二进制数据
export const exportCategoryData = () =>
request.get(`${api_name}/exportData`,{responseType:'blob'})
// 导出数据
const exportData = () => {
exportCategoryData().then(res => {
const bolb = new Blob([res])
const link = document.createElement('a')
// 创建a标签后将bolb对象转换为url
// URL.createObjectURL()方法会根据传入的参数创建一个指向该参数对象的URL.
link.href = window.URL.createObjectURL(bolb)
// 设置下载文件的名称
link.download = "分类数据.xlsx"
// 模拟点击下载链接
link.click()
})
}
导入数据:
将外部的excel文件导入系统,需要满足excel中的列名和和对应的Vo实体类的属性要一一对应。
controller:获取上传的文件需要使用MultipartFile对象
// 导入分类
@PostMapping(value="/importData")
public Result importData(MultipartFile file){
// 获取上传的文件
categoryService.importData(file);
return Result.build(null,ResultCodeEnum.SUCCESS);
}
service:使用EasyExcel获取上传的excel中的数据,需要监听器 + EasyExcel.read(InputStream文件输入流,类型Class,监听器).sheet().doRead()结合使用.
public void importData(MultipartFile file) {
try {
// 每次读取都是新创建的对象,避免了并发问题
ExcelListener<CategoryExcelVo> excelListener = new ExcelListener(categoryMapper);
EasyExcel.read(file.getInputStream(), CategoryExcelVo.class,excelListener).sheet().doRead();
} catch (IOException e) {
e.printStackTrace();
throw new FrankException(ResultCodeEnum.DATA_ERROR);
}
}
在项目的Listener包下创建监听器文件:
ExcelListener:该类需要实现ReadListener接口,该接口中有两个方法是必须重写的,一个是invoke方法,该方法会从表格的第二行开始读取将每行的读取内容封装到T的对象中去,excel一共有几行数据该方法就是执行几次。
public void invoke(T data, AnalysisContext context) {}
另一个是doAfterAllAnalysed()方法,它是在excel读取所有的操作结束后执行。
public void doAfterAllAnalysed(AnalysisContext context) {}
ExcelListener这个类不能加上@Component注解,不能交给Spring管理!因为一旦给Spring管理,该类会变成单例,如果多个人同时读取文件会调用同一个Listener,无法区分是哪个文件读取出来的数据,造成并发问题。若需要在监听器中操作mapper,官网给出的解决方案是构造器传递mapper,在调用的时候需要手动进行new操作。
当读取的excel文件中的行数过多时,不能读一行添加一次数据库,会造成资源的消耗,产生OOM,官网给出的方案是批量添加数据,创建一个常量BATCH _COUNT用来存放一次读取的条数,再创建List集合cachedDataList用来存放这些内容。最后在invoke方法中批量添加。
若一次需要添加的数据没有超过BATCH _COUNT的值,则需要执行doAfterAllAnalysed方法,完成数据库的添加操作。
// 这个类不能交给spring管理,否则会变成单例,多个人同时读取文件时就调用同一个Listener 无法区分是哪个文件读取出来的(造成并发的问题)
public class ExcelListener<T> implements ReadListener<T> {
// 创建List集合用于缓存数据 行数过多每读取一行加数据库会造成资源的消耗,容易OOM
// 创建一个集合定义一个常量,为了做批量操作,每一百条数据加一次数据库
private static final int BATCH_COUNT = 100;
private List<T> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
// 使用构造传递mapper,操作数据库,因为不能交给spring管理
private CategoryMapper categoryMapper;
public ExcelListener(CategoryMapper categoryMapper) {
this.categoryMapper = categoryMapper;
}
@Override
public void onException(Exception exception, AnalysisContext context) throws Exception {
ReadListener.super.onException(exception, context);
}
@Override
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
ReadListener.super.invokeHead(headMap, context);
}
// 从表格的第二行开始读取将每行的读取内容封装到T的对象中去,将每行的数据加到cachedDataList集合中, 当集合达到100 调用方法把数据加到数据库中 加完后将集合清理为初始状态
@Override
public void invoke(T data, AnalysisContext context) {
// 把每行数据的对象t放到cachedDataList的集合中去
cachedDataList.add(data);
if(cachedDataList.size() >= BATCH_COUNT) {
// 调用方法批量添加到数据库中
saveData();
// 清理list集合 即重新初始化
cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
}
}
private void saveData() {
categoryMapper.catchInsert((List<CategoryExcelVo>)cachedDataList);
}
@Override
public void extra(CellExtra extra, AnalysisContext context) {
ReadListener.super.extra(extra, context);
}
// 所有操作都完成后执行
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 保存数据 如果数据的行数没有达到100行 就执行下面的代码将数据加到数据库中
saveData();
}
@Override
public boolean hasNext(AnalysisContext context) {
return ReadListener.super.hasNext(context);
}
// 使用构造方法传递
}
Mapper:
@Mapper
public interface CategoryMapper {
void catchInsert(List<CategoryExcelVo> cachedDataList);
}
<mapper namespace="com.frank.spzx.manager.mapper.CategoryMapper">
<!-- 批量保存分类的方法-->
<insert id="catchInsert" useGeneratedKeys="true" keyProperty="id">
insert into category(
id,name,image_url,parent_id,status,order_num,create_time,update_time,is_deleted
) values
<foreach collection="cachedDataList" item="item" separator=",">
(
#{item.id},
#{item.name},
#{item.imageUrl},
#{item.parentId},
#{item.status},
#{item.orderNum},
now(),
now(),
0
)
</foreach>
</insert>
</mapper>
前端:
<el-dialog v-model="dialogImportVisible" title="导入" width="30%">
<el-form label-width="120px">
<el-form-item label="分类文件">
<el-upload class="upload-demo"
action="http://localhost:8501/admin/product/category/importData"
:on-success="onUploadSuccess"
:headers="headers">
<el-button type="primary">上传</el-button>
</el-upload>
</el-form-item>
</el-form>
</el-dialog>
let dialogImportVisible = ref(false)
let headers = {
token:useApp().authorization.token
}
// 打开导入数据
const importData = () => {
dialogImportVisible.value = true
}
//分类数据文件excel上传
const onUploadSuccess = async (response,file) => {
ElMessage.success('操作成功')
dialogImportVisible.value = false
let result = await findCategoryByParentId(0)
list.value = result.data
}
SpringTask定时任务
现在需要统计每天的订单总交易金额,我们可以使用如下的sql语句实现:
但是如果数据量很大,每次查询交易总金额都需要执行一次这样的sql语句会很消耗性能。常用的方法是开启定时任务,设定定时任务程序每天两点执行一次,在订单表中查询前一天的交易总金额数据,然后将结果写入统计结果表。
SpringTask就是spring中的一个模块,
在项目下创建一个task文件夹存放定时任务的类:
随后创建定时任务类:
在类上添加@Component注解将其交给Spring管理。需要执行定时任务需要在类的方法上加上@Scheduled + cron表达式,其中表达式的内容就是需要定时任务执行的要求
@Component
public class OrderStatisticsTask {
// 测试定时任务 让该方法每五秒执行一次
// 注解@Scheduled + cron 表达式
// 其中cron表达式设定执行规则
@Scheduled(cron="0/5 * * * * ?")
public void test() {
System.out.println(new Date().toInstant());
}
}
在百度直接搜索cron表达式在线工具,自动生成表达式。这里的需求是间隔五秒打印一下当前时间。最后还要在启动类上加上@EnableScheduling开启定时任务。
随后启动项目控制台就会间隔五秒输出当前的时间。
注意:cron表达式这里只能由六位组成,即(秒 分 时 日 月 周 年) 中的年是不能包括在里面,因为表达式是不能跨年的只能使用前面六位