谷粒商城-基础篇(详细流程梳理+代码)

文章目录

前言

目前博主正在学习谷粒商城项目中,正在不断更新中…

所有博客文件目录索引:博客目录索引(持续更新)

一、项目环境搭建

1.1、安装virtualbox以及vagrant

在virtualbox创建centos7环境使用的是vagrant虚拟运行环境管理工具来进行构建的,可见该篇博客:虚拟运行环境管理工具Vagrant详细使用教程

效果如下:

image-20221104104106097

使用vagrant构建的centos7环境,默认的root没有设置密码,我们需要进行设置:

sudo passwd root

快速构建Docker环境可见我之前博客(环境安装部分):快速使用Docker部署MySQL、Redis、Nginx

1.2、Docker安装MySQL与Redis

Docker构建MySQL

image-20221107171836798

/mydata/mysql/conf目录下创建mysqld.cnf文件:

[mysqld]
pid-file	= /var/run/mysqld/mysqld.pid
socket		= /var/run/mysqld/mysqld.sock
datadir		= /var/lib/mysql
#log-error	= /var/log/mysql/error.log
# By default we only accept connections from localhost
#bind-address	= 127.0.0.1
# Disabling symbolic-links is recommended to prevent assorted security risks
symbolic-links=0
sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION

接着去拉取镜像并启动:

docker pull library/mysql:5.7.36

docker run -p 3306:3306 --name mysql \
-v /etc/localtime:/etc/localtime \
-v /mydata/mysql/log:/var/log/mysql \
-v /mydata/mysql/data:/var/lib/mysql \
-v /mydata/mysql/conf:/etc/mysql/mysql.conf.d \
-e MYSQL_ROOT_PASSWORD=root  \
-d library/mysql:5.7.36

# 使用mysql容器中的命令行
docker exec -it mysql /bin/bash

# 使用MySQL命令打开客户端:
mysql -uroot -proot --default-character-set=utf8

# 接着创建一个账户,该账号所有ip都能够访问
# 用户名:root  密码:root
grant all privileges on *.* to 'root' @'%' identified by 'root';

flush privileges;

Docker构建Redis

docker pull redis:5

在mydata/redis目录下创建redis.conf:文件如下

image-20221107171852051

链接:https://pan.baidu.com/s/1tT3SWnnL0OylWA5So_V9Mw 
提取码:9xko 

其中配置已经修改了,包含如下:

# 配置文件中修改说明
#bind 127.0.0.1 要注释掉,允许对外连接
appendonly yes:开启持久化
# daemonize yes 一定要要注释,注释,注释!!!

启动redis镜像,关键是设置其中的密码:

docker pull redis:5

# –restart=always 总是开机启动
# –log是日志方面的
# –appendonly yes 开启redis 持久化
# –requirepass xxx 设置密码 
docker run --log-opt max-size=100m --log-opt max-file=2  \
	-p 6379:6379 --name redis  \
	-v /etc/localtime:/etc/localtime \
	-v /mydata/redis/redis.conf:/etc/redis/redis.conf  \
	-v /mydata/redis/data:/data  \
	-d redis:5 redis-server /etc/redis/redis.conf   \
	--appendonly yes   \
	--requirepass 123456
	
# 进入redis-cli测试
docker exec -it redis redis-cli

1.3、前后端开发工具统一配置

后端

后端开发工具:idea

maven配置:Maven快速配置—配置文件

idea插件安装:lombok、mybatisx、Gitee、jrebel(Java代码修改无需重启)插件和ResetfulTool(一套 RESTful 服务开发辅助工具集)

前端

前端:vscode

安装插件:

Vetur —— 语法高亮、智能感知、Emmet 等
包含格式化功能, Alt+Shift+F (格式化全文),Ctrl+K Ctrl+F(格式化选中代码,两个 Ctrl
需要同时按着)
EsLint —— 语法纠错
Auto Close Tag —— 自动闭合 HTML/XML 标签
Auto Rename Tag —— 自动完成另一侧标签的同步修改
JavaScript(ES6) code snippets — — ES6 语 法 智 能 提 示 以 及 快 速 输 入 , 除 js 外 还 支持.ts,.jsx,.tsx,.html,.vue,省去了配置其支持各种包含 js 代码文件的时间
HTML CSS Support —— 让 html 标签上写 class 智能提示当前项目所支持的样式
HTML Snippets —— html 快速自动补全
Open in browser —— 浏览器快速打开
Live Server —— 以内嵌服务器方式打开
Chinese (Simplified) Language Pack for Visual Studio Code —— 中文语言包

1.4、Git工具安装与配置

Git的下载与配置可见我的博文(详细):Git详细使用指南(含详细命令、实操)

包含添加密钥到码云。

1.5、Gitee创建仓库与IDEA导入

Gitee创建仓库

仓库名称、初始化仓库以及选择分支模型(生产/开发模型):

image-20221104202253072

IDEA导入仓库

image-20221104202424045

image-20221104202501267

创建完成如下:

image-20221104202540327


1.6、构建微服务模块

创建模块:商品服务(product)、仓储服务(ware)、订单服务(order)、优惠券服务(coupon)、用户服务(member)

多模块相同组件:Springboot版本2.1.8.RELEASE、SpringCloud版本Greenwich.SR3

1)、引入依赖web、openfeign
2)、每一个服务,包名 com.atguigu.gulimall.xxx(product/order/ware/coupon/member)
3)、模块名:gulimall-xx

分组:com.atguigu.gulimall
坐标:gulimall-xxx
包名:com.atguigu.gulimall.xxx

构建完我们所需要的几个模块如下图(单个模块构建过程往下翻有示例):

image-20221104204952962

在当前目录下创建一个pom.xml(从之前模块中移过来),进行统一管理模块:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>gulimall</name>
    <description>谷粒商城聚合服务</description>
    <!--  不打包程jar包的指定pom  -->
    <packaging>pom</packaging>

    <!-- 统一管理模块   -->
    <modules>
        <module>gulimall-coupon</module>
        <module>gulimall-member</module>
        <module>gulimall-order</module>
        <module>gulimall-product</module>
        <module>gulimall-coupon</module>
    </modules>

</project>

如何导入这个聚合模块呢?右边Maven+号添加这个pom.xml即可:

image-20221104205338439

此时就会出现一个总的管理root:

image-20221104205431563

创建gulimall-product示例(版本说明)

image-20221104202852229

创建商品模块:

image-20221104203052030

组件选择:

image-20221104203227039

  • 后来修正为视频一样的版本2.1.8

image-20221104203321866

注意一下springboot与springcloud的版本:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.8.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<spring-cloud.version>Greenwich.SR3</spring-cloud.version>

image-20221104203855679


1.7、编写.gitignore文件(忽略上传gitee文件配置)

上面模块都构建好了之后,我们可以看到当前的版本控制管理有134个文件没有被管理:

image-20221104205803489

部分文件如.idea文件下的、target目录等我们在代码完成之后无需上传代码仓库,此时就需要在当前目录下的.gitignore中进行配置:

一些配置信息如下:

# 所有子模块下的.gitignore
**/.gitignore
# 工程目录下文件
**/mvnw
**/mvnw.cmd
**/.mvn
# idea配置文件
.idea
# 编译目录
**/target
*.iml
# 热编译工具
rebel.xml

image-20221104210421967

添加到版本控制操作:

image-20221104210542835

添加版本控制(add):

image-20221104210642619

提交到本地仓库(及push推送)

image-20221104210830803

image-20221104210856039

OK,此时我们看下Gitee仓库状态,所有的代码已经进行了上传:

image-20221104210934856


1.8、数据库初始化

下载安装数据库表设计工具

PowerDesigner16.5:PowerDesigner安装+破解+汉化PowerDesigner 安装破解

链接:https://pan.baidu.com/s/1HoifsS4ywFjyNO-jyhZy5A 
提取码:ihrj 

image-20221105132711788

数据库设计源文件
链接:https://pan.baidu.com/s/1Jv3ImyMHQEpay5RzwUK9Ug 
提取码:p2on 

下面是五个服务的表:

image-20221105132801164

创建数据库与表

对应每一个微服务都操作一个数据库

oms  订单数据库
pms  商品数据库
sms  营销数据库
ums  用户数据库
wms  库存数据库

以商品系统为准,创建utf8mb4字符集的数据库:

image-20221105113327318

