谷粒商城-个人笔记(高级篇五)

目录

十一、支付

1、内网穿透

1)、简介

2)、使用场景

3)、内网穿透常用软件和安装

2、支付整合

1)、支付宝加密原理

2)、配置支付宝沙箱环境

3)、订单支付与同步通知

4)、订单列表页渲染完成

5)、异步通知内网穿透环境搭建

6)、收单

十二、秒杀服务

1、秒杀服务后台管理系统调整

2、搭建秒杀服务环境

3、定时任务

4、商品上架

1)、秒杀架构思路

2)、存储模型设计

3)、定时上架

4)、获取最近三天的秒杀信息

5)、在redis中保存秒杀场次信息

6)、在redis中保存秒杀商品信息

5、幂等性保证

6、获取当前的秒杀商品

1)、获取当前秒杀商品

2)首页获取并拼装数据

7、商品详情页获取当前商品的秒杀信息

8、秒杀系统设计

9、登录检查

10、秒杀

1)、秒杀接口

2)、创建订单

11、秒杀页面完成


 

十一、支付

1、内网穿透

谷粒商城-个人笔记(高级篇五)_第1张图片

1)、简介

内网穿透功能可以允许我们使用外网的网址来访问主机

正常的外网需要访问我们项目的流程是:

1、买服务器并且有公网固定ID

2、买域名映射到服务器的IP

3、域名需要进行备案和审核

2)、使用场景

1、开发测试(微信、支付宝)

2、智慧互联

3、远程控制

4、私有云

3)、内网穿透常用软件和安装

续断:https://www.zhexi.tech/

第一步:登录

第二步:安装客户端

谷粒商城-个人笔记(高级篇五)_第2张图片

第三步:安装(一定使用管理员身份安装,否则安装失败)

谷粒商城-个人笔记(高级篇五)_第3张图片

安装好之后,会网站会感应到我们的主机

谷粒商城-个人笔记(高级篇五)_第4张图片

第四步:新建隧道

谷粒商城-个人笔记(高级篇五)_第5张图片

隧道建立好,会给我们生成一个域名

2、支付整合

1)、支付宝加密原理

  • 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥
  • 在发送订单数据时,直接使用明文,但会使用商户私钥加一个对应的签名,支付宝端会使用商户公钥对签名进行验签,只有数据明文和签名对应的时候才能说明传输正确
  • 支付成功后,支付宝发送支付成功数据之外,还会使用支付宝私钥加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥延签,成功后才能确认

2)、配置支付宝沙箱环境

1、导入依赖

        
        
        
            com.alipay.sdk
            alipay-sdk-java
            4.9.28.ALL
        

抽取支付工具类并进行配置

成功调用该接口后,返回的数据就是支付页面的html,因此后续会使用@ResponseBody

添加“com.atguigu.gulimall.order.config.AlipayTemplate”类,代码如下:

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {

    //在支付宝创建的应用的id
    private   String app_id = "2016102600763190";

    // 商户私钥,您的PKCS8格式RSA2私钥
    private String merchant_private_key = "MjXN6Hnj8k2GAriRFt0BS9gjihbl9Rt38VMNbBi3Vt3Cy6TOwANLLJ/DfnYjRqwCG81fkyKlDqdsamdfCiTysCa0gQKBgQDYQ45LSRxAOTyM5NliBmtev0lbpDa7FqXL0UFgBel5VgA1Ysp0+6ex2n73NBHbaVPEXgNMnTdzU3WF9uHF4Gj0mfUzbVMbj/YkkHDOZHBggAjEHCB87IKowq/uAH/++Qes2GipHHCTJlG6yejdxhOsMZXdCRnidNx5yv9+2JI37QKBgQCw0xn7ZeRBIOXxW7xFJw1WecUV7yaL9OWqKRHat3lFtf1Qo/87cLl+KeObvQjjXuUe07UkrS05h6ijWyCFlBo2V7Cdb3qjq4atUwScKfTJONnrF+fwTX0L5QgyQeDX5a4yYp4pLmt6HKh34sI5S/RSWxDm7kpj+/MjCZgp6Xc51g==";

    // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    private String alipay_public_key = "MIIBIjA74UKxt2F8VMIRKrRAAAuIMuawIsl4Ye+G12LK8P1ZLYy7ZJpgZ+Wv5nOs3DdoEazgCERj/ON8lM1KBHZOAV+TkrIcyi7cD1gfv4a1usikrUqm8/qhFvoiUfyHJFv1ymT7C4BI6aHzQ2zcUlSQPGoPl4C11tgnSkm3DlH2JZKgaIMcCOnNH+qctjNh9yIV9zat2qUiXbxmrCTtxAmiI3I+eVsUNwvwIDAQAB";

    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    private  String notify_url="http://**.natappfree.cc/payed/notify";

    // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    private  String return_url="http://order.gulimall.com/memberOrder.html";

    // 签名方式
    private  String sign_type = "RSA2";

    // 字符编码格式
    private  String charset = "utf-8";

    // 支付宝网关; https://openapi.alipaydev.com/gateway.do
    private  String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";

    public  String pay(PayVo vo) throws AlipayApiException {

        //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
        //1、根据支付宝的配置生成一个支付客户端
        AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
                app_id, merchant_private_key, "json",
                charset, alipay_public_key, sign_type);

        //2、创建一个支付请求 //设置请求参数
        AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
        alipayRequest.setReturnUrl(return_url);
        alipayRequest.setNotifyUrl(notify_url);

        //商户订单号,商户网站订单系统中唯一订单号,必填
        String out_trade_no = vo.getOut_trade_no();
        //付款金额,必填
        String total_amount = vo.getTotal_amount();
        //订单名称,必填
        String subject = vo.getSubject();
        //商品描述,可空
        String body = vo.getBody();

        alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
                + "\"total_amount\":\""+ total_amount +"\","
                + "\"subject\":\""+ subject +"\","
                + "\"body\":\""+ body +"\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:"+result);

        return result;

    }

