This commit is contained in:
2025-10-24 17:12:18 +08:00
commit 9dead7a890
2004 changed files with 298646 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
使用`mybatis plus` 进行分页的时候是无法进行一对多、多对多的分页的。最主要的原因是因为该框架无法清楚count的依据是什么以哪个表算出来的行数为准但是我们所有的分页格式已经统一好使用`IPage`对象了,那么该如何适配一对多、多对多分页呢?
## PageAdapter
使用分页时,前端传入的数据统一格式为`current`当前页,`size`每页大小。而我们在数据库中要将这两个数据变更为从第几行到第几行,所以我们需要简单的适配一下:
```java
@Data
public class PageAdapter{
private int begin;
private int end;
public PageAdapter(Page page) {
int[] startEnd = PageUtil.transToStartEnd((int) page.getCurrent(), (int) page.getSize());
this.begin = startEnd[0];
this.end = startEnd[1];
}
}
```
## Count
在使用`mybatis plus` 进行分页的时候该工具会自动为我们编写count的sql而一对多进行分页时如
1个订单有5个订单项在使用`mybatis plus` 生成的`count sql` 会认为每行都是一条数据导致最后认为会有5条订单信息实际上应该只有1条订单信息。这个时候我们必须自己手写`count sql`,并区分`records sql`
具体例子可以查看`OrderServiceImpl`
```java
@Override
public IPage<Order> pageOrdersDetialByOrderParam(Page<Order> page, OrderParam orderParam) {
page.setRecords(orderMapper.listOrdersDetialByOrderParam(new PageAdapter(page), orderParam));
page.setTotal(orderMapper.countOrderDetial(orderParam));
return page;
}
```

View File

@@ -0,0 +1,117 @@
在小程序登陆的时候,在`MiniAppAuthenticationProvider`中我们看到这样一行代码
```java
yamiUserDetailsService.insertUserIfNecessary(appConnect);
```
这便是商城用户创建的代码,在`YamiUserServiceImpl#insertUserIfNecessary()`方法中,有一个这样的注解
```java
@RedisLock(lockName = "insertUser", key = "#appConnect.appId + ':' + #appConnect.bizUserId")
```
这里便用了分布式锁,为什么我们要在这里使用锁?分布式锁又是什么?
- 由于用户是通过登录直接注册的,如果一个用户在不刻意之间,又或者前端写的东西有点问题,这就会导致整个系统创建了两个相同的用户,这是非常危险的事情,所以创建用户这里必须加锁。
- 至于为什么使用分布式锁是因为我们虽然没有用上spring cloud、dubbo之类的东西实际上我们也是希望我们的商城可以多实例部署的也就是可以搞分布式的。因此用了分布式锁
分布式锁简单来说就是锁而且还是适合分布式环境的。分布式说起来也很奇怪要是有什么不能共享的东西那就抽出来共享。比如本地数据缓存不能共享那么就抽出一个如redis之类的东西进行共享。session不能共享那么就将session抽出来丢到redis之类的东西又能共享了。
锁不能共享同样可以丢一个标记到redis由于redis是单线程的所以也不用担心redis的线程安全的问题。这个标记就是一个锁的标记那样你就实现了分布式锁...
我们看回`@RedisLock` 该类,里面有个`expire()`方法
```java
/**
* 过期毫秒数,默认为5000毫秒
*
* @return 锁的时间
*/
int expire() default 5000;
```
由于网络稳定、宕机等各种原因,分布式锁,必须要有过期时间,否则锁无法释放的话,会阻塞一片的实例。
## 实现一个简单的分布式锁注解
由于自己去实现redis的分布式锁是比较困难的问题还要考虑redis复制宕机之类的问题所以我们使用一个比较优秀的开源项目 **redisson**来实现我们的分布式锁
`@RedisLock`所注解的方法,会被 `RedisLockAspect` 进行切面管理,代码如下:
```java
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String spel = redisLock.key();
String lockName = redisLock.lockName();
// redissonClient 也就是通过redisson 进行对锁管理
RLock rLock = redissonClient.getLock(getRedisKey(joinPoint,lockName,spel));
rLock.lock(redisLock.expire(),redisLock.timeUnit());
Object result = null;
try {
//执行方法
result = joinPoint.proceed();
} finally {
rLock.unlock();
}
return result;
}
```
## 识别spel表达式
`@RedisLock(lockName = "insertUser", key = "#appConnect.appId + ':' + #appConnect.bizUserId")``#appConnect.appId` 也仅仅是表示一串字符串而已,而能将其变成表达式,需要一定的转换`SpelUtil.parse`
```java
/**
* 支持 #p0 参数索引的表达式解析
* @param rootObject 根对象,method 所在的对象
* @param spel 表达式
* @param method ,目标方法
* @param args 方法入参
* @return 解析后的字符串
*/
public static String parse(Object rootObject,String spel, Method method, Object[] args) {
if (StrUtil.isBlank(spel)) {
return StrUtil.EMPTY;
}
//获取被拦截方法参数名列表(使用Spring支持类库)
StandardReflectionParameterNameDiscoverer standardReflectionParameterNameDiscoverer = new StandardReflectionParameterNameDiscoverer();
String[] paraNameArr = standardReflectionParameterNameDiscoverer.getParameterNames(method);
if (ArrayUtil.isEmpty(paraNameArr)) {
return spel;
}
//使用SPEL进行key的解析
ExpressionParser parser = new SpelExpressionParser();
//SPEL上下文
StandardEvaluationContext context = new MethodBasedEvaluationContext(rootObject,method,args,standardReflectionParameterNameDiscoverer);
//把方法参数放入SPEL上下文中
for (int i = 0; i < paraNameArr.length; i++) {
context.setVariable(paraNameArr[i], args[i]);
}
return parser.parseExpression(spel).getValue(context, String.class);
}
```
同时我们也害怕redis的key发生冲突所以会对key加上一些统一的前缀
redis 锁的key能够识别`spel` 表达式,并且不和其他方法的锁名称或缓存名称重复
```java
/**
* 将spel表达式转换为字符串
* @param joinPoint 切点
* @return redisKey
*/
private String getRedisKey(ProceedingJoinPoint joinPoint,String lockName,String spel) {
Signature signature = joinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
Object target = joinPoint.getTarget();
Object[] arguments = joinPoint.getArgs();
return REDISSON_LOCK_PREFIX + lockName + StrUtil.COLON + SpelUtil.parse(target,spel, targetMethod, arguments);
}
```