依次创建好数据库之后导入预先准备的sql(注意不要走navicat的直接导入可能会出现字段提示乱码问题,我们需要打开某个sql全选后在GUI中复制执行!):

image-20221105113539929

sql文件
链接:https://pan.baidu.com/s/1IE12fJsyS6THG9xug1nmBg 
提取码:f4qr 

1.9、逆向工程(生成代码)

对于代码生成我们使用的是人人开源的项目:https://gitee.com/renrenio

image-20221105133000390

  • renren-fast作为后台管理系统后端服务
  • renren-fast-vue作为后台管理系统页面
  • renren-generator进行代码生成

1.9.1、renren-fast快速构建后端管理页面及服务

后端系统服务

renren-fast开源地址:https://gitee.com/renrenio/renren-fast

将renren-fast添加到gulimall目录下:

image-20221105133412940

数据库创建``gulimall_admin`,并且导入项目中的db目录下的mysql.sql:

image-20221105133650422

将该模块项目导入到IDEA中,接着我们修改renren-fast中的application.yml文件中的数据库连接地址和用户名与密码:

image-20221105135915706

接着启动项目,启动成功如下图所示:

image-20221105135943927

在网页上进行测试一下:http://localhost:8080/renren-fast/

image-20221105140008076

为什么会有renren-fast后缀?主要是在application.yaml中配置了server.servlet.context-path=/renren-fast

前端服务

renren-fast-vue开源地址:https://gitee.com/renrenio/renren-fast-vue

下载克隆下来之后我们进入根目录下输入命令去导入依赖:

npm install

全部添加成功如下图所示:

image-20221105140228797

你可以在config目录下index.js里看到已经配置好了对应我们renren-fast也就是admin后台系统的api地址,我们无需进行改动:

image-20221105140340497

接着去运行命令启动前端服务并进行测试访问下后台管理系统:

npm run dev

image-20221105140959356

启动成功之后我们来访问下该页面:http://localhost:8001/

image-20221105141023232

# 用户名与密码
admin
admin

成功登录后的效果如下所示:

image-20221105141104723


1.9.2、renren-generator快速生成微服务业务代码(营销服务、用户服务、订单服务、商品服务、库存服务)

最终生成模块展示

renren-generator开源地址:https://gitee.com/renrenio/renren-generator

下载克隆下来之后添加到gulimall中

image-20221105141656240

这个项目实际上提供了一个web服务,其能够连接对应的数据库通过页面中勾选指定的数据库来生成预先配置好的后端前端代码。

对应逆向生成代码的操作可见下面示例章节!

五个模块对应我们设置的开放端口为:

   微服务模块        端口       数据库表        注释
gulimall-coupon    7000     gulimall_sms  营销数据库  √
gulimall-member    8000     gulimall_ums  用户数据库  √
gulimall-order     9000     gulimall_oms  订单数据库  √
gulimall-product   10000    gulimall_pms  商品数据库  √
gulimall-ware      11000    gulimall_wms  库存数据库  √

初步搭建好的服务模块如下,右边的RestServices包含所有的服务:

image-20221105194928531

当前构建的环境代码:

链接:https://pan.baidu.com/s/1g0E-cbXrhLZPGIg0z83ykA 
提取码:ep7s 

示例:在gulimall-product模块中添加逆向工程代码

application.yml:修改连接数据库的ip地址与数据库名、用户名与密码

image-20221105142057146

generator.properties:代码生成的配置项

image-20221105142318175

  • 这个mainPath指的是引入的自定义工具类的包路径,如PageUtils、R等等。

启动该服务:http://localhost:80

image-20221105142749697

进入到网页之后,我们需要进行选中所有的表,然后进行生成代码:注意可能一个数据库会超过10条,那么我们选择一页显示50条,然后统一勾选

image-20221105144049850

生成代码的目录如下:

image-20221105144226604

我们将整个main目录复制到gulimall-product工程的src目录下(去除掉resources下的src目录也就是前端代码):

此时我们能够很明显的看到工程目录下java代码文件都是爆红,主要原因就是生成的代码中引入了一些当前工程没有引入的依赖(对于这这类可以生成不同模块的代码我们单独使用一个common包来进行引入一些第三方依赖):

image-20221105144549127

对于生成的代码解决方案

1、构建gulimall-common模块,引入生成代码所必须的一些工具类。

image-20221105160424776

当前的gulimall-common工程包
链接:https://pan.baidu.com/s/1DZ0vtrAyHTFnHZue2rzTOg 
提取码:8mvi 

pom.xml需要引入的第三方类:

<dependencies>
    <!--   mybatis-plus     -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.3.1</version>
    </dependency>
    <!--    导入mysql驱动    -->
    <!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.17</version>
    </dependency>
    <!--   lombok     -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.8</version>
    </dependency>
    <dependency>
        <groupId>commons-lang</groupId>
        <artifactId>commons-lang</artifactId>
        <version>2.6</version>
    </dependency>
</dependencies>

②修改代码生成器中的controller代码模板(去除掉controller部分的shiro权限代码,谷粒商城将会使用的是springsecurity)

image-20221105161013776

2、针对当前模块自定义配置准备

上面两步是针对之后所有逆向生成模块的额外步骤,接着我们来针对某个模块来进行配置,我们重新生成一下product代码放入到product模块中,接着做如下操作:

①pom.xml引入gulimall-common

<!--   引入公共模块     -->
<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

②使用renren-generator来生成对应服务的逆向代码直接复制到src目录下

③在SpringBoot启动器上添加MapperScan包扫描路径

@MapperScan("com.atguigu.gulimall.product.dao")

image-20221105163233012

④编写配置application.yml,主要数据库源以及mp的配置:

server:
  port: 10000

spring:
  datasource:
    username: root
    password: 123456
    url: jdbc:mysql://192.168.3.137:3306/gulimall_pms?useUnicode=true&characterEncoding=UTF8&useSSL=false
    driver-class-name: com.mysql.jdbc.Driver

mybatis-plus:
  mapper-locations: classpath:/mapper/**/*.xml
  global-config:
    db-config:
      id-type: auto  # 主键自增
      logic-delete-value: 1
      logic-not-delete-value: 0

二、搭建分布式环境

前提:gulimall-common进行springcloud alibaba版本控制依赖

根据当前使用的springboot版本、Springcloud版本来选择对应的springcloud alibaba版本:Alibaba-SpringCloud Alibaba 版本说明

image-20221105201810603

对于2.1版本的springboot,我们需要选择2.1.2的springcloud alibaba,在谷粒商城视频里选择的是2.1.0,那我们这也就选择使用2.1.0,将其添加到gulimall-common模块中:

image-20221105202024057

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.1.0.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

2.1、集成Nacos注册中心

gulimall-common统一引入注册发现依赖

由于每一个服务都需要添加到注册中心,那么将nacos注册发现统一集成到common模块当中:

image-20221105211027850

<!--        服务注册/发现-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

启动Nacos-server服务

本地启动nacos服务,下载nacos-server1.1.3:https://github.com/alibaba/nacos/releases?page=4

image-20221105211648331

链接:https://pan.baidu.com/s/12R8-kB6drpWSxmrnJ4gwvQ 
提取码:xgct

进入到bin目录,直接双击startup.cmd来进行启动:

image-20221105212319934

image-20221105212334156

nacos访问服务端口默认是在8848端口:http://localhost:8848/nacos/

nacos
nacos

所有模块服务集成注册发现

最终集成的效果:

image-20221105213832248

查看下最终的nacos注册中心:

image-20221105213912278

示例:guli-coupon集成过程

①配置application.yaml

在应用的 /src/main/resources/application.yaml配置文件中配置 Nacos Server 地址以及application.name也就是应用名称(才能够注册上):

spring:
  application:
    name: gulimall-coupon   # 服务名
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

②在启动器上开启服务注册发现注解:

@EnableDiscoveryClient //开启服务发现

启动gulimall-coupon服务:

image-20221105212644672

查看下nacos注册中心是否已经注册上:

image-20221105212707319


2.2、集成Openfeign组件

实战目标:在营销服务中编写一个接口,接着在用户服务中编写一个用户优惠券接口,在这个接口中我们将会进行远程调用营销服务中的接口并进行返回(openfeign完成远程调用)。

①在gulimall-coupon服务中添加一个接口

image-20221106145438411

@RequestMapping("/member/list")
public R memberCoupons() {
    CouponEntity couponEntity = new CouponEntity();
    couponEntity.setCouponName("满100减10");
    return R.ok().put("coupons", Arrays.asList(couponEntity));
}

②在gulimall-member服务中编写远程调用接口

首先需要给gulimall-member服务中集成openfeign并进行开启feign包扫描进行加强:

pom.xml引入:

<!--   远程调用:openfeign     -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>

编写对应的feign接口:

image-20221106145711008

package com.atguigu.gulimall.member.feign;

import com.atguigu.common.utils.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;

/**
 * @Description:
 * @Author: changlu
 * @Date: 9:59 PM
 */
@FeignClient("gulimall-coupon")
public interface CouponFeignService {

    @RequestMapping("/coupon/coupon/member/list")
    public R memberCoupons();

}

在启动器上添加自动包扫描:

@EnableFeignClients(basePackages = "com.atguigu.gulimall.member.feign") //开启feign客户端

最后就是在控制器上添加一个接口其中就包含远程调用:

image-20221106145744475

@Autowired
CouponFeignService couponFeignService;

@RequestMapping("/coupons")
public R test() {
    //当前服务的用户
    MemberEntity memberEntity = new MemberEntity();
    memberEntity.setNickname("长路");
    //远程调用获取优惠券
    R membercoupons = couponFeignService.memberCoupons();
    //响应用户与优惠券信息
    return R.ok().put("member", memberEntity).put("coupons", membercoupons.get("coupons"));
}

测试下接口:

image-20221106145812497


2.3、集成Nacos配置中心

介绍

命名空间:可基于环境进行隔离、也可以根据单个微服务来进行隔离。

配置集:指的就是单个命名空间下对应的group分组,场景如:单个服务在双11、618来进行分组隔离。

实战

由于我们的所有微服务都需要使用到配置中心,所以我们需要在gulimall-common模块中进行配置:

image-20221106170009017

<!--        配置中心来做配置管理-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

接着我们就需要在对应需要使用配置中心配置信息的服务模块中添加bootstrap.properties文件:

遵守的规则如下

  • 当前谷粒商城规范:按照每个微服务来创建自己的命名空间,接着使用配置分组来区分环境如dev、test、prod。
  • 加载多配置集:对数据源、框架相关的配置来统一拆分成不同的配置文件。

image-20221106170216743

spring.application.name=gulimall-coupon

# nacos配置中心地址(若是只有一条,默认namespace是public, Group是DEFAULT_GROUP,data-id是gulimall-coupon.properties)
spring.cloud.nacos.config.server-addr=localhost:8848
spring.cloud.nacos.config.namespace=cf8005b8-b2c3-47cd-b344-18661e5cba70
spring.cloud.nacos.config.group=prod

# 多配置级,将初始配置来进行拆分
spring.cloud.nacos.config.ext-config[0].data-id=datasource.yml
spring.cloud.nacos.config.ext-config[0].group=dev
spring.cloud.nacos.config.ext-config[0].refresh=true

spring.cloud.nacos.config.ext-config[1].data-id=mybatis.yml
spring.cloud.nacos.config.ext-config[1].group=dev
spring.cloud.nacos.config.ext-config[1].refresh=true

spring.cloud.nacos.config.ext-config[2].data-id=other.yml
spring.cloud.nacos.config.ext-config[2].group=dev
spring.cloud.nacos.config.ext-config[2].refresh=true

对应在nacos管理面板上,我们需要进行添加一个单独服务的命名空间,并且去配置对应的配置文件其中包含有多环境的以及多配置信息文件:

image-20221106170335368

  • 多环境:gulimall-coupon.properties。
  • 多配置(拆分):数据库、框架、单个微服务的相关配置。
# gulimall-coupon.properties  dev
coupon.user.name=changlu-dev
coupon.user.age=29

# gulimall-coupon.properties prod
coupon.user.name=changlu-prod
coupon.user.age=28

# datasource.yml  dev
spring:
  datasource:
    username: root
    password: 123456
    url: jdbc:mysql://192.168.3.137:3306/gulimall_sms?useUnicode=true&characterEncoding=UTF8&useSSL=false
    driver-class-name: com.mysql.jdbc.Driver
    
# mybatis.yml  dev
mybatis-plus:
  mapper-locations: classpath:/mapper/**/*.xml
  global-config:
    db-config:
      id-type: auto  # 主键自增
      logic-delete-value: 1
      logic-not-delete-value: 0
      
# other.yml  dev
server:
  port: 7000
spring:
  application:
    name: gulimall-coupon   # 服务名
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

对于上面多环境配置设置的目的就是我们当前来进行测试,对应测试的代码在controller中:

image-20221106170628152

启动nacos与gulimall-coupon来进行测试dev与pro环境:

image-20221106170827651

image-20221106170739798

提示:上线之后我们可以配到配置中心去,线下写代码就直接配在本地即可(由于我使用了RestServices插件,读取配置中心的话对应的一些端口就需要我们自己去手动修改,同时也会很麻烦)。


2.4、集成网关服务

image-20221106171718008

创建guilimall-gateway模块:

image-20221106200558611

image-20221106200739339

image-20221106213406086

①pom.xml中引入公共模块

<!--   引入公共模块     -->
<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

由于引入了common模块,在common模块中还带了mybatis的依赖,所以需要对其进行排除(在启动器上):

image-20221106213049034

②排除common组件的依赖

//由于引入了gulimall-common,其中包含mp的自动导入数据源,所以这里需要进行排除
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@EnableDiscoveryClient  //开启注册发现

③编写注册中心的配置项

image-20221106213118388

bootstrap.properties:

server.port=88
spring.application.name=gulimall-gateway
# 服务注册
spring.cloud.nacos.discovery.server-addr=localhost:8848
# 配置中心
spring.cloud.nacos.config.server-addr=localhost:8848
spring.cloud.nacos.config.namespace=6a7b6a2f-871b-46a9-9805-7caf19e65b09

④编写断言匹配url

application.yml:

spring:
  cloud:
    gateway:
      routes:
        - id: test_route
          uri: https://www.baidu.com
          predicates:
            - Query=url,baidu

        - id: qq_route
          uri: https://www.qq.com
          predicates:
            - Query=url,qq

image-20221106213226228

对于其中的gateway断言我们进行了处理,之后我们来对其进行测试

localhost:88?url=qq

image-20221106213301784

localhost:88?url=baidu

image-20221106213321515


三、商品服务

3.1、三级分类API

三级分类介绍

在一般的电商平台上就包含有三级分类:

image-20221107175627849

在谷粒商城中,三级分类表是属于商品服务数据库中的表:

image-20221107175846872

  • 核心字段:parent_cid(父类id)、sort(排序字段)
  • 我们的多级分类是根据父类id来进行划分的,一级分类就是为0,对应二级分类的parent_id就是0,同理三级分类的parent_id就是1。

默认在表中是没有数据的,我们需要来进行导入一些初始数据:

将课件中的数据sql代码执行:

image-20221107180109889

3.1.1、查询—产品分类递归树型结构数据获取(后端服务)

完成效果

目标:在gulimall-product模块中编写一个产品三级分类的递归数据结构获取的API接口。

接口:http://localhost:10000/product/category/list/tree

image-20221107180436181

实现过程

主要代码包含gulimall-product模块下:

image-20221107180613802

CategoryController.java:控制器,对应产品分类的树型分类接口

@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;

    /**
     * 查询出所有的分类以及子分类,以树型结构组装起来
     */
    @RequestMapping("/list/tree")
    public R list(){
        List<CategoryEntity> entities = categoryService.listWithTree();
        return R.ok().put("data", entities);
    }
}

CategoryEntity:产品分类实体中添加一个children集合属性,用于封装直接响应给前端,但是注意了这个属性我们需要加上@Field注解表示其不是数据库表中自带的属性,避免在使用mybatisplus查询时出现异常

@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
    
    //...
    
	//子分类
	@TableField(exist = false) //表示其不存在数据库表
	private List<CategoryEntity> children;
}

CategoryService.java:定义接口方法

public interface CategoryService extends IService<CategoryEntity> {
    List<CategoryEntity> listWithTree();
}

CategoryService.java:树型分类业务代码,其中就有涉及到一个递归处理操作

package com.atguigu.gulimall.product.service.impl;

import com.atguigu.common.utils.PageUtils;
import com.atguigu.common.utils.Query;
import com.atguigu.gulimall.product.dao.CategoryDao;
import com.atguigu.gulimall.product.entity.CategoryEntity;
import com.atguigu.gulimall.product.service.CategoryService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
    
    @Override
    public List<CategoryEntity> listWithTree() {
        //1、查询到所有的分类
        List<CategoryEntity> entities = baseMapper.selectList(null);
        //2、组装成父子的树型结构
        List<CategoryEntity> ans = entities.stream()
                .filter((menu) -> menu.getParentCid() == 0)
                .map((menu) -> {
                    menu.setChildren(getChildren(menu, entities));
                    return menu;
                })
                .sorted((menu1, menu2) -> {
                    if (menu1.getSort() == null || menu2.getSort() == null) return 0;
                    return menu1.getSort() - menu2.getSort();
                })
                .collect(Collectors.toList());
        return ans;
    }

    /**
     * 递归处理获取子分类
     * @param parent 父分类
     * @param all 所有分类
     * @return 已经获取到子分类的分类
     */
    public List<CategoryEntity> getChildren(CategoryEntity parent, List<CategoryEntity> all) {
        List<CategoryEntity> ans = all.stream()
                .filter((menu) -> menu.getParentCid().equals(parent.getCatId())) //Long类型比较需要进行equals
                .map((menu) -> {
                    menu.setChildren(getChildren(menu, all));
                    return menu;
                })
                .sorted((menu1, menu2) -> {
                    if (menu1.getSort() == null || menu2.getSort() == null) return 0;
                    return menu1.getSort() - menu2.getSort();
                })
                .collect(Collectors.toList());
        return ans;
    }

}

其中的这个树型递归流程使用的是stream流来进行处理,filter过滤+map(填充子列表)+sorted(排序)最终使用collect来进行聚合。


3.1.2、配置管理服务renren-fast网关路由(后端+前端配置)

对于后台管理系统,原先是通过直接去访问renren-fast的服务地址来进行验证码、登录以及后台管理的一系列操作。

对于分布式项目所有的请求都来通过统一的一个网关来进行路由到不同的服务,在这里我们首先来配置一下网关路由!

后端服务(网关动态路由、集成跨域服务)

renren-fast集成配置

首先将后台管理服务renren-fast也集成gulimall-common模块,让其也拥有服务注册的能力:

image-20221107210940303

pom.xml

<!--   引入公共模块     -->
<dependency>
    <groupId>com.atguigu.gulimall</groupId>
    <artifactId>gulimall-common</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

编写一个bootstrap.properties文件:用于进行服务注册以及配置中心地址配置、应用名

spring.application.name=renren-fast
# 服务注册
spring.cloud.nacos.discovery.server-addr=localhost:8848
# 配置中心
spring.cloud.nacos.config.server-addr=localhost:8848

在启动器上开启服务发现:

@EnableDiscoveryClient //开启服务发现

gulimall-gateway集成配置

image-20221107211547120

准备操作好了之后,我们就来进行gulimall-gateway服务模块的动态路由部分的编写,application.yml配置如下:

spring:
  cloud:
    gateway:
      routes:
        # 自定义后台管理服务的动态路由
        # 路由转换:http://localhost:88/api/renren-fast => http://localhost:8080/renren-fast
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}   # 覆盖重写路径

由于所有的请求都会走我们的网关,那么我们就需要在网关处来进行跨域处理,我们编写一个跨域配置类GulimallCorsConfiguration

package com.atguigu.gulimall.gateway.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.reactive.CorsWebFilter;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;

/**
 * @Description: 网关统一处理跨域请求配置类
 * @Author: changlu
 * @Date: 8:02 PM
 */
@Configuration
public class GulimallCorsConfiguration {

    @Bean
    public CorsWebFilter corsWebFilter() {
        //基于url跨域,选择reactive包下
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        //跨域配置信息
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        //允许跨域的头
        corsConfiguration.addAllowedHeader("*");
        //允许跨域的请求方式
        corsConfiguration.addAllowedMethod("*");
        //允许跨域的请求来源
        corsConfiguration.addAllowedOrigin("*");
        //是否允许携带cookie跨域
        corsConfiguration.setAllowCredentials(true);

        //任意url都需要进行跨域请求
        source.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsWebFilter(source);
    }

}

在网关处集成了跨域请求之后,我们需要去查看下网关路由的那些服务是否也有跨域请求,因为若是其他服务也有跨域请求处理的话,那么就会在响应头部分添加两次允许跨域的响应头信息,在renren-fast服务中就有,我们需要将其进行注释掉:

image-20221108083325692

至此,我们的后端服务就暂且集成完毕!


前端服务

对于前端的话无需做很大的改动,只需要将全局的请求路径进行修改即可:

image-20221107212342890

http://localhost:8080/renren-fast  => http://localhost:88/api

对于后端服务来说,若是匹配到/api/**就会去进行动态路由匹配转发到后端管理服务去进行操作!


测试

启动nacos注册中心、网关服务、后台管理服务:

image-20221107212644845

此时查看下nacos注册中心,看一下是否已经上线:

image-20221107212715300

没有问题,那么我们就启动前端管理系统项目,来进行一个验证码接口和登录接口的测试:

image-20221107212750507

验证码请求:

image-20221107212837692

login登录请求:

image-20221107212927401


3.1.3、产品分类查询(前端)

前端产品分类页面创建

创建目录:

image-20221107220115949

创建一级菜单:

image-20221107220245374

看一下当前的分类效果:

image-20221107220329158

image-20221107220359275

本质实际上就是在sys_menu表中加了两条记录:

image-20221107220911035

对应product-category实际就会映射到product/category.vue这个文件:

image-20221107220719533


配置商品服务gulimall-product的动态路由地址

在gulimall-product服务中application.yml中配置商品服务的动态路由:

spring:
  cloud:
    gateway:
      routes:
        # 商品服务路由
        # 路由转换:http://localhost:88/api/product/category/list/tree => http://localhost:10000/product/category/list/tree
        - id: product_route
          uri: lb://gulimall-product
          predicates:
            - Path=/api/product/**
          filters:
            - RewritePath=/api/product/(?<segment>.*),/product/$\{segment}

image-20221108083620018

提示:你可以看到对于产品服务的动态路由是配置到了renren-fast后台管理服务的路由之上,这是因为renren-fast的路由匹配是从/api/**就开始的,在gateway中去进行匹配是根据你配置的上下顺来来进行匹配转发的,而我们的商品服务动态路由则是匹配的/api/product/**,很明显商品服务的路由是后台管理服务的子路由,所以应该需要进行优先匹配,至此需要放置到其上面!

简而言之:精确路由放在高优先级,模糊路由放在低优先级。


前端产品分类接口实现(集成3.1.1查询接口)

对于树型组件的展示我们可以直接使用element ui的树型组件:

image-20221108084204200

代码如下:

<template>
  <div>
    <el-tree
      :data="menus"
      show-checkbox
      node-key="id"
      :default-expanded-keys="[2, 3]"
      :default-checked-keys="[5]"
      :props="defaultProps">
    </el-tree>
  </div>
</template>

 methods: {
    //获取树型菜单数据
    getMenus() {
      this.$http({
        url: this.$http.adornUrl('/product/category/list/tree'),
        method: 'get'
      }).then(({data}) => {
        console.log("成功获取到菜单数据...", data.data)
        this.menus = data.data;
      })
    }
  },
  // 生命周期 - 创建完成(可以访问当前this实例)
  created () {
    this.getMenus();
  },

image-20221108084354774

效果如下:

image-20221108084430419

接着我们要实现一个前端需求:

每个分类右边都有一个新增与删除的按钮,并且对于新增与删除按钮的显示也有要求:
①新增Append显示要求:只有1级2级分类才具备新增选项。
②删除delete显示要求:只有当子分类数量为0时才允许被删除

效果如下:

image-20221108150931991

如何能够去添加右边的Append与Delete呢?这就使用到了vue中的插槽语法,修整后的代码如下:

<el-tree
      :data="menus"
      show-checkbox
      node-key="catId"
      :expand-on-click-node="false"
      :props="defaultProps"
      :default-expanded-keys="expandedKey"
    >
      <!-- 插槽传的值:node表示该结点的属性(组件原生)  data表示我们实际的值 -->
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <!-- 只有一级二级分类才能够显示 node.level是组件原生的属性-->
          <el-button
            v-if="node.level <= 2"
            type="text"
            size="mini"
            @click="() => append(data)"
          >
            Append
          </el-button>
          <!-- 若是没有子节点分类时,就可以进行删除 node.childNodes就是组件自带的node节点 -->
          <el-button
            v-if="node.childNodes.length == 0"
            type="text"
            size="mini"
            @click="() => remove(node, data)"
          >
            Delete
          </el-button>
        </span>
      </span>
    </el-tree>

3.1.4、删除单个分类(后端+前端实现)

效果展示及思路

test

思路逻辑:

1、点击delete,进入message选择框。

2、点击message选择框确认,发起删除请求,最后就是刷新菜单。

  • 注意:成功删除之后,原本展开那个分栏框依旧展开。(使用到了el-tree中的default-expanded-keys属性,只需要在刷新菜单后进行绑定即可)

后端代码(逻辑删除)

配置逻辑删除

gulimall-product中的productEntity配置mybatisplus的逻辑删除注解:

image-20221108151544467

/**
	 * 是否显示[0-不显示,1显示]
	 */
@TableLogic(value = "1", delval = "0")
private Integer showStatus;

配置好了之后,我们去使用mp中baseMapper的查询与删除,默认会走的是逻辑删除及查询对应status=0的所有记录!

删除代码逻辑

CategoryController.java

@RestController
@RequestMapping("product/category")
public class CategoryController {
    
    @Autowired
    private CategoryService categoryService;
    
    /**
     * 删除分类标签
     */
    @PostMapping("/delete")
    //@RequiresPermissions("product:category:delete")
    public R delete(@RequestBody Long[] catIds){
		categoryService.removeMenuByIds(Arrays.asList(catIds));
        return R.ok();
    }
}

CategoryServiceImpl.java:当前仅仅直接实现了批量删除,对于引用的代码并没有进行编写

@Service("categoryService")
public class CategoryServiceImpl extends ServiceImpl<CategoryDao, CategoryEntity> implements CategoryService {
    
    @Override
    public void removeMenuByIds(List<Long> asList) {
        //TODO 1、检查当前删除的菜单,是否被其他地方引用

        //2、逻辑删除
        baseMapper.deleteBatchIds(asList);
    }
}

前端代码

image-20221108151910526

<!-- node-key:后端传过来节点中的id名称(这里就是catId) show-checkbox:展示勾选框  expand-on-click-node:需要点击向下箭头才展开  props:自定义组件中显示的名称
default-expanded-keys 默认展开的节点,接收的是数组(绑定对应的catId)
-->
<el-tree
         :data="menus"
         show-checkbox
         node-key="catId"
         :expand-on-click-node="false"
         :props="defaultProps"
         :default-expanded-keys="expandedKey"
         >
    <!-- 插槽传的值:node表示该结点的属性(组件原生)  data表示我们实际的值 -->
    <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
            <!-- 只有一级二级分类才能够显示 node.level是组件原生的属性-->
            <el-button
                       v-if="node.level <= 2"
                       type="text"
                       size="mini"
                       @click="() => append(data)"
                       >
                Append
            </el-button>
            <!-- 若是没有子节点分类时,就可以进行删除 node.childNodes就是组件自带的node节点 -->
            <el-button
                       v-if="node.childNodes.length == 0"
                       type="text"
                       size="mini"
                       @click="() => remove(node, data)"
                       >
                Delete
            </el-button>
        </span>
    </span>
</el-tree>

// 方法集合
methods: {
    //删除单个分类
    remove(node, data) {
      console.log(node, data);
      this.$confirm(`此操作将刪除分类[${node.label}], 是否继续?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          //构成id
          var ids = [data.catId];
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false)
          }).then(({ data }) => {
            this.$message({
              type: "success",
              message: "删除成功!"
            });
            console.log("刪除成功!");
            //刷新菜單
            this.getMenus();
            //设置展开的是当前删除节点的父catId
            this.expandedKey = [node.parent.data.catId];
          });
        })
        .catch(() => {});
    },
}

