From a0bd1b96a087aa64e65087052d6b6b05a6ba65c0 Mon Sep 17 00:00:00 2001 From: LGH <1242479791@qq.com> Date: Fri, 22 May 2020 15:43:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=A4=A7=E9=87=8F=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E5=A6=82=E6=9E=9C=E8=A7=89=E5=BE=97=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E6=9C=89=E7=82=B9?= =?UTF-8?q?=E9=9A=BE=E7=9C=8B=E6=87=82=E7=9A=84=EF=BC=8C=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=9C=A8=E7=9C=8B=E4=BA=91=E6=9F=A5=E7=9C=8B=EF=BC=8C=E7=9C=8B?= =?UTF-8?q?=E4=BA=91=E7=9A=84=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E6=B8=85=E6=99=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/常见问题.md | 12 + doc/接口设计/1. 购物车的设计.md | 82 ++++++ doc/接口设计/2. 订单设计-确认订单.md | 200 ++++++++++++++ doc/接口设计/3. 订单设计-提交订单.md | 151 +++++++++++ doc/接口设计/4. 订单设计-支付.md | 139 ++++++++++ doc/接口设计/必读.md | 8 + .../docker/Docker Compose 安装与卸载.md | 142 ++++++++++ doc/生产环境/docker/centos jdk安装.md | 8 + doc/生产环境/docker/docker centos 安装.md | 90 +++++++ doc/生产环境/docker/docker 容器的基本操作.md | 41 +++ doc/生产环境/docker/docker 镜像的基本操作.md | 41 +++ doc/生产环境/docker/使用docker部署商城.md | 15 ++ doc/生产环境/docker/通过yum安装maven.md | 14 + doc/生产环境/安装mysql.md | 104 ++++++++ doc/生产环境/安装redis.md | 62 +++++ doc/认证与授权/从授权开始看源码.md | 251 ++++++++++++++++++ doc/认证与授权/自己写个授权的方法-开源版.md | 125 +++++++++ 17 files changed, 1485 insertions(+) create mode 100644 doc/常见问题.md create mode 100644 doc/接口设计/1. 购物车的设计.md create mode 100644 doc/接口设计/2. 订单设计-确认订单.md create mode 100644 doc/接口设计/3. 订单设计-提交订单.md create mode 100644 doc/接口设计/4. 订单设计-支付.md create mode 100644 doc/接口设计/必读.md create mode 100644 doc/生产环境/docker/Docker Compose 安装与卸载.md create mode 100644 doc/生产环境/docker/centos jdk安装.md create mode 100644 doc/生产环境/docker/docker centos 安装.md create mode 100644 doc/生产环境/docker/docker 容器的基本操作.md create mode 100644 doc/生产环境/docker/docker 镜像的基本操作.md create mode 100644 doc/生产环境/docker/使用docker部署商城.md create mode 100644 doc/生产环境/docker/通过yum安装maven.md create mode 100644 doc/生产环境/安装mysql.md create mode 100644 doc/生产环境/安装redis.md create mode 100644 doc/认证与授权/从授权开始看源码.md create mode 100644 doc/认证与授权/自己写个授权的方法-开源版.md diff --git a/doc/常见问题.md b/doc/常见问题.md new file mode 100644 index 0000000..426ea88 --- /dev/null +++ b/doc/常见问题.md @@ -0,0 +1,12 @@ +这里整理了一些经常会被问到的问题: +1. 微信小程序为啥会提示这个错误消息:![](images/TIM图片20190829110522.png) +答: 你这个问题,我感觉是微信开发工具的问题,我们这边很久以前出现过一次,后来更新微信开发工具又好了...有的人更新了也没好,推荐不要追求太新,使用正式版的开发工具。。。 + +2. 为什么微信小程序登录会提示无效的code ? +答:你看看微信小程序配置的appid和你api配置的appid是不是一样的 + +3. 为什么vue打包之后,或者修改url之后,无法登录? +答:你用chrome按f12看看console提示的信息如:`Access-Control-Allow-Origin` 那就是跨域了,再看看network的请求方法是不是`options`,但是返回不是200,这也是跨域了。 + +4. 跨域了怎么办? +跨域产生的原因是因为浏览器的同源策略,也就是说这个是浏览器的问题,你用`postman`去请求,都是没有问题,返回200的,浏览器才会出现这种奇怪的问题。要解决这个问题,就要清楚同源策略是啥,也就是浏览器认为:域名、协议、端口相同才是相同的源,也就是要想办法让前端的域名、协议、端口和接口的相同。而实际上前端和服务器怎么可以在一个端口呢?那就需要一些转发的工具,将同一个端口,不同路径的请求,转发到不同的端口,具体操作可以看 【生产环境nginx安装与跨域配置】 diff --git a/doc/接口设计/1. 购物车的设计.md b/doc/接口设计/1. 购物车的设计.md new file mode 100644 index 0000000..22bb443 --- /dev/null +++ b/doc/接口设计/1. 购物车的设计.md @@ -0,0 +1,82 @@ +建议阅读前,先阅读《商城表设计-购物车》相关文档 + +我们的购物车只有一个表:`tz_basket` 非常简单,但是关联了非常多的表。比如: + +- 购物车有商品,关联商品表 +- 每个商品都有sku,关联sku表 +- 一个购物车有多个店铺的商品,关联店铺表 +- 一个购物车肯定是和用户有关的,关联用户表 + + + +我们对商品进行添加,修改,其实都很简单,最为让人难以理解的是如何将这些字段进行组合,关联满减满折等一系列的活动。 + +我们先来看下是如何获取商品信息的 + +```java + @PostMapping("/info") + @ApiOperation(value = "获取用户购物车信息", notes = "获取用户购物车信息,参数为用户选中的活动项数组,以购物车id为key") + public ResponseEntity> info(@RequestBody Map basketIdShopCartParamMap) { + String userId = SecurityUtils.getUser().getUserId(); + + // 更新购物车信息, + if (MapUtil.isNotEmpty(basketIdShopCartParamMap)) { + basketService.updateBasketByShopCartParam(userId, basketIdShopCartParamMap); + } + + // 拿到购物车的所有item + List shopCartItems = basketService.getShopCartItems(userId); + return ResponseEntity.ok(basketService.getShopCarts(shopCartItems)); + + } +``` + +这里面传了一个参数:`Map basketIdShopCartParamMap` 这里是当用户改变了某件商品的满减满折活动时,重新改变满减满折信息以后计算加个的一个方法。当然在开源是没有这个满减模块的,只有思路,具体实现需要靠自己了。 + +我们继续往下看,这里面`basketService.getShopCartItems(userId)`使用的直接是从数据库中获取的数据,而真正对满减满折、店铺等进行排列组合的,在于`basketService.getShopCarts(shopCartItems)` 这个方法。 + + + +我们进到`getShopCarts`方法内部,可以查看到一行代码`applicationContext.publishEvent(new ShopCartEvent(shopCart, shopCartItemDtoList));`,这里使用的事件的模式。这个事件的主要作用是用于对模块之间的解耦,比如我们清楚的知道当购物车需要计算价格的时候,需要满减模块的配合,进行“装饰”。最后将装饰回来的东西,返回给前端。 + + + +我们现在看看购物车返回的数据`ResponseEntity>`,我们清楚一个购物车是分多个店铺的,每一个店铺就是一个`ShopCartDto`,我们看下这个`bean`。 + +```java +@Data +public class ShopCartDto implements Serializable { + + @ApiModelProperty(value = "店铺ID", required = true) + private Long shopId; + + @ApiModelProperty(value = "店铺名称", required = true) + private String shopName; + + @ApiModelProperty(value = "购物车满减活动携带的商品", required = true) + private List shopCartItemDiscounts; + +} +``` + +其实一个店铺下面是有多个商品的,但是根据京东的划分,每当有满减之类的活动时,满减活动的商品总是要归到一类的,所以,每个店铺下面是多个满减活动(`List`),满减活动下面是多个商品(购物项`List`),到此你就能明白了`ShopCartItemDiscountDto` 里面的`ChooseDiscountItemDto` 是什么东西了,这个是选中的满减项。 + +```java +public class ShopCartItemDiscountDto implements Serializable { + + @ApiModelProperty(value = "已选满减项", required = true) + private ChooseDiscountItemDto chooseDiscountItemDto; + + @ApiModelProperty(value = "商品列表") + private List shopCartItems; +} +``` + +我们再留意`ShopCartItemDto` 这个`bean` ,发现还有这个东西: + +```java +@ApiModelProperty("参与满减活动列表") +private List discounts = new ArrayList<>(); +``` + +其实购物车的每个购物项,都是有很多个满减的活动的,可以自主选择满减活动,然后进行组合,生成新的优惠。而在这选择新的活动类型时,就需要购物车就行新的价格计算。这也就是为什么获取用户购物车信息,也就是`/info`接口需要一个这个参数的原因了`Map basketIdShopCartParamMap` diff --git a/doc/接口设计/2. 订单设计-确认订单.md b/doc/接口设计/2. 订单设计-确认订单.md new file mode 100644 index 0000000..6282286 --- /dev/null +++ b/doc/接口设计/2. 订单设计-确认订单.md @@ -0,0 +1,200 @@ +下单简单的分成几个步骤: + +1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面 +2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格 +3. 提交订单,选择支付方式进行支付 +4. 支付完毕 + + + +## 第一步: + +1. 用户点击“立即购买”或“购物车-结算”进入到“确认订单”页面,相关url`/p/order/confirm` + +我们希望能够有个统一下单的接口,不太希望“立即购买”和“购物车-结算”两个不同的接口影响到后面所有的流程,毕竟谁也不想一个差不多一样的接口,要写两遍,所以我们看下我们的系统是如何做的。 + + + +```java +public class OrderParam { + @ApiModelProperty(value = "购物车id 数组") + private List basketIds; + + @ApiModelProperty(value = "立即购买时提交的商品项") + private OrderItemParam orderItem; +} +``` + +这里使用了两种情况: + +- 假设`basketIds` 不为空,则说明是从购物车进入 +- 假设`orderItem` 不为空,则说明是从立即购买进入 + +通过`basketService.getShopCartItemsByOrderItems(orderParam.getBasketIds(),orderParam.getOrderItem(),userId)` 这个方法对两种情况进行组合,此时并不能将购物车商品删除,因为删除购物车中的商品,是在第三步提交订单的时候进行的,不然用户点击返回键,看到购物车里面的东西还没提交订单,东西就消失了,会感觉很奇怪。 + + + +我们重新回到`controller`层,我们看到了一行熟悉的代码`basketService.getShopCarts` + +```java + @PostMapping("/confirm") + @ApiOperation(value = "结算,生成订单信息", notes = "传入下单所需要的参数进行下单") + public ResponseEntity confirm(@Valid @RequestBody OrderParam orderParam) { + // 根据店铺组装购车中的商品信息,返回每个店铺中的购物车商品信息 + List shopCarts = basketService.getShopCarts(shopCartItems); + } +``` + +这行代码我们再《购物车的设计》这篇已经着重讲过了,但是我们在这为什么还需要这个东西呢? + +很简单,无论是点击“立即购买”或“购物车-结算”,事实上都是通过用户计算过一遍金额了,而且甚至有满减满折之类的活动,都是通过了统一的计算的。而这一套计算的流程,我们并不希望重新写一遍。所以当然是能够使用之前计算的金额,那是最好的咯。 + + + + + +## 第二步: + +2. 在“确认订单”页面选择收货地址,优惠券等,重新计算运费、订单价格 + +我们知道无论是在第一步还是第二步,本质上还是在确认订单的页面,其中订单页面的数据结构并没有发生任何的变化,所以其实第一步第二步是可以写在一起的。所以我们可以看到`OrderParam` 还多了两个参数 + +```java +public class OrderParam { + @ApiModelProperty(value = "地址ID,0为默认地址",required=true) + @NotNull(message = "地址不能为空") + private Long addrId; + + @ApiModelProperty(value = "用户是否改变了优惠券的选择,如果用户改变了优惠券的选择,则完全根据传入参数进行优惠券的选择") + private Integer userChangeCoupon; + + @ApiModelProperty(value = "优惠券id数组") + private List couponIds; +} +``` + +但是有个问题,就是在于用户点击立即购买的时候,没有地址,那样如何计算运费呢?答案就是使用默认地址进行计算呀~ + + + +我们看下计算订单的事件,事实上有很多营销活动的时候,订单的计算也是非常的复杂的,所以我们和购物车一样,采用事件的驱动,一个接一个的对订单进行“装饰”,最后生成`ShopCartOrderMergerDto`一个合并的对象 + +```java + @PostMapping("/confirm") + @ApiOperation(value = "结算,生成订单信息", notes = "传入下单所需要的参数进行下单") + public ResponseEntity confirm(@Valid @RequestBody OrderParam orderParam) { + for (ShopCartDto shopCart : shopCarts) { + applicationContext.publishEvent(new ConfirmOrderEvent(shopCartOrder,orderParam,shopAllShopCartItems)); + + } + } +``` + +我们看下`ConfirmOrderListener` 这个事件里面的默认监听器,这里 + + + +```java +public class ConfirmOrderListener { + @EventListener(ConfirmOrderEvent.class) + @Order(ConfirmOrderOrder.DEFAULT) + public void defaultConfirmOrderEvent(ConfirmOrderEvent event) { + + + ShopCartOrderDto shopCartOrderDto = event.getShopCartOrderDto(); + + OrderParam orderParam = event.getOrderParam(); + + String userId = SecurityUtils.getUser().getUserId(); + + // 订单的地址信息 + UserAddr userAddr = userAddrService.getUserAddrByUserId(orderParam.getAddrId(), userId); + + double total = 0.0; + + int totalCount = 0; + + double transfee = 0.0; + + for (ShopCartItemDto shopCartItem : event.getShopCartItems()) { + // 获取商品信息 + Product product = productService.getProductByProdId(shopCartItem.getProdId()); + // 获取sku信息 + Sku sku = skuService.getSkuBySkuId(shopCartItem.getSkuId()); + if (product == null || sku == null) { + throw new YamiShopBindException("购物车包含无法识别的商品"); + } + if (product.getStatus() != 1 || sku.getStatus() != 1) { + throw new YamiShopBindException("商品[" + sku.getProdName() + "]已下架"); + } + + totalCount = shopCartItem.getProdCount() + totalCount; + total = Arith.add(shopCartItem.getProductTotalAmount(), total); + // 用户地址如果为空,则表示该用户从未设置过任何地址相关信息 + if (userAddr != null) { + // 每个产品的运费相加 + transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr)); + } + + shopCartItem.setActualTotal(shopCartItem.getProductTotalAmount()); + shopCartOrderDto.setActualTotal(Arith.sub(total, transfee)); + shopCartOrderDto.setTotal(total); + shopCartOrderDto.setTotalCount(totalCount); + shopCartOrderDto.setTransfee(transfee); + } + } +} +``` + +值得留意的是,有那么一行代码 + +```java + // 用户地址如果为空,则表示该用户从未设置过任何地址相关信息 + if (userAddr != null) { + // 每个产品的运费相加 + transfee = Arith.add(transfee, transportManagerService.calculateTransfee(shopCartItem, userAddr)); + } +``` +运费是根据用户地址进行计算,当然还包括运费模板啦,想了解运费模板的,可以参考运费模板相关的章节。 + +那么有人就问了,那么优惠券呢?优惠券是有另一个监听器进行监听计算价格啦,购买了专业版或以上的版本就能看到源码咯~ + + + +我们看看返回给前端的订单信息: + +```java +@Data +public class ShopCartOrderMergerDto implements Serializable{ + + @ApiModelProperty(value = "实际总值", required = true) + private Double actualTotal; + + @ApiModelProperty(value = "商品总值", required = true) + private Double total; + + @ApiModelProperty(value = "商品总数", required = true) + private Integer totalCount; + + @ApiModelProperty(value = "订单优惠金额(所有店铺优惠金额相加)", required = true) + private Double orderReduce; + + @ApiModelProperty(value = "地址Dto", required = true) + private UserAddrDto userAddr; + + @ApiModelProperty(value = "每个店铺的购物车信息", required = true) + private List shopCartOrders; + + @ApiModelProperty(value = "整个订单可以使用的优惠券列表", required = true) + private List coupons; +} + +``` + +这里又有一段我们熟悉的代码: + +```java +@ApiModelProperty(value = "每个店铺的购物车信息", required = true) +private List shopCartOrders; +``` +没错这里返回的数据格式,和购物车的格式是一样的,因为第一步当中已经说明,订单来自于购物车的计算,所以会在基础上条件新的数据,基本上就是返回给前端的数据了。 diff --git a/doc/接口设计/3. 订单设计-提交订单.md b/doc/接口设计/3. 订单设计-提交订单.md new file mode 100644 index 0000000..219107b --- /dev/null +++ b/doc/接口设计/3. 订单设计-提交订单.md @@ -0,0 +1,151 @@ +> 首先我们在这里严重的批评一些,在接口订单的接口中,直接传订单金额,而不是使用下单是已经计算好金额的人,这些接口岂不是使用0.01就能将全部的商品都买下来了? + + + +我们回到订单设计这一个模块,首先我们在确认订单的时候就已经将价格计算完成了,那么我们肯定是想将计算结果给保留下来的,至于计算的过程,我们并不希望这个过程还要进行一遍的计算。 + + + +我们返回确认订单的接口,看到这样一行代码: + +```java + @ApiOperation(value = "结算,生成订单信息", notes = "传入下单所需要的参数进行下单") + public ResponseEntity confirm(@Valid @RequestBody OrderParam orderParam) { + orderService.putConfirmOrderCache(userId,shopCartOrderMergerDto); + } +``` + + + +这里每经过一次计算,就将整个订单通过`userId`进行了保存,而这个缓存的时间为30分钟,当用户使用 + +```java + @PostMapping("/submit") + @ApiOperation(value = "提交订单,返回支付流水号", notes = "根据传入的参数判断是否为购物车提交订单,同时对购物车进行删除,用户开始进行支付") + public ResponseEntity submitOrders(@Valid @RequestBody SubmitOrderParam submitOrderParam) { + ShopCartOrderMergerDto mergerOrder = orderService.getConfirmOrderCache(userId); + if (mergerOrder == null) { + throw new YamiShopBindException("订单已过期,请重新下单"); + } + + // 省略中间一大段。。。 + + orderService.removeConfirmOrderCache(userId); + } +``` + +当无法获取缓存的时候告知用户订单过期,当订单进行提交完毕的时候,将之前的缓存给清除。 + + + +我们又回到提交订单中间这几行代码: + +```java +List orders = orderService.submit(userId,mergerOrder); +``` + +这行代码也就是提交订单的核心代码 + +```java +eventPublisher.publishEvent(new SubmitOrderEvent(mergerOrder, orderList)); +``` + +其中这里依旧是使用时间的方式,将订单进行提交,看下这个`SubmitOrderEvent`的默认监听事件。 + +```java +@Component("defaultSubmitOrderListener") +@AllArgsConstructor +public class SubmitOrderListener { + public void defaultSubmitOrderListener(SubmitOrderEvent event) { + // ... + } +} +``` + + + +这里有几段值得注意的地方: + +- 这里是`UserAddrOrder` 并不是`UserAddr`: + +```java +// 把订单地址保存到数据库 +UserAddrOrder userAddrOrder = mapperFacade.map(mergerOrder.getUserAddr(), UserAddrOrder.class); +if (userAddrOrder == null) { + throw new YamiShopBindException("请填写收货地址"); +} +userAddrOrder.setUserId(userId); +userAddrOrder.setCreateTime(now); +userAddrOrderService.save(userAddrOrder); +``` + +这里是将订单的收货地址进行了保存入库的操作,这里是绝对不能只保存用户的地址id在订单中的,要将地址入库,原因是如果用户在订单中设置了一个地址,如果用户在订单还没配送的时候,将自己的地址改了的话。如果仅采用关联的地址,就会出现问题。 + + + +- 为每个店铺生成一个订单 + +```java +// 每个店铺生成一个订单 +for (ShopCartOrderDto shopCartOrderDto : shopCartOrders) { + +} +``` + +这里为每个店铺创建一个订单,是为了,以后平台结算给商家时,每个商家的订单单独结算。用户确认收货时,也可以为每家店铺单独确认收货。 + + + +- 使用雪花算法生成订单id, 如果对雪花算法感兴趣的,可以去搜索下相关内容: + +```java +String orderNumber = String.valueOf(snowflake.nextId()); +``` + +我们不想单多台服务器生成的id冲突,也不想生成uuid这样的很奇怪的字符串id,更不想直接使用数据库主键这种东西时,雪花算法就出现咯。 + + + +- 当用户提交订单的时候,购物车里面勾选的商品,理所当然的要清空掉 + +```java +// 删除购物车的商品信息 +if (!basketIds.isEmpty()) { + basketMapper.deleteShopCartItemsByBasketIds(userId, basketIds); + +} +``` + + + +- 使用数据库的乐观锁,防止超卖: + +```java +if (skuMapper.updateStocks(sku) == 0) { + skuService.removeSkuCacheBySkuId(key, sku.getProdId()); + throw new YamiShopBindException("商品:[" + sku.getProdName() + "]库存不足"); + } +``` + +```sql +update tz_sku set stocks = stocks - #{sku.stocks}, version = version + 1,update_time = NOW() where sku_id = #{sku.skuId} and #{sku.stocks} <= stocks +``` + +超卖一直是一件非常令人头疼的事情,如果对订单直接加悲观锁的话,那么下单的性能将会很差。商城最重要的就是下单啦,要是性能很差,那人家还下个鬼的单哟,所以我们采用数据库的乐观锁进行下单。 + +所谓乐观锁,就是在 where 条件下加上极限的条件,比如在这里就是更新的库存小于或等于商品的库存,在这种情况下可以对库存更新成功,则更新完成了,否则抛异常(真正的定义肯定不是这样的啦,你可以百度下 “乐观锁更新库存”)。注意这里在抛异常以前,应该将缓存也更新了,不然无法及时更新。 + + + +最后我们回到`controller` + +```java +return ResponseEntity.ok(new OrderNumbersDto(orderNumbers.toString())); +``` + +这里面返回了多个订单项,这里就变成了并单支付咯,在多个店铺一起进行支付的时候需要进行并单支付的操作,一个店铺的时候,又要变成一个订单支付的操作,可是我们只希望有一个统一支付的接口进行调用,所以我们的支付接口要进行一点点的设计咯。 + + + + + diff --git a/doc/接口设计/4. 订单设计-支付.md b/doc/接口设计/4. 订单设计-支付.md new file mode 100644 index 0000000..2118437 --- /dev/null +++ b/doc/接口设计/4. 订单设计-支付.md @@ -0,0 +1,139 @@ +> 我们的支付时不允许在订单的支付接口传订单金额的,所以我们采用了订单号进行支付的形式 + +## 支付 + +我们来到`PayController` ,这里就是统一支付的接口,当然这里的统一支付采用的是微信支付。 + +我们直接看一下核心代码: + +```java +PayInfoDto payInfo = payService.pay(userId, payParam); +``` + +再看看里面的代码: + +```java + // 修改订单信息 + for (String orderNumber : orderNumbers) { + OrderSettlement orderSettlement = new OrderSettlement(); + orderSettlement.setPayNo(payNo); + orderSettlement.setPayType(payParam.getPayType()); + orderSettlement.setUserId(userId); + orderSettlement.setOrderNumber(orderNumber); + orderSettlementMapper.updateByOrderNumberAndUserId(orderSettlement); + + Order order = orderMapper.getOrderByOrderNumber(orderNumber); + prodName.append(order.getProdName()).append(StrUtil.COMMA); + } +``` + +这里对传过来的支付参数`orderNumbers`进行了拆分,为每个订单的结算信息都进行了更新,所以这里便支持了分单支付和并单支付的流程。 + + + +订单金额: + +```java +// 除了ordernumber不一样,其他都一样 +List settlements = orderSettlementMapper.getSettlementsByPayNo(payNo); +// 应支付的总金额 +double payAmount = 0.0; +for (OrderSettlement orderSettlement : settlements) { + payAmount = Arith.add(payAmount, orderSettlement.getPayAmount()); +} +``` + +这里面应支付的金额是通过数据库中获取的订单金额,是不接受任何前端传入的订单金额的。 + + + +## 支付回调 + + + +我们回到`controller` + +```java +orderRequest.setNotifyUrl(apiConfig.getDomainName() + "/notice/pay/order"); +``` + +这里面规定的,订单回调的地址,这也就是为什么需要`api.properties` 传入`api.domainName`的原因 + + + +根据订单配置`/notice/pay/order`,我们去到订单回调的`controller`既`PayNoticeController` + +- 验签 + +因为订单的已经决定的订单已经支付成功,所以订单的回调是需要做一些验证的。不然谁都可以调用订单回调的地址,实在是十分危险。 + +其实`wxjava`这个工具包已经对返回的参数进行了校验 + +```java +WxPayOrderNotifyResult parseOrderNotifyResult = wxMiniPayService.parseOrderNotifyResult(xmlData); +``` + +在上面这个方法之下,就有那么一句话 + +```java +result.checkResult(this, this.getConfig().getSignType(), false); +``` + + + +- 更新支付状态 + +我们看看这里的业务核心方法: + +```java +// 根据内部订单号更新order settlement +payService.paySuccess(payNo, bizPayNo); +``` + + + +```java + @Override + @Transactional(rollbackFor = Exception.class) + public List paySuccess(String payNo, String bizPayNo) { + List orderSettlements = orderSettlementMapper.selectList(new LambdaQueryWrapper().eq(OrderSettlement::getPayNo, payNo)); + + OrderSettlement settlement = orderSettlements.get(0); + + // 订单已支付 + if (settlement.getPayStatus() == 1) { + log.info("订单已支付,settlement.id:{}",settlement.getSettlementId()); + return null; + } + // 修改订单结算信息 + if (orderSettlementMapper.updateToPay(payNo, settlement.getVersion()) < 1) { + throw new YamiShopBindException("结算信息已更改"); + } + + + List orderNumbers = orderSettlements.stream().map(OrderSettlement::getOrderNumber).collect(Collectors.toList()); + + // 将订单改为已支付状态 + orderMapper.updateByToPaySuccess(orderNumbers, PayType.WECHATPAY.value()); + + List orders = orderNumbers.stream().map(orderNumber -> { + Order order = orderMapper.getOrderByOrderNumber(orderNumber); + order.setOrderItems(orderItemMapper.listByOrderNumber(orderNumber)); + return order; + }).collect(Collectors.toList()); + eventPublisher.publishEvent(new PaySuccessOrderEvent(orders)); + return orderNumbers; + } +``` + +这里无非就是找到原来的订单,将订单变成已支付的状态。 + + + +而这里同样有事件支付成功的事件 + +```java +eventPublisher.publishEvent(new PaySuccessOrderEvent(orders)); +``` + +这里的事件也是和营销活动有关的,比如分销,这些代码也是专业版才有的。 diff --git a/doc/接口设计/必读.md b/doc/接口设计/必读.md new file mode 100644 index 0000000..babb5fe --- /dev/null +++ b/doc/接口设计/必读.md @@ -0,0 +1,8 @@ +这里只有几点说明: + +1. 这里写的是接口设计,如果你整个接口的接口文档,只需要启动api这个项目,然后访问 http://localhost:8086/doc.html + +2. 这里写的是如何设计,我们就不水接口文档几十页了好吧。 +3. 我们说了数据库基于b2b2c其实接口也是基于b2b2c,所以你才会在很多的接口的当中看到很多的`shop_id`。 +4. b2b2c和b2c的接口设计,很多情况下,也就是多了一个`shop_id`而已。 + diff --git a/doc/生产环境/docker/Docker Compose 安装与卸载.md b/doc/生产环境/docker/Docker Compose 安装与卸载.md new file mode 100644 index 0000000..b81d49c --- /dev/null +++ b/doc/生产环境/docker/Docker Compose 安装与卸载.md @@ -0,0 +1,142 @@ +`Compose` 支持 Linux、macOS、Windows 10 三大平台。 + +`Compose` 可以通过 Python 的包管理工具 `pip` 进行安装,也可以直接下载编译好的二进制文件使用,甚至能够直接在 Docker 容器中运行。 + +前两种方式是传统方式,适合本地环境下安装使用;最后一种方式则不破坏系统环境,更适合云计算场景。 + +`Docker for Mac` 、`Docker for Windows` 自带 `docker-compose` 二进制文件,安装 Docker 之后可以直接使用。 + +```bash +$ docker-compose --version + +docker-compose version 1.17.1, build 6d101fb +``` + +Linux 系统请使用以下介绍的方法安装。 + +## 安装方法一:二进制包 + +在 Linux 上的也安装十分简单,从 [官方 GitHub Release](https://github.com/docker/compose/releases) 处直接下载编译好的二进制文件即可。 + +例如,在 Linux 64 位系统上直接下载对应的二进制包。 + +```bash +$ sudo curl -L https://github.com/docker/compose/releases/download/1.17.1/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose +$ sudo chmod +x /usr/local/bin/docker-compose +``` + +## 安装方法二:PIP 安装 + +*注:* `x86_64` 架构的 Linux 建议按照上边的方法下载二进制包进行安装,如果您计算机的架构是 `ARM`(例如,树莓派),再使用 `pip` 安装。 + +这种方式是将 Compose 当作一个 Python 应用来从 pip 源中安装。 + +1、安装python-pip + +```bash +yum -y install epel-release + +yum -y install python-pip +``` + + +执行安装命令: + +```bash +pip install -U docker-compose +``` + +可以看到类似如下输出,说明安装成功。 + +```bash +Collecting docker-compose + Downloading docker-compose-1.17.1.tar.gz (149kB): 149kB downloaded +... +Successfully installed docker-compose cached-property requests texttable websocket-client docker-py dockerpty six enum34 backports.ssl-match-hostname ipaddress +``` + +查看版本号 +``` +docker-compose version +``` + +bash 补全命令 + +将对应版本号的docker-compose补全如:下面的`1.8.0`替换成 `1.24.1` + +```bash +$ curl -L https://raw.githubusercontent.com/docker/compose/1.8.0/contrib/completion/bash/docker-compose > /etc/bash_completion.d/docker-compose +``` + + + +## 容器中执行 + +Compose 既然是一个 Python 应用,自然也可以直接用容器来执行它。 + +```bash +$ curl -L https://github.com/docker/compose/releases/download/1.8.0/run.sh > /usr/local/bin/docker-compose +$ chmod +x /usr/local/bin/docker-compose +``` + +实际上,查看下载的 `run.sh` 脚本内容,如下 + +```bash +set -e + +VERSION="1.8.0" +IMAGE="docker/compose:$VERSION" + + +# Setup options for connecting to docker host +if [ -z "$DOCKER_HOST" ]; then + DOCKER_HOST="/var/run/docker.sock" +fi +if [ -S "$DOCKER_HOST" ]; then + DOCKER_ADDR="-v $DOCKER_HOST:$DOCKER_HOST -e DOCKER_HOST" +else + DOCKER_ADDR="-e DOCKER_HOST -e DOCKER_TLS_VERIFY -e DOCKER_CERT_PATH" +fi + + +# Setup volume mounts for compose config and context +if [ "$(pwd)" != '/' ]; then + VOLUMES="-v $(pwd):$(pwd)" +fi +if [ -n "$COMPOSE_FILE" ]; then + compose_dir=$(dirname $COMPOSE_FILE) +fi +# TODO: also check --file argument +if [ -n "$compose_dir" ]; then + VOLUMES="$VOLUMES -v $compose_dir:$compose_dir" +fi +if [ -n "$HOME" ]; then + VOLUMES="$VOLUMES -v $HOME:$HOME -v $HOME:/root" # mount $HOME in /root to share docker.config +fi + +# Only allocate tty if we detect one +if [ -t 1 ]; then + DOCKER_RUN_OPTIONS="-t" +fi +if [ -t 0 ]; then + DOCKER_RUN_OPTIONS="$DOCKER_RUN_OPTIONS -i" +fi + +exec docker run --rm $DOCKER_RUN_OPTIONS $DOCKER_ADDR $COMPOSE_OPTIONS $VOLUMES -w "$(pwd)" $IMAGE "$@" +``` + +可以看到,它其实是下载了 `docker/compose` 镜像并运行。 + +## 卸载 + +如果是二进制包方式安装的,删除二进制文件即可。 + +```bash +rm /usr/local/bin/docker-compose +``` + +如果是通过 `pip` 安装的,则执行如下命令即可删除。 + +```bash +pip uninstall docker-compose +``` diff --git a/doc/生产环境/docker/centos jdk安装.md b/doc/生产环境/docker/centos jdk安装.md new file mode 100644 index 0000000..657347d --- /dev/null +++ b/doc/生产环境/docker/centos jdk安装.md @@ -0,0 +1,8 @@ +(1)安装JDK + +安装JDK,如果没有java-1.8.0-openjdk-devel就没有javac命令 + +```bash +yum install java-1.8.0-openjdk java-1.8.0-openjdk-devel +``` + diff --git a/doc/生产环境/docker/docker centos 安装.md b/doc/生产环境/docker/docker centos 安装.md new file mode 100644 index 0000000..fbf4212 --- /dev/null +++ b/doc/生产环境/docker/docker centos 安装.md @@ -0,0 +1,90 @@ +## 安装 Docker +从 2017 年 3 月开始 docker 在原来的基础上分为两个分支版本: Docker CE 和 Docker EE。 + +Docker CE 即社区免费版,Docker EE 即企业版,强调安全,但需付费使用。 + +本文介绍 Docker CE 的安装使用。 + +移除旧的版本: + +``` +$ sudo yum remove docker \ + docker-client \ + docker-client-latest \ + docker-common \ + docker-latest \ + docker-latest-logrotate \ + docker-logrotate \ + docker-selinux \ + docker-engine-selinux \ + docker-engine +``` + +安装一些必要的系统工具: + +``` +sudo yum install -y yum-utils device-mapper-persistent-data lvm2 +``` + +添加软件源信息: + +``` +sudo yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo +``` + +更新 yum 缓存: + +``` +sudo yum makecache fast +``` + +安装 Docker-ce: + +``` +sudo yum -y install docker-ce +``` + +查看已安装docker版本 +``` +docker version +``` + +启动 Docker 后台服务 + +``` +sudo systemctl start docker +``` + +开机启动 + +``` +systemctl enable docker +``` + + +## 镜像加速 +鉴于国内网络问题,后续拉取 Docker 镜像十分缓慢,我们可以需要配置加速器来解决,推荐使用的是docker官方推荐的中国镜像地址:https://registry.docker-cn.com + +新版的 Docker 使用 `/etc/docker/daemon.json`(Linux,没有请新建)。 + +请在该配置文件中加入(没有该文件的话,请先建一个): +```javascript +{ + "registry-mirrors": ["https://registry.docker-cn.com"] +} +``` + +重启docker + +``` +sudo systemctl daemon-reload +sudo systemctl restart docker +``` + +### 检查加速器是否生效 + +配置加速器之后,如果拉取镜像仍然十分缓慢,请手动检查加速器配置是否生效,在命令行执行 `docker info`,如果从结果中看到了如下内容,说明配置成功。 +``` +Registry Mirrors: + https://registry.docker-cn.com/ +``` diff --git a/doc/生产环境/docker/docker 容器的基本操作.md b/doc/生产环境/docker/docker 容器的基本操作.md new file mode 100644 index 0000000..46b15de --- /dev/null +++ b/doc/生产环境/docker/docker 容器的基本操作.md @@ -0,0 +1,41 @@ +## Docker 获取镜像 + +之前提到过,[Docker Hub](https://hub.docker.com/) 上有大量的高质量的镜像可以用,这里我们就说一下怎么获取这些镜像。 + +从 Docker 镜像仓库获取镜像的命令是 `docker pull`。其命令格式为: +``` +# docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签] +docker pull [OPTIONS] NAME[:TAG|@DIGEST] + +``` + +具体的选项可以通过 docker pull --help 命令看到,这里我们说一下镜像名称的格式。 + +- Docker 镜像仓库地址:地址的格式一般是 <域名/IP>[:端口号]。默认地址是 Docker Hub。 +- 仓库名:如之前所说,这里的仓库名是两段式名称,即 <用户名>/<软件名>。对于 Docker Hub,如果不给出用户名,则默认为 library,也就是官方镜像。 +比如: +``` +# 向docker拉取,最小化的jre 1.8的运行环境(anapsix/alpine-java 项目名称name,8_server-jre_unlimited为标签tag) +docker pull anapsix/alpine-java:8_server-jre_unlimited +``` + + +从下载过程中可以看到我们之前提及的分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件。下载过程中给出了每一层的 ID 的前 12 位。并且下载结束后,给出该镜像完整的 sha256 的摘要,以确保下载一致性。 + +### 查看已下载镜像列表 +`docker images` 或 `docker image ls` + +``` +[root@localhost ~]# docker images +REPOSITORY TAG IMAGE ID CREATED SIZE +anapsix/alpine-java 8_server-jre_unlimited 49d744fbb526 5 months ago 126MB +``` + +### 删除镜像 +`docker image rm IMAGE_ID|NAME [IMAGE_ID|NAME...]` 或 `docker rmi IMAGE_ID|NAME [IMAGE_ID|NAME...]` + +### 清空虚悬镜像 + +docker在构建了一个新的镜像(名字和tag都一样的)之后,旧的那个镜像就会变成一个虚悬镜像(旧的镜像就没有名字了),此时旧的镜像就没啥用了,可以一件清空 + +`docker image prune` diff --git a/doc/生产环境/docker/docker 镜像的基本操作.md b/doc/生产环境/docker/docker 镜像的基本操作.md new file mode 100644 index 0000000..46b15de --- /dev/null +++ b/doc/生产环境/docker/docker 镜像的基本操作.md @@ -0,0 +1,41 @@ +## Docker 获取镜像 + +之前提到过,[Docker Hub](https://hub.docker.com/) 上有大量的高质量的镜像可以用,这里我们就说一下怎么获取这些镜像。 + +从 Docker 镜像仓库获取镜像的命令是 `docker pull`。其命令格式为: +``` +# docker pull [选项] [Docker Registry 地址[:端口号]/]仓库名[:标签] +docker pull [OPTIONS] NAME[:TAG|@DIGEST] + +``` + +具体的选项可以通过 docker pull --help 命令看到,这里我们说一下镜像名称的格式。 + +- Docker 镜像仓库地址:地址的格式一般是 <域名/IP>[:端口号]。默认地址是 Docker Hub。 +- 仓库名:如之前所说,这里的仓库名是两段式名称,即 <用户名>/<软件名>。对于 Docker Hub,如果不给出用户名,则默认为 library,也就是官方镜像。 +比如: +``` +# 向docker拉取,最小化的jre 1.8的运行环境(anapsix/alpine-java 项目名称name,8_server-jre_unlimited为标签tag) +docker pull anapsix/alpine-java:8_server-jre_unlimited +``` + + +从下载过程中可以看到我们之前提及的分层存储的概念,镜像是由多层存储所构成。下载也是一层层的去下载,并非单一文件。下载过程中给出了每一层的 ID 的前 12 位。并且下载结束后,给出该镜像完整的 sha256 的摘要,以确保下载一致性。 + +### 查看已下载镜像列表 +`docker images` 或 `docker image ls` + +``` +[root@localhost ~]# docker images +REPOSITORY TAG IMAGE ID CREATED SIZE +anapsix/alpine-java 8_server-jre_unlimited 49d744fbb526 5 months ago 126MB +``` + +### 删除镜像 +`docker image rm IMAGE_ID|NAME [IMAGE_ID|NAME...]` 或 `docker rmi IMAGE_ID|NAME [IMAGE_ID|NAME...]` + +### 清空虚悬镜像 + +docker在构建了一个新的镜像(名字和tag都一样的)之后,旧的那个镜像就会变成一个虚悬镜像(旧的镜像就没有名字了),此时旧的镜像就没啥用了,可以一件清空 + +`docker image prune` diff --git a/doc/生产环境/docker/使用docker部署商城.md b/doc/生产环境/docker/使用docker部署商城.md new file mode 100644 index 0000000..917b437 --- /dev/null +++ b/doc/生产环境/docker/使用docker部署商城.md @@ -0,0 +1,15 @@ +> 在阅读本章节前,我们回认为您已经会安装并且使用docker,如果您不会安装使用docker的话,请阅读相关章节 + + + +**如果无法理解我们所编写的 `Dockerfile`强烈的不推荐使用docker进行生产环境部署!!!** + +0. 将整个项目上传到centos中,进入到项目根目录 +1. 安装 `docker` (参考《docker centos 安装》) +2. 安装`docker-compose`(参考《Docker Compose 安装与卸载》) +3. 安装`open-jdk1.8`(参考《centos jdk安装》) +4. 安装`maven`(参考《通过yum安装maven》) +5. 使用 `mvn clean package -DskipTests` 命令进行打包 +6. 使用 `docker-compose up` 启动项目 +7. 使用nginx将请求指向特定的端口。 + diff --git a/doc/生产环境/docker/通过yum安装maven.md b/doc/生产环境/docker/通过yum安装maven.md new file mode 100644 index 0000000..067cfc2 --- /dev/null +++ b/doc/生产环境/docker/通过yum安装maven.md @@ -0,0 +1,14 @@ +安装maven的前提是安装jdk,参考《linux jdk安装》 + +```bash +// 使用配置工具配置第三方epel源仓库 +yum-config-manager --add-repo http://repos.fedorapeople.org/repos/dchen/apache-maven/epel-apache-maven.repo +yum-config-manager --enable epel-apache-maven +// 安装maven +yum install -y apache-maven +// 验证maven,验证是否为Oracle字样,默认有版本输出 +mvn -version +//最后确认下yum源地址有没被误改 +yum repolist +``` + diff --git a/doc/生产环境/安装mysql.md b/doc/生产环境/安装mysql.md new file mode 100644 index 0000000..abf59fd --- /dev/null +++ b/doc/生产环境/安装mysql.md @@ -0,0 +1,104 @@ +本文为大家介绍了*CentOS* 7 64位 安装 *MySQL5.7* 的详细步骤 + +## 1、配置YUM源 + +在[MySQL]官网中下载YUM源rpm安装包:http://dev.mysql.com/downloads/repo/yum/ + +\# 下载mysql源安装包 + +``` +shell> wget http://dev.mysql.com/get/mysql57-community-release-el7-8.noarch.rpm +``` + +#安装mysql源 + +```shell +shell> yum localinstall mysql57-community-release-el7-8.noarch.rpm +``` + +检查mysql源是否安装成功 + +```shell +shell> yum repolist enabled | grep "mysql.*-community.*" +``` + + + +## 2、安装MySQL + +```shell +shell> yum install mysql-community-server +``` + + + +## 3、配置默认编码为utf8 并且设置不区分大小写 + +修改/etc/my.cnf配置文件,在[mysqld]下添加编码配置,如下所示: + +```mysql +[mysqld] + +character_set_server=utf8 + +init_connect='SET NAMES utf8' + +lower_case_table_names=1 +``` + + + +## 4、启动MySQL服务 + +```shell +shell> systemctl start mysqld +``` + + + +## 5、开机启动 + +```shell +shell> systemctl enable mysqld +shell> systemctl daemon-reload +``` + + + +## 6、修改root默认密码 + +mysql安装完成之后,在/var/log/mysqld.log文件中给root生成了一个默认密码。通过下面的方式找到root默认密码,然后登录mysql进行修改: + +```shell +shell> grep 'temporary password' /var/log/mysqld.log +``` + +查看到密码后用root登录修改密码 + +```shell +shell> mysql -uroot -p +``` + +```mysql +mysql> ALTER USER 'root'@'localhost' IDENTIFIED BY 'MyNewPass4!'; +``` + +或者 + +```mysql +mysql> set password for 'root'@'localhost'=password('MyNewPass4!'); +``` + +**注意**:mysql5.7默认安装了密码安全检查插件(validate_password),默认密码检查策略要求密码必须包含:大小写字母、数字和特殊符号,并且长度不能少于8位。否则会提示ERROR 1819 (HY000): Your password does not satisfy the current policy requirements错误 + + + +## 7、创建数据库并添加远程登录用户 + +默认只允许root帐户在本地登录,如果要在其它机器上连接mysql,必须修改root允许远程连接,或者添加一个允许远程连接的帐户,为了安全起见,我添加一个新的帐户: + +```mysql +mysql> create database yamidb CHARACTER SET utf8 COLLATE utf8_general_ci; +mysql> GRANT ALL PRIVILEGES ON yamidb.* TO 'yami'@'%' IDENTIFIED BY 'Yami@2019'; +``` + diff --git a/doc/生产环境/安装redis.md b/doc/生产环境/安装redis.md new file mode 100644 index 0000000..9dd1df2 --- /dev/null +++ b/doc/生产环境/安装redis.md @@ -0,0 +1,62 @@ +## 安装redis + +``` +#安装tcl redis需要 +wget http://downloads.sourceforge.net/tcl/tcl8.6.8-src.tar.gz +tar xzvf tcl8.6.8-src.tar.gz -C /usr/local/ +cd /usr/local/tcl8.6.8/unix/ +./configure +make && make install + +#安装redis +wget http://download.redis.io/releases/redis-4.0.11.tar.gz +tar xzvf redis-4.0.11.tar.gz -C /usr/local/ +cd /usr/local/redis-4.0.11/ +make && make test && make install +``` + +## redis的生产环境启动方案 + +要把redis作为一个系统的daemon进程去运行的,每次系统启动,redis进程一起启动 + +1. wget下载redis解压出来的文件夹里面有个utils,utils目录下有个redis_init_script脚本 +2. 将redis_init_script脚本拷贝到linux的/etc/init.d目录中,将redis_init_script重命名为redis_6379,6379是我们希望这个redis实例监听的端口号 +3. 修改redis_6379脚本的第6行的REDISPORT,设置为相同的端口号(默认就是6379) +4. 创建两个目录:/etc/redis(存放redis的配置文件),/var/redis/6379(存放redis的持久化文件) +5. 修改redis配置文件(默认在根目录下,redis.conf),拷贝到/etc/redis目录中,修改名称为6379.conf +6. 修改redis.conf中的部分配置为生产环境 + +``` +daemonize yes 让redis以daemon进程运行 +pidfile /var/run/redis_6379.pid 设置redis的pid文件位置 +port 6379 设置redis的监听端口号 +dir /var/redis/6379 设置持久化文件的存储位置 +``` + +1. 启动redis,执行cd /etc/init.d, chmod 777 redis_6379,./redis_6379 start +2. 确认redis进程是否启动,ps -ef | grep redis +3. 让redis跟随系统启动自动启动 + +在redis_6379脚本中,最上面,加入两行注释 + +``` +# chkconfig: 2345 90 10 + +# description: Redis is a persistent key-value database +``` + +执行 + +``` +chkconfig redis_6379 on +``` + +## redis cli的使用 + +redis-cli SHUTDOWN,连接本机的6379端口停止redis进程 + +redis-cli -h 127.0.0.1 -p 6379 SHUTDOWN,制定要连接的ip和端口号 + +redis-cli PING,ping redis的端口,看是否正常 + +redis-cli,进入交互式命令行 diff --git a/doc/认证与授权/从授权开始看源码.md b/doc/认证与授权/从授权开始看源码.md new file mode 100644 index 0000000..82f3026 --- /dev/null +++ b/doc/认证与授权/从授权开始看源码.md @@ -0,0 +1,251 @@ +> 如果不理解oauth协议的推荐阅读 阮一峰的[理解OAuth 2.0](http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html) + +当然,我们也要简单介绍下oauth的运行流程: + +``` + +--------+ +---------------+ + | |--(A)- Authorization Request ->| Resource | + | | | Owner | + | |<-(B)-- Authorization Grant ---| | + | | +---------------+ + | | + | | +---------------+ + | |--(C)-- Authorization Grant -->| Authorization | + | Client | | Server | + | |<-(D)----- Access Token -------| | + | | +---------------+ + | | + | | +---------------+ + | |--(E)----- Access Token ------>| Resource | + | | | Server | + | |<-(F)--- Protected Resource ---| | + +--------+ +---------------+ +``` + +运行流程如下图,摘自RFC 6749。 + + + +- (A)用户打开客户端以后,客户端要求用户给予授权。 +- (B)用户同意给予客户端授权。 +- (C)客户端使用上一步获得的授权,向认证服务器申请令牌。 +- (D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。 +- (E)客户端使用令牌,向资源服务器申请获取资源。 +- (F)资源服务器确认令牌无误,同意向客户端开放资源。 + + + +我们是对内的系统,并不需要那么复杂的流程,所以我们看下oauth的授权模式当中的密码模式: + +``` + +----------+ + | Resource | + | Owner | + | | + +----------+ + v + | Resource Owner + (A) Password Credentials + | + v + +---------+ +---------------+ + | |>--(B)---- Resource Owner ------->| | + | | Password Credentials | Authorization | + | Client | | Server | + | |<--(C)---- Access Token ---------<| | + | | (w/ Optional Refresh Token) | | + +---------+ +---------------+ +``` + +这里的流程相对就比较简单了: + +(A)用户向客户端提供用户名和密码。 + +(B)客户端将用户名和密码发给认证服务器,向后者请求令牌。 + +(C)认证服务器确认无误后,向客户端提供访问令牌。 + + + +现在将简单的转换下思路: + +- `Resource Owner`:资源拥有者,拥有订单,购物车等数据的人,既用户 + +- `Client`:客户端,浏览器 + +- `Authorization Server`:认证服务器,也就是服务器咯。 + + + +在此A、B、C三个流程就变成了: + +(A)用户在浏览器输入用户名和密码。 + +(B)浏览器将用户名和密码发给服务器,向后者请求令牌(token)。 + +(C)服务器确认无误后,返回token给用户。 + + + +但是根据标准的流程,并没有验证码之类的容身之地。而`spring security oauth2` 给我们提供的只能是标准的流程,所以我们对代码进行一些适配,能够适应我们自己的需求。 + + + +## spring的部分源码 + +我们先来看下`spring security oauth2`的部分源码 + +首先我们直接进行授权的时候,调用的url大概为:`http://localhost:8080/oauth/token?username=user_1&password=123456&grant_type=password&scope=select&client_id=client_2&client_secret=123456`,那么授权肯定是与该链接相关联的。基于这个猜测,我们去寻找源码吧。 + + + +在`idea`中使用全局搜索,搜索 字符串`"/oauth/token"`(带着引号),发现了一个类,似乎与这个请求有关 `ClientCredentialsTokenEndpointFilter` + +```java +public class ClientCredentialsTokenEndpointFilter extends AbstractAuthenticationProcessingFilter { + + public ClientCredentialsTokenEndpointFilter() { + this("/oauth/token"); + } +} +``` + +``` +ClientCredentialsTokenEndpointFilter + ---> AbstractAuthenticationProcessingFilter + ---> GenericFilterBean + ---> Filter +``` + +发现,这个类是一个 `Filter` 也就是过滤器,通过这个过滤器,过滤请求,那么,我们去看看`doFilter`方法咯,`doFilter` 在 `ClientCredentialsTokenEndpointFilter` 的父类 `AbstractAuthenticationProcessingFilter` 上。 + +我们看看`AbstractAuthenticationProcessingFilter`: + +```java + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest request = (HttpServletRequest) req; + HttpServletResponse response = (HttpServletResponse) res; + // 如果不是认证的请求,直接下一个filter + // 这里是怎么判断是否是下一个请求呢? + // 答:看看url是不是上面ClientCredentialsTokenEndpointFilter 创建时传过来的url,也就是 /oauth/token + if (!requiresAuthentication(request, response)) { + chain.doFilter(request, response); + return; + } + + Authentication authResult; + try { + // 调用attemptAuthentication 方法,返回一个 Authentication 的实现类,也就是认证信息,这个实现类非常重要!!! + authResult = attemptAuthentication(request, response); + // 如果找不到,那就没了 + if (authResult == null) { + return; + } + } + // 调用成功的方法 + successfulAuthentication(request, response, chain, authResult); + } +``` + +这里最重要的方法`attemptAuthentication` 生成一个授权信息,能够返回,则证明登录已经成功了,所以真正的登录与这里有关。 + +我们回到`ClientCredentialsTokenEndpointFilter` 这个实现类里面看看`attemptAuthentication`方法吧 + +```java + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + + // ======精简没啥用的方法======== + + // 构造一个UsernamePasswordAuthenticationToken + UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(clientId, + clientSecret); + // 调用认证方法进行认证 + return this.getAuthenticationManager().authenticate(authRequest); + + } +``` + +我们通过添加断点可以发现 `this.getAuthenticationManager()` 是一个`ProviderManager` 对象,我们看下 + +`this.getAuthenticationManager().authenticate()` 里面的 `authenticate` + +```java +public class ProviderManager{ + public Authentication authenticate(Authentication authentication) + throws AuthenticationException { + + Authentication result = null; + + for (AuthenticationProvider provider : getProviders()) { + // 在一堆的provider中寻找到一个合适的授权提供者 + if (!provider.supports(toTest)) { + continue; + } + // 由授权提供者进行授权 + result = provider.authenticate(authentication); + } + if (result != null) { + return result; + } + } +} +``` + +一路追踪到这里,我们发现,实际上,是通过`provider.supports(toTest)` 寻找一个合适的授权提供者,使用`provider.authenticate(authentication)`就行授权,而`supports` 的依据是通过之前生成的token来判断是否支持: + +```java + public boolean supports(Class authentication) { + return (UsernamePasswordAuthenticationToken.class + .isAssignableFrom(authentication)); + } +``` + + + +我们整理下这几个流程 + +``` +ClientCredentialsTokenEndpointFilter.doFilter() + --> AbstractAuthenticationProcessingFilter.attemptAuthentication() + --> ProviderManager.authenticate() + --> AuthenticationProvider.supports() + --> AuthenticationProvider.authenticate() +``` + + + +我们可以看到这里主要就是干了几件事情 + +- 通过filter 确定登录要过滤的url +- 通过filter 确定生成的`AbstractAuthenticationToken` 比如 `UsernamePasswordAuthenticationToken` +- 通过生成的`AbstractAuthenticationToken` 确定`AuthenticationProvider` +- 通过`AuthenticationProvider` 最后调用 `authenticate()`方法最后进行授权 + + + +最后通过`RequestMapping` 返回 + +```java +@FrameworkEndpoint +public class TokenEndpoint extends AbstractEndpoint{ + @RequestMapping(value = "/oauth/token", method=RequestMethod.POST) + public ResponseEntity postAccessToken(Principal principal, @RequestParam + Map parameters) throws HttpRequestMethodNotSupportedException { + + + String clientId = getClientId(principal); + ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId); + + TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient); + + OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest); + + return getResponse(token); + } +} +``` + diff --git a/doc/认证与授权/自己写个授权的方法-开源版.md b/doc/认证与授权/自己写个授权的方法-开源版.md new file mode 100644 index 0000000..a8770ac --- /dev/null +++ b/doc/认证与授权/自己写个授权的方法-开源版.md @@ -0,0 +1,125 @@ +通过【从授权开始看源码】我们可以看到这里主要就是干了几件事情 + +- 通过filter 确定登录要过滤的url +- 通过filter 确定生成的`AbstractAuthenticationToken` 比如 `UsernamePasswordAuthenticationToken` +- 通过生成的`AbstractAuthenticationToken` 确定`AuthenticationProvider` +- 通过`AuthenticationProvider` 最后调用 `authenticate()`方法最后进行授权 + + + +根据上面我们对自己对代码进行了一些封装 + +我们先来看`LoginAuthenticationFilter` + +```java +public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + private AuthenticationTokenParser authenticationTokenParser; + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + AbstractAuthenticationToken authRequest = authenticationTokenParser.parse(requestBody); + + return this.getAuthenticationManager().authenticate(authRequest); + } + + + public void setAuthenticationTokenParser(AuthenticationTokenParser authenticationTokenParser) { + this.authenticationTokenParser = authenticationTokenParser; + } +} +``` + +这里的登录继承了`UsernamePasswordAuthenticationFilter` 里面写了 + +```java +public UsernamePasswordAuthenticationFilter() { + super(new AntPathRequestMatcher("/login", "POST")); +} +``` + +这就是为什么登录的接口是`/login`的原因 + + + +我们再来看看生成`AbstractAuthenticationToken `的方法 + +`AbstractAuthenticationToken authRequest = authenticationTokenParser.parse(requestBody);` + +这里决定了生成什么token,将会决定后面的`AuthenticationProvider` + + + +我们先来看`AdminAuthenticationProvider` + +``` + @Override + public boolean supports(Class authentication) { + return AdminAuthenticationToken.class.isAssignableFrom(authentication); + } +``` + +这里决定`AdminAuthenticationToken` 是通过`AdminAuthenticationProvider` 进行校验 + + + +再来看下完整的`AdminAuthenticationProvider` 你就知道验证码在哪里校验的了,是不是很简单 + +```java +public class AdminAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { + + private final YamiUserDetailsService yamiUserDetailsService; + + private final PasswordEncoder passwordEncoder; + + @Override + protected UserDetails retrieveUser(String username, Authentication authentication) throws BaseYamiAuth2Exception { + UserDetails user; + try { + user = yamiUserDetailsService.loadUserByUsername(username); + } catch (UsernameNotFoundExceptionBase var6) { + throw new UsernameNotFoundExceptionBase("账号或密码不正确"); + } + if (!user.isEnabled()) { + throw new UsernameNotFoundExceptionBase("账号已被锁定,请联系管理员"); + } + return user; + } + + @Override + protected void additionalAuthenticationChecks(UserDetails sysUser, Authentication authentication) throws BaseYamiAuth2Exception { + AdminAuthenticationToken adminAuthenticationToken = (AdminAuthenticationToken) authentication; + + String kaptchaKey = SecurityConstants.SPRING_SECURITY_RESTFUL_IMAGE_CODE + adminAuthenticationToken.getSessionUUID(); + + String kaptcha = RedisUtil.get(kaptchaKey); + + RedisUtil.del(kaptchaKey); + + if(StrUtil.isBlank(adminAuthenticationToken.getImageCode()) || !adminAuthenticationToken.getImageCode().equalsIgnoreCase(kaptcha)){ + throw new ImageCodeNotMatchExceptionBase("验证码有误"); + } + + + + String encodedPassword = sysUser.getPassword(); + String rawPassword = authentication.getCredentials().toString(); + + // 密码不正确 + if (!passwordEncoder.matches(rawPassword,encodedPassword)){ + throw new BadCredentialsExceptionBase("账号或密码不正确"); + } + } + + @Override + protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) { + AdminAuthenticationToken result = new AdminAuthenticationToken(user, authentication.getCredentials()); + result.setDetails(authentication.getDetails()); + return result; + } + +} + +``` +