View File

@@ -0,0 +1,129 @@
身为服务器的开发者我们是无法相信用户输入的任何东西的。比如金额不能从前端传过来使用会失效的token等。当然用户除了会传入一些假数据也会传入一些假的脚本比较出名的就是**xss攻击**
网上有很多说解决xss攻击的方法有很多都是和前端有关而实际上在后台这最后一个防御当中是最为重要的。
在mall4j这个项目里面使用了一个过滤器 `XssFilter`
```
public class XssFilter implements Filter {
Logger logger = LoggerFactory.getLogger(getClass().getName());
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException{
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
logger.info("uri:{}",req.getRequestURI());
// xss 过滤
chain.doFilter(new XssWrapper(req), resp);
}
}
```
主要是通过 `new XssWrapper(req)` 这个对象进行一系列的过滤,而 `XssWrapper` 是通过`Jsoup`进行用户输入的一系列过滤。毕竟专业的事情要交给专业的人来搞定。就此,我们通过简单的设置就完成了对**xss攻击**的防御。
```java
public class XssWrapper extends HttpServletRequestWrapper {
/**
* Constructs a request object wrapping the given request.
*
* @param request The request to wrap
* @throws IllegalArgumentException if the request is null
*/
public XssWrapper(HttpServletRequest request) {
super(request);
}
/**
* 对数组参数进行特殊字符过滤
*/
@Override
public String[] getParameterValues(String name) {
String[] values = super.getParameterValues(name);
if (values == null) {
return null;
}
int count = values.length;
String[] encodedValues = new String[count];
for (int i = 0; i < count; i++) {
encodedValues[i] = cleanXSS(values[i]);
}
return encodedValues;
}
/**
* 对参数中特殊字符进行过滤
*/
@Override
public String getParameter(String name) {
String value = super.getParameter(name);
if (StrUtil.isBlank(value)) {
return value;
}
return cleanXSS(value);
}
/**
* 获取attribute,特殊字符过滤
*/
@Override
public Object getAttribute(String name) {
Object value = super.getAttribute(name);
if (value instanceof String && StrUtil.isNotBlank((String) value)) {
return cleanXSS((String) value);
}
return value;
}
/**
* 对请求头部进行特殊字符过滤
*/
@Override
public String getHeader(String name) {
String value = super.getHeader(name);
if (StrUtil.isBlank(value)) {
return value;
}
return cleanXSS(value);
}
private String cleanXSS(String value) {
return XssUtil.clean(value);
}
}
```
这里面最主要的方法就是`XssUtil.clean(value)` -> `Jsoup.clean(content, "", WHITE_LIST, OUTPUT_SETTINGS)` 这面最总要的是有个白名单列表 `WHITE_LIST` 来自我们仔细观察白名单列表会发现这里面是部分携带html的部分标签进入从而防止xss攻击
```java
new Whitelist().addTags(
"a", "b", "blockquote", "br", "caption", "cite", "code", "col",
"colgroup", "dd", "div", "dl", "dt", "em", "h1", "h2", "h3", "h4", "h5", "h6",
"i", "img", "li", "ol", "p", "pre", "q", "small", "span", "strike", "strong",
"sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "u",
"ul")
.addAttributes("a", "href", "title")
.addAttributes("blockquote", "cite")
.addAttributes("col", "span", "width")
.addAttributes("colgroup", "span", "width")
.addAttributes("img", "align", "alt", "height", "src", "title", "width")
.addAttributes("ol", "start", "type")
.addAttributes("q", "cite")
.addAttributes("table", "summary", "width")
.addAttributes("td", "abbr", "axis", "colspan", "rowspan", "width")
.addAttributes(
"th", "abbr", "axis", "colspan", "rowspan", "scope",
"width")
.addAttributes("ul", "type")
.addProtocols("a", "href", "ftp", "http", "https", "mailto")
.addProtocols("blockquote", "cite", "http", "https")
.addProtocols("cite", "cite", "http", "https")
.addProtocols("img", "src", "http", "https")
.addProtocols("q", "cite", "http", "https")
```