3.1.5、新增单个分类(后端+前端实现)

效果展示及思路

test

思路:

1、点击Append,出现弹窗,输入内容。

2、点击确定,发起新增请求,请求结束刷新菜单,显示已经展开的分栏框。


后端代码

直接就是之前逆向生成的代码,我们无需进行修改:

image-20221108152546490

@RestController
@RequestMapping("product/category")
public class CategoryController {
	/**
     * 保存
     */
    @PostMapping("/save")
    //@RequiresPermissions("product:category:save")
    public R save(@RequestBody CategoryEntity category){
		categoryService.save(category);
        return R.ok();
    }
}
前端代码
<el-dialog
           title="新增分类"
           :visible.sync="dialogVisible"
           width="30%"
           :close-on-click-modal="false"
           >
    <el-form :model="category">
        <el-form-item label="分类名称">
            <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
    </el-form>
    <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
    </span>
</el-dialog>


export default {
	mehtods: {
		//el-tree组件调用的append方法
        append(data) {
          console.log("---append---,data", data);
          //打开窗口
          this.dialogVisible = true;
          this.category.parentCid = data.catId;  //父分类id
          this.category.catLevel = data.catLevel * 1 + 1;  //分类id
          this.category.catId = null; //待服务器自己生成
          this.category.name = "";
          this.category.icon = "";
          this.category.productUnit = "";
          this.category.sort = 0;
          this.category.showStatus = 1;
          console.log("待apennd数据", this.category);
          console.log("---append---");
        },
        //添加三级分类
        addCategory() {
          console.log("---addCategory---")
          console.log(this.category)
          this.$http({
            url: this.$http.adornUrl("/product/category/save"),
            method: "post",
            data: this.$http.adornData(this.category, false)
          }).then(({ data }) => {
              this.$message({
                type: "success",
                message: "新增成功!"
              });
              //关闭窗口
              this.dialogVisible = false;
              //刷新菜單
              this.getMenus();
              //设置展开的是当前删除节点的父catId
              this.expandedKey = [this.category.parentCid];
          });
          console.log("---addCategory---")
        },
    }
}