添加“com.atguigu.gulimall.order.vo.PayVo”类,代码如下:

@Data
public class PayVo {
    private String out_trade_no; // 商户订单号 必填
    private String subject; // 订单名称 必填
    private String total_amount;  // 付款金额 必填
    private String body; // 商品描述 可空
}

添加配置

支付宝相关的设置
alipay.app_id=自己的APPID

修改支付页的支付宝按钮

          
  • 支付宝
  • 3)、订单支付与同步通知

    添加“com.atguigu.gulimall.order.web.PayWebController”类,代码如下:

    @Controller
    public class PayWebController {
        @Autowired
        AlipayTemplate alipayTemplate;
    
        @Autowired
        OrderService orderService;
    
        @ResponseBody
        @GetMapping("/payOrder")
        public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
    
    //        PayVo payVo = new PayVo();
    //        payVo.setBody();//订单备注
    //        payVo.setOut_trade_no();//订单号
    //        payVo.setSubject();//订单主题
    //        payVo.setTotal_amount();//订单金额
            PayVo payVo = orderService.getOrderPay(orderSn);
            //返回的是一个页面。将此页面直接交给浏览器就行
            String pay = alipayTemplate.pay(payVo);
            System.out.println(pay);
            return "hello";
        }
    }
    

    修改“com.atguigu.gulimall.order.service.OrderService”类,代码如下:

        /**
         * 获取当前订单地支付信息
         * @param orderSn
         * @return
         */
        PayVo getOrderPay(String orderSn);

    修改“com.atguigu.gulimall.order.service.impl.OrderServiceImpl”类,代码如下:

        @Override
        public PayVo getOrderPay(String orderSn) {
            PayVo payVo = new PayVo();
            OrderEntity order = this.getOrderByOrderSn(orderSn);
            //支付金额设置为两位小数,否则会报错
            BigDecimal bigDecimal = order.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
            payVo.setTotal_amount(bigDecimal.toString());
            payVo.setOut_trade_no(order.getOrderSn());
            List order_sn = orderItemService.list(new QueryWrapper().eq("order_sn", orderSn));
            OrderItemEntity entity = order_sn.get(0);
            //订单名称
            payVo.setSubject(entity.getSkuName());
            //商品描述
            payVo.setBody(entity.getSkuAttrsVals());
            return payVo;
        }

    http://order.gulimall.com/payOrder?orderSn=202012051517520571335121191551672321

    运行结果

    支付宝的响应:

    我们可以看出返回的结果是html 。所以我们直接修改这个接口,让他返回是html页面

    @ResponseBody
        @GetMapping(value = "payOrder", produces = "text/html")
        public String payOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
    
    //        PayVo payVo = new PayVo();
    //        payVo.setBody();//订单备注
    //        payVo.setOut_trade_no();//订单号
    //        payVo.setSubject();//订单主题
    //        payVo.setTotal_amount();//订单金额
            PayVo payVo = orderService.getOrderPay(orderSn);
            //返回的是一个页面。将此页面直接交给浏览器就行
            String pay = alipayTemplate.pay(payVo);
            System.out.println(pay);
            return pay;
        }

     谷粒商城-个人笔记(高级篇五)_第6张图片

    • 1、将支付页让浏览器显示
    • 2、支付成功以后,我们要跳到用户的订单列表页

    修改“com.atguigu.gulimall.order.config.AlipayTemplate”类,代码如下:

        // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
        //同步通知,支付成功,一般跳转到成功页
        private  String return_url = "http://member.gulimall.com/memberOrder";

     gulimall-member

    添加thymeleaf模板引擎

            
            
                org.springframework.boot
                spring-boot-starter-thymeleaf
            

    添加订单页的html(orderList.html)

    往虚拟机的添加订单页的静态资源(在/mydata/nginx/html/static/目录下,创建member文件夹)

    谷粒商城-个人笔记(高级篇五)_第7张图片

    修改静态资源访问路径

    做登录拦截添加SpringSession依赖 

            
            
                org.springframework.session
                spring-session-data-redis
            
    
            
            
                org.springframework.boot
                spring-boot-starter-data-redis
                
                    
                        io.lettuce
                        lettuce-core
                    
                
            
            
                redis.clients
                jedis
            
    

     添加配置

    spring.session.store-type=redis
    
    spring.redis.host=172.20.10.9

    主启动类添加SpringSession自动开启

    谷粒商城-个人笔记(高级篇五)_第8张图片

    添加“com.atguigu.gulimall.member.config.GulimallSessionConfig”类,代码如下:

    @Configuration
    public class GulimallSessionConfig {
    
        @Bean
        public CookieSerializer cookieSerializer(){
            DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
            cookieSerializer.setDomainName("gulimall.com");
            cookieSerializer.setCookieName("GULISESSION");
            return cookieSerializer;
        }
    
        @Bean
        public RedisSerializer springSessionDefaultRedisSerializer(){
            return new GenericJackson2JsonRedisSerializer();
        }
    }
    
    

    添加登录拦截器“com.atguigu.gulimall.member.interceptor.LoginUserInterceptor”

    @Component
    public class LoginUserInterceptor implements HandlerInterceptor {
    
        public static ThreadLocal loginUser = new ThreadLocal<>();
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String requestURI = request.getRequestURI();
            AntPathMatcher matcher = new AntPathMatcher();
            boolean status = matcher.match("/member/**", requestURI);
            if (status){
                return true;
            }
            
    
            MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
            if (attribute != null){
                loginUser.set(attribute);
                return true;
            }else {
                //没登录就去登录
                request.getSession().setAttribute("msg","请先进行登录");
                response.sendRedirect("http://auth.gulimall.com/login.html");
                return false;
            }
    
        }
    }
    

    把登录拦截器配置到spring里

    添加“com.atguigu.gulimall.member.config.MemberWebConfig”类,代码如下:

    @Configuration
    public class MemberWebConfig implements WebMvcConfigurer {
    
        @Autowired
        LoginUserInterceptor loginUserInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");
        }
    }

    在gulimall-gateway配置路由:

            - id: gulimall_member_route
              uri: lb://gulimall-member
              predicates:
                - Host=member.gulimall.com

    添加域名(C:\Windows\System32\drivers\etc\hosts)

    谷粒商城-个人笔记(高级篇五)_第9张图片

    修改首页我的订单地访问路径gulimall-product (index.html)

              
  • 我的订单
  • 找到沙箱环境里面有沙箱账号

    谷粒商城-个人笔记(高级篇五)_第10张图片

    谷粒商城-个人笔记(高级篇五)_第11张图片

    谷粒商城-个人笔记(高级篇五)_第12张图片

    谷粒商城-个人笔记(高级篇五)_第13张图片

    谷粒商城-个人笔记(高级篇五)_第14张图片

    谷粒商城-个人笔记(高级篇五)_第15张图片

    4)、订单列表页渲染完成

    修改“com.atguigu.gulimall.member.web.MemberWebController”类,代码如下“:

    @Controller
    public class MemberWebController {
        @Autowired
        OrderFeignService orderFeignService;
    
        @GetMapping("/memberOrder.html")
        public String memberOrderPage(@RequestParam(value = "pageNum",defaultValue = "1") Integer pageNum, Model model){
            //查出当前登录用户的所有订单列表数据
            Map page = new HashMap<>();
            page.put("page",pageNum.toString());
            //分页查询当前用户的所有订单及对应订单项
            R r = orderFeignService.listWithItem(page);
            model.addAttribute("orders",r);
            return "orderList";
        }
    }

    添加“com.atguigu.gulimall.member.feign.OrderFeignService”类,代码如下:

    @FeignClient("gulimall-order")
    public interface OrderFeignService {
    
        @PostMapping("/order/order/listWithItem")
        public R listWithItem(@RequestBody Map params);
    }

    因为订单服务做了用户登录的拦截,所以远程调用订单服务需要用户信息,我们给它共享cookies

    添加“com.atguigu.gulimall.member.config.GuliFeignConfig”类,代码如下:

    @Configuration
    public class GuliFeignConfig {
    
        @Bean("requestInterceptor")
        public RequestInterceptor requestInterceptor(){
            return new RequestInterceptor(){
                @Override
                public void apply(RequestTemplate requestTemplate) {
                    System.out.println("RequestInterceptor线程..."+Thread.currentThread().getId());
                    //1、RequestContextHolder拿到刚进来的请求
                    ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                    if (attributes != null){
                        HttpServletRequest request = attributes.getRequest();//老请求
                        if (request != null){
                            //同步请求头数据。Cookie
                            String cookie = request.getHeader("Cookie");
                            //给新请求同步了老请求的cookie
                            requestTemplate.header("Cookie",cookie);
                            System.out.println("feign远程之前先执行RequestInterceptor.apply()");
                        }
                    }
                }
            };
        }
    }
    

    远程服务:gulimall-order

    修改“com.atguigu.gulimall.order.controller.OrderController”类,代码如下:

        /**
         * 分页查询当前登录用户的所有订单
         * @param params
         * @return
         */
        @PostMapping("/listWithItem")
        public R listWithItem(@RequestBody Map params){
            PageUtils page = orderService.queryPageWithItem(params);
    
            return R.ok().put("page", page);
        }

    修改“com.atguigu.gulimall.order.service.OrderService”类,代码如下:

     PageUtils queryPageWithItem(Map params);

    修改“com.atguigu.gulimall.order.service.impl.OrderServiceImpl”类,代码如下:

        @Override
        public PageUtils queryPageWithItem(Map params) {
            MemberResponseVO memberResponseVO = LoginUserInterceptor.loginUser.get();
            IPage page = this.page(
                    new Query().getPage(params),
                    new QueryWrapper().eq("member_id",memberResponseVO.getId()).orderByDesc("id")
            );
            List order_sn = page.getRecords().stream().map(order -> {
                List entities = orderItemService.list(new QueryWrapper().eq("order_sn", order.getOrderSn()));
                order.setItemEntities(entities);
                return order;
            }).collect(Collectors.toList());
            page.setRecords(order_sn);
            return new PageUtils(page);
        }

    修改OrderEntity

    修改orderList.html

            
    2017-12-09 20:50:10 订单号:[[${order.orderSn}]] 70207298274 谷粒商城

    [[${item.skuName}]]

    找搭配
    x[[${item.skuQuantity}]]
    [[${order.receiverName}]]

    总额 ¥[[${order.payAmount}]]


    在线支付

    • 待付款
    • 已付款
    • 已发货
    • 已完成
    • 已取消
    • 售后中
    • 售后完成
    • 跟踪
      普通快递 运单号:390085324974
      • [北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您 的快件已签收,感谢您使用韵达快递)签收
      • [北京市] 在北京昌平区南口公司进行签收扫描,快件已被拍照(您 的快件已签收,感谢您使用韵达快递)签收
      • [北京昌平区南口公司] 在北京昌平区南口公司进行派件扫描
      • [北京市] 在北京昌平区南口公司进行派件扫描;派送业务员:业务员;联系电话:17319268636
    • 订单详情

    取消订单

    催单

    谷粒商城-个人笔记(高级篇五)_第16张图片

    5)、异步通知内网穿透环境搭建

    • 订单支付成功后支付宝会回调商户接口,这个时候需要修改订单状态
    • 由于同步跳转可能由于网络问题失败,所以使用异步通知
    • 支付宝使用的是最大努力通知方案,保障数据一致性,隔一段时间会通知商户支付成功,直到返回success

    1)内网穿透设置异步通知地址

    • 将外网映射到本地的order.gulimall.com:80

    • 由于回调的请求头不是order.gulimall.com,因此nginx转发到网关后找不到对应的服务,所以需要对nginx进行设置

      /payed/notify异步通知转发至订单服务

    设置异步通知的地址

    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    private  String notify_url = "http://8xlc1ea491.*****.tech/payed/notify";

    谷粒商城-个人笔记(高级篇五)_第17张图片

    修改内网穿透

    nginx配置访问/payed/notify异步通知转发至订单服务

    谷粒商城-个人笔记(高级篇五)_第18张图片

    谷粒商城-个人笔记(高级篇五)_第19张图片

    配置好之后,重启nginx

    http://8xlc1ea491.52http.tech/payed/notify?name=hello访问还是404,查看日志

    谷粒商城-个人笔记(高级篇五)_第20张图片

    上面日志显示默认以本地的方式访问所以直接访问静态资源/static/..,我们访问这个域名下的/payed路径,我们要添加这个域名,并把host改成order.gulimall.com服务。不然默认以本地的方式访问

    谷粒商城-个人笔记(高级篇五)_第21张图片

    再次重启niginx

    修改登录拦截器给他放行

    @Component
    public class LoginUserInterceptor implements HandlerInterceptor {
    
        public static ThreadLocal loginUser = new ThreadLocal<>();
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            String requestURI = request.getRequestURI();
            AntPathMatcher matcher = new AntPathMatcher();
            boolean status = matcher.match("/order/order/status/**", requestURI);
            boolean payed = matcher.match("/payed/**", requestURI);
            if (status || payed)
                return true;
    
    
            MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER);
            if (attribute != null){
                loginUser.set(attribute);
                return true;
            }else {
                //没登录就去登录
                request.getSession().setAttribute("msg","请先进行登录");
                response.sendRedirect("http://auth.gulimall.com/login.html");
                return false;
            }
        }
    }
    

    谷粒商城-个人笔记(高级篇五)_第22张图片

    2)验证签名

    修改“com.atguigu.gulimall.order.listener.OrderPayedListener”类,代码如下:

    @RestController
    public class OrderPayedListener {
    
        @Autowired
        OrderService orderService;
    
        @Autowired
        AlipayTemplate alipayTemplate;
        /**
         * 支付宝成功异步通知
         * @param request
         * @return
         */
        @PostMapping("/payed/notify")
        public String handleAlipayed(PayAsyncVo vo, HttpServletRequest request) throws AlipayApiException {
            System.out.println("收到支付宝异步通知******************");
            // 只要收到支付宝的异步通知,返回 success 支付宝便不再通知
            // 获取支付宝POST过来反馈信息
            //TODO 需要验签
            Map params = new HashMap<>();
            Map requestParams = request.getParameterMap();
            for (String name : requestParams.keySet()) {
                String[] values = requestParams.get(name);
                String valueStr = "";
                for (int i = 0; i < values.length; i++) {
                    valueStr = (i == values.length - 1) ? valueStr + values[i]
                            : valueStr + values[i] + ",";
                }
                //乱码解决,这段代码在出现乱码时使用
                // valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
                params.put(name, valueStr);
            }
    
            boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
                    alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名
    
            if (signVerified){
                System.out.println("支付宝异步通知验签成功");
                //修改订单状态
                orderService.handlePayResult(vo);
                return "success";
            }else {
                System.out.println("支付宝异步通知验签失败");
                return "error";
            }
        }
    
    }
    

    添加“com.atguigu.gulimall.order.vo.PayAsyncVo”类,代码如下:

    @ToString
    @Data
    public class PayAsyncVo {
    
        private String gmt_create;
        private String charset;
        private String gmt_payment;
        private Date notify_time;
        private String subject;
        private String sign;
        private String buyer_id;//支付者的id
        private String body;//订单的信息
        private String invoice_amount;//支付金额
        private String version;
        private String notify_id;//通知id
        private String fund_bill_list;
        private String notify_type;//通知类型; trade_status_sync
        private String out_trade_no;//订单号
        private String total_amount;//支付的总额
        private String trade_status;//交易状态  TRADE_SUCCESS
        private String trade_no;//流水号
        private String auth_app_id;//
        private String receipt_amount;//商家收到的款
        private String point_amount;//
        private String app_id;//应用id
        private String buyer_pay_amount;//最终支付的金额
        private String sign_type;//签名类型
        private String seller_id;//商家的id
    
    }
    

    修改“com.atguigu.gulimall.order.service.OrderService”类,代码如下:

     String handlePayResult(PayAsyncVo vo);

    修改“com.atguigu.gulimall.order.service.impl.OrderServiceImpl”类,代码如下:

        /**
         * 处理支付成功返回结果
         * @param vo
         * @return
         */
        @Override
        public String handlePayResult(PayAsyncVo vo) {
            //1、保存交易流水
            PaymentInfoEntity infoEntity = new PaymentInfoEntity();
            infoEntity.setAlipayTradeNo(vo.getTrade_no());
            infoEntity.setAlipayTradeNo(vo.getOut_trade_no());
            infoEntity.setPaymentStatus(vo.getTrade_status());
            infoEntity.setCallbackTime(vo.getNotify_time());
            paymentInfoService.save(infoEntity);
    
            //2、修改订单状态信息
            if (vo.getTrade_status().equals("TRADE_SUCCESS") || vo.getTrade_status().equals("TRADE_FINISHED")){
                //支付成功状态
                String outTradeNo = vo.getOut_trade_no();
                this.baseMapper.updateOrderStatus(outTradeNo,OrderStatusEnum.PAYED.getCode());
            }
            return "success";
        }
    

    修改“com.atguigu.gulimall.order.dao.OrderDao”类,代码如下:

    @Mapper
    public interface OrderDao extends BaseMapper {
    
        void updateOrderStatus(@Param("outTradeNo") String outTradeNo, @Param("code") Integer code);
    
    }

    OrderDao.xml

        
            update oms_order set status = #{code} where order_sn = #{outTradeNo}
        
    #springMVC的日期格式化
    spring.mvc.date-format=yyyy-MM-dd HH:mm:ss

    6)、收单

    谷粒商城-个人笔记(高级篇五)_第23张图片

    添加超时时间

    谷粒商城-个人笔记(高级篇五)_第24张图片

    谷粒商城-个人笔记(高级篇五)_第25张图片

    十二、秒杀服务

    1、秒杀服务后台管理系统调整

    配置网关

        - id: coupon_route
              uri: lb://gulimall-coupon
              predicates:
                - Path=/api/coupon/**
              filters:
                - RewritePath=/api/(?.*),/$\{segment}

    谷粒商城-个人笔记(高级篇五)_第26张图片

    修改“com.atguigu.gulimall.coupon.service.impl.SeckillSkuRelationServiceImpl”代码如下:

    @Service("seckillSkuRelationService")
    public class SeckillSkuRelationServiceImpl extends ServiceImpl implements SeckillSkuRelationService {
    
        @Override
        public PageUtils queryPage(Map params) {
            QueryWrapper queryWrapper = new QueryWrapper();
            //场次id不是null
            String promotionSessionId = (String) params.get("promotionSessionId");
            if (!StringUtils.isEmpty(promotionSessionId)){
                queryWrapper.eq("promotion_session_id",promotionSessionId);
            }
            IPage page = this.page(
                    new Query().getPage(params),
                    queryWrapper
            );
    
            return new PageUtils(page);
        }
    
    

    2、搭建秒杀服务环境

    谷粒商城-个人笔记(高级篇五)_第27张图片

    搭建秒杀服务环境

    1)、导入pom.xml依赖

    
    
        4.0.0
        
            org.springframework.boot
            spring-boot-starter-parent
            2.3.4.RELEASE
             
        
        com.atguigu.gulimall
        gulimall-seckill
        0.0.1-SNAPSHOT
        gulimall-seckill
        秒杀
    
        
            1.8
            Hoxton.SR8
        
    
        
            
            
                org.redisson
                redisson
                3.13.4
            
    
            
                com.auguigu.gulimall
                gulimall-commom
                0.0.1-SNAPSHOT
                
                    
                        com.alibaba.cloud
                        spring-cloud-starter-alibaba-seata
                    
                
            
            
                org.springframework.boot
                spring-boot-starter-data-redis
            
            
                org.springframework.boot
                spring-boot-starter-web
            
            
                org.springframework.cloud
                spring-cloud-starter-openfeign
            
    
            
                org.springframework.boot
                spring-boot-devtools
                runtime
                true
            
            
                org.projectlombok
                lombok
                true
            
            
                org.springframework.boot
                spring-boot-starter-test
                test
            
        
    
        
            
                
                    org.springframework.cloud
                    spring-cloud-dependencies
                    ${spring-cloud.version}
                    pom
                    import
                
            
        
    
        
            
                
                    org.springframework.boot
                    spring-boot-maven-plugin
                
            
        
    
        
            
                spring-milestones
                Spring Milestones
                https://repo.spring.io/milestone
            
        
    
    
    

    2)、添加配置

    spring.application.name=gulimall-seckill
    server.port=25000
    spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
    spring.redis.host=172.20.10.9

    3)、主启动类添加注解

    @EnableFeignClients
    @EnableDiscoveryClient
    @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
    public class GulimallSeckillApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(GulimallSeckillApplication.class, args);
        }
    
    }

     

    3、定时任务

    谷粒商城-个人笔记(高级篇五)_第28张图片

    谷粒商城-个人笔记(高级篇五)_第29张图片

    谷粒商城-个人笔记(高级篇五)_第30张图片

    定时任务

    1、@EnableScheduling 开启定时任务

    2、@Scheduled 开启一个定时任务

    3、自动配置类 TaskSchedulingAutoConfiguration

    异步任务 

    1、@EnableAsync 开启异步任务功能

    2、 @Async 给希望异步执行的方法上标注

    3、自动配置类 TaskExecutionAutoConfiguration 属性绑定在TaskExecutionProperties

    @Slf4j
    @Component
    @EnableAsync
    @EnableScheduling
    public class HelloSchedule {
    
        /**
         * 1、Spring中6位组成,不允许7位d的年
         * 2、周的位置,1-7代表周一到周日
         * 3、定时任务不应该阻塞。默认是阻塞的
         *      1)、可以让业务运行以异步的方式,自己提交到线程池
         *      2)、支持定时任务线程池;设置TaskSchedulingProperties;
         *              spring.task.scheduling.pool.size=5
         *      3)、让定时任务异步执行
         *          异步任务
         *
         *      解决:使用异步任务来完成定时任务不阻塞的功能
         */
        @Async
        @Scheduled(cron = "*/5 * * * * ?")
        public void hello() throws InterruptedException {
            log.info("hello......");
            Thread.sleep(3000);
        }
    }
    

    配置定时任务参数

    spring.task.execution.pool.core-size=20
    spring.task.execution.pool.max-size=50

     

    4、商品上架

    1)、秒杀架构思路

    • 项目独立部署,独立秒杀模块gulimall-seckill
    • 使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
    • 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口
    • 库存预热,先从数据库中扣除一部分库存以redisson信号量的形式存储在redis中
    • 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单

    谷粒商城-个人笔记(高级篇五)_第31张图片

    2)、存储模型设计

    添加“com.atguigu.gulimall.seckill.to.SeckillSkuRedisTo”类,代码如下:

    import lombok.Data;
    import java.util.Date;
    import java.util.List;
    
    /**
     * @Description: SeckillSkuRedisTo
     * @Author: WangTianShun
     * @Date: 2020/12/13 10:27
     * @Version 1.0
     */
    @Data
    public class SeckillSessionWithSkus {
        private Long id;
        /**
         * 场次名称
         */
        private String name;
        /**
         * 每日开始时间
         */
        private Date startTime;
        /**
         * 每日结束时间
         */
        private Date endTime;
        /**
         * 启用状态
         */
        private Integer status;
        /**
         * 创建时间
         */
        private Date createTime;
    
        private List relationEntities;
    
    }
    

    添加“com.atguigu.gulimall.seckill.vo.SkuInfoVo”类,代码如下:

    package com.atguigu.gulimall.seckill.vo;
    
    import lombok.Data;
    
    import java.math.BigDecimal;
    
    /**
     * @Description: SkuInfoVo
     * @Author: WangTianShun
     * @Date: 2020/12/13 10:59
     * @Version 1.0
     */
    @Data
    public class SkuInfoVo {
    
        private Long skuId;
        /**
         * spuId
         */
        private Long spuId;
        /**
         * sku名称
         */
        private String skuName;
        /**
         * sku介绍描述
         */
        private String skuDesc;
        /**
         * 所属分类id
         */
        private Long catalogId;
        /**
         * 品牌id
         */
        private Long brandId;
        /**
         * 默认图片
         */
        private String skuDefaultImg;
        /**
         * 标题
         */
        private String skuTitle;
        /**
         * 副标题
         */
        private String skuSubtitle;
        /**
         * 价格
         */
        private BigDecimal price;
        /**
         * 销量
         */
        private Long saleCount;
    }
    

    添加“com.atguigu.gulimall.seckill.vo.SeckillSkuVo”类,代码如下:

    package com.atguigu.gulimall.seckill.vo;
    
    import lombok.Data;
    
    import java.math.BigDecimal;
    
    /**
     * @Description: SeckillSkuVo
     * @Author: WangTianShun
     * @Date: 2020/12/13 10:30
     * @Version 1.0
     */
    @Data
    public class SeckillSkuVo {
        private Long id;
        /**
         * 活动id
         */
        private Long promotionId;
        /**
         * 活动场次id
         */
        private Long promotionSessionId;
        /**
         * 商品id
         */
        private Long skuId;
        /**
         * 秒杀价格
         */
        private BigDecimal seckillPrice;
        /**
         * 秒杀总量
         */
        private Integer seckillCount;
        /**
         * 每人限购数量
         */
        private Integer seckillLimit;
        /**
         * 排序
         */
        private Integer seckillSort;
    
    }
    

    添加代码如下:

    package com.atguigu.gulimall.seckill.vo;
    
    
    import lombok.Data;
    import java.util.Date;
    import java.util.List;
    
    /**
     * @Description: SeckillSkuRedisTo
     * @Author: WangTianShun
     * @Date: 2020/12/13 10:27
     * @Version 1.0
     */
    @Data
    public class SeckillSessionWithSkus {
        private Long id;
        /**
         * 场次名称
         */
        private String name;
        /**
         * 每日开始时间
         */
        private Date startTime;
        /**
         * 每日结束时间
         */
        private Date endTime;
        /**
         * 启用状态
         */
        private Integer status;
        /**
         * 创建时间
         */
        private Date createTime;
    
        private List relationEntities;
    
    }
    

    3)、定时上架

    • 开启对定时任务的支持

    配置定时任务 添加“com.atguigu.gulimall.seckill.config.ScheduledConfig”类,代码如下

    @EnableAsync // 开启对异步的支持,防止定时任务之间相互阻塞
    @EnableScheduling // 开启对定时任务的支持
    @Configuration
    public class ScheduledConfig {
    }
    • 每天凌晨三点远程调用coupon服务上架最近三天的秒杀商品
    • 由于在分布式情况下该方法可能同时被调用多次,因此加入分布式锁,同时只有一个服务可以调用该方法

    添加“com.atguigu.gulimall.seckill.scheduled.SeckillSkuScheduled”类,代码如下

    @Slf4j
    @Service
    public class SeckillSkuScheduled {
    
        @Autowired
        SeckillService seckillService;
    
        //TODO 幂等性处理
        @Scheduled(cron = "0 0 3 * * ?")
        public void uploadSeckillSkuLatest3Days(){
            //重复上架无需处理
            log.info("上架秒杀的信息......");
            seckillService.uploadSeckillSkuLatest3Days();
        }
    }

    添加“com.atguigu.gulimall.seckill.service.SeckillService”类,代码如下

    public interface SeckillService {
        void uploadSeckillSkuLatest3Days();
    }

    添加“com.atguigu.gulimall.seckill.service.impl.SeckillServiceImpl”类,代码如下

    @Service
    public class SeckillServiceImpl implements SeckillService {
    
        @Autowired
        CouponFeignService couponFeignService;
    
        @Autowired
        StringRedisTemplate redisTemplate;
    
        @Autowired
        ProductFeignService productFeignService;
    
        @Autowired
        RedissonClient redissonClient;
    
        private final String SESSIONS_CACHE_PREFIX = "seckill:sessions:";
    
        private final String SKUKILL_CACHE_PREFIX = "seckill:skus:";
    
        private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";//+商品随机码
    
        @Override
        public void uploadSeckillSkuLatest3Days() {
            // 1、扫描最近三天需要参与秒杀的活动
            R session = couponFeignService.getLasts3DaySession();
            if (session.getCode() == 0){
                // 上架商品
                List data = session.getData(new TypeReference>() {
                });
                // 缓存到redis
    
                // 1、缓存活动信息
                saveSessionInfos(data);
    
                // 2、缓存获得关联商品信息
                saveSessionSkuInfos(data);
            }
        }
    
       
    }
    

    4)、获取最近三天的秒杀信息

    • 获取最近三天的秒杀场次信息,再通过秒杀场次id查询对应的商品信息

    添加“com.atguigu.gulimall.seckill.fegin.ProductFeignService”类,代码如下:

    @FeignClient("gulimall-coupon")
    public interface CouponFeignService {
    
        @GetMapping("/coupon/seckillsession/lasts3DaySession")
        R getLasts3DaySession();
    }
    

    添加“com.atguigu.gulimall.coupon.controller.SeckillSessionController”类,代码如下:

        @GetMapping("/lasts3DaySession")
        public R getLasts3DaySession(){
            List session = seckillSessionService.getLasts3DaySession();
            return R.ok().setData(session);
        }

    添加“com.atguigu.gulimall.coupon.service.SeckillSessionService”类,代码如下:

     List getLasts3DaySession();

    添加“com.atguigu.gulimall.coupon.service.impl.SeckillSessionServiceImpl”类,代码如下:

        @Override
        public List getLasts3DaySession() {
            //计算最近三天
            LocalDate now = LocalDate.now();
            LocalDate plus = now.plusDays(3);
            List list = this.list(new QueryWrapper().between("start_time", startTime(), endTime()));
            if (null != list && list.size() >0){
                List collect = list.stream().map(session -> {
                    Long id = session.getId();
                    List relationEntities = seckillSkuRelationService.list(new QueryWrapper().eq("promotion_session_id", id));
                    session.setRelationEntities(relationEntities);
                    return session;
                }).collect(Collectors.toList());
                return collect;
            }
    
            return null;
        }
    
        /**
         * 起始时间
         * @return
         */
        private String  startTime(){
            LocalDate now = LocalDate.now();
            LocalTime time = LocalTime.MIN;
            LocalDateTime start = LocalDateTime.of(now, time);
    
            String format = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            return format;
        }
    
        /**
         * 结束时间
         * @return
         */
        private String endTime(){
            LocalDate now = LocalDate.now();
            LocalDate localDate = now.plusDays(2);
            LocalTime time = LocalTime.MIN;
            LocalDateTime end = LocalDateTime.of(localDate, time);
            String format = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
            return format;
        }

    修改“com.atguigu.gulimall.coupon.service.impl.SeckillSkuRelationServiceImpl”类,代码如下:

    @Service("seckillSkuRelationService")
    public class SeckillSkuRelationServiceImpl extends ServiceImpl implements SeckillSkuRelationService {
    
        @Override
        public PageUtils queryPage(Map params) {
            QueryWrapper queryWrapper = new QueryWrapper();
            //场次id不是null
            String promotionSessionId = (String) params.get("promotionSessionId");
            if (!StringUtils.isEmpty(promotionSessionId)){
                queryWrapper.eq("promotion_session_id",promotionSessionId);
            }
            IPage page = this.page(
                    new Query().getPage(params),
                    queryWrapper
            );
    
            return new PageUtils(page);
        }
    
    }

    5)、在redis中保存秒杀场次信息

       private void saveSessionInfos(List sessions){
            if (!CollectionUtils.isEmpty(sessions)){
                sessions.stream().forEach(session -> {
                    Long startTime = session.getStartTime().getTime();
                    Long endTime = session.getEndTime().getTime();
                    String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
                    Boolean hasKey = redisTemplate.hasKey(key);
                    if (!hasKey){
                        List collect = session.getRelationEntities()
                                .stream()
                                .map(item -> item.getPromotionId().toString() +"_"+ item.getSkuId().toString())
                                .collect(Collectors.toList());
                        // 缓存活动信息
                        redisTemplate.opsForList().leftPushAll(key, collect);
                    }
    
                });
            }
        }

    6)、在redis中保存秒杀商品信息

        private void saveSessionSkuInfos(List sessions){
            // 准备hash操作
            BoundHashOperations ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
            sessions.stream().forEach(session -> {
                session.getRelationEntities().stream().forEach(seckillSkuVo -> {
                    // 缓存商品
                    SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                    // 1、sku的基本信息
                    R r = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
                    if (0  == r.getCode()){
                        SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference() {
                        });
                        redisTo.setSkuInfo(skuInfo);
                    }
                    // 2、sku的秒杀信息
                    BeanUtils.copyProperties(seckillSkuVo, redisTo);
    
                    // 3、设置当前商品的秒杀时间信息
                    redisTo.setStartTime(session.getStartTime().getTime());
                    redisTo.setEndTime(session.getEndTime().getTime());
    
                    // 4、随机码
                    String token = UUID.randomUUID().toString().replace("_", "");
                    redisTo.setRandomCode(token);
                    // 5、使用库存作为分布式信号量 限流
                    RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                    // 商品可以秒杀的数量作为信号量
                    semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
    
                    String jsonString = JSON.toJSONString(redisTo);
                    ops.put(seckillSkuVo.getSkuId(), jsonString);
                });
            });
    
        }

    添加“com.atguigu.gulimall.seckill.fegin.ProductFeignService”类,代码如下:

    @FeignClient("gulimall-product")
    public interface ProductFeignService {
    
        @RequestMapping("/product/skuinfo/info/{skuId}")
        R getSkuInfo(@PathVariable("skuId") Long skuId);
    }
    

    5、幂等性保证

    谷粒商城-个人笔记(高级篇五)_第32张图片

    • 分布式锁。锁的业务执行完成,状态已经更新完成。释放锁以后。其他人获取到就会拿到最新的状态

    修改“com.atguigu.gulimall.seckill.scheduled.SeckillSkuScheduled”类,打码如下

    @Slf4j
    @Service
    public class SeckillSkuScheduled {
    
        @Autowired
        SeckillService seckillService;
    
        @Autowired
        RedissonClient redissonClient;
    
        private final String upload_lock = "seckill:upload:lock";
    
        // TODO 幂等性处理
        @Scheduled(cron = "*/3 0 0 * * ?")
        public void uploadSeckillSkuLatest3Days(){
            // 重复上架无需处理
            log.info("上架秒杀的信息......");
            // 分布式锁。锁的业务执行完成,状态已经更新完成。释放锁以后。其他人获取到就会拿到最新的状态
            RLock lock = redissonClient.getLock(upload_lock);
            lock.lock(10, TimeUnit.SECONDS);
            try{
                seckillService.uploadSeckillSkuLatest3Days();
            }finally {
                lock.unlock();
            }
    
        }
    }
    

    修改“com.atguigu.gulimall.seckill.service.impl.SeckillServiceImpl”类,代码如下

    private void saveSessionInfos(List sessions){
            sessions.stream().forEach(session -> {
                Long startTime = session.getStartTime().getTime();
                Long endTime = session.getEndTime().getTime();
                String key = SESSIONS_CACHE_PREFIX + startTime + "_" + endTime;
                Boolean hasKey = redisTemplate.hasKey(key);
    
                if (!hasKey){
                    List collect = session.getRelationEntities().stream().map(item -> item.getPromotionId().toString() +"_"+ item.getSkuId().toString()).collect(Collectors.toList());
                    // 缓存活动信息
                    redisTemplate.opsForList().leftPushAll(key, collect);
                }
    
            });
        }
    
    private void saveSessionSkuInfos(List sessions){
            // 准备hash操作
            BoundHashOperations ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
            sessions.stream().forEach(session -> {
                // 随机码
                String token = UUID.randomUUID().toString().replace("_", "");
                session.getRelationEntities().stream().forEach(seckillSkuVo -> {
                    if (!ops.hasKey(seckillSkuVo.getPromotionSessionId().toString() + "_" + seckillSkuVo.getSkuId().toString())){
                        // 缓存商品
                        SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                        // 1、sku的基本信息
                        R r = productFeignService.getSkuInfo(seckillSkuVo.getSkuId());
                        if (0  == r.getCode()){
                            SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference() {
                            });
                            redisTo.setSkuInfo(skuInfo);
                        }
                        // 2、sku的秒杀信息
                        BeanUtils.copyProperties(seckillSkuVo, redisTo);
    
                        // 3、设置当前商品的秒杀时间信息
                        redisTo.setStartTime(session.getStartTime().getTime());
                        redisTo.setEndTime(session.getEndTime().getTime());
    
                        // 4、随机码
                        redisTo.setRandomCode(token);
    
                        String jsonString = JSON.toJSONString(redisTo);
                        ops.put(seckillSkuVo.getPromotionSessionId().toString() + "_" + seckillSkuVo.getSkuId().toString(), jsonString);
                        // 如果当前这个场次的商品的库存信息已经上架就不需要上架
                        // 5、使用库存作为分布式信号量 限流
                        RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
                        // 商品可以秒杀的数量作为信号量
                        semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
                    }
    
                });
            });
    

     

    6、获取当前的秒杀商品

    1)、获取当前秒杀商品

    添加“com.atguigu.gulimall.seckill.controller.SeckillController”类,代码如下

    @RestController
    public class SeckillController {
    
        @Autowired
        SeckillService seckillService;
        /**
         * 返回当前时间可以参与的秒杀商品信息
         * @return
         */
        @GetMapping(value = "/getCurrentSeckillSkus")
        public R getCurrentSeckillSkus() {
            //获取到当前可以参加秒杀商品的信息
            List vos = seckillService.getCurrentSeckillSkus();
    
            return R.ok().setData(vos);
        }
    
    
    }
    

    添加“com.atguigu.gulimall.seckill.service.SeckillService”类:代码如下:

        /**
         * 返回当前时间可以参与的秒杀商品信息
         * @return
         */
        List getCurrentSeckillSkus();

    修改“com.atguigu.gulimall.seckill.service.impl.SeckillServiceImpl”类,代码如下:

        @Override
        public List getCurrentSeckillSkus() {
            Set keys = redisTemplate.keys(SESSIONS_CACHE_PREFIX + "*");
            long currentTime = System.currentTimeMillis();
            for (String key : keys) {
                String replace = key.replace(SESSIONS_CACHE_PREFIX, "");
                String[] split = replace.split("_");
                long startTime = Long.parseLong(split[0]);
                long endTime = Long.parseLong(split[1]);
                // 当前秒杀活动处于有效期内
                if (currentTime > startTime && currentTime < endTime) {
                    // 获取这个秒杀场次的所有商品信息
                    List range = redisTemplate.opsForList().range(key, -100, 100);
                    BoundHashOperations hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
                    assert range != null;
                    List strings = hashOps.multiGet(range);
                    if (!CollectionUtils.isEmpty(strings)) {
                        return strings.stream().map(item -> JSON.parseObject(item, SeckillSkuRedisTo.class))
                                .collect(Collectors.toList());
                    }
                    break;
                }
            }
            return null;
        }

    2)首页获取并拼装数据

    配置网关

            - id: gulimall_seckill_route
              uri: lb://gulimall-seckill
              predicates:
                - Host=seckill.gulimall.com
    

    配置域名

    谷粒商城-个人笔记(高级篇五)_第33张图片

      你可能感兴趣的:(实战项目专题)