View File

@@ -0,0 +1,226 @@
# 上传下载
我们对文件上传进行了分别封装了多个组件:
- 单图片上传(替换图片):`src\components\pic-upload`
- 多图片上传:`src\components\mul-pic-upload`
- 文件上传:`src\components\file-upload`
上述这些文件上传,都是基于`el-upload`进行封装
## 单图片上传
在商品分类这个模块的弹框中可以找到单图片上传的例子对应vue代码位置`src\views\modules\category-add-or-update.vue`
html:
```html
<pic-upload v-model="dataForm.pic"></pic-upload>
```
js:
```javascript
import PicUpload from '@/components/pic-upload'
export default {
data () {
return {
dataForm: {
pic: ''
}
},
components: {
PicUpload
}
}
```
这里的文件上传使用起来非常简单,只需要将最终文件上传完成后的路径进行双向绑定即可
## 多图片上传
在商品发布这个模块的中可以找到多图片上传的例子对应vue代码位置`src\views\modules\category-add-or-update.vue`
html:
```html
<mul-pic-upload v-model="dataForm.imgs" />
```
js:
```javascript
import MulPicUpload from '@/components/mul-pic-upload'
export default {
data () {
return {
dataForm: {
imgs: ''
}
},
components: {
MulPicUpload
}
}
```
这里的文件上传使用起来也非常简单,最后返回的数据,为以逗号分隔的图片路径连接的字符串
## 服务端代码
直接的文件上传的例子与多图片上传的例子类似,这里便不一一举例了。
我们可以查看三个文件上传的源码,都有那么两句话`:action="$http.adornUrl('/admin/file/upload/element')"` `:headers="{Authorization: $cookie.get('Authorization')}"`,其中由于规定后台所有请求都需要通过 `spring security`的授权,所以需要携带通用请求头`headers`,而`action`则是对应后台服务器的路径
我们查看后台`FileController` 这里对文件上传的接口进行了统一的管理:
```java
@RestController
@RequestMapping("/admin/file")
public class FileController {
@Autowired
private AttachFileService attachFileService;
@PostMapping("/upload/element")
public R<String> uploadElementFile(@RequestParam("file") MultipartFile file) throws IOException{
if(file.isEmpty()){
return R.ok();
}
String fileName = attachFileService.uploadFile(file.getBytes(),file.getOriginalFilename());
return R.ok(fileName);
}
}
```
同时我们查看`attachFileService` 的实现类,可以知道该文件上传是通过七牛云进行实现的
```java
@Service
public class AttachFileServiceImpl extends ServiceImpl<AttachFileMapper, AttachFile> implements AttachFileService {
@Autowired
private AttachFileMapper attachFileMapper;
@Autowired
private UploadManager uploadManager;
@Autowired
private BucketManager bucketManager;
@Autowired
private Qiniu qiniu;
@Autowired
private Auth auth;
public final static String NORM_MONTH_PATTERN = "yyyy/MM/";
@Override
public String uploadFile(byte[] bytes,String originalName) throws QiniuException {
String extName = FileUtil.extName(originalName);
String fileName =DateUtil.format(new Date(), NORM_MONTH_PATTERN)+ IdUtil.simpleUUID() + "." + extName;
AttachFile attachFile = new AttachFile();
attachFile.setFilePath(fileName);
attachFile.setFileSize(bytes.length);
attachFile.setFileType(extName);
attachFile.setUploadTime(new Date());
attachFileMapper.insert(attachFile);
String upToken = auth.uploadToken(qiniu.getBucket(),fileName);
Response response = uploadManager.put(bytes, fileName, upToken);
Json.parseObject(response.bodyString(), DefaultPutRet.class);
return fileName;
}
}
```
在这里面注入了非常多的七牛云的配置,而配置文件的来源,来自
```java
@Configuration
public class FileUploadConfig {
@Autowired
private Qiniu qiniu;
/**
* 华南机房
*/
@Bean
public com.qiniu.storage.Configuration qiniuConfig() {
return new com.qiniu.storage.Configuration(Zone.zone2());
}
/**
* 构建一个七牛上传工具实例
*/
@Bean
public UploadManager uploadManager() {
return new UploadManager(qiniuConfig());
}
/**
* 认证信息实例
* @return
*/
@Bean
public Auth auth() {
return Auth.create(qiniu.getAccessKey(), qiniu.getSecretKey());
}
/**
* 构建七牛空间管理实例
*/
@Bean
public BucketManager bucketManager() {
return new BucketManager(auth(), qiniuConfig());
}
}
```
## 注册七牛云账号
现在已经9102年了很少上传文件到本地了一般都是上传到oss我们这里选择[七牛云存储](https://www.qiniu.com/products/kodo) ,如果没有账号的可以注册一个,创建一个华南地区的云存储空间
![img](https://box.kancloud.cn/c72238c384fb43c2c0b3161162880056_1909x545.png)
### 修改后台配置
平台端(vue)修改文件`.env.production`(生产环境)/ `.env.development`(开发环境)
里面的`VUE_APP_BASE_API`为api接口请求地址 `VUE_APP_RESOURCES_URL`为静态资源文件url
// api接口请求地址
VUE_APP_BASE_API = 'http://127.0.0.1:8085'
// 静态资源文件url
VUE_APP_RESOURCES_URL = 'https://img.mall4j.com/'
### 更新于2023.03.27 本地上传配置
-`shop.properties` 更新了本地上传的配置,修改`shop.imgUpload.uploadType=1`文件上传类型为1是使用本地上传vue中的`resourcesUrl`也配置对应的本地路径
- 全局搜索`/mall4j/img`,替换成你想要的图片路径,若按本代码默认的则前端的资源路径为`http://ip: + admin服务的端口号/mall4j/img/`
### 更新于2024.09.14 七牛云配置
-`shop.properties` 中,修改`shop.imgUpload.uploadType=2`文件上传类型为2是使用七牛云vue中的`resourcesUrl`配置对应的`shop.qiniu.resourcesUrl`
- 分别将`shop.qiniu.accessKey``shop.qiniu.secretKey``shop.qiniu.bucket``shop.qiniu.zone``shop.qiniu.resourcesUrl`
替换成你的七牛云配置,最后前端的资源路径为`shop.qiniu.resourcesUrl`

View File

@@ -0,0 +1,151 @@
## 权限控制
#### 前端权限控制
在商城运营时,我们可能是多个人员共同操作我们的系统,但是每个操作人员所具备的权限应该不同,权限的不同主要表现在两个部分,即导航菜单的查看权限和页面增删改操作按钮的操作权限。我们的把页面导航菜单查看权限和页面操作按钮统一存储在菜单数据库表中,菜单类型页面资源的类型。类型包括目录 、菜单 、按钮。
#### 权限标识
权限标识用来进行权限控制的唯一标识,主要是进行增删改查的权限控制。
权限标识包括:新增 编辑 删除 查看等,格式结构类似**xxx:xxx:xxx** 如:**admin:user:update**。
#### 导航菜单权限流程
用户登录之后,跳转至首页,前端发送请求到后台获取该用户下的所有菜单权限与认证权限数据,认证权限为约束用户增删改查操作,在路由导航守卫路由时加载用户导航菜单并存储到本地存储中。导航栏从本地存储读取菜单列表并进行渲染。
#### 页面按钮权限实现
用户登录系统之后,跳转到首页,在路由导航守卫路由时加载用户权限标识集合。返回结果是用户权限标识的集合,页面操作按钮提供权限标识,查询该权限标识是否在用户权限标识集合中,如有存在,则将按钮为可见状态,如不存在,则将按钮为不可见状态,根据需求,也可以设置成禁用状态。
#### 加载导航菜单权限与页面按钮权限数据
##### 动态路由与导航栏
`router/index.js`中,从后台加载导航菜单、页面按钮权限数据,并将数据保存到本地存储中,如下所示:
```javascript
router.beforeEach((to, from, next) => {
// 添加动态(菜单)路由
if (router.options.isAddDynamicMenuRoutes || fnCurrentRouteType(to, globalRoutes) === 'global') {
next()
} else {
http({
url: http.adornUrl('/sys/menu/nav'),
method: 'get',
params: http.adornParams()
}).then(({ data }) => {
sessionStorage.setItem('authorities', JSON.stringify(data.authorities || '[]'))
fnAddDynamicMenuRoutes(data.menuList)
router.options.isAddDynamicMenuRoutes = true
sessionStorage.setItem('menuList', JSON.stringify(data.menuList || '[]'))
next({ ...to, replace: true })
}).catch((e) => {
console.log(`%c${e} 请求菜单列表和权限失败,跳转至登录页!!`, 'color:blue')
router.push({ name: 'login' })
})
}
})
```
通过`fnAddDynamicMenuRoutes()`方法,动态加载菜单到路由中保存到本地存储`sessionStorage`中。但是现在只有路由,还需要将导航栏展示出来。在`main-sidebar.vue`中,我们将本地存储中菜单数据取出来,然后对导航栏动态渲染出来,并通过**menuId**与动态(菜单)路由进行匹配跳转至指定路由,这样,当我们点击菜单的时候,就会跳转至特定的路由。
```javascript
created () {
this.menuList = JSON.parse(sessionStorage.getItem('menuList') || '[]')
this.dynamicMenuRoutes = JSON.parse(sessionStorage.getItem('dynamicMenuRoutes') || '[]')
this.routeHandle(this.$route)
}
<sub-menu v-for="menu in menuList"
:key="menu.menuId"
:menu="menu"
:dynamicMenuRoutes="dynamicMenuRoutes">
</sub-menu>
```
`sub-menu`组件的部分代码
```html
<template>
<el-submenu
v-if="menu.list && menu.list.length >= 1"
:index="menu.menuId + ''"
:popper-class="'site-sidebar--' + sidebarLayoutSkin + '-popper'">
<template slot="title">
<icon-svg :name="menu.icon || ''" class="site-sidebar__menu-icon"></icon-svg>
<span>{{ menu.name }}</span>
</template>
<sub-menu
v-for="item in menu.list"
:key="item.menuId"
:menu="item"
:dynamicMenuRoutes="dynamicMenuRoutes">
</sub-menu>
</el-submenu>
<el-menu-item v-else :index="menu.menuId + ''" @click="gotoRouteHandle(menu)">
<icon-svg :name="menu.icon || ''" class="site-sidebar__menu-icon"></icon-svg>
<span>{{ menu.name }}</span>
</el-menu-item>
</template>
```
##### 按钮权限
在组件中根据外部方法传入的权限标识进行权限判断,如果权限存在,则显示为可见状态,否则不可见。
```html
<el-button type="primary"
icon="el-icon-plus"
size="small"
v-if="isAuth('admin:indexImg:save')"
@click.stop="addOrUpdateHandle()">新增</el-button>
```
通过`isAuth(“权限标识”)`,判断按钮是否有相同的标识,如果有则可见,否则不可见
```javascript
/**
* 是否有权限
* @param {*} key
*/
export function isAuth (key) {
let authorities = JSON.parse(sessionStorage.getItem('authorities') || '[]')
if (authorities.length) {
for (const i in authorities) {
const element = authorities[i]
if (element.authority === key) {
return true
}
}
}
return false
}
```
注:后台通过`@PreAuthorize("@pms.hasPermission('admin:user:update')")`来定义请求所需要的权限如果用户没有该权限后台就会抛出401未授权状态码前端捕获到该状态码后会登出当前的账号让用户重新登陆。
![img](https://box.kancloud.cn/627e371fbaf45d74782a99fb888026ec_546x519.png)
#### 后台菜单管理、角色管理与管理员列表
##### 菜单管理
在【系统管理】-【菜单管理】中,我们可以通过类配置的方式,更直观的对菜单列表增删改查进行管理。
菜单类型包括目录 、菜单 、按钮。
目录为导航栏的大的分类,菜单为分类下的每一项,每个菜单需要绑定上级及填写对应跳转的路由,路由路径对应工程的目录如下图:
![img](https://box.kancloud.cn/fc23477a687599f9e01dee8f45f3b161_706x608.png)
在新增按钮权限时,注意授权标识要与后台一致,新增完之后需要重启刷新生效。
![img](https://box.kancloud.cn/6ffabbbbf2b4f641126dd946f5d79f99_699x432.png)
![img](https://box.kancloud.cn/0e22aebd4c42f3a48e2bf3a7b4e43548_770x82.png)
##### 角色管理
在【系统管理】-【角色管理】中,管理员可以新增角色,并且赋予该角色可以访问的权限项。
![img](https://box.kancloud.cn/2da280c4cb37ac290875afba404c15be_1374x959.png)
##### 管理员管理
在【系统管理】- 【管理员列表】中,拥有该权限的管理员可以对其进行管理,该管理员可添加或修改管理权限,并可分配列表中的用户角色。
![img](https://box.kancloud.cn/6e086f97d7d3adfa80cb1d25ff078c9e_677x429.png)

View File

@@ -0,0 +1,214 @@
## 后台异常处理
在开发过程中,不可避免的是需要处理各种异常,异常处理方法随处可见,所以代码中就会出现大量的`try {...} catch {...} finally {...}` 代码块,不仅会造成大量的冗余代码,而且还影响代码的可读性,所以对异常统一处理非常有必要。为此,我们定义了一个统一的异常类`RuntimeException` 与异常管理类 `DefaultExceptionHandlerConfig`
我们先来看下 `RuntimeException`的代码
```java
@Getter
public class RuntimeException extends RuntimeException{
/**
*
*/
private static final long serialVersionUID = -4137688758944857209L;
/**
* http状态码
*/
private String code;
private Object object;
private R<?> R;
public RuntimeException(ResponseEnum responseEnum) {
super(responseEnum.getMsg());
this.code = responseEnum.value();
}
/**
* @param responseEnum
*/
public RuntimeException(ResponseEnum responseEnum, String msg) {
super(msg);
this.code = responseEnum.value();
}
public RuntimeException(R<?> R) {
this.R = R;
}
public RuntimeException(String msg) {
super(msg);
this.code = ResponseEnum.SHOW_FAIL.value();
}
public RuntimeException(String msg, Object object) {
super(msg);
this.code = ResponseEnum.SHOW_FAIL.value();
this.object = object;
}
}
```
`ResponseEnum`为我们自定义的返回状态码的枚举类,定义为一个枚举类,更直观处理异常返回的状态码及异常内容,以后每增加一种异常情况,只需增加一个枚举实例即可,不用每一种异常都定义一个异常类。
```java
public enum ResponseEnum {
/**
* ok
*/
OK("00000", "ok"),
SHOW_FAIL("A00001", ""),
/**
* 用于直接显示提示用户的错误,内容由输入内容决定
*/
/**
* 用于直接显示提示系统的成功,内容由输入内容决定
*/
SHOW_SUCCESS("A00002", ""),
/**
* 未授权
*/
UNAUTHORIZED("A00004", "Unauthorized"),
/**
* 服务器出了点小差
*/
EXCEPTION("A00005", "服务器出了点小差"),
/**
* 方法参数没有校验,内容由输入内容决定
*/
METHOD_ARGUMENT_NOT_VALID("A00014", "方法参数没有校验");
private final String code;
private final String msg;
public String value() {
return code;
}
public String getMsg() {
return msg;
}
ResponseEnum(String code, String msg) {
this.code = code;
this.msg = msg;
}
@Override
public String toString() {
return "ResponseEnum{" + "code='" + code + '\'' + ", msg='" + msg + '\'' + "} " + super.toString();
}
}
```
再来看看 `DefaultExceptionHandlerConfig`
```java
@Slf4j
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
@ExceptionHandler({ MethodArgumentNotValidException.class, BindException.class })
public ResponseEntity<R<List<String>>> methodArgumentNotValidExceptionHandler(Exception e) {
log.error("methodArgumentNotValidExceptionHandler", e);
List<FieldError> fieldErrors = null;
if (e instanceof MethodArgumentNotValidException) {
fieldErrors = ((MethodArgumentNotValidException) e).getBindingResult().getFieldErrors();
}
if (e instanceof BindException) {
fieldErrors = ((BindException) e).getBindingResult().getFieldErrors();
}
if (fieldErrors == null) {
return ResponseEntity.status(HttpStatus.OK)
.body(R.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID));
}
List<String> defaultMessages = new ArrayList<>(fieldErrors.size());
for (FieldError fieldError : fieldErrors) {
defaultMessages.add(fieldError.getField() + ":" + fieldError.getDefaultMessage());
}
return ResponseEntity.status(HttpStatus.OK)
.body(R.fail(ResponseEnum.METHOD_ARGUMENT_NOT_VALID, defaultMessages));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<R<?>> unauthorizedExceptionHandler(RuntimeException e){
log.error("mall4jExceptionHandler", e);
R<?> R = e.getR();
if (R!=null) {
return ResponseEntity.status(HttpStatus.OK).body(R);
}
// 失败返回消息 状态码固定为直接显示消息的状态码
return ResponseEntity.status(HttpStatus.OK).body(R.fail(e.getCode(),e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<R<Object>> exceptionHandler(Exception e){
log.error("exceptionHandler", e);
return ResponseEntity.status(HttpStatus.OK).body(R.fail(ResponseEnum.EXCEPTION));
}
}
```
## 前台异常处理
前端请求与相应做了封装,请求响应的内容会被拦截器所拦截,当后台返回给前台特定的状态码,前台将显示不同报错信息。请求响应非常常见,我们查看在`src\utils\httpRequest.js`里面的其中一段代码
```javascript
http.interceptors.response.use(response => {
return response
}, error => {
switch (error.response.status) {
case 400:
Message.error(error.response.data)
break
case 401:
clearLoginInfo()
router.push({ name: 'login' })
break
case 405:
Message.error('http请求方式有误')
break
case 500:
Message.error('服务器出了点小差,请稍后再试')
break
case 501:
Message.error('服务器不支持当前请求所需要的某个功能')
break
}
return Promise.reject(error)
})
```
这里将会统一拦截返回的状态码如`400`,进行错误提示。
## RESTful 风格
我们的上述代码使用http状态码对请求进行统一响应其中最大的
RESTful架构就是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便所以正得到越来越多网站的采用。
[RESTful概述](https://blog.igevin.info/posts/restful-architecture-in-general/)

View File

@@ -0,0 +1,90 @@
## 系统日志
利用`spring`框架中`aop`,我们可以实现业务代码与系统级服务进行解耦,例如日志记录、事务及其他安全业务等,可以使得我们的工程更加容易维护、优雅。如何在系统中添加相应的日志呢?
##### 添加依赖
```
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
```
##### 自定义注解
```java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SysLog {
String value() default "";
}
```
##### 配置切面
```java
@Aspect
@Component
public class SysLogAspect {
@Autowired
private SysLogService sysLogService;
private static Logger logger = LoggerFactory.getLogger(SysLogAspect.class);
@Around("@annotation(sysLog)")
public Object around(ProceedingJoinPoint joinPoint,com.pxdj.api.common.annotation.SysLog sysLog) throws Throwable {
long beginTime = SystemClock.now();
//执行方法
Object result = joinPoint.proceed();
//执行时长(毫秒)
long time = SystemClock.now() - beginTime;
SysLog sysLogEntity = new SysLog();
if(sysLog != null){
//注解上的描述
sysLogEntity.setOperation(sysLog.value());
}
//请求的方法名
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
sysLogEntity.setMethod(className + "." + methodName + "()");
//请求的参数
Object[] args = joinPoint.getArgs();
String params = Json.toJsonString(args[0]);
sysLogEntity.setParams(params);
//设置IP地址
sysLogEntity.setIp(IPHelper.getIpAddr());
//用户名
String username = SecurityUtils.getSysUser().getUsername();
sysLogEntity.setUsername(username);
sysLogEntity.setTime(time);
sysLogEntity.setCreateDate(new Date());
//保存系统日志
sysLogService.save(sysLogEntity);
return result;
}
}
```
将自定义的注解作为切入点,参数是`ProceedingJoinPoint``sysLog``ProceedingJoinPoint`用来获取当前执行的方法,`syslog`用来获取注解里面的值。
#### 在需要记录日志的方法上,添加注解`@SysLog(value)`
```java
@SysLog("修改角色")
@PutMapping
@PreAuthorize("@pms.hasPermission('sys:role:update')")
public R<Void> update(@RequestBody SysRole role){
sysRoleService.updateRoleAndRoleMenu(role);
return R.ok();
}
```
当操作这个方法时,将会被记录到数据库中,在日志管理中能看到相应操作的内容。
![img](https://box.kancloud.cn/4ff625398e31974b7de6fe9e06c2b847_1373x202.png)

View File

@@ -0,0 +1,139 @@
我们后台使用`spring` 为我们提供好的统一校验的工具`spring-boot-starter-validation`对请求进行校验。
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
```
这里通过注解封装了几种常用的校验
- `@NotNull` 不能为null
- `@NotEmpty` 不能为null、空字符串、空集合
- `@NotBlank` 不能为null、空字符串、纯空格的字符串
- `@Min` 数字最小值不能小于x
- `@Max` 数字最大值不能大于x
- `@Email` 字符串为邮件格式
- `@Max` 数字最大值不能大于x
- `@Size` 字符串长度最小为x、集合长度最小为x
- `@Pattern` 正则表达式
我们以`SysUser`为例,看看怎么使用
```java
public class SysUser implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 用户ID
*
*/
@TableId
private Long userId;
/**
* 用户名
*/
@NotBlank(message="用户名不能为空")
@Size(min = 2,max = 20,message = "用户名长度要在2-20之间")
private String username;
/**
* 密码
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
/**
* 邮箱
*/
@NotBlank(message="邮箱不能为空")
@Email(message="邮箱格式不正确")
private String email;
/**
* 手机号
*/
@Pattern(regexp="0?1[0-9]{10}",message = "请输入正确的手机号")
private String mobile;
/**
* 状态 0禁用 1正常
*/
private Integer status;
/**
* 用户所在店铺id
*/
private Long shopId;
/**
* 角色ID列表
*/
@TableField(exist=false)
private List<Long> roleIdList;
/**
* 创建时间
*/
private Date createTime;
}
```
我们在Controller层使用该bean并使用`@Valid`注解,使校验的注解生效,如`SysUserController`
```java
@RestController
@RequestMapping("/sys/user")
public class SysUserController {
/**
* 保存用户
*/
@SysLog("保存用户")
@PostMapping
@PreAuthorize("@pms.hasPermission('sys:user:save')")
public R<String> save(@Valid @RequestBody SysUser user){
String username = user.getUsername();
SysUser dbUser = sysUserService.getOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username));
if (dbUser!=null) {
return R.fail("该用户已存在");
}
user.setShopId(SecurityUtils.getSysUser().getShopId());
user.setPassword(passwordEncoder.encode(user.getPassword()));
sysUserService.saveUserAndUserRole(user);
return R.ok();
}
}
```
并且在`DefaultExceptionHandlerConfig` 拦截由`@Valid` 触发的异常信息并返回:
```java
@RestController
@RestControllerAdvice
public class DefaultExceptionHandlerConfig {
@ExceptionHandler(BindException.class)
public R<String> bindExceptionHandler(BindException e){
e.printStackTrace();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getBindingResult().getFieldErrors().get(0).getDefaultMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public R<String> methodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e){
e.printStackTrace();
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getBindingResult().getFieldErrors().get(0).getDefaultMessage());
}
}
```

View File

@@ -0,0 +1,154 @@
## 通用分页表格实现
前端基于VUE的轻量级表格插件 `avue`
后端分页组件使用Mybatis分页插件 `MybatisPlus`
> 分页实现流程,以【系统管理-管理员列表】为例
后台vue文件位置目录 `\src\views\modules\sys\user.vue`
1、`avue`组件的几个通用配置
```html
<avue-crud ref="crud"
:page="page"
:data="dataList"
:option="tableOption"
@search-change="searchChange"
@selection-change="selectionChange"
@on-load="getDataList">
</avue-crud>
```
`avue`定义了很多的事件,其中一个为 `@on-load`当该组件加载的时候,将会调用该方法。同时也对很多数据进行了双向绑定如:`:page="page"` 分页参数、`:data="dataList"` 分页的具体列表数据、`:option="tableOption"` 表格显示的列
2、通用的列表、搜索
`avue`规定表格的构建是通过JS对象进行配置的而不是通过dom类似于传统的layui还有一个主要的原因是这个表格可以同时生成搜索、分页。
```javascript
import { tableOption } from '@/crud/sys/user'
```
我们查看下该类的代码:
```javascript
export const tableOption = {
border: true,
selection: true,
index: false,
indexLabel: '序号',
stripe: true,
menuAlign: 'center',
menuWidth: 350,
align: 'center',
refreshBtn: true,
searchSize: 'mini',
addBtn: false,
editBtn: false,
delBtn: false,
viewBtn: false,
props: {
label: 'label',
value: 'value'
},
column: [{
label: '用户名',
prop: 'username',
search: true
}, {
label: '邮箱',
prop: 'email'
}, {
label: '手机号',
prop: 'mobile'
}, {
label: '创建时间',
prop: 'createTime'
}, {
label: '状态',
prop: 'status',
type: 'select',
dicData: [
{
label: '禁用',
value: 0
}, {
label: '正常',
value: 1
}
]
}]
}
```
这里的 `search: true` 也就是搜索框出现用户名搜索
```javascript
{
label: '用户名',
prop: 'username',
search: true
}
```
具体可以通过[avue官网-crud文档](https://avuejs.com/doc/crud/crud-doc)获取文档进行查询
3、 通用的搜索和加载
```javascript
getDataList (page, params) {
this.dataListLoading = true
this.$http({
url: this.$http.adornUrl('/sys/user/page'),
method: 'get',
params: this.$http.adornParams(
Object.assign(
{
current: page == null ? this.page.currentPage : page.currentPage,
size: page == null ? this.page.pageSize : page.pageSize
},
params
)
)
}).then(({ data }) => {
this.dataList = data.records
this.page.total = data.total
this.dataListLoading = false
})
}
```
4、服务端`SysUserController`
```java
@RestController
@RequestMapping("/sys/user")
public class SysUserController {
@Autowired
private SysUserService sysUserService;
/**
* 所有用户列表
*/
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('sys:user:page')")
public R<IPage<SysUser>> page(String username,PageParam<SysUser> page){
IPage<SysUser> sysUserPage = sysUserService.page(page, new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getShopId, SecurityUtils.getSysUser().getShopId())
.like(StrUtil.isNotBlank(username), SysUser::getUsername, username));
return R.ok(sysUserPage);
}
}
```

View File

@@ -0,0 +1,14 @@
# 目录结构
~~~
yami-shops
├── mall4m -- 小程序代码
├── mall4v -- 后台vue代码
├── yami-shop-admin -- 后台vue接口工程[8085]
├── yami-shop-api -- 前端(小程序)接口工程[8086]
├── yami-shop-bean -- 所有公共的实体类,商城基本流程所需的实体类
├── yami-shop-common -- 前后台需要用到的公共配置,工具类等的集合地
├── yami-shop-security -- oauth2.0 授权认证模块
├── yami-shop-service -- 前后台需要用到的公共的、商城基本流程所需的servicedao的集合地
├── yami-shop-sys -- 后台用户角色权限管理模块
~~~