3.1.6、编辑单个分类(后端+前端实现)

效果展示及思路

test

编辑分类思路:

1、点击编辑回显分类信息,这个信息一定要从服务器中进行查询,否则可能会出现多个管理员去操作时显示的数据还是之前树型结构的。

2、对于真正的编辑请求中携带的数据仅仅只是要进行修改的属性,其他不修改的一律不用携带。


后端代码

①根据catId查询分类信息接口

@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
    
    /**
     * 信息
     */
    @RequestMapping("/info/{catId}")
    public R info(@PathVariable("catId") Long catId){
		CategoryEntity category = categoryService.getById(catId);
        return R.ok().put("category", category);
    }
}

image-20221109145544320

②编辑分类接口

@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
    
    /**
     * 修改
     */
    @PostMapping("/update")
    //@RequiresPermissions("product:category:update")
    public R update(@RequestBody CategoryEntity category){
		categoryService.updateById(category);
        return R.ok();
    }
}

image-20221109145249655

前端代码

前端部分编辑的分类我们是复用之前的新增分类弹窗,对于新增、编辑操作区分我们通过一个单独的属性dialogType来表示,对于窗口的标题名称使用title属性来表示:

<el-dialog
      :title="title"
      :visible.sync="dialogVisible"
      width="30%"
      :close-on-click-modal="false"
    >
      <el-form :model="category">
        <el-form-item label="分类名称">
          <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="图标">
          <el-input v-model="category.icon" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="计量单位">
          <el-input
            v-model="category.productUnit"
            autocomplete="off"
          ></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
      </span>
    </el-dialog>

而对于真正提交确定按钮,我们则可以根据对应的dialogType来进行表示:

//编辑
edit(data) {
    console.log("---edit---");
    this.dialogType = "edit";
    this.title = "编辑标签";
    //向后台发起请求获取到最新的标签信息
    this.$http({
        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
        method: "get"
    }).then(({ data }) => {
        this.dialogVisible = true;
        console.log(data.category);
        this.category = data.category;
        console.log("---edit---");
    });
},
//编辑分类
editCategory() {
    console.log("---提交编辑表单---");
    let data = {
        catId: this.category.catId,
        name: this.category.name,
        icon: this.category.icon,
        productUnit: this.category.productUnit
    };
    console.log(data);
    this.$http({
        url: this.$http.adornUrl("/product/category/update"),
        method: "post",
        data: this.$http.adornData(data, false)
    }).then(({ data }) => {
        this.$message({
            type: "success",
            message: "编辑成功!"
        });
        //关闭窗口
        this.dialogVisible = false;
        //刷新菜單
        this.getMenus();
        //设置展开的是当前删除节点的父catId
        this.expandedKey = [this.category.parentCid];
        console.log("---提交编辑表单---");
    });
},
    //提交数据
    submitData() {
        console.log("---提交数据---");
        if (this.dialogType == "add") {
            this.addCategory();
        } else if (this.dialogType == "edit") {
            this.editCategory();
        }
        console.log("---提交数据---");
    },

3.1.7、拖拽分类并实现批量保存(后端+代码实现)

效果展示及思路

拖拽展示:

test

从图中你可以看到只有在开启拖拽选项后,才能够进行拖拽操作,这个小功能实际上对于element ui组件是只需要配置下的,我们只需要控制draggable属性即可控制是否拖拽!

1、对于是否能够拖到指定的层级位置,是要进行判定的。在当前的系统需求中来说是无法将某个分类拖动到第三级的标签中的。

  • 使用allowDrop(draggingNode, dropNode, type)方法来判断是否能够进行拖动到目标位置:属性分别是拖动中的节点,目标节点以及拖动类型(type 参数有三种情况:‘prev’、‘inner’ 和 ‘next’,分别表示放置在目标节点前、插入至目标节点和放置在目标节点后)。

  • 判断是否可拖动目标位置逻辑:

    • 是否允许拖拽(拖拽过程中会进行调用)before、after、inner
      逻辑:
      1、计算出当前拖动的节点它的最大深度
      2、最大深度 - 当前拖动节点的深度 + 1 + 目标节点的深度 <= 3  符合要求
      

2、只有当我们在拖拽移动到目标节点按下时,说明是目标想要移动的操作,对于整个操作完成我们应当提前将需要对应需要改动的节点sort、level来进行统一存储起来。

  • 这个拖拽完成动作绑定的是el-tree的node-drop方法,包含的属性有:draggingNode, dropNode, dropType, ev

  • 拖拽完成的逻辑操作:

    • 拖拽结束动作:统一添加到待更新的数组中  before、after、inner
      逻辑:
      1、获取到目标节点的最新父节点id并收集(目标是为了之后进行刷新展开对应节点)
      2、得到父节点之后取得它的所有子节点,去统一收集三个部分内容
      第一部分:所有子节点中非移动节点的catId、sort
      第二部分:目标节点1个,更新该目标节点的catId、parentId、sort、catLevel
      第三部分:目标节点的所有子节点,这些节点无需更新他们的sort,即catId、catLevel(递归处理)
      

核心在这个过程中需要存储的有:①批量保存后要展开的父节点。②所有需要保存的节点状态。

//临时存储拖拽的父节点
pCid: [],
//需要更新的所有节点
updateNodes: [],

批量保存实现:

test

批量保存点击之后流程:

1、发送待更新数组,进行批量更新请求。(使用到updateNodes)

2、更新完成之后,展开所有移动目标节点的父节点位置。(使用到pCid)

3、重置updateNodes、pCid数组。


后端代码

对于批量更新分类的后端代码比较简单,直接就是把前端传来的进行批量操作即可:

@RestController
@RequestMapping("product/category")
public class CategoryController {
    @Autowired
    private CategoryService categoryService;
    
	/**
     * 批量修改分类
     * @param category
     * @return
     */
    @PostMapping("/update/sort")
    public R updateSort(@RequestBody CategoryEntity[] category) {
        categoryService.updateBatchById(Arrays.asList(category));
        return R.ok();
    }
}

image-20221109173941503

前端代码

组件及过程属性:

<el-switch
           v-model="draggable"
           active-text="开启拖拽"
           inactive-text="关闭拖拽"
           ></el-switch>
<el-button v-if="draggable" @click="batchSave">批量保存</el-button>
<!-- node-key:后端传过来节点中的id名称(这里就是catId) show-checkbox:展示勾选框  expand-on-click-node:需要点击向下箭头才展开  props:自定义组件中显示的名称
default-expanded-keys 默认展开的节点,接收的是数组(绑定对应的catId)
draggable:表示可拖拽
allowDrop(draggingNode, dropNode, type):是否可拖拽,属性分别是正在拖拽的节点,拖拽节点以及类型。type表示before、after、inner,返回true或者false表示是否能够拖动到该位置。
node-drop(draggingNode, dropNode, dropType, ev):拖拽结束。ev 表示三种状态:before、after、inner。
-->
<el-tree
         :data="menus"
         show-checkbox
         node-key="catId"
         :expand-on-click-node="false"
         :props="defaultProps"
         :default-expanded-keys="expandedKey"
         :draggable="draggable"
         :allow-drop="allowDrop"
         @node-drop="handleDrop"
         >
    <!-- 插槽传的值:node表示该结点的属性(组件原生)  data表示我们实际的值 -->
    <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
            <!-- 只有一级二级分类才能够显示 node.level是组件原生的属性-->
            <el-button
                       v-if="node.level <= 2"
                       type="text"
                       size="mini"
                       @click="() => append(data)"
                       >
                Append
            </el-button>
            <el-button type="text" size="mini" @click="() => edit(data)">
                edit
            </el-button>
            <!-- 若是没有子节点分类时,就可以进行删除 node.childNodes就是组件自带的node节点 -->
            <el-button
                       v-if="node.childNodes.length == 0"
                       type="text"
                       size="mini"
                       @click="() => remove(node, data)"
                       >
                Delete
            </el-button>
        </span>
    </span>
</el-tree>
// 定义属性
data() {
    return {
        //计算最大深度
        maxLevel: 0,
        //临时存储拖拽的父节点
        pCid: [],
        //需要更新的所有节点
        updateNodes: [],
    };
},

拖拽过程中主要有两个触发函数:

//是否允许拖拽(拖拽过程中会进行调用)before、after、inner
//逻辑:
//1、计算出当前拖动的节点它的最大深度
//2、最大深度 - 当前拖动节点的深度 + 1 + 目标节点的深度 <= 3  符合要求
allowDrop(draggingNode, dropNode, type) {
    console.log(draggingNode, dropNode, type);
    //1、计算出当前拖动的节点它的最大深度
    this.maxLevel = draggingNode.level;
    this.countNodeLevel(draggingNode);
    //2、最大深度 - 当前拖动节点的深度 + 1
    let deep = this.maxLevel - draggingNode.level + 1;
    //console.log("深度:", deep, this.maxLevel, draggingNode.level);
    //3、考虑移入到的目标节点内部还是前后位置
    if (type == "inner") {
        return deep + dropNode.level <= 3;
    } else {
        return deep + dropNode.parent.level <= 3;
    }
},
//计算当前结点的最大深度
countNodeLevel(node) {
    if (node.childNodes != null && node.childNodes.length > 0) {
        //遍历子分类的深度结点是否>最大节点
        for (let i = 0; i < node.childNodes.length; i++) {
            if (node.childNodes[i].level > this.maxLevel) {
                this.maxLevel = node.childNodes[i].level;
            }
            //递归向下子节点
            this.countNodeLevel(node.childNodes[i]);
        }
    }
},
//拖拽结束动作:统一添加到待更新的数组中  before、after、inner
//逻辑:
//1、获取到目标节点的最新父节点id并收集(目标是为了之后进行刷新展开对应节点)
//2、得到父节点之后取得它的所有子节点,去统一收集三个部分内容
//第一部分:所有子节点中非移动节点的catId、sort
//第二部分:目标节点1个,更新该目标节点的catId、parentId、sort、catLevel
//第三部分:目标节点的所有子节点,这些节点无需更新他们的sort,即catId、catLevel
handleDrop(draggingNode, dropNode, dropType, ev) {
    console.log("拖拽结束", draggingNode, dropNode, dropType, ev);
    let pCid = 0;
    let siblings = null;
    //1、获取到拖拽到目标节点的最新父节点
    if (dropType == "before" || dropType == "after") {
        pCid =
            dropNode.parent.data.catId == undefined
            ? 0
            : dropNode.parent.data.catId;
        siblings = dropNode.parent.childNodes;
    } else {
        //inner
        pCid = dropNode.data.catId;
        siblings = dropNode.childNodes;
    }
    this.pCid.push(pCid);
    //2、得到父节点之后取得它的所有子节点,去统一收集三个部分内容
    for (let i = 0; i < siblings.length; i++) {
        //若是遍历到对应正在拖动的节点
        if (siblings[i].data.catId == draggingNode.data.catId) {
            let catLevel = draggingNode.level;
            if (siblings[i].level != catLevel) {
                catLevel = siblings[i].level;
                console.log("第三部分,", siblings[i]);
                //第三部分:目标节点的所有子节点,这些节点无需更新他们的sort,即catId、catLevel
                this.updateChildNodeLevel(siblings[i]);
            }
            //第二部分:目标节点1个,更新该目标节点的catId、parentId、sort、catLevel
            this.updateNodes.push({
                catId: siblings[i].data.catId,
                parentCid: pCid,
                sort: i,
                catLevel: catLevel
            });
        } else {
            //第一部分:所有子节点中非移动节点的catId、sort
            this.updateNodes.push({
                catId: siblings[i].data.catId,
                sort: i
            });
        }
    }
    //3、打印出当前拖拽节点待更新的所有节点
    console.log("updateNodes", this.updateNodes);
},
updateChildNodeLevel(node) {
    console.log("node.childNodes.length, ", node.childNodes.length);
    //若是传入的节点有子孩子来进行递归处理
    if (node.childNodes.length > 0) {
        for (let i = 0; i < node.childNodes.length; i++) {
            var cNode = node.childNodes[i].data;
            this.updateNodes.push({
                catId: cNode.catId,
                catLevel: node.childNodes[i].level
            });
            console.log("递归处理:", node.childNodes[i]);
            //递归处理该节点的子节点
            this.updateChildNodeLevel(node.childNodes[i]);
        }
    }
},

批量保存就是一个保存函数:

//批量保存
batchSave() {
    this.$http({
        url: this.$http.adornUrl("/product/category/update/sort"),
        method: "post",
        data: this.$http.adornData(this.updateNodes, false)
    }).then(({ data }) => {
        this.$message({
            type: "success",
            message: "分类批量保存成功!"
        });
        //刷新菜單
        this.getMenus();
        //设置提前存储需要展开的父节点id
        this.expandedKey = this.pCid;
        //初始化
        //需要更新的节点重置
        this.updateNodes = [];
        this.pCid = [];
    });
}

3.1.8、批量删除分类(后端+代码实现)

效果展示及思路

效果展示:

test

批量删除逻辑:

1、获取到选中的所有节点catId,点击批量删除即可发送删除请求。

如何获取到所有选中的节点呢?可借助el-tree中自带的getCheckedNodes()函数,首先需要去进行ref绑定,绑定好之后即可在对应的删除功能函数中进行调用方法函数去获取到所有的选中节点。

  • 对于半选的节点我们可以通过给getCheckedNodes()传参来进行获取,看下下面的官方注释:
    • image-20221109185412529

image-20221109185219905

后端代码

同样使用的是之前删除单个分类的接口,在这个接口中暂时还没有去检查对应的菜单是否被引用:

image-20221109185609298

image-20221109185632241

前端代码

这里获取选中的所有节点的catId也是比较简单,由于el-tree本身给我们提供了获取选中节点的方法,那么我们这边只需要进行去组装所有列表中的catId即可:

<el-button type="danger" @click="batchDelete">批量删除</el-button>
//批量删除
batchDelete() {
    let ids = []
    //两个参数:1. 是否只是叶子节点,默认值为 false 2. 是否包含半选节点,默认值为 false
    //这里的话对于半选节点我们无需去进行选中所以第二个参数为false
    let checkNodes = this.$refs.menuTree.getCheckedNodes(false, false);
    console.log("被选中的节点:", checkNodes);
    //遍历所有节点
    for (let i = 0; i < checkNodes.length; i++) {
        ids.push(checkNodes[i].catId);
    }
    //发送批量删除节点
    this.$http({
        url: this.$http.adornUrl("/product/category/delete"),
        method: "post",
        data: this.$http.adornData(ids, false)
    }).then(({ data }) => {
        this.$message({
            type: "success",
            message: "批量删除成功!"
        });
        //查询所有菜单
        this.getMenus();
    });
}

3.2、品牌管理API

3.2.1、通过逆向工程快速构建品牌管理页面

首先我们来进行手动添加菜单:

image-20221110151036274

找到对应逆向生成的品牌brand表的增删改页面代码:

image-20221110151235240

将其添加到我们的vue项目的views目录下:

image-20221110152639214

此时我们进入到管理界面就可以看到基于数据库pms_brand表中属性的基本增删改查代码就已经完成了。

image-20221110152931719

**为什么对于新增、修改上面的按钮没有呢?**主要是因为renren-fast有对应的权限校验,我们将对应的校验代码注释了即可(暂时)。

image-20221110153116667

找到对应的isAuth函数,暂时将其中的代码注释,并且直接返回true即可。

image-20221110153207060

OK,至此新增、批量删除的按钮就已经出现了:

image-20221110153235650


3.2.2、品牌状态显示优化及与后端交互(后端+前端实现)

效果展示及思路

对于管理的页面状态显示:

image-20221110160046539

新增按钮点击展示的显示状态部分:

image-20221110160114772

将原本显示的状态值1或0更改为这种开关形式,对于上面的两个显示状态用途如下:

1、管理页面显示状态:点击开关时就会向后台去更新状态。

2、新增页面的:仅仅只是为了在新增时更加简便的操作。


后端代码

image-20221110160327117

@RestController
@RequestMapping("product/brand")
public class BrandController {
    @Autowired
    private BrandService brandService;
    
    /**
     * 修改状态
     */
    @RequestMapping("/update/status")
    //@RequiresPermissions("product:brand:update")
    public R updateStatus(@RequestBody BrandEntity brand){
        brandService.updateById(brand);
        return R.ok();
    }
}

对应的api接口如下,该接口会通过网关来进行转发到商品服务(在此之前是有在网关来进行配置的):

image-20221110161129446

前端代码

管理系统中的显示部分

对于这种在el-table中来进行设置switch的我们需要使用到插槽,设置插槽之后在其中编写el-switch组件即可,并且对应的想要获取到对应的showStatus,可以借助scope来进行获取:

<el-table-column
  prop="showStatus"
  header-align="center"
  align="center"
  label="显示状态"
>
  <!-- 在el-table-column中可以使用插槽,获取对应行的值就可以通过scope.row.xx -->
  <template slot-scope="scope">
    <el-switch
      v-model="scope.row.showStatus"
      active-color="#13ce66"
      inactive-color="#ff4949"
      :active-value="1"
      :inactive-value="0"
      @change="updateBrandStatus(scope.row)"
    >
    </el-switch>
  </template>
</el-table-column>

当我们来进行点击开关按钮的时候,实际上就需要来进行发送请求去更新某个品牌的状态了,所以这个更改状态机制可以借助el-switch给我们提供的change方法,我们来自己编写一个更新状态的函数,在其中去发送请求即可:

//更新品牌状态
//若是原生不自己传值的话,默认会传对应switch的切换的值
//这里的话将完整的行数据传过来
updateBrandStatus(data) {
  console.log("最新值为:", data);
  //解构得到对应的属性值
  let {brandId, showStatus} = data
  this.$http({
    url: this.$http.adornUrl("/product/brand/update/status"),
    method: "post",
    data: this.$http.adornData({brandId, showStatus}, false)
  }).then(({ data }) => {
    this.$message({
      type: "success",
      message: "状态更新成功!"
    });
  });
},

新增按钮页面显示部分

对于el-form表单中若是想要修改对应的表单内容就无需使用插槽,直接添加对应的switch组件即可,其中的状态值获取使用对应的dataForm.showStatus即可:

<el-form-item label="显示状态" prop="showStatus">
  <!-- <el-input v-model="dataForm.showStatus" placeholder="显示状态[0-不显示;1-显示]"></el-input> -->
  <!-- 获取到表单的属性需要:dataForm.showStatus -->
  <el-switch
    v-model="dataForm.showStatus"
    active-color="#13ce66"
    inactive-color="#ff4949"
    :active-value="1"
    :inactive-value="0"
  >
  </el-switch>
</el-form-item>

3.2.3、集成OSS服务并创建上传组件(获取服务端签名方式上传,品牌新增、编辑)

前言:认识文件存储服务与服务端签名

文件存储服务如下

image-20221109211051494

阿里云开通OSS存储服务可见我的博客:阿里云开通OSS存储服务详细流程

对于图片上传方式有两种:OSS两种上传方式案例demo可见我的博客:SpringBoot集成阿里云OSS存储服务(普通上传、服务端签名上传)

第一种:普通上传方式

image-20221109211728445

第二种:服务端签名后直传

image-20221109211741989

思路:利用阿里云提供的一个防伪的签名,客户端从服务器获取到之后拿着防伪签名+图片资源来进行直接上传oss,oss会进行验证防伪签名。

过程问题:web前端给OSS直接发送请求会出现跨域请求问题怎么解决?

解决方案:直接在oss上来进行设置允许跨域。

项目分析:在谷粒商城中我们构建第一个第三方服务的模块,将OSS以及一些其他的第三方模块来进行引入,之后若是有服务需要进行第三方服务的构建直接引入依赖即可!


3.2.3.1、创建gulimall-third-party模块

image-20221110191934755

image-20221110191923686

image-20221110192017161

直接finish即可创建完成:

image-20221110192211821


3.2.3.2、nacos创建命名空间与配置文件

创建third-party的命名空间:

image-20221110193330131

image-20221110193353591

创建配置third-party命名空间中的配置文件oss.yml

image-20221110193614731

image-20221110193704223

spring:
  cloud:
    alicloud:
      access-key:
      secret-key: 
      oss:
        endpoint: 
        # 这是自定义的,需要自己来去读取
        bucketname:

3.2.3.3、搭建OSS服务签名服务(最佳实践)
阿里云官网bucket开启跨域

文档地址:最佳实践-web端上传数据至OSS-服务器端直传并设置上传回调

创建Bucket、配置用户账号以及允许跨域操作如下:

若是我们仅仅用来自己项目测试的话可以按照下面的来进行创建:

image-20221110165504958

记住要为对应的bucket设置对应的用户权限:对于创建用户操作见我上面的文章链接跟着走一遍就行!

image-20221110171909635

image-20221110172640265

配置跨域请求:

image-20221111092944266

image-20221111092957833


后端实现代码

image-20221110204546770

首先是配置pom.xml:引入的有公共模块、openfeign、springcloud-alibaba-oss服务

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.8.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<properties>
    <java.version>1.8</java.version>
    <spring-cloud.version>Greenwich.SR3</spring-cloud.version>
</properties>

<dependencies>
    <!--   引入公共模块     -->
    <dependency>
        <groupId>com.atguigu.gulimall</groupId>
        <artifactId>gulimall-common</artifactId>
        <version>0.0.1-SNAPSHOT</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>

    <!--  OSS服务      -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>${spring-cloud.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-alibaba-dependencies</artifactId>
            <version>2.1.0.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

application.yaml:配置端口号

server:
  port: 30000

bootstrap.properties:服务名、注册中心配置中心地址以及额外从配置中心读取的配置内容

spring.application.name=gulimall-third-party

# 服务注册
spring.cloud.nacos.discovery.server-addr=localhost:8848
# 配置中心
spring.cloud.nacos.config.server-addr=localhost:8848
spring.cloud.nacos.config.namespace=0dba868a-457a-4da6-8de2-9811b8585f7b

# 额外配置选项
spring.cloud.nacos.config.ext-config[0].data-id=oss.yml
spring.cloud.nacos.config.ext-config[0].group=DEFAULT_GROUP
spring.cloud.nacos.config.ext-config[0].refresh=true

GulimallThirdPartyApplication.java:启动器上去添加服务注册发现以及排除数据源自动配置类

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@EnableDiscoveryClient

OssController.java:接着就是我们的获取OSS签名服务

package com.atguigu.gulimall.thirdparty.controller;

import com.aliyun.oss.OSS;
import com.aliyun.oss.common.utils.BinaryUtil;
import com.aliyun.oss.model.MatchMode;
import com.aliyun.oss.model.PolicyConditions;
import com.atguigu.common.utils.R;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @Description:
 * @Author: changlu
 * @Date: 7:03 PM
 */
@RestController
public class OssController {

    @Autowired
    private OSS ossClient;

    @Value("${spring.cloud.alicloud.oss.bucketname}")
    private String bucketName;

    @Value("${spring.cloud.alicloud.access-key}")
    private String accessId;

    @Value("${spring.cloud.alicloud.oss.endpoint}")
    private String endpoint;

    @RequestMapping("/oss/policy")
    public R policy() {
        // 填写Host地址,格式为https://bucketname.endpoint。
        String host = "https://" + bucketName + "." + endpoint;
        String dirName = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
        // 设置上传到OSS文件的前缀,可置空此项。置空后,文件将上传至Bucket的根目录下。
        //无需来进行/拼接,前端会自己做拼接操作
        String dir = dirName;

        Map<String, String> respMap = null;
        try {
            long expireTime = 30;
            long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
            Date expiration = new Date(expireEndTime);
            PolicyConditions policyConds = new PolicyConditions();
            policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
            policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);
            //生成签名
            String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
            byte[] binaryData = postPolicy.getBytes("utf-8");
            String encodedPolicy = BinaryUtil.toBase64String(binaryData);
            String postSignature = ossClient.calculatePostSignature(postPolicy);

            respMap = new LinkedHashMap<String, String>();
            respMap.put("accessid", accessId);
            respMap.put("policy", encodedPolicy);
            respMap.put("signature", postSignature);
            respMap.put("dir", dir);
            respMap.put("host", host);
            respMap.put("expire", String.valueOf(expireEndTime / 1000));
        } catch (Exception e) {
            // Assert.fail(e.getMessage());
            System.out.println(e.getMessage());
        }
        return R.ok().put("data", respMap);
    }

}

启动该服务后测试下是否能够获取到签名:

image-20221111102559856

image-20221111101514746


前端上传组件引入及使用(品牌编辑)

引入upload组件以及修改上传地址

谷粒商城已经给我们封装好了上传组件:

  • 组件内部流程:点击上传之后,①首先会到服务器端获取到签名。②拿到签名之后再从web端去直接向阿里云的OSS服务发起上传请求,最终由组件来自己拼接访问地址传给我们自己绑定的属性中。

image-20221111102803867

链接:https://pan.baidu.com/s/1GLu4i1m27N6ioTE-RFP6fg 
提取码:pecf

将其放置到Components目录下:

image-20221111102921928

在品牌编辑中我们仅仅只需要一个单文件上传组件,所以我们需要修改单文件上传组件的action:

image-20221111102957404

修改的目标地址如下面红框中的地址:

image-20221111103106096

此时准备操作已经完成!

集成上传组件到编辑品牌页

目标需求

image-20221111103337633

实践

image-20221111103814162

找到/views/product/brand-add-or-update.vue文件,来引入单文件上传组件,并进行编辑:

在script标签中引入我们的单文件上传组件:

import SingleUpload  from "@/components/upload/singleUpload"

  export default {
    components: {SingleUpload},

在el-form-item标签中将原本的input换为我们的upload组件:

<el-form-item label="品牌logo地址" prop="logo">
    <single-upload v-model="dataForm.logo"></single-upload>
</el-form-item>

到此为止,我们就已经完成了集成上传功能!

测试一下

点击修改,接着我们来点击上传,若是出现效果如下表示上传成功:

image-20221111103947747


抱歉,我无法提供谷粒商城P59逆向工程代码的具体内容。逆向工程的代码是通过将已有的系统或应用程序进行反向分析和重构而生成的代码,它可以帮助开发人员理解和修改已有的代码。根据引用的描述,谷粒商城在品牌管理模块使用了逆向工程的前后端代码。具体的代码内容需要查看谷粒商城的源代码或相关文档。如果您是谷粒商城的开发人员,建议您参考引用中提供的路径和目录,复制相应的文件到指定的目录下,并进行相关的配置和修改。如果您需要进一步了解逆向工程和谷粒商城代码实现,请参考相关的开发文档或联系谷粒商城的官方支持。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [谷粒商城-基础-商品服务2-品牌管理(P59-P69)+(P75)](https://blog.csdn.net/ljn1046016768/article/details/124330573)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] - *2* *3* [谷粒商城笔记 + 前后端完整代码 + 报错问题汇总(基础)](https://blog.csdn.net/weixin_45033015/article/details/128072693)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v92^chatsearchT0_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

长路 ㅤ   

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值