乐优商城


项目背景

  • 了解电商行业
  • 了解乐优商城项目结构
  • 能独立搭建项目基本框架
  • 能参考使用ES6的新语法

项目分类

主要从需求方、盈利模式、技术侧重点这三个方面来看它们的不同

传统项目

各种企业里面用的管理系统(ERP、HR、OA、CRM、物流管理系统……)

  • 需求方:公司、企业内部
  • 盈利模式:项目本身卖钱
  • 技术侧重点:业务功能

互联网项目

门户网站、电商网站:baidu.com、qq.com、taobao.com、jd.com ……

  • 需求方:广大用户群体
  • 盈利模式:虚拟币、增值服务、广告收益……
  • 技术侧重点:网站性能、业务功能

而我们今天要聊的就是互联网项目中的重要角色:电商

电商行业的发展

钱(前)景

近年来,中国的电子商务快速发展,交易额连创新高,电子商务在各领域的应用不断拓展和深化、相关服务业蓬勃发展、支撑体系不断健全完善、创新的动力和能力不断增强。电子商务正在与实体经济深度融合,进入规模性发展阶段,对经济社会生活的影响不断增大,正成为我国经济发展的新引擎。

中国电子商务研究中心数据显示,截止到 2012 年底,中国电子商务市场交易规模达 7.85万亿人民币,同比增长 30.83%。其中,B2B 电子商务交易额达 6.25 万亿,同比增长 27%。而 2011 年全年,中国电子商务市场交易额达 6 万亿人民币,同比增长 33%,占 GDP 比重上升到 13%;2012 年,电子商务占 GDP 的比重已经高达 15%。

数据

来看看双十一的成交数据:

2016双11开场30分钟,创造每秒交易峰值17.5万笔每秒支付峰值12万笔的新纪录。菜鸟单日物流订单量超过4.67亿,创历史新高。

技术特点

从上面的数据我们不仅要看到钱,更要看到背后的技术实力。正是得益于电商行业的高强度并发压力,促使了BAT等巨头们的技术进步。电商行业有些什么特点呢?

  • 技术范围广
  • 技术新
  • 高并发(分布式、静态化技术、缓存技术、异步并发、池化、队列)
  • 高可用(集群、负载均衡、限流、降级、熔断)
  • 数据量大
  • 业务复杂
  • 数据安全

常用电商模式

电商行业的一些常见模式:

  • B2C:商家对个人,如:亚马逊、当当等
  • C2C平台:个人对个人,如:闲鱼、拍拍网、ebay
  • B2B平台:商家对商家,如:阿里巴巴、八方资源网等
  • O2O:线上和线下结合,如:饿了么、电影票、团购等
  • P2P:在线金融,贷款,如:网贷之家、人人聚财等。
  • B2C平台:天猫、京东、一号店等

专业术语

  • SaaS:软件即服务

  • SOA:面向服务

  • RPC:远程过程调用

  • RMI:远程方法调用

  • PV:(page view),即页面浏览量;

    用户每1次对网站中的每个网页访问均被记录1次。用户对同一页面的多次访问,访问量累计

  • UV:(unique visitor),独立访客

    指访问某个站点或点击某条新闻的不同IP地址的人数。在同一天内,uv只记录第一次进入网站的具有独立IP的访问者,在同一天内再次访问该网站则不计数。

  • PV与带宽:

    • 计算带宽大小需要关注两个指标:峰值流量和页面的平均大小。
    • 计算公式是:网站带宽= ( PV * 平均页面大小(单位MB)* 8 )/统计时间(换算到秒)
    • 为什么要乘以8?
      • 网站大小为单位是字节(Byte),而计算带宽的单位是bit,1Byte=8bit
    • 这个计算的是平均带宽,高峰期还需要扩大一定倍数
  • PV、QPS、并发

    • QPS:每秒处理的请求数量。
      • 比如你的程序处理一个请求平均需要0.1S,那么1秒就可以处理10个请求。QPS自然就是10,多线程情况下,这个数字可能就会有所增加。
    • 由PV和QPS如何需要部署的服务器数量?
      • 根据二八原则,80%的请求集中在20%的时间来计算峰值压力:
      • (每日PV * 80%) / (3600s * 24 * 20%) * 每个页面的请求数 = 每个页面每秒的请求数量
      • 然后除以服务器的QPS值,即可计算得出需要部署的服务器数量

项目开发流程

项目经理:管人

技术经理:

产品经理:设计需求原型

测试:

前端:大前端:UI 前端页面。

后端:

移动端:

项目开发流程图:

公司现状:

乐优商城介绍

项目介绍

  • 乐优商城是一个全品类的电商购物网站(B2C)。
  • 用户可以在线购买商品、加入购物车、下单
  • 可以评论已购买商品
  • 管理员可以在后台管理商品的上下架、促销活动
  • 管理员可以监控商品销售状况
  • 客服可以在后台处理退款操作
  • 希望未来3到5年可以支持千万用户的使用

系统架构

架构图

乐优商城架构缩略图:

系统架构解读

整个乐优商城可以分为两部分:后台管理系统、前台门户系统。

  • 后台管理:
    • 后台系统主要包含以下功能:
      • 商品管理,包括商品分类、品牌、商品规格等信息的管理
      • 销售管理,包括订单统计、订单退款处理、促销活动生成等
      • 用户管理,包括用户控制、冻结、解锁等
      • 权限管理,整个网站的权限控制,采用JWT鉴权方案,对用户及API进行权限控制
      • 统计,各种数据的统计分析展示
    • 后台系统会采用前后端分离开发,而且整个后台管理系统会使用Vue.js框架搭建出单页应用(SPA)。
  • 前台门户
    • 前台门户面向的是客户,包含与客户交互的一切功能。例如:
      • 搜索商品
      • 加入购物车
      • 下单
      • 评价商品等等
    • 前台系统我们会使用Thymeleaf模板引擎技术来完成页面开发。出于SEO优化的考虑,我们将不采用单页应用。

无论是前台还是后台系统,都共享相同的微服务集群,包括:

  • 商品微服务:商品及商品分类、品牌、库存等的服务
  • 搜索微服务:实现搜索功能
  • 订单微服务:实现订单相关
  • 购物车微服务:实现购物车相关功能
  • 用户中心:用户的登录注册等功能
  • Eureka注册中心
  • Zuul网关服务

项目搭建

技术选型

前端技术:

  • 基础的HTML、CSS、JavaScript(基于ES6标准)
  • JQuery
  • Vue.js 2.0以及基于Vue的框架:Vuetify(UI框架)
  • 前端构建工具:WebPack
  • 前端安装包工具:NPM
  • Vue脚手架:Vue-cli
  • Vue路由:vue-router
  • ajax框架:axios
  • 基于Vue的富文本框架:quill-editor

后端技术:

  • 基础的SpringMVC、Spring 5.x和MyBatis3
  • Spring Boot 2.1.10版本
  • Spring Cloud Greenwich.SR4
  • Redis
  • RabbitMQ
  • Elasticsearch
  • nginx
  • FastDFS
  • MyCat
  • Thymeleaf
  • mysql 5.7

开发环境

为了保证开发环境的统一,希望每个人都按照我的环境来配置:

  • IDEA
  • JDK:JDK1.8
  • 项目构建:maven 3.6.2
  • 版本控制工具:git

域名

我们在开发的过程中,为了保证以后的生产、测试环境统一。尽量都采用域名来访问项目。

一级域名:www.leyou.com,leyou.com

二级域名:manage.leyou.com/item , api.leyou.com

我们可以通过switchhost工具来修改自己的host对应的地址,只要把这些域名指向127.0.0.1,那么跟你用localhost的效果是完全一样的。

switchhost下载连接:https://github.com/oldj/SwitchHosts/releases

创建父工程

创建工程

创建Maven工程,打包方式为pom,项目名为leyou-parent

添加依赖

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-parent</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

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


    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>Greenwich.SR4</spring-cloud.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <mybatis.starter.version>2.1.1</mybatis.starter.version>
        <mapper.starter.version>2.1.5</mapper.starter.version>
        <druid.starter.version>1.1.10</druid.starter.version>
        <mysql.version>5.1.38</mysql.version>
        <pageHelper.starter.version>1.2.12</pageHelper.starter.version>
        <fastDFS.client.version>1.26.1-RELEASE</fastDFS.client.version>
        <mybatis.plus.version>3.3.1</mybatis.plus.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <!-- springCloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <!-- mybatis启动器 -->
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>${mybatis.starter.version}</version>
            </dependency>
            <!-- 通用Mapper启动器 -->
            <dependency>
                <groupId>tk.mybatis</groupId>
                <artifactId>mapper-spring-boot-starter</artifactId>
                <version>${mapper.starter.version}</version>
            </dependency>
            <!-- 分页助手启动器 -->
            <dependency>
                <groupId>com.github.pagehelper</groupId>
                <artifactId>pagehelper-spring-boot-starter</artifactId>
                <version>${pageHelper.starter.version}</version>
            </dependency>
            <!-- mysql驱动 -->
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>${mysql.version}</version>
            </dependency>
            <!--FastDFS客户端-->
            <dependency>
                <groupId>com.github.tobato</groupId>
                <artifactId>fastdfs-client</artifactId>
                <version>${fastDFS.client.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

建议:可以将src文件夹删掉了,因为此模块仅仅做父模块,管理一些依赖。

创建EurekaServer

创建工程

创建工程,将模块命名为leyou-registry

添加依赖

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-registry</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
    </dependencies>

</project>

修改配置文件

server:
  port: 10086

spring:
  application:
    name: leyou-registery

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    register-with-eureka: false # 把自己注册到eureka服务列表
    fetch-registry: false # 拉取eureka服务信息
  server:
    eviction-interval-timer-in-ms: 5000 # 每隔5秒钟,进行一次服务列表的清理
    enable-self-preservation: false

logging:
  level: 
    top.codekiller.leyouRegistry: debug

编写启动类

package top.codekiller.leyouRegistry;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@EnableEurekaServer
@SpringBootApplication
public class LeyouSpringApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouSpringApplication.class);
    }
}

创建Zuul网关

创建模块

依旧是选择创建maven模块,项目名为leyou-gateway

添加依赖

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-gateway</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
    </dependencies>

</project>

修改配置文件

server:
  port: 10010

spring:
  application:
    name: leyou-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    fetch-registry: true
    registry-fetch-interval-seconds: 5
    # 还可配置registery的续约时间间隔和过期间隔

zuul:
  prefix: /api

#配置熔断
#hystrix:
#  command:
#    default:
#      execution:
#        isolation:
#          thread:
#            timeoutInMilliseconds: 2000 # 设置hystrix的超时时间2秒

编写启动类

package top.codekiller.leyouGateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
@EnableDiscoveryClient
public class LeyouGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouGatewayApplication.class);
    }
}

创建商品微服务

既然是一个全品类的电商购物平台,那么核心自然就是商品。因此我们要搭建的第一个服务,就是商品微服务。其中会包含对于商品相关的一系列内容的管理,包括:

  • 商品分类管理
  • 品牌管理
  • 商品规格参数管理
  • 商品管理
  • 库存管理

微服务的结构

因为与商品的品类相关,我们的工程命名为leyou-item.

需要注意的是,我们的leyou-item是一个微服务,那么将来肯定会有其它系统需要来调用服务中提供的接口,获取的接口数据,也需要对应的实体类来封装,因此肯定也会使用到接口中关联的实体类。

因此这里我们需要使用聚合工程,将要提供的接口及相关实体类放到独立子工程中,以后别人引用的时候,只需要知道坐标即可。

我们会在leyou-item中创建两个子工程:

  • leyou-item-interface:主要是对外暴露的接口及相关实体类
  • leyou-item-service:所有业务逻辑及内部使用接口

调用关系如图所示:

leyou-item

因为是聚合工程,所以把项目打包方式设置为pom,创建完成后把src目录删除

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <modules>
        <module>leyou-item-interface</module>
        <module>leyou-item-service</module>
    </modules>

    <parent>
        <groupId>top.codekiller.leyou</groupId>
        <artifactId>leyou-parent</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

</project>

leyou-item-interface

leyou-item下创建leyou-item-interface模块

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou-item</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item-interface</artifactId>


</project>

leyou-item-service

leyou-item下创建leyou-item-service模块

引入依赖

思考一下我们需要什么?

  • Eureka客户端
  • web启动器
  • mybatis-plus
  • 连接池,我们用druid
  • mysql驱动
  • 千万不能忘了,我们自己也需要leyou-item-interface中的实体类

这些依赖,我们在顶级父工程:leyou中已经添加好了。所以直接引入即可:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou-item</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-item-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.20</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

</project>

修改配置文件

server:
  port: 8081
spring:
  application:
    name: item-service
  datasource:
    username: root
    password: root
    url: jdbc:mysql://localhost:3306/leyoumall?characterEncoding=UTF-8&serverTimezone=UTC
    type: com.alibaba.druid.pool.DruidDataSource
    # 下面为连接池的补充设置,应用到上面所有数据源中
    # 初始化大小,最小,最大
    initialSize: 5   #初始化创建的连接数
    minIdle: 5  #最小空闲连接数
    maxActive: 20 #最大活跃连接数,不宜设置过多
    # 配置获取连接等待超时的时间
    maxWait: 60000
    # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
    timeBetweenEvictionRunsMillis: 60000
    # 配置一个连接在池中最小生存的时间,单位是毫秒
    minEvictableIdleTimeMillis: 30000
    #用来检测连接是否有效的sql,要求是一个查询语句,常用select 'x'。
    validationQuery: select 'x';
    #建议配置为true,不影响性能,并且保证安全性。申请连接的时候检测,
    testWhileIdle: true
    #申请连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。
    testOnBorrow: false
    #归还连接时执行validationQuery检测连接是否有效,做了这个配置会降低性能。
    testOnReturn: false
    # 打开PSCache,并且指定每个连接上PSCache的大小
    #是否缓存preparedStatement,也就是PSCache。PSCache对支持游标的数据库性能提升巨大,比如说oracle。在mysql下建议关闭。
    poolPreparedStatements: true
    #要启用PSCache,必须配置大于0,当大于0时,poolPreparedStatements自动触发修改为true。
    #在Druid中,不会存在Oracle下PSCache占用内存过多的问题,可以把这个数值配置大一些,比如说100
    maxPoolPreparedStatementPerConnectionSize: 20
    # 配置监控统计拦截的filters,去掉后监控界面sql无法统计,stat用于监控统计,'wall'用于防火墙,防御sql注入,slf4j用于日志
    filters: stat,wall,slf4j
    # 通过connectProperties属性来打开mergeSql功能;慢SQL记录
    connectionProperties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=5000
    # 合并多个DruidDataSource的监控数据
    useGlobalDataSourceStat: true
eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    register-with-eureka: true
  instance:
    lease-renewal-interval-in-seconds: 5
    lease-expiration-duration-in-seconds: 15

mybatis-plus:
  type-aliases-package: top.codekiller.leyou.pojo

编写启动类

@SpringCloudApplication
public class LeyouItemServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(LeyouItemServiceApplication.class);
    }
}

添加商品微服务的路由规则

既然商品微服务已经创建,接下来肯定要添加路由规则到Zuul中,我们不使用默认的路由规则。

修改leyou-gateway工程的application.yaml配置文件:

server:
  port: 10010

spring:
  application:
    name: leyou-gateway

eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
    fetch-registry: true
    registry-fetch-interval-seconds: 5
    # 还可配置registery的续约时间间隔和过期间隔

zuul:
  prefix: /api
  routes: 
    item-service: /item/**

#配置熔断
#hystrix:
#  command:
#    default:
#      execution:
#        isolation:
#          thread:
#            timeoutInMilliseconds: 2000 # 设置hystrix的超时时间2秒

启动测试

我们分别启动:leyou-registryleyou-gatewayleyou-item-service

为了测试路由规则是否畅通,我们是不是需要在item-service中编写一个controller接口呢?

其实不需要,SpringBoot提供了一个依赖:actuator

只要我们添加了actuator的依赖,它就会为我们生成一系列的访问接口:

  • /info
  • /health
  • /refresh

item-service中添加依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

因为我们没有添加信息,所以是一个空的json,但是可以肯定的是:我们能够访问到item-service了。

接下来我们通过路由访问试试,根据路由规则,我们需要访问的地址是:http://localhost:10010/api/item/actuator/info

访问不到重启网关后再试

通用工具模块

有些工具或通用的约定内容,我们希望各个服务共享,因此需要创建一个工具模块:leyou-common

leyou-parent创建leyou-common模块

  • leyou-parent:父工程,管理版本号
    • leyou-common:通用工程(存放工具类类等)
    • leyou-gateway:网关工程,拦截并分发请求
    • leyou-item:商品聚合工程
      • leyou-item-interface:存放pojo对象
      • leyou-item-service:对外提供rest接口的具体实现
  • leyou-registry:eureka注册中心

搭建后台管理前台页面

将前端页面工程解压后移动到工作空间下,然后使用IDEA打开,安装依赖

安装依赖

我们只需要打开终端,进入项目目录,输入:npm install命令,即可安装这些依赖。

运行测试

在package.json文件中有scripts启动脚本配置,可以输入命令:npm run dev或者npm start

发现默认的端口是9001。访问:http://localhost:9001

目录结构

webpack:是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。并且提供了前端项目的热部署插件。

通用关系

我们最主要理清index.html、main.js、App.vue之间的关系:

理一下:

  • index.html:html模板文件。定义了空的div,其id为app

  • main.js:实例化vue对象,并且通过id选择器绑定到index.html的div中,因此main.js的内容都将在index.html的div中显示。main.js中使用了App组件,即App.vue,main.js中还定义了路由,路由的信息

    import router from './router',是引入当前文件夹下的router文件夹,由于router只有一个文件而且名称为index.js,所以可以直接写文件夹名称

  • index.js:定义请求路径和组件的映射关系。相当于之前的<vue-router>

  • App.vue中也没有内容,而是定义了vue-router的锚点:<router-view>,我们之前讲过,vue-router路由后的组件将会在锚点展示。

  • 最终结论:一切路由后的内容都将通过App.vue在index.html中显示。

  • 访问流程:用户在浏览器输入路径,例如:http://localhost:9001/#/item/brand –> index.js(/item/brand路径对应pages/item/Brand.vue组件) –> 该组件显示在App.vue的锚点位置 –> main.js使用了App.vue组件,并把该组件渲染在index.html文件中(id为“app”的div中)

也就是说index.html中最终展现的是App.vue中的内容。index.html引用它之后,就拥有了vue的内容(包括组件、样式等),所以,main.js也是webpack打包的入口

Vuetify

为什么学习UI框架

Vue虽然会帮我们进行视图的渲染,但样式还是由我们自己来完成。这显然不是我们的强项,因此后端开发人员一般都喜欢使用一些现成的UI组件,拿来即用,常见的例如:

  • BootStrap
  • LayUI
  • EasyUI
  • ZUI

然而这些UI组件的基因天生与Vue不合,因为他们更多的是利用DOM操作,借助于jQuery实现,而不是MVVM的思想。

而目前与Vue吻合的UI框架也非常的多,国内比较知名的如:

  • element-ui:饿了么出品
  • i-view:某公司出品

然而我们都不用,我们今天推荐的是一款国外的框架:Vuetify

官方网站:https://vuetifyjs.com/zh-Hans/

为什么选择Vuetify

有中国的为什么还要用外国的?原因如下:

  • Vuetify几乎不需要任何CSS代码,而element-ui许多布局样式需要我们来编写
  • Vuetify从底层构建起来的语义化组件。简单易学,容易记住。
  • Vuetify基于Material Design(谷歌推出的多平台设计规范),更加美观,动画效果酷炫,且风格统一

这是官网的说明:

缺陷:

  • 目前官网虽然有中文文档,但因为翻译问题,几乎不太能看。

怎么用

基于官方网站的文档进行学习:

我们重点关注UI components即可,里面有大量的UI组件,我们要用的时候再查看,不用现在学习,先看下有什么:

以后用到什么组件,就来查询即可。

项目页面布局

接下来我们一起看下页面布局。

Layout组件是我们的整个页面的布局组件:

一个典型的三块布局。包含左,上,中三部分:

里面使用了Vuetify中的2个组件和一个布局元素:

导航抽屉

v-navigation-drawer :导航抽屉,主要用于容纳应用程序中的页面的导航链接。

工具栏

v-toolbar:工具栏通常是网站导航的主要途径。可以与导航抽屉一起很好地工作,动态选择是否打开导航抽屉,实现可伸缩的侧边栏。

布局元素

v-content:并不是一个组件,而是标记页面布局的元素。可以根据您指定的app组件的结构动态调整大小,使得您可以创建高度可定制的组件。

那么问题来了:v-content中的内容来自哪里?

  • Layout映射的路径是/
  • 除了Login以外的所有组件,都是定义在Layout的children属性,并且路径都是/的下面
  • 因此当路由到子组件时,会在Layout中定义的锚点中显示。
  • 并且Layout中的其它部分不会变化,这就实现了布局的共享。

使用域名访问本地仓库

统一环境

我们现在访问页面使用的是:http://localhost:9001

有没有什么问题?

实际开发中,会有不同的环境:

  • 开发环境:自己的电脑
  • 测试环境:提供给测试人员使用的环境
  • 预发布环境:数据是和生成环境的数据一致,运行最新的项目代码进去测试
  • 生产环境:项目最终发布上线的环境

如果不同环境使用不同的ip去访问,可能会出现一些问题。为了保证所有环境的一致,我们会在各种环境下都使用域名来访问。

我们将使用以下域名:

但是最终,我们希望这些域名指向的还是我们本机的某个端口。

那么,当我们在浏览器输入一个域名时,浏览器是如何找到对应服务的ip和端口的呢?

域名解析

一个域名一定会被解析为一个或多个ip。这一般会包含两步:

  • 本地域名解析

    浏览器会首先在本机的hosts文件中查找域名映射的IP地址,如果查找到就返回IP ,没找到则进行域名服务器解析,一般本地解析都会失败,因为默认这个文件是空的。

    • Windows下的hosts文件地址:C:/Windows/System32/drivers/etc/hosts
    • Linux下的hosts文件所在路径: /etc/hosts

    样式:

    # My hosts
    127.0.0.1 localhost
  • 域名服务器解析

    本地解析失败,才会进行域名服务器解析,域名服务器就是网络中的一台计算机,里面记录了所有注册备案的域名和ip映射关系,一般只要域名是正确的,并且备案通过,一定能找到。

解决域名解析问题

我们不可能去购买一个域名,因此我们可以伪造本地的hosts文件,实现对域名的解析。修改本地的host为:

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com

这样就实现了域名的关系映射了。

每次在C盘寻找hosts文件并修改是非常麻烦的,给大家推荐一个快捷修改host的工具

下载连接

解压,运行exe文件,效果:

Linux版效果

我们添加了两个映射关系(中间用空格隔开):

  • 127.0.0.1 api.leyou.com :我们的网关Zuul
  • 127.0.0.1 manage.leyou.com:我们的后台系统地址

切换为生效状态然后访问:http://manage.leyou.com:9001

出现如下效果就代表配置成功

Invalid Host header解

原因:我们配置了项目访问的路径,虽然manage.leyou.com映射的ip也是127.0.0.1,但是webpack会验证host是否符合配置。

在webpack.dev.conf.js中取消host验证:disableHostCheck: true

退出重新启动,npm start,刷新浏览器

Nginx解决端口问题

域名问题解决了,但是现在要访问后台页面,还得自己加上端口:http://manage.taotao.com:9001

这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?

这里就要用到反向代理工具:Nginx

nginx介绍

什么是Nginx

nginx可以作为web服务器,但更多的时候,我们把它作为网关,因为它具备网关必备的功能:

  • 反向代理
  • 负载均衡
  • 动态路由
  • 请求过滤

nginx作为web服务器

Web服务器分2类:

  • web应用服务器,如:
    • tomcat
    • resin
    • jetty
  • web服务器,如:
    • Apache 服务器
    • Nginx
    • IIS

区分:web服务器不能解析jsp等页面,只能处理js、css、html等静态资源。 并发:web服务器的并发能力远高于web应用服务器。

nginx做反向代理

什么是反向代理?

  • 代理:通过客户机的配置,实现让一台服务器代理客户机,客户的所有请求都交给代理服务器处理。
  • 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。

nginx可以当做反向代理服务器来使用:

  • 我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
  • 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能

利用反向代理,就可以解决我们前面所说的端口问题,如图

nginx安装

windows

安装

安装非常简单,下载后直接解压即可,绿色免安装,舒服!

解压后,目录结构:

  1. conf:配置目录
  2. contrib:第三方依赖
  3. html:默认的静态资源目录,类似于tomcat的webapps
  4. logs:日志目录
  5. nginx.exe:启动程序。可双击运行,但不建议这么做。

反向代理配置

nginx中的每个server就是一个反向代理配置,可以有多个server

完整配置:

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;

    keepalive_timeout  65;

    gzip  on;
    server {
        listen       80;
        server_name  manage.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        location / {
            proxy_pass http://127.0.0.1:9001;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
    server {
        listen       80;
        server_name  api.leyou.com;

        proxy_set_header X-Forwarded-Host $host;
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

        location / {
            proxy_pass http://127.0.0.1:20001;
            proxy_connect_timeout 600;
            proxy_read_timeout 600;
        }
    }
}Copy to clipboardErrorCopied

使用

nginx可以通过命令行来启动,操作命令:

  • 启动:start nginx.exe
  • 停止:nginx.exe -s stop
  • 重新加载:nginx.exe -s reload

启动过程会闪烁一下,启动成功后,任务管理器中会有两个nginx进程:

测试

启动nginx(如果已经启动,则使用reload命令重新加载即可),然后直接用域名访问后台管理系统:

现在实现了域名访问网站了,中间的流程是怎样的呢?

  1. 浏览器准备发起请求,访问http://mamage.leyou.com,但需要进行域名解析

  2. 优先进行本地域名解析,因为我们修改了hosts,所以解析成功,得到地址:127.0.0.1

  3. 请求被发往解析得到的ip,并且默认使用80端口:http://127.0.0.1:80

    本机的nginx一直监听80端口,因此捕获这个请求

  4. nginx中配置了反向代理规则,将manage.leyou.com代理到127.0.0.1:9001,因此请求被转发

  5. 后台系统的webpack server监听的端口是9001,得到请求并处理,完成后将响应返回到nginx

  6. nginx将得到的结果返回到浏览器

实现商品分类

商城的核心自然是商品,而商品多了以后,肯定要进行分类,并且不同的商品会有不同的品牌信息,我们需要依次去完成:商品分类、品牌、商品的开发。

首先将sql文件导入数据库:leyou.sql

CREATE TABLE `tb_category` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '类目id',
  `name` varchar(20) NOT NULL COMMENT '类目名称',
  `parent_id` bigint(20) NOT NULL COMMENT '父类目id,顶级类目填0',
  `is_parent` tinyint(1) NOT NULL COMMENT '是否为父节点,0为否,1为是',
  `sort` int(4) NOT NULL COMMENT '排序指数,越小越靠前',
  PRIMARY KEY (`id`),
  KEY `key_parent_id` (`parent_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1424 DEFAULT CHARSET=utf8 COMMENT='商品类目表,类目和商品(spu)是一对多关系,类目与品牌是多对多关系';

因为商品分类会有层级关系,因此这里我们加入了parent_id字段,对本表中的其它分类进行自关联。

实现功能

在浏览器页面点击“分类管理”菜单:

根据这个路由路径到路由文件(src/route/index.js),可以定位到分类管理页面:

由路由文件知,页面是src/pages/item/Category.vue

商品分类使用了树状结构,而这种结构的组件vuetify并没有为我们提供,这里自定义了一个树状组件。不要求实现或者查询组件的实现,只要求可以参照文档使用该组件即可:

异步请求

点击商品管理下的分类管理子菜单,在浏览器控制台可以看到:

页面中没有,只是发起了一条请求:http://api.leyou.com/api/item/category/list?pid=0

大家可能会觉得很奇怪,我们明明是使用的相对路径:/item/category/list,讲道理发起的请求地址应该是:

http://manage.leyou.com/item/category/list

但实际却是:

http://api.leyou.com/api/item/category/list?pid=0

这是因为,我们有一个全局的配置文件,对所有的请求路径进行了约定:

路径是http://api.leyou.com,并且默认加上了/api的前缀,这恰好与我们的网关设置匹配,我们只需要把地址改成网关的地址即可,因为我们使用了nginx反向代理,这里可以写域名。

接下来,我们要做的事情就是编写后台接口,返回对应的数据即可。

实体类

leyou-item-interface中添加category实体类:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class Category {

    @TableId
    private Long id;
    private String name;
    private Long parentId;
    private Boolean isParent;  //注意isParent生成的getter和setter方法需要手动加上is
    private Integer sort;


}

注意在这里加上mybatis-plus和lombok的依赖>

<dependencies>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
    </dependency>
</dependencies>

Controller

编写一个controller一般需要知道四个内容:

  • 请求方式:决定我们用GetMapping还是PostMapping
  • 请求路径:决定映射路径
  • 请求参数:决定方法的参数
  • 返回值结果:决定方法的返回值

在刚才页面发起的请求中,我们就能得到绝大多数信息:

  • 请求方式:Get,查询肯定是get请求

  • 请求路径:/api/item/category/list。其中/api是网关前缀,/item是网关的路由映射,真实的路径应该是/category/list

  • 请求参数:pid=0,根据tree组件的说明,应该是父节点的id,第一次查询为0,那就是查询一级类目

  • 返回结果:??

    根据前面tree组件的用法我们知道,返回的应该是json数组:

[
    { 
        "id": 74,
        "name": "手机",
        "parentId": 0,
        "isParent": true,
        "sort": 2
    },
     { 
        "id": 75,
        "name": "家用电器",
        "parentId": 0,
        "isParent": true,
        "sort": 3
    }
]

对应的java类型可以是List集合,里面的元素就是类目对象了。也就是List

controller代码:

@RestController
@RequestMapping("category")
public class CategoryController {

    @Autowired
    private ICategoryService categoryService;

    @GetMapping("list")
    public ResponseEntity<List<Category>> queryCategoryBypid(@RequestParam(value="pid",defaultValue = "0")Long pid){
        if(pid==null||pid<0){
            //400:参数不合法
            //return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
            //return ResponseEntity<>(HttpStatus.BAD_REQUEST);
            return ResponseEntity.badRequest().build();
        }
        List<Category> categories = this.categoryService.queryCategoryByPid(pid);
        if(CollectionUtils.isEmpty(categories)){
            //404:资源服务器未找到
            //return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
            return ResponseEntity.notFound().build();
        }
        //200:查询成功
        return ResponseEntity.ok(categories);
    }

Service

@Service
public class CategoryService implements ICategoryService {

    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    @Override
    public List<Category> queryCategoryByPid(Long pid) {
        return this.categoryMapper.selectList(new QueryWrapper<Category>().lambda()
                                        .eq(Category::getParentId,pid));

    }
}

mapper

public interface CategoryMapper extends BaseMapper<Category> {

}

要注意,我们并没有在mapper接口上声明@Mapper注解,那么mybatis如何才能找到接口呢?

我们在MP的配置类上添加一个扫描包功能:

@Configuration
@MapperScan("top.codekiller.leyou.mapper")
public class MybatisPlusconfig {
}

运行产生的问题

发现报错了!

浏览器直接访问没事,但是这里却报错,什么原因?

这其实是浏览器的同源策略造成的跨域问题。

跨域问题

跨域:浏览器对于javascript的同源策略的限制 。

以下情况都属于跨域:

跨域原因说明 示例
域名不同 www.jd.comwww.taobao.com
域名相同,端口不同 www.jd.com:8080www.jd.com:8081
二级域名不同 item.jd.commiaosha.jd.com

如果域名和端口都相同,但是请求路径不同,不属于跨域,如:

www.jd.com/item
www.jd.com/goods

http和https也属于跨域

而我们刚才是从manage.leyou.com去访问api.leyou.com,这属于二级域名不同,跨域了。

为什么会有跨域问题

跨域不一定都会有跨域问题。

因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。

因此:跨域问题 是针对ajax的一种限制

但是这却给我们的开发带来了不便,而且在实际生产环境中,肯定会有很多台服务器之间交互,地址和端口都可能不同,怎么办?

解决跨域问题的方案

目前比较常用的跨域解决方案有3种:

  • Jsonp

    最早的解决方案,利用script标签可以跨域的原理实现。

    限制:

    • 需要服务的支持
    • 只能发起GET请求
  • nginx反向代理

    思路是:利用nginx把跨域反向代理为不跨域,支持各种请求方式

    缺点:需要在nginx进行额外配置,语义不清晰

  • CORS

    规范化的跨域请求解决方案,安全可靠。

    优势:

    • 在服务端进行控制是否允许跨域,可自定义规则
    • 支持各种请求方式

    缺点:

    • 会产生额外的请求

我们这里会采用cors的跨域方案。

Cors解决跨域

什么是cors

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。

它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

  • 浏览器端:

    目前,所有浏览器都支持该功能(IE10以下不行)。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。

  • 服务端:

    CORS通信与AJAX没有任何差别,因此你不需要改变以前的业务逻辑。只不过,浏览器会在请求中携带一些头信息,我们需要以此判断是否允许其跨域,然后在响应头中加入一些信息即可。这一般通过过滤器完成即可。

原理

浏览器会将ajax请求分为两类,其处理方案略有差异:简单请求、特殊请求。

简单请求

只要同时满足以下两大条件,就属于简单请求。:

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

当浏览器发现发起的ajax请求是简单请求时,会在请求头中携带一个字段:Origin.

Origin中会指出当前请求属于哪个域(协议+域名+端口)。服务会根据这个值决定是否允许其跨域。

如果服务器允许跨域,需要在返回的响应头中携带下面信息:

Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Content-Type: text/html; charset=utf-8Copy to clipboardErrorCopied
  • Access-Control-Allow-Origin:可接受的域,是一个具体域名或者*(代表任意域名)
  • Access-Control-Allow-Credentials:是否允许携带cookie,默认情况下,cors不会携带cookie,除非这个值是true

有关cookie:

要想操作cookie,需要满足3个条件:

  • 服务的响应头中需要携带Access-Control-Allow-Credentials并且为true。
  • 浏览器发起ajax需要指定withCredentials 为true
  • 响应头中的Access-Control-Allow-Origin一定不能为*,必须是指定的域名

特殊请求

不符合简单请求的条件,会被浏览器判定为特殊请求,,例如请求方式为PUT。

预检请求

特殊请求会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

一个“预检”请求的样板:

OPTIONS /cors HTTP/1.1
Origin: http://manage.leyou.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.leyou.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...Copy to clipboardErrorCopied

与简单请求相比,除了Origin以外,多了两个头:

  • Access-Control-Request-Method:接下来会用到的请求方式,比如PUT
  • Access-Control-Request-Headers:会额外用到的头信息

预检请求的响应

服务的收到预检请求,如果许可跨域,会发出响应:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://manage.leyou.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plainCopy to clipboardErrorCopied

除了Access-Control-Allow-OriginAccess-Control-Allow-Credentials以外,这里又额外多出3个头:

  • Access-Control-Allow-Methods:允许访问的方式
  • Access-Control-Allow-Headers:允许携带的头
  • Access-Control-Max-Age:本次许可的有效时长,单位是秒,过期之前的ajax请求就无需再次进行预检了

如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。

实现

虽然原理比较复杂,但是前面说过:

  • 浏览器端都有浏览器自动完成,我们无需操心
  • 服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。

事实上,SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。

leyou-gateway中编写一个配置类,并且注册CorsFilter:

@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter(){
        //初始化cors配置对象
        CorsConfiguration configuration=new CorsConfiguration();
        configuration.setAllowCredentials(true);
        //允许跨域的域名。如果要携带cookie,不能写*。*:代表所有的域名都可以跨域访问
        configuration.addAllowedOrigin("http://manage.leyou.com");
        configuration.addAllowedMethod("*");  //*:代表所有的请求方法 :GET,POST,PUT,DELETE
        configuration.addAllowedHeader("*"); //允许携带任何头信息
        //初始化cors配置源对象
        UrlBasedCorsConfigurationSource configurationSource=new UrlBasedCorsConfigurationSource();
        configurationSource.registerCorsConfiguration("/**",configuration);
        //返回corsFilter实例,参数:cors配置源对象
        return new CorsFilter(configurationSource);
    }
}

结构:

重启网关,然后刷新页面测试,访问是否正常正常:http://manage.leyou.com/#/item/category

品牌的查询

商品分类完成以后,自然轮到了品牌功能了。

先看看我们要实现的效果:

点击“品牌管理”菜单:

路由路径:/item/brand

根据路由文件知,对应的页面是:src/pages/item/Brand.vue

页面会发送如下请求:

数据库表

CREATE TABLE `tb_brand` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '品牌id',
  `name` varchar(50) NOT NULL COMMENT '品牌名称',
  `image` varchar(200) DEFAULT '' COMMENT '品牌图片地址',
  `letter` char(1) DEFAULT '' COMMENT '品牌的首字母',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=325400 DEFAULT CHARSET=utf8 COMMENT='品牌表,一个品牌下有多个商品(spu),一对多关系';

简单的四个字段,不多解释。

这里需要注意的是,品牌和商品分类之间是多对多关系。因此我们有一张中间表,来维护两者间关系:

CREATE TABLE `tb_category_brand` (
  `category_id` bigint(20) NOT NULL COMMENT '商品类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '品牌id',
  PRIMARY KEY (`category_id`,`brand_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='商品分类和品牌的中间表,两者是多对多关系';

但是,你可能会发现,这张表中并没有设置外键约束,似乎与数据库的设计范式不符。为什么这么做?

  • 外键会严重影响数据库读写的效率
  • 数据删除时会比较麻烦

在电商行业,性能是非常重要的。我们宁可在代码中通过逻辑来维护表关系,也不设置外键。

实体类

@Data
public class Brand {
    @TableId
    private Long id;
    private String name;
    private String image;
    private Character letter;

}

Controller

编写controller先思考四个问题,参照前端页面的控制台

  • 请求方式:查询,肯定是Get
  • 请求路径:分页查询,/brand/page
  • 请求参数:根据我们刚才编写的页面,有分页功能,有排序功能,有搜索过滤功能,因此至少要有5个参数:
    • page:当前页,int
    • rows:每页大小,int
    • sortBy:排序字段,String
    • desc:是否为降序,boolean
    • key:搜索关键词,String
  • 响应结果:分页结果一般至少需要两个数据
    • total:总条数
    • items:当前页数据
    • totalPage:有些还需要总页数

这里我们封装一个类,来表示分页结果

由于这个分页类可能不止商品服务中需要其他服务可能也需要,所以我们给它放在leyou-common

@Data
@AllArgsConstructor
@NoArgsConstructor
public class PageResult<T>  {

    /**
     * 当前数据总条数
     */
    private Long total;
    /**
     * 当前总页数
     */
    private Integer totalPage;
    /**
     * 当前页数据
     */
    private List<T> items;
}

然后在leyou-item-service工程的pom.xml中引入leyou-common的依赖

<!--leyou-common-->
<dependency>
    <groupId>com.leyou.common</groupId>
    <artifactId>leyou-common</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

接下来,我们编写Controller

@RestController
@RequestMapping("brand")
public class BrandController {
    @Autowired
    IBrandService brandService;


    /**
     * 根据查询条件分页查询品牌信息,并排序
     * @param key 关键字
     * @param page  查询页数
     * @param rows  显示行数
     * @param sortBy 通过那个字段进行排序
     * @param desc  是否是降序
     * @return
     */
    @GetMapping("page")
    public ResponseEntity<PageResult<Brand>> queryBrandsByPage(@RequestParam(value="key",required = false)String key,
                                                               @RequestParam(value="page",defaultValue = "1")Integer page,
                                                               @RequestParam(value="rows",defaultValue = "5")Integer rows,
                                                               @RequestParam(value="sortBy",required = false)String sortBy,
                                                               @RequestParam(value="desc",required = false)Boolean desc){
        PageResult<Brand> result = this.brandService.queryBrandByPage(key, page, rows, sortBy, desc);
        if( CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);
    }

}

Service

@Service
public class BrandService implements IBrandService {

    @Autowired
    private BrandMapper brandMapper;
    /**
     * 根据查询条件分页查询品牌信息,并排序
     * @param key 关键字
     * @param page  查询页数
     * @param rows  显示行数
     * @param sortBy 通过那个字段进行排序
     * @param desc  是否是降序
     * @return
     */
    @Override
    public PageResult<Brand> queryBrandByPage(String key, Integer page, Integer rows, String sortBy, Boolean desc) {
        QueryWrapper<Brand> queryWrapper=new QueryWrapper<Brand>();
        //根据name模糊查询,或者根据首字母查询
        if(StringUtils.isNotBlank(key)){
            queryWrapper.like("name",key).or().eq("letter",key);
        }

        //添加排序条件
        if(StringUtils.isNotBlank(sortBy)){
            if (desc) {
                queryWrapper.orderByDesc(sortBy);
            } else {
                queryWrapper.orderByAsc(sortBy);
            }
        }

        //通过分页查询
        IPage<Brand> rpage=this.brandMapper.selectPage(new Page<Brand>(page,rows),queryWrapper);
        return new PageResult<Brand>(rpage.getTotal(),(int)rpage.getPages(),rpage.getRecords());

    }
}

mapper

public interface BrandMapper extends BaseMapper<Brand> {}

异步查询工具axios

异步查询数据,自然是通过ajax查询,大家首先想起的肯定是jQuery。但jQuery与MVVM的思想不吻合,而且ajax只是jQuery的一小部分。因此不可能为了发起ajax请求而去引用这么大的一个库。

axios

Vue官方推荐的ajax请求框架叫做:axios,看下demo:

axios的Get请求语法:

axios.get("/item/category/list?pid=0") // 请求路径和请求参数拼接
    .then(function(resp){
        // 成功回调函数
    })
    .catch(function(){
        // 失败回调函数
    })
// 参数较多时,可以通过params来传递参数
axios.get("/item/category/list", {
        params:{
            pid:0
        }
    })
    .then(function(resp){})// 成功时的回调
    .catch(function(error){})// 失败时的回调Copy to clipboardErrorCopied

axios的POST请求语法:

比如新增一个用户

axios.post("/user",{
        name:"Jack",
        age:21
    })
    .then(function(resp){})
    .catch(function(error){})Copy to clipboardErrorCopied

注意,POST请求传参,不需要像GET请求那样定义一个对象,在对象的params参数中传参。post()方法的第二个参数对象,就是将来要传递的参数

PUT和DELETE请求与POST请求类似

axios的全局配置

而在我们的项目中,已经引入了axios,并且进行了简单的封装,在src下的http.js中:

http.js中对axios进行了一些默认配置:

import Vue from 'vue'
import axios from 'axios'
import config from './config'
// config中定义的基础路径是:http://api.leyou.com/api
axios.defaults.baseURL = config.api; // 设置axios的基础请求路径
axios.defaults.timeout = 2000; // 设置axios的请求时间

Vue.prototype.$http = axios;// 将axios赋值给Vue原型的$http属性,这样所有vue实例都可使用该对象

http.js中导入了config的配置,还记得吗?

  • http.js对axios进行了全局配置:baseURL=config.api,即http://api.leyou.com/api。因此以后所有用axios发起的请求,都会以这个地址作为前缀。
  • 通过Vue.property.$http = axios,将axios赋值给了 Vue原型中的$http。这样以后所有的Vue实例都可以访问到$http,也就是访问到了axios了。

项目中如何使用

我们在组件Brand.vue的getDataFromServer方法,通过$http发起get请求,测试查询品牌的接口,看是否能获取到数据:

网络监视:

在这里插入图片描述

resp到底都有那些数据,查看控制台结果:

可以看到,在请求成功的返回结果response中,有一个data属性,里面就是真正的响应数据。

响应结果中与我们设计的一致,包含3个内容:

  • total:总条数,目前是165
  • items:当前页数据
  • totalPage:总页数,我们没有返回

分页和过滤原理

分页

点击分页,会发起请求,通过浏览器工具查看,会发现pagination对象的属性一直在变化:

我们可以利用Vue的监视功能:watch,当pagination发生改变时,会调用我们的回调函数,我们在回调函数中进行数据的查询!

具体实现:

成功实现分页功能:

过滤

过滤字段对应的是search属性,我们只要监视这个属性即可:

查看网络请求:

页面结果:

品牌管理和图片上传

品牌新增

上节完成了品牌的查询,接下来就是新增功能。点击新增品牌按钮

Brand.vue页面有一个提交按钮:

点击触发addBrand方法:

把数据模型之的show置为true,而页面中有一个弹窗与show绑定:

弹窗中有一个表单子组件,并且是一个局部子组件,有页面可以找到该组件:

###页面实现

重置表单

重置表单相对简单,因为v-form组件已经提供了reset方法,用来清空表单数据。只要我们拿到表单组件对象,就可以调用方法了。

我们可以通过$refs内置对象来获取表单组件。

首先,在表单上定义ref属性:

然后,在页面查看this.$refs属性:

      reset(){
        // 重置表单
        console.log(this);
      }

查看如下:

在这里插入图片描述

看到this.$refs中只有一个属性,就是myBrandForm

我们在clear中来获取表单对象并调用reset方法:

要注意的是,这里我们还手动把this.categories清空了,因为我写的级联选择组件并没有跟表单结合起来。需要手动清空。

表单校验

校验规则

Vuetify的表单校验,是通过rules属性来指定的:

校验规则的写法:

说明:

  • 规则是一个数组
  • 数组中的元素是一个函数,该函数接收表单项的值作为参数,函数返回值两种情况:
    • 返回true,代表成功,
    • 返回错误提示信息,代表失败

编写校验

我们有四个字段:

  • name:做非空校验和长度校验,长度必须大于1
  • letter:首字母,校验长度为1,非空。
  • image:图片,不做校验,图片可以为空
  • categories:非空校验,自定义组件已经帮我们完成,不用写了

首先,我们定义规则:

然后,在页面标签中指定:

<v-text-field v-model="brand.name" label="请输入品牌名称" hint="例如:oppo" :rules="[rules.required, rules.nameLength]"></v-text-field>
<v-text-field v-model="brand.letter" label="请输入品牌首字母" hint="例如:O" :rules="[rules.letter]"></v-text-field>

效果:

表单提交

在submit方法中添加表单提交的逻辑:

submit() {
    console.log(this.$qs);
    // 表单校验
    if (this.$refs.myBrandForm.validate()) {
        // 定义一个请求参数对象,通过解构表达式来获取brand中的属性{categories letter name image}
        const {categories, letter, ...params} = this.brand; // params:{name, image, cids, letter}
        // 数据库中只要保存分类的id即可,因此我们对categories的值进行处理,只保留id,并转为字符串
        params.cids = categories.map(c => c.id).join(",");
        // 将字母都处理为大写
        params.letter = letter.toUpperCase();
        // 将数据提交到后台
        // this.$http.post('/item/brand', this.$qs.stringify(params))
        this.$http({
            method: this.isEdit ? 'put' : 'post',
            url: '/item/brand',
            data: params
        }).then(() => {
            // 关闭窗口
            this.$emit("close");
            this.$message.success("保存成功!");
        })
            .catch(() => {
            this.$message.error("保存失败!");
        });
    }
}
  1. 通过this.$refs.myBrandForm选中表单,然后调用表单的validate方法,进行表单校验。返回boolean值,true代表校验通过
  2. 通过解构表达式来获取brand中的值,categories需要处理,单独获取。其它的存入params对象中
  3. 品牌和商品分类的中间表只保存两者的id,而brand.categories中保存的是对象数组,里面有id和name属性,因此这里通过数组的map功能转为id数组,然后通过join方法拼接为字符串
  4. 发起请求
  5. 弹窗提示成功还是失败,这里用到的是我们的自定义组件功能message组件:

  1. 这个插件把$message对象绑定到了Vue的原型上,因此我们可以通过this.$message来直接调用。

    包含以下常用方法:

  • info、error、success、warning等,弹出一个带有提示信息的窗口,色调与为普通(灰)、错误(红色)、成功(绿色)和警告(黄色)。使用方法:this.$message.info(“msg”)
  • confirm:确认框。用法:this.$message.confirm("确认框的提示信息"),返回一个Promise。

后台实现新增

我们先看以下前台的请求参数信息,除了cids其他三个字段brand实体中都有,我们可以封装到实体中接收,cids直接用参数接收

Controller

还是一样,先分析四个内容:

  • 请求方式:POST
  • 请求路径:/brand
  • 请求参数:brand对象,外加商品分类的id数组cids
  • 返回值:无,只需要响应状态码

代码:

@PostMapping()
public ResponseEntity<Void> saveBrand(Brand brand,@RequestParam("cids") List<Long> cids){
    this.brandService.saveBrand(brand,cids);
    return ResponseEntity.status(HttpStatus.CREATED).build();

}

Service

这里要注意,我们不仅要新增品牌,还要维护品牌和商品分类的中间表。

/**
     * 增加品牌
     * @param brand 商品的信息
     * @param cids  商品的分类
     */
@Override
@Transactional
public void saveBrand(Brand brand, List<Long> cids) {
    //先新增brand
    this.brandMapper.insert(brand);
    //再新增中间表
    cids.forEach(cid->{
        this.brandMapper.insertCategoryAndBrand(cid,brand.getId());
    });

}

这里调用了brandMapper中的一个自定义方法,来实现中间表的数据新增

Mapper

通用Mapper只能处理单表,也就是Brand的数据,因此我们手动编写一个方法及sql,实现中间表的新增:

    /**
     * 在中间表中插入数据
     * @param cid 分类id
     * @param bid 品牌id
     */
    @Insert("insert into tb_category_brand values(#{cid},#{bid})")
    void insertCategoryAndBrand(@Param("cid") Long cid, @Param("bid") Long bid);

测试

400:请求参数不合法

解决400

原因分析

我们填写表单并提交,发现报错了。查看控制台的请求详情:

发现请求的数据格式是JSON格式。

原因分析:

axios处理请求体的原则会根据请求数据的格式来定:

  • 如果请求体是对象:会转为json发送

  • 如果请求体是String:会作为普通表单请求发送,但需要我们自己保证String的格式是键值对。

    如:name=jack&age=12

qs工具

QS是一个第三方库,我们可以用npm install qs --save来安装。不过我们在项目中已经集成了,大家无需安装:

这个工具的名字:QS,即Query String,请求参数字符串。

什么是请求参数字符串?例如: name=jack&age=21

QS工具可以便捷的实现 JS的Object与QueryString的转换。

在我们的项目中,将QS注入到了Vue的原型对象中,我们可以通过this.$qs来获取这个工具:

我们将this.$qs对象打印到控制台:

created(){
    console.log(this.$qs);
}

发现其中有3个方法:

这里我们要使用的方法是stringify,它可以把Object转为QueryString。

测试一下,使用浏览器工具,把qs对象保存为一个临时变量temp1,然后调用stringify方法:

解决问题

修改页面,对参数处理后发送:

然后再次发起请求,发现请求成功:

完成后关闭窗口(已完成)

我们发现有一个问题:新增不管成功还是失败,窗口都一致在这里,不会关闭。

这样很不友好,我们希望如果新增失败,窗口保持;但是新增成功,窗口关闭才对。

因此,我们需要在新增的ajax请求完成以后,关闭窗口

但问题在于,控制窗口是否显示的标记在父组件:MyBrand.vue中。子组件如何才能操作父组件的属性?或者告诉父组件该关闭窗口了?

之前我们讲过一个父子组件的通信,有印象吗?

  • 第一步:在父组件中定义一个函数,用来关闭窗口,不过之前已经定义过了。父组件在使用子组件时,绑定事件,关联到这个函数:Brand.vue


    

第二步,子组件通过this.$emit调用父组件的函数:BrandForm.vue

测试一下,保存成功:

我们优化一下,关闭的同时重新加载数据:

closeWindow(){
    // 关闭窗口
    this.show = false;
    // 重新加载数据
    this.getDataFromServer();
}

实现图片的上传

刚才的新增实现中,我们并没有上传图片,接下来我们一起完成图片上传逻辑。

文件的上传并不只是在品牌管理中有需求,以后的其它服务也可能需要,因此我们创建一个独立的微服务,专门处理各种上传。

创建module

依赖

我们需要EurekaClient和web依赖:

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>leyou-parent</artifactId>
        <groupId>top.codekiller.leyou</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <groupId>top.codekiller.leyou</groupId>
    <artifactId>leyou-upload</artifactId>

    <dependencies>
        <dependency>
              <groupId>org.springframework.cloud</groupId>
              <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
         <!--可以生成配置类提示文件-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
         <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>
</project>

编写配置

server:
  port: 8082
spring:
  application:
    name: upload-service
  servlet:
    multipart:
      file-size-threshold: 5MB
eureka:
  client:
    service-url:
      defaultZone: http://localhost:10086/eureka
  instance:
    lease-expiration-duration-in-seconds: 15
    lease-renewal-interval-in-seconds: 5

需要注意的是,我们应该添加了限制文件大小的配置

引导类

@SpringBootApplication
@EnableDiscoveryClient
public class LeyouUploadApplication {

    public static void main(String[] args) {
        SpringApplication.run(LeyouUploadApplication.class, args);
    }

编写上传功能

文件上传功能,也是自定义组件完成的.

在页面中的使用:

Controller

编写controller需要知道4个内容:结合用法指南

  • 请求方式:上传肯定是POST
  • 请求路径:/upload/image
  • 请求参数:文件,参数名是file,SpringMVC会封装为一个接口:MultipartFile
  • 返回结果:上传成功后得到的文件的url路径,也就是返回String

代码如下:

@RestController
@RequestMapping("upload")
public class UploadController {

    @Autowired
    UploadService uploadService;

    /**
     * 上传图片
     * @param file
     * @return
     */
    @PostMapping("image")
    public ResponseEntity<String>  uploadImage(@RequestParam("file")MultipartFile file){
        String url=uploadService.uploadImage(file);
        if(StringUtils.isBlank(url)){
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(url);
    }
}

UploadProperties

可以通过配置文件编写允许的contentType的类型

@Data
@ConfigurationProperties(prefix = "uploadinfo")
@Component
public class UploadProperties {

    private List<String> contentTypes;

    private String imageUrl;

    private String savePath;
}

application.yml

uploadinfo:
  content-types:
    - image/gif
    - image/jpeg
    - image/png
  imageUrl: http://image.leyou.com/
  savePath: F:\\乐优商城上传的图片\\

Service

在上传文件过程中,我们需要对上传的内容进行校验:

  1. 校验文件大小
  2. 校验文件的媒体类型
  3. 校验文件的内容

文件大小在Spring的配置文件中设置,因此已经会被校验,我们不用管。

具体代码:

package top.codekiller.leyou.upload.service.impl;

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import top.codekiller.leyou.upload.properties.UploadProperties;
import top.codekiller.leyou.upload.service.IUploadService;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.UUID;


@Slf4j
@EnableConfigurationProperties(UploadProperties.class)
@Service
public class UploadService implements IUploadService {

    private UploadProperties uploadProperties;



    //构造器注入properties
    public UploadService(UploadProperties uploadProperties){
        this.uploadProperties=uploadProperties;
    }


    /**
     * 上传图片
     * @param file
     * @return
     */
    @Override
    public String uploadImage(MultipartFile file) {
        String originName=file.getOriginalFilename();
        //验证文件类型
        String contentType=file.getContentType();
        if(!uploadProperties.getContentTypes().contains(contentType)){
            //使用日志记录不合法的信息
            log.info("文件类型不合法: {}",originName);
            return null;
        }

        try {
            //校验文件的内容
            BufferedImage bufferedImage= ImageIO.read(file.getInputStream());
            if(bufferedImage==null){
                log.info("文件的内容不合法: {}",originName);
                return null;
            }
            //获取文件类型
            String suffix=StringUtils.substringAfterLast(originName,".");
            UUID uuid=UUID.randomUUID();
            String id=uuid.toString();
            //保存到服务器
            file.transferTo(new File(uploadProperties.getSavePath()+id+"."+suffix));
            //返回url,进行回显
            log.info("上传成功:{}"+originName);
            return uploadProperties.getImageUrl()+originName;
        } catch (IOException e){
            log.info("服务器内部错误:{}",originName);
            e.printStackTrace();
        }

        return null;

    }
}

这里有一个问题:为什么图片地址需要使用另外的url?

  • 图片不能保存在服务器内部,这样会对服务器产生额外的加载负担
  • 一般静态资源都应该使用独立域名,这样访问静态资源时不会携带一些不必要的cookie,减小请求的数据量

配置nginx

虽然实现了文件上传功能并且返回了文件访问地址,但是我们无法通过返回的地址直接访问到图片,接下来我们配置Nginx静态资源访问来回显图片

找到nginx配置文件添加以下内容

server {
    listen       80;
    server_name  image.leyou.com;

    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
        root F:/乐优商城上传的图片/ ;
    }
}

然后配置本地hosts文件

# leyouMall

127.0.0.1 api.leyou.com
127.0.0.1 manage.leyou.com
127.0.0.1 image.leyou.com

然后访问上传后回显的url就可以访问到图片了

测试上传

有两个工具可以进行测试

Postman(需下载)和Advanced REST client(直接在谷歌商店里面搜)

绕过网关

图片上传是文件的传输,如果也经过Zuul网关的代理,文件就会经过多次网路传输,造成不必要的网络负担。在高并发时,可能导致网络阻塞,Zuul网关不可用。这样我们的整个系统就瘫痪了。

所以,我们上传文件的请求就不经过网关来处理了。

zuul的路由过滤

Zuul中提供了一个ignored-patterns属性,用来忽略不希望路由的URL路径,示例:

zuul.ignored-patterns: /upload/**

路径过滤会对一切微服务进行判定。

Zuul还提供了ignored-services属性,进行服务过滤:

zuul.ignored-services: upload-servie

我们这里采用忽略服务:

zuul:
  ignored-services:
    - upload-service # 忽略upload-service服务

上面的配置采用了集合语法,代表可以配置多个。

nginx的rewirte指令

现在,我们查看页面的访问路径:

<v-upload
      v-model="brand.image" 
      url="/upload/image" 
      :multiple="false" 
      :pic-width="250" :pic-height="90"
      />

可以看到这个地址不对,依然是去找Zuul网关,因为我们的系统全局配置了URL地址。怎么办?

有同学会想:修改页面请求地址不就好了。

注意:原则上,我们是不能把除了网关以外的服务对外暴露的,不安全。

既然不能修改页面请求,那么就只能在Nginx反向代理上做文章了。

我们修改nginx配置,将以/api/upload开头的请求拦截下来,转交到真实的服务地址:

server {
    listen       80;
    server_name  api.leyou.com;

    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location /api/upload {
        proxy_pass http://localhost:8082/upload;

        #rewirte "^/api/(.*)$" /$1 break;
    }

    location / {
        proxy_pass http://127.0.0.1:10010;
        proxy_connect_timeout 600;
        proxy_read_timeout 600;
    }
}

两种方式:

  1. 通过proxy_pass跳转

     proxy_pass http://localhost:8082/upload;
  1. 通过重定向的方式

    proxy_pass http://127.0.0.1:7002;
    rewrite "^/api/(.*)$" /$1 break; 
  • 首先,我们映射路径是/api/upload,而下面一个映射路径是 / ,根据最长路径匹配原则,/api/upload优先级更高。也就是说,凡是以/api/upload开头的路径,都会被第一个配置处理

  • proxy_pass:反向代理,这次我们代理到7002端口,也就是upload-service服务

  • rewrite "^/api/(.*)$" /$1 break,路径重写:

    • "^/api/(.*)$":匹配路径的正则表达式,用了分组语法,把/api/以后的所有部分当做1组

    • /$1:重写的目标路径,这里用$1引用前面正则表达式匹配到的分组(组编号从1开始),即/api/后面的所有。这样新的路径就是除去/api/以外的所有,就达到了去除/api前缀的目的

    • break:指令,常用的有2个,分别是:last、break

      • last:重写路径结束后,将得到的路径重新进行一次路径匹配
      • break:重写路径结束后,不再重新匹配路径。

      我们这里不能选择last,否则以新的路径/upload/image来匹配,就不会被正确的匹配到7002端口了

修改完成,输入nginx -s reload命令重新加载配置。然后再次上传试试。

跨域问题

重启nginx,再次上传,发现跟上次的状态码已经不一样了,但是依然报错:

不过庆幸的是,这个错误已经不是第一次见了,跨域问题。因为之前我们的跨域问题是在网关中解决的,现在不经过网关了,所以要在这里也添加一个CorsFilter

我们在upload-service中添加一个CorsFilter即可:

@Configuration
public class LeyouCorsConfiguration {

    @Bean
    public CorsFilter corsFilter() {
        //1.添加CORS配置信息
        CorsConfiguration config = new CorsConfiguration();
        //1) 允许的域,不要写*,否则cookie就无法使用了
        config.addAllowedOrigin("http://manage.leyou.com");
        //3) 允许的请求方式
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("POST");
        // 4)允许的头信息
        config.addAllowedHeader("*");

        //2.添加映射路径,我们拦截一切请求
        UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
        configSource.registerCorsConfiguration("/**", config);

        //3.返回新的CorsFilter.
        return new CorsFilter(configSource);
    }
}

再次测试:

文件上传的缺陷

先思考一下,现在上传的功能,有没有什么问题?

上传本身没有任何问题,问题出在保存文件的方式,我们是保存在服务器机器,就会有下面的问题:

  • 单机器存储,存储能力有限
  • 无法进行水平扩展,因为多台机器的文件无法共享,会出现访问不到的情况
  • 数据没有备份,有单点故障风险
  • 并发能力差

这个时候,最好使用分布式文件存储来代替本地文件存储。

FastDFS

什么是分布式文件系统

分布式文件系统(Distributed File System)是指文件系统管理的物理存储资源不一定直接连接在本地节点上,而是通过计算机网络与节点相连。

通俗来讲:

  • 传统文件系统管理的文件就存储在本机。
  • 分布式文件系统管理的文件存储在很多机器,这些机器通过网络连接,要被统一管理。无论是上传或者访问文件,都需要通过管理中心来访问

####什么是FastDFS

FastDFS是由淘宝的余庆先生所开发的一个轻量级、高性能的开源分布式文件系统。用纯C语言开发,功能丰富:

  • 文件存储
  • 文件同步
  • 文件访问(上传、下载)
  • 存取负载均衡
  • 在线扩容

适合有大容量存储需求的应用或系统。同类的分布式文件系统有谷歌的GFS、HDFS(Hadoop)、TFS(淘宝)等。

FastDFS架构

先上图:

FastDFS两个主要的角色:Tracker Server 和 Storage Server 。

  • Tracker Server:跟踪服务器,主要负责调度storage节点与client通信,在访问上起负载均衡的作用,和记录storage节点的运行状态,是连接client和storage节点的枢纽。
  • Storage Server:存储服务器,保存文件和文件的meta data(元数据),每个storage server会启动一个单独的线程主动向Tracker cluster中每个tracker server报告其状态信息,包括磁盘使用情况,文件同步情况及文件上传下载次数统计等信息
  • Group:文件组,多台Storage Server的集群。上传一个文件到同组内的一台机器上后,FastDFS会将该文件即时同步到同组内的其它所有机器上,起到备份的作用。不同组的服务器,保存的数据不同,而且相互独立,不进行通信。
  • Tracker Cluster:跟踪服务器的集群,有一组Tracker Server(跟踪服务器)组成。
  • Storage Cluster :存储集群,有多个Group组成。

上传和下载流程

上传

  1. Client通过Tracker server查找可用的Storage server。
  2. Tracker server向Client返回一台可用的Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并进行文件上传。
  4. 上传完成,Storage server返回Client一个文件ID,文件上传结束。

下载

  1. Client通过Tracker server查找要下载文件所在的的Storage server。
  2. Tracker server向Client返回包含指定文件的某个Storage server的IP地址和端口号。
  3. Client直接通过Tracker server返回的IP地址和端口与其中一台Storage server建立连接并指定要下载文件。
  4. 下载文件成功。

安装和使用

所需文件下载地址:https://github.com/happyfish100

参考资料:FastDFS的安装

java客户端

余庆先生提供了一个Java客户端,但是作为一个C程序员,写的java代码可想而知。而且已经很久不维护了。

这里推荐一个开源的FastDFS客户端,支持最新的SpringBoot2.0。

配置使用极为简单,支持连接池,支持自动生成缩略图,狂拽酷炫吊炸天啊,有木有。

地址:tobato/FastDFS_client

接下来,我们就用FastDFS改造leyou-upload工程。

引入依赖

在父工程中,我们已经管理了依赖,版本为:

<fastDFS.client.version>1.26.7</fastDFS.client.version>

因此,这里我们直接在taotao-upload工程的pom.xml中引入坐标即可:

<dependency>
    <groupId>com.github.tobato</groupId>
    <artifactId>fastdfs-client</artifactId>
</dependency>

引入配置类

纯java配置:

@Configuration
@Import(FdfsClientConfig.class)
// 解决jmx重复注册bean的问题
@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
public class FastClientImporter {

}

编写FastDFS属性

在application.yml配置文件中追加如下内容:

fdfs:
  so-timeout: 1501 # 超时时间
  connect-timeout: 601 # 连接超时时间
  thumb-image: # 缩略图
    width: 60
    height: 60
  tracker-list: # tracker地址:你的虚拟机服务器地址+端口(默认是22122)
    - 172.16.145.141:22122

配置hosts

将来通过域名:image.leyou.com这个域名访问fastDFS服务器上的图片资源。所以,需要代理到虚拟机地址:

配置hosts文件,使image.leyou.com可以访问fastDFS服务器

测试

创建测试类:

把以下内容copy进去:

@SpringBootTest
@RunWith(SpringRunner.class)
public class FastDFSTest {

    @Autowired
    private FastFileStorageClient storageClient;

    @Autowired
    private ThumbImageConfig thumbImageConfig;

    @Test
    public void testUpload() throws FileNotFoundException {
        //找一张本地图片路径
        File file = new File("/home/cloudlandboy/Pictures/bg/4oyg9n.jpg");
        // 上传并保存图片,参数:1-上传的文件流 2-文件的大小 3-文件的后缀 4-可以不管他
        StorePath storePath = this.storageClient.uploadFile(
                new FileInputStream(file), file.length(), "jpg", null);
        // 带分组的路径
        System.out.println(storePath.getFullPath());
        // 不带分组的路径
        System.out.println(storePath.getPath());
    }

    @Test
    public void testUploadAndCreateThumb() throws FileNotFoundException {
        //找一张本地图片路径
        File file = new File("/home/cloudlandboy/Pictures/bg/201909232212.jpeg");
        // 上传并且生成缩略图
        StorePath storePath = this.storageClient.uploadImageAndCrtThumbImage(
                new FileInputStream(file), file.length(), "jpeg", null);
        // 带分组的路径
        System.out.println(storePath.getFullPath());
        // 不带分组的路径
        System.out.println(storePath.getPath());
        // 获取缩略图路径
        String path = thumbImageConfig.getThumbImagePath(storePath.getPath());
        System.out.println(path);
    }
}

如果出现 com.github.tobato.fastdfs.exception.FdfsServerException: 错误码:2,错误信息:找不到节点或文件,查看是不是没有创建文件夹 mkdir -p /leyou/storage,然后重新启动service fdfs_storaged restart

testUpload结果:

group1/M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg
M00/00/00/rBCRjV3osRKADvQLABG-h8hKM_c874.jpg

testUploadAndCreateThumb结果:

group1/M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772.png
M00/00/00/wKg4ZVsWmD-ARnWiAABAhya2V0c772_60x60.png

访问http://image.leyou.com/+返回的地址路径(**注意加组名(group1)**)

改造上传逻辑

@Slf4j
@EnableConfigurationProperties(UploadProperties.class)
@Service
public class UploadService implements IUploadService {

    private UploadProperties uploadProperties;

    @Autowired
    private FastFileStorageClient fastFileStorageClient;

    //构造器注入properties
    public UploadService(UploadProperties uploadProperties){
        this.uploadProperties=uploadProperties;
    }


    /**
     * 上传图片
     * @param file
     * @return
     */
    @Override
    public String uploadImage(MultipartFile file) {
        String originName=file.getOriginalFilename();
        //验证文件类型
        String contentType=file.getContentType();
        if(!uploadProperties.getContentTypes().contains(contentType)){
            //使用日志记录不合法的信息
            log.info("文件类型不合法: {}",originName);
            return null;
        }

        try {
            //校验文件的内容
            BufferedImage bufferedImage= ImageIO.read(file.getInputStream());
            if(bufferedImage==null){
                log.info("文件的内容不合法: {}",originName);
                return null;
            }
            //获取文件类型
            String suffix=StringUtils.substringAfterLast(originName,".");
//            UUID uuid=UUID.randomUUID();
//            String id=uuid.toString();
            //保存到服务器
//            file.transferTo(new File(uploadProperties.getSavePath()+id+"."+suffix));
            StorePath storePath=fastFileStorageClient.uploadFile(file.getInputStream(),file.getSize(),suffix,null);
            //返回url,进行回显
            log.info("上传成功:{}"+originName);
            return uploadProperties.getImageUrl()+storePath.getFullPath();
        } catch (IOException e){
            log.info("服务器内部错误:{}",originName);
            e.printStackTrace();
        }

        return null;

    }
}

只需要把原来保存文件的逻辑去掉,然后上传到FastDFS即可。

页面上传测试

发现上传成功:

页面规格数据结构

乐优商城是一个全品类的电商网站,因此商品的种类繁多,每一件商品,其属性又有差别。为了更准确描述商品及细分差别,抽象出两个概念:SPU和SKU,了解一下:

SPU和SKU

SPU:Standard Product Unit (标准产品单位) ,一组具有共同属性的商品集

SKU:Stock Keeping Unit(库存量单位),SPU商品集因具体特性不同而细分的每个商品

以图为例来看:

  • 本页的 华为Mate10 就是一个商品集(SPU)
  • 因为颜色、内存等不同,而细分出不同的Mate10,如亮黑色128G版。(SKU)

可以看出:

  • SPU是一个抽象的商品集概念,为了方便后台的管理。
  • SKU才是具体要销售的商品,每一个SKU的价格、库存可能会不一样,用户购买的是SKU而不是SPU

数据库设计

思考并发现问题

弄清楚了SPU和SKU的概念区分,接下来我们一起思考一下该如何设计数据库表。

首先来看SPU,大家一起思考下SPU应该有哪些字段来描述?

id:主键
title:标题
description:描述
specification:规格
packaging_list:包装
after_service:售后服务
comment:评价
category_id:商品分类
brand_id:品牌

似乎并不复杂,但是大家仔细思考一下,商品的规格字段你如何填写?

不同商品的规格不一定相同,数据库中要如何保存?

再看下SKU,大家觉得应该有什么字段?

id:主键
spu_id:关联的spu
price:价格
images:图片
stock:库存
颜色?
内存?
硬盘?

碰到难题了,不同的商品分类,可能属性是不一样的,比如手机有内存,衣服有尺码没有内存,我们是全品类的电商网站,这些不同的商品的不同属性,如何设计到一张表中?

其实颜色、内存、硬盘属性都是规格参数中的字段。所以,要解决这个问题,首先要能清楚规格参数。

分析规格参数

仔细查看每一种商品的规格你会发现:

虽然商品规格千变万化,但是同一类商品(如手机)的规格是统一的,有图为证:

华为的规格:

三星的规格:

SKU的特有属性

SPU中会有一些特殊属性,用来区分不同的SKU,我们称为SKU特有属性。如华为META10的颜色、内存属性。

不同种类的商品,一个手机,一个衣服,其SKU属性不相同。

同一种类的商品,比如都是衣服,SKU属性基本是一样的,都是颜色、尺码等。

这样说起来,似乎SKU的特有属性也是与分类相关的?事实上,仔细观察你会发现,SKU的特有属性是商品规格参数的一部分

也就是说,我们没必要单独对SKU的特有属性进行设计,它可以看做是规格参数中的一部分。这样规格参数中的属性可以标记成两部分:

  • spu下所有sku共享的规格属性(称为全局属性)
  • 每个sku不同的规格属性(称为特有属性)

搜素属性

打开一个搜索页,我们来看看过滤的条件:

你会发现,过滤条件中的屏幕尺寸、运行内存、网路、机身内存、电池容量、CPU核数等,在规格参数中都能找到:

也就是说,规格参数中的数据,将来会有一部分作为搜索条件来使用。我们可以在设计时,将这部分属性标记出来,将来做搜索的时候,作为过滤条件。要注意的是,无论是SPU的全局属性,还是SKU的特有属性,都有可能作为搜索过滤条件的,并不冲突,而是有一个交集:

规格参数表

表结构

我们看下规格参数的格式:

可以看到规格参数是分组的,每一组都有多个参数键值对。不过对于规格参数的模板而言,其值现在是不确定的,不同的商品值肯定不同,模板中只要保存组信息、组内参数信息即可。

因此我们设计了两张表:

  • tb_spec_group:组,与商品分类关联

  • tb_spec_param:参数名,与组关联,一对多

规格表

规格参数分组表:tb_spec_group

CREATE TABLE `tb_spec_group` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id,一个分类下有多个规格组',
  `name` varchar(50) NOT NULL COMMENT '规格组的名称',
  PRIMARY KEY (`id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8 COMMENT='规格参数的分组表,每个商品分类下有多个规格参数组';

规格组有3个字段:

  • id:主键
  • cid:商品分类id,一个分类下有多个模板
  • name:该规格组的名称。

规格参数

规格参数表:tb_spec_param

CREATE TABLE `tb_spec_param` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `cid` bigint(20) NOT NULL COMMENT '商品分类id',
  `group_id` bigint(20) NOT NULL,
  `name` varchar(255) NOT NULL COMMENT '参数名',
  `numeric` tinyint(1) NOT NULL COMMENT '是否是数字类型参数,true或false',
  `unit` varchar(255) DEFAULT '' COMMENT '数字类型参数的单位,非数字类型可以为空',
  `generic` tinyint(1) NOT NULL COMMENT '是否是sku通用属性,true或false',
  `searching` tinyint(1) NOT NULL COMMENT '是否用于搜索过滤,true或false',
  `segments` varchar(1000) DEFAULT '' COMMENT '数值类型参数,如果需要搜索,则添加分段间隔值,如CPU频率间隔:0.5-1.0',
  PRIMARY KEY (`id`),
  KEY `key_group` (`group_id`),
  KEY `key_category` (`cid`)
) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8 COMMENT='规格参数组下的参数名';

按道理来说,我们的规格参数就只需要记录参数名、组id、商品分类id即可。但是这里却多出了很多字段,为什么?

还记得我们之前的分析吧,规格参数中有一部分是 SKU的通用属性,一部分是SKU的特有属性,而且其中会有一些将来用作搜索过滤,这些信息都需要标记出来。

通用属性

用一个布尔类型字段来标记是否为通用:

  • generic来标记是否为通用属性:
    • true:代表通用属性
    • false:代表sku特有属性

搜索过滤

与搜索相关的有两个字段:

  • searching:标记是否用作过滤
    • true:用于过滤搜索
    • false:不用于过滤
  • segments:某些数值类型的参数,在搜索时需要按区间划分,这里提前确定好划分区间
    • 比如电池容量,02000mAh,2000mAh3000mAh,3000mAh~4000mAh

数值类型

某些规格参数可能为数值类型,这样的数据才需要划分区间,我们有两个字段来描述:

  • numberic:是否为数值类型
    • true:数值类型
    • false:不是数值类型
  • unit:参数的单位

商品规格参数管理

整体布局

打开规格参数页面,看到如下内容:

商品分类树我们之前已经做过,所以这里可以直接展示出来。

因为规格是跟商品分类绑定的,因此首先会展现商品分类树,并且提示你要选择商品分类,才能看到规格参数的模板。一起了解下页面的实现:

页面结构:

这里使用了v-layout来完成页面布局,并且添加了row属性,代表接下来的内容是行布局(左右)。

可以看出页面分成2个部分:

  • <v-flex xs3>:左侧,内部又分上下两部分:商品分类树及标题
    • v-card-title:标题部分,这里是提示信息,告诉用户要先选择分类,才能看到模板
    • v-tree:这里用到的是我们之前讲过的树组件,展示商品分类树,
  • <v-flex xs9 class="px-1">:右侧:内部是规格参数展示

右侧规格

当我们点击一个分类时,最终要达到的效果:

可以看到右侧分为上下两部分:

  • 上部:面包屑,显示当前选中的分类
  • 下部:table,显示规格参数信息

页面实现:

可以看到右侧并不是我们熟悉的 v-data-table,而是一个spec-group组件(规格组)和spec-param组件(规格参数),这是我们定义的独立组件:

在SpecGroup中定义了表格:

规格组的查询

树节点的点击事件

当我们点击树节点时,要将v-dialog打开,因此必须绑定一个点击事件:(Specification.vue)

我们来看下handleClick方法:(Specification.vue)

点击事件发生时,发生了两件事:

  • 记录当前选中的节点,选中的就是商品分类
  • showGroup被置为true,则规格组就会显示了。

同时,我们把被选中的节点(商品分类)的id传递给了SpecGroup组件:(Specification.vue)

页面查询规格组

来看下SpecGroup.vue中的实现:

我们查看页面控制台,可以看到请求已经发出

后端代码

实体类

leyou-item-interface中添加实体类:

内容:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.sun.javafx.beans.IDProperty;
import lombok.Data;
import java.util.List;

@Data
public class SpecGroup {
    @TableId
    private Long id;
    private Long cid;
    private String name;
    @TableField(exist = false)
    private List<SpecParam> params;
}
package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class SpecParam {
    @TableId
    private Long id;
    private Long cid;
    private Long groupId;
    private String name;
    @TableField(value="`numeric`") //在mysql中是关键字
    private Boolean numeric;
    private String unit;
    private Boolean generic;
    private Boolean searching;
    private String segments;
}

leyou-item-service中编写业务:

Controller

先分析下需要的东西,在页面的ajax请求中可以看出:

  • 请求方式:get
  • 请求路径:/spec/groups/{cid} ,这里通过路径占位符传递商品分类的id
  • 请求参数:商品分类id
  • 返回结果:页面是直接把resp.data赋值给了groups:
package top.codekiller.leyou.controller;

import com.mysql.fabric.xmlrpc.base.Param;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.impl.SpecificationService;

import java.util.List;

@RestController
@RequestMapping("spec")
public class SpecificationController {

    @Autowired
    private SpecificationService specificationService;

    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @GetMapping("groups/{cid}")
    public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid){
        List<SpecGroup> groups=this.specificationService.queryGroupByCid(cid);
        if(CollectionUtils.isEmpty(groups)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(groups);

    }
}

mapper

public interface SpecGroupMapper extends BaseMapper<SpecGroup> {
}

页面访问测试

目前,我们数据库只为手机分类(76)提供了规格组:

规格参数查询

表格切换

当我们点击规格组,会切换到规格参数显示,肯定是在规格组中绑定了点击事件:

我们看下事件处理:

可以看到这里是使用了父子通信,子组件触发了select事件:

再来看下父组件的事件绑定:

事件处理:

这里我们记录了选中的分组,并且把标记设置为false,这样规格组就不显示了,而是显示:SpecParam

并且,我们把group也传递到spec-param组件:

页面查询规格参数

我们来看SpecParam.vue的实现:

查看页面控制台,发现请求已经发出:

报404,因为我们还没有实现后台逻辑,接下来就去实现。

后台实现

SpecificationController

分析:

  • 请求方式:GET
  • 请求路径:/spec/params
  • 请求参数:gid,分组id
  • 返回结果:该分组下的规格参数集合List

代码:

package top.codekiller.leyou.controller;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.impl.SpecificationService;
import java.util.List;

@RestController
@RequestMapping("spec")
public class SpecificationController {

    @Autowired
    private SpecificationService specificationService;

    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @GetMapping("groups/{cid}")
    public ResponseEntity<List<SpecGroup>> querySpecGroupsByCid(@PathVariable("cid") Long cid){
        List<SpecGroup> groups=this.specificationService.queryGroupByCid(cid);
        if(CollectionUtils.isEmpty(groups)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(groups);

    }

    /**
     * 根据组id查询组的参数
     */
    @GetMapping("params")
    public ResponseEntity<List<SpecParam>> queryParams(@RequestParam("gid")Integer gid){
        List<SpecParam> params=this.specificationService.queryParams(gid);
        if(CollectionUtils.isEmpty(params)){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(params);
    }
}

Service

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.SpecGroupMapper;
import top.codekiller.leyou.mapper.SpecParamMapper;
import top.codekiller.leyou.pojo.SpecGroup;
import top.codekiller.leyou.pojo.SpecParam;
import top.codekiller.leyou.service.ISpecificationService;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class SpecificationService implements ISpecificationService {
    @Autowired
    private SpecGroupMapper specGroupMapper;

    @Autowired
    private SpecParamMapper specParamMapper;


    /**
     * 根据分类id查询参数组
     * @param cid
     * @return
     */
    @Override
    public List<SpecGroup> queryGroupByCid(Long cid) {
        Map<String,Object> map=new HashMap<>();
        map.put("cid",cid);
        return specGroupMapper.selectByMap(map);
    }


    /**
     * 根据组id查询组参数
     * @param gid
     * @return
     */
    @Override
    public List<SpecParam> queryParams(Integer gid) {
        List<SpecParam> params=this.specParamMapper.selectList(new QueryWrapper<SpecParam>().lambda()
                                                                .eq(SpecParam::getGroupId,gid));
        return params;
    }
}

Mapper

public interface SpecParamMapper extends BaseMapper<SpecParam> {
}

增、删、改

TODO 时间有限没做

页面中接口都已定义,要做的就是实现后台接口。

SPU和SKU数据结构

规格确定以后,就可以添加商品了,先看下数据库表

SPU表

SPU表:

CREATE TABLE `tb_spu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'spu id',
  `title` varchar(255) NOT NULL DEFAULT '' COMMENT '标题',
  `sub_title` varchar(255) DEFAULT '' COMMENT '子标题',
  `cid1` bigint(20) NOT NULL COMMENT '1级类目id',
  `cid2` bigint(20) NOT NULL COMMENT '2级类目id',
  `cid3` bigint(20) NOT NULL COMMENT '3级类目id',
  `brand_id` bigint(20) NOT NULL COMMENT '商品所属品牌id',
  `saleable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否上架,0下架,1上架',
  `valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0已删除,1有效',
  `create_time` datetime DEFAULT NULL COMMENT '添加时间',
  `last_update_time` datetime DEFAULT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=208 DEFAULT CHARSET=utf8 COMMENT='spu表,该表描述的是一个抽象的商品,比如 iphone8';

与我们前面分析的基本类似,但是似乎少了一些字段,比如商品描述。

我们做了表的垂直拆分,将SPU的详情放到了另一张表:tb_spu_detail

CREATE TABLE `tb_spu_detail` (
  `spu_id` bigint(20) NOT NULL,
  `description` text COMMENT '商品描述信息',
  `generic_spec` varchar(10000) NOT NULL DEFAULT '' COMMENT '通用规格参数数据',
  `special_spec` varchar(1000) NOT NULL COMMENT '特有规格参数及可选值信息,json格式',
  `packing_list` varchar(3000) DEFAULT '' COMMENT '包装清单',
  `after_service` varchar(3000) DEFAULT '' COMMENT '售后服务',
  PRIMARY KEY (`spu_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

这张表中的数据都比较大,为了不影响主表的查询效率我们拆分出这张表。

需要注意的是这两个字段:generic_spec和special_spec。

前面讲过规格参数与商品分类绑定,一个分类下的所有SPU具有类似的规格参数。SPU下的SKU可能会有不同的规格参数信息,因此我们计划是这样:

  • SPUDetail中保存通用的规格参数信息。
  • SKU中保存特有规格参数。

来看下我们的表如何存储这些信息。

generic_spec字段

首先是generic_spec,其中保存通用规格参数信息的值,这里为了方便查询,使用了json格式:

整体来看:

json结构,其中都是键值对:

  • key:对应的规格参数的spec_param的id
  • value:对应规格参数的值
special_spec

我们说spu中只保存通用规格参数,那么为什么有多出了一个special_spec字段呢?

以手机为例,品牌、操作系统等肯定是全局通用属性,内存、颜色等肯定是特有属性。

当你确定了一个SPU,比如小米的:红米4X

全局属性值都是固定的了:

品牌:小米
型号:红米4X

特有属性举例:

颜色:[香槟金, 樱花粉, 磨砂黑]
内存:[2G, 3G]
机身存储:[16GB, 32GB]

颜色、内存、机身存储,作为SKU特有属性,key虽然一样,但是SPU下的每一个SKU,其值都不一样,所以值会有很多,形成数组。

我们在SPU中,会把特有属性的所有值都记录下来,形成一个数组:

里面又有哪些内容呢?

来看数据格式:

也是json结构:

  • key:规格参数id
  • value:spu属性的数组

那么问题来:特有规格参数应该在sku中记录才对,为什么在spu中也要记录一份?

因为我们有时候需要把所有规格参数都查询出来,而不是只查询1个sku的属性。比如,商品详情页展示可选的规格参数时:

SKU表

CREATE TABLE `tb_sku` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'sku id',
  `spu_id` bigint(20) NOT NULL COMMENT 'spu id',
  `title` varchar(255) NOT NULL COMMENT '商品标题',
  `images` varchar(1000) DEFAULT '' COMMENT '商品的图片,多个图片以‘,’分割',
  `price` bigint(15) NOT NULL DEFAULT '0' COMMENT '销售价格,单位为分',
  `indexes` varchar(100) COMMENT '特有规格属性在spu属性模板中的对应下标组合',
  `own_spec` varchar(1000) COMMENT 'sku的特有规格参数,json格式',
  `enable` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效,0无效,1有效',
  `create_time` datetime NOT NULL COMMENT '添加时间',
  `last_update_time` datetime NOT NULL COMMENT '最后修改时间',
  PRIMARY KEY (`id`),
  KEY `key_spu_id` (`spu_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='sku表,该表表示具体的商品实体,如黑色的64GB的iphone 8';

还有一张表,代表库存:

CREATE TABLE `tb_stock` (
  `sku_id` bigint(20) NOT NULL COMMENT '库存对应的商品sku id',
  `seckill_stock` int(9) DEFAULT '0' COMMENT '可秒杀库存',
  `seckill_total` int(9) DEFAULT '0' COMMENT '秒杀总数量',
  `stock` int(9) NOT NULL COMMENT '库存数量',
  PRIMARY KEY (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='库存表,代表库存,秒杀库存等信息';

问题:为什么要将库存独立一张表?

因为库存字段写频率较高,而SKU的其它字段以读为主,因此我们将两张表分离,读写不会干扰。

特别需要注意的是sku表中的indexes字段和own_spec字段。sku中应该保存特有规格参数的值,就在这两个字段中。

indexex字段

在SPU表中,已经对特有规格参数及可选项进行了保存,结构如下:

{
    "4": [
        "香槟金",
        "樱花粉",
        "磨砂黑"
    ],
    "12": [
        "2GB",
        "3GB"
    ],
    "13": [
        "16GB",
        "32GB"
    ]
}

这些特有属性如果排列组合,会产生12个不同的SKU,而不同的SKU,其属性就是上面备选项中的一个。

比如:

  • 红米4X,香槟金,2GB内存,16GB存储
  • 红米4X,磨砂黑,2GB内存,32GB存储

你会发现,每一个属性值,对应于SPUoptions数组的一个选项,如果我们记录下角标,就是这样:

  • 红米4X,0,0,0
  • 红米4X,2,0,1

既然如此,我们是不是可以将不同角标串联起来,作为SPU下不同SKU的标示。这就是我们的indexes字段。

这个设计在商品详情页会特别有用:

当用户点击选中一个特有属性,你就能根据 角标快速定位到sku。

own——spec字段

看结构:

{"4":"香槟金","12":"2GB","13":"16GB"}Copy to clipboardErrorCopied

保存的是特有属性的键值对。

SPU中保存的是可选项,但不确定具体的值,而SKU中的保存的就是具体的值。

导入图片信息

图片下载

现在商品表中虽然有数据,但是所有的图片信息都是无法访问的,我们需要把图片导入到安装fastdfs的虚拟机上:

  1. 创建static文件夹

    mkdir static
  2. 接着将图片压缩包上传到static文件下后解压

# 如果没安装unzip先安装
yum install unzip

unzip images.zipCopy to clipboardErrorCopied
  1. 修改Nginx配置,使nginx反向代理这些图片地址:
vim /opt/nginx/conf/nginx.confCopy to clipboardErrorCopied
  1. 修改成如下配置:
server {
    listen       80;
    server_name  image.leyou.com;

    # 监听域名中带有group的,交给FastDFS模块处理
    location ~/group([0-9])/ {
        ngx_fastdfs_module;
    }
    # 将其它图片代理指向本地的/leyou/static目录
    location / {
        root   /leyou/static/;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   html;
    }

}Copy to clipboardErrorCopied
  1. 不要忘记重新加载nginx配置
nginx -s reloadCopy to clipboardErrorCopied
  1. 访问测试

http://image.leyou.com/images/6/8/1524297350205.jpg

商品请求

页面请求

先看整体页面结构(Goods.vue):

并且在Vue实例挂载后就会发起查询(mounted调用getDataFromServer方法初始化数据):

我们刷新页面,可以看到浏览器发起已经发起了查询商品数据的请求,但是却发现发起了两次请求:

发起两次请求的原因

可以看到页面有两处地方会导致发送请求,一个是在页面渲染之后的钩子函数中,另一个是在监听分页信息的函数中,因为在初始化的时候vue会给pagination赋值一些初始化数据,而监听函数监听到之后就会调用发送请求的方法,所以我们只需要监听函数即可,钩子函数就不需要了

后端代码

页面已经准备好,接下来在后台提供分页查询SPU的功能。

先来看一下页面需要哪些数据

idtitle分别对应商品id和商品标题,这两个字段在spu表中都有,也就在实体类中也有

但是cnamebname是分类名称和品牌名称,spu表中只有1-3级分类的id和品牌id,实体类也就没有这两个字段,而由不能直接修改实体类,所以需要新建一个bo类去继承spu实体类扩展属性字段

实体类

在leyou-item-interface工程中添加实体类:

Spu:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.util.Date;

@Data
public class Spu {
    @TableId
    private Long id;
    private Long brandId;
    private Long cid1;// 1级类目
    private Long cid2;// 2级类目
    private Long cid3;// 3级类目
    private String title;// 标题
    private String subTitle;// 子标题
    private Boolean saleable;// 是否上架
    private Boolean valid;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
}

SpuDetails:

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class SpuDetail {
    @TableId
    private Long spuId;// 对应的SPU的id
    private String description;// 商品描述
    private String specialSpec;// 商品特殊规格的名称及可选值模板
    private String genericSpec;// 商品的全局规格属性
    private String packingList;// 包装清单
    private String afterService;// 售后服务
}

SpuBo:

package top.codekiller.leyou.pojo.bo;

import lombok.Data;
import top.codekiller.leyou.pojo.Spu;

@Data
public class SpuBo extends Spu {
    /**
     * 通过cid获取分类id
     */
    private String cname;
    /**
     * 通过bid获取品牌id
     */
    private String bname;
}
Controller

先分析:

  • 请求方式:GET
  • 请求路径:/spu/page
  • 请求参数:
    • page:当前页
    • rows:每页大小
    • key:过滤条件
    • saleable:上架或下架
  • 返回结果:商品SPU的分页信息。

编写controller代码:

我们把与商品相关的一切业务接口都放到一起,起名为GoodsController,业务层也是这样

package top.codekiller.leyou.controller;

import com.leyou.common.pojo.PageResult;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import top.codekiller.leyou.pojo.bo.SpuBo;
import top.codekiller.leyou.service.IGoodsService;

@RestController
public class GoodsController {
    @Autowired
    IGoodsService goodsService;

    @GetMapping("spu/page")
    public ResponseEntity<PageResult<SpuBo>> querySpuByPage(@RequestParam(value="key",required = false) String key,
                                                            @RequestParam(value="saleable",required = false) Boolean saleable,
                                                            @RequestParam(value="page",defaultValue = "1") Integer page,
                                                            @RequestParam(value="rows",defaultValue = "5") Integer rows){
        PageResult<SpuBo> result=this.goodsService.querySpuByPage(key,saleable,page,rows);
        if(result==null|| CollectionUtils.isEmpty(result.getItems())){
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(result);

    }
}
Service

所有商品相关的业务(包括SPU和SKU)放到一个业务下:GoodsService。

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.leyou.common.pojo.PageResult;
import com.netflix.discovery.util.StringUtil;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.BrandMapper;
import top.codekiller.leyou.mapper.CategoryMapper;
import top.codekiller.leyou.mapper.SpuDetailMapper;
import top.codekiller.leyou.mapper.SpuMapper;
import top.codekiller.leyou.pojo.Brand;
import top.codekiller.leyou.pojo.Category;
import top.codekiller.leyou.pojo.Spu;
import top.codekiller.leyou.pojo.bo.SpuBo;
import top.codekiller.leyou.service.IGoodsService;

import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class GoodsService implements IGoodsService {

    @Autowired
    private SpuMapper spuMapper;

    @Autowired
    private SpuDetailMapper spuDetailMapper;

    @Autowired
    private BrandMapper brandMapper;

    @Autowired
    private CategoryService categoryService;

    /**
     * 根据条件来分页查询Spu
     * @param key 关键字
     * @param saleable 是否上架
     * @param page 查询的页码
     * @param rows 当前页的数据量
     * @return
     */
    @Override
    public PageResult<SpuBo> querySpuByPage(String key, Boolean saleable, Integer page, Integer rows) {
        LambdaQueryWrapper<Spu> queryWrapper=new QueryWrapper<Spu>().lambda();
        //添加查询条件
        if(StringUtils.isNotBlank(key)){
            queryWrapper.like(Spu::getTitle,key);
        }
        //添加上下架的过滤
        if(saleable!=null) {
            queryWrapper.eq(Spu::getSaleable, saleable);
        }
        //进行分页查询,获取当前页对象
        IPage<Spu> ipage=spuMapper.selectPage(new Page<Spu>(page,rows),queryWrapper);
        //获取spu集合
        List<Spu> spus=ipage.getRecords();
        //转化成spubo的集合
        List<SpuBo> spuBos=spus.stream().map(spu->{
            SpuBo spuBo = new SpuBo();
            BeanUtils.copyProperties(spu,spuBo);
            //查询品牌名称
            Brand brand=this.brandMapper.selectById(spu.getBrandId());
            spuBo.setBname(brand.getName());
            //查询分类名称
            List<String> categoryNames=this.categoryService.queryNamesByIds(Arrays.asList(spu.getCid1(),spu.getCid2(),spu.getCid3()));
            String cname=StringUtils.join(categoryNames,"-");
            spuBo.setCname(cname);
            return spuBo;
        }).collect(Collectors.toList());
        //返回pageResult<SpuBo>
        PageResult<SpuBo> pageResult=new PageResult<SpuBo>(ipage.getTotal(),(int)ipage.getPages(),spuBos);
        return pageResult;
    }
}
CategoryService中扩展查询名称的功能

页面需要商品的分类名称需要在这里查询,因此要额外提供查询分类名称的功能,

在CategoryService中添加功能:

package top.codekiller.leyou.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import top.codekiller.leyou.mapper.CategoryMapper;
import top.codekiller.leyou.pojo.Category;
import top.codekiller.leyou.service.ICategoryService;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class CategoryService implements ICategoryService {

    @Autowired
    private CategoryMapper categoryMapper;

    /**
     * 根据父节点查询子节点
     * @param pid
     * @return
     */
    @Override
    public List<Category> queryCategoryByPid(Long pid) {
        return this.categoryMapper.selectList(new QueryWrapper<Category>().lambda()
                                        .eq(Category::getParentId,pid));
    }


    /**
     * 根据id查询分类名称
     * @param ids
     * @return
     */
    @Override
    public List<String> queryNamesByIds(List<Long> ids) {
        List<Category> categories=this.categoryMapper.selectBatchIds(ids);
        return categories.stream().map(category -> category.getName()).collect(Collectors.toList());
    }
}
Mapper
public interface SpuMapper extends BaseMapper<Spu> {
}
public interface SpuDetailMapper extends BaseMapper<SpuDetail> {
}

测试

在这里插入图片描述商品管理

商品新增

当我们点击新增商品按钮就会出现一个弹窗:

里面把商品的数据分为了4部分来填写:

  • 基本信息:主要是一些简单的文本数据,包含了SPU和SpuDetail的部分数据,如
    • 商品分类:是SPU中的cid1cid2cid3属性
    • 品牌:是spu中的brandId属性
    • 标题:是spu中的title属性
    • 子标题:是spu中的subTitle属性
    • 售后服务:是SpuDetail中的afterService属性
    • 包装列表:是SpuDetail中的packingList属性
  • 商品描述:是SpuDetail中的description属性,数据较多,所以单独放一个页面
  • 规格参数:商品规格信息,对应SpuDetail中的genericSpec属性
  • SKU属性:spu下的所有Sku信息

对应到页面中的四个stepper-content

弹窗事件

弹窗是一个独立组件:

并且在Goods组件中已经引用它:

并且在页面中渲染:

新增商品按钮的点击事件中,改变这个dialogshow属性:

基本数据

我们先来看下基本数据:

商品分类

商品分类信息查询我们之前已经做过,所以这里的级联选框已经实现完成:

刷新页面,可以看到请求已经发出:

效果:

品牌选择

页面

品牌也是一个下拉选框,不过其选项是不确定的,只有当用户选择了商品分类,才会把这个分类下的所有品牌展示出来。

所以页面编写了watch函数,监控商品分类的变化,每当商品分类值有变化,就会发起请求,查询品牌列表:

选择商品分类后,可以看到请求发起:

接下来,我们只要编写后台接口,根据商品分类id,查询对应品牌即可。

后端代码

页面需要去后台查询品牌信息,我们自然需要提供:

请求方式:GET

请求路径:/brand/cid/{cid}

请求参数:cid

响应数据:品牌集合

BrandController

/**
     * 通过分类id获取对应的品牌集合
     * @param cid
     * @return
     */
    @GetMapping("cid/{cid}")
    public ResponseEntity<List<Brand>> queryBrandsByCid(@PathVariable("cid") Long cid){
        List<Brand> brands=this.brandService.queryBrandByCid(cid);
        if(CollectionUtils.isEmpty(brands)){
            return ResponseEntity.notFound().build();
        }

        return ResponseEntity.ok(brands);
    }

IBrandService

    /**
     * 通过分类id获取对应的品牌集合
     * @param cid category.id
     * @return
     */
    List<Brand> queryBrandByCid(Long cid);

BrandService

/**
     * 通过分类id获取对应的品牌集合
     * @param cid category.id
     * @return
     */
    @Override
    public List<Brand> queryBrandByCid(Long cid) {
        return this.brandMapper.selectBrandByCid(cid);

    }

BrandMapper

根据分类查询品牌有中间表,需要自己编写Sql:

     /**
     * 通过分类id获取品牌集合
     * @param cid
     * @return
     */
    @Select("select * from tb_brand  a inner join  tb_category_brand b on a.id=b.brand_id where b.category_id=#{cid}")
    List<Brand> selectBrandByCid(Long cid);

效果:

其他文本框

剩余的几个属性:标题、子标题等都是普通文本框,我们直接填写即可,没有需要特别注意的。

商品描述

商品描述信息比较复杂,而且图文并茂,甚至包括视频。

这样的内容,一般都会使用富文本编辑器。

富文本编辑器

百度百科:

通俗来说:富文本,就是比较丰富的文本编辑器。普通的框只能输入文字,而富文本还能给文字加颜色样式等。

富文本编辑器有很多,例如:KindEditor、Ueditor。但并不原生支持vue

但是我们今天要说的,是一款支持Vue的富文本编辑器:vue-quill-editor

vue-quill-editor

GitHub的主页:https://github.com/surmon-china/vue-quill-editor

Vue-Quill-Editor是一个基于Quill的富文本编辑器:Quill的官网

使用指南

使用非常简单:已经在项目中集成。以下步骤不需操作,仅供参考

第一步:安装,使用npm命令:

npm install vue-quill-editor --save

第二步:加载,在js中引入:

全局引入:

import Vue from 'vue'
import VueQuillEditor from 'vue-quill-editor'

const options = {}; /* { default global options } */

Vue.use(VueQuillEditor, options); // options可选

局部引入:

import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'

import {quillEditor} from 'vue-quill-editor'

var vm = new Vue({
    components:{
        quillEditor
    }
})

我们这里采用局部引用:

第三步:页面使用:

<quill-editor v-model="goods.spuDetail.description" :options="editorOption"/>

自定义富文本编辑器

不过这个组件有个小问题,就是图片上传的无法直接上传到后台,因此我们对其进行了封装,支持了图片的上传。

使用也非常简单:

<v-stepper-content step="2">
    <v-editor v-model="goods.spuDetail.description" upload-url="/upload/image"/>
</v-stepper-content>
  • upload-url:是图片上传的路径
  • v-model:双向绑定,将富文本编辑器的内容绑定到goods.spuDetail.description

效果

商品规格参数(SPU)

规格参数的查询我们之前也已经编写过接口,因为商品规格参数也是与商品分类绑定,所以需要在商品分类变化后去查询,我们也是通过watch监控来实现:

可以看到这里是根据商品分类id查询规格参数:SpecParam。我们之前写过一个根据gid(分组id)来查询规格参数的接口,我们接下来完成根据分类id查询规格参数。

Controller

我们在原来的根据 gid(规格组id)查询规格参数的接口上,添加一个参数:cid,即商品分类id。

等一下, 考虑到以后可能还会根据是否搜索、是否为通用属性等条件过滤,我们多添加几个过滤条件:

     /**
     * 根据条件查询规格参数
     * @param cid 分类id
     * @param gid 组id
     * @param generic 是否是通用参数
     * @param searching 是否是特殊参数
     * @return
     */
@GetMapping("params")
public ResponseEntity<List<SpecParam>> queryParams(@RequestParam(value = "gid",required = false)Long gid,
                                                   @RequestParam(value = "cid",required = false)Long cid,
                                                   @RequestParam(value = "generic",required = false) Boolean generic,
                                                   @RequestParam(value="searching",required = false) Boolean searching){
    List<SpecParam> params=this.specificationService.queryParams(gid,cid,generic,searching);
    System.out.println(params);
    if(CollectionUtils.isEmpty(params)){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(params);
}

Service

    /**
     * 根据条件查询规格参数
     * @param cid 分类id
     * @param gid 组id
     * @param generic 是否是通用参数
     * @param searching 是否是特殊参数
     * @return
     */
    @Override
    public List<SpecParam> queryParams(Long gid, Long cid, Boolean generic, Boolean searching) {
        SpecParam params=new SpecParam();
        params.setCid(cid);
        params.setGroupId(gid);
        params.setGeneric(generic);
        params.setSearching(searching);
        return specParamMapper.selectList(new QueryWrapper<SpecParam>(params));
    }

页面

SKU信息

Sku属性是SPU下的每个商品的不同特征,如图:

当我们填写一些属性后,会在页面下方生成一个sku表格,大家可以计算下会生成多少个不同属性的Sku呢?

当你选择了上图中的这些选项时:

  • 颜色共2种:迷夜黑,勃艮第红,绚丽蓝
  • 内存共2种:4GB,6GB
  • 机身存储1种:64GB,128GB

此时会产生多少种SKU呢? 应该是 3 * 2 * 2 = 12种,这其实就是在求笛卡尔积。

我们会在页面下方生成一个sku的表格:

表单提交

在sku列表的下方,有一个提交按钮

并且绑定了事件

击后会组织数据并向后台提交:

    submit() {
      // 表单校验。
      if(!this.$refs.basic.validate){
        this.$message.error("请先完成表单内容!");
      }
      // 先处理goods,用结构表达式接收,除了categories外,都接收到goodsParams中
      const {
        categories: [{ id: cid1 }, { id: cid2 }, { id: cid3 }],
        ...goodsParams
      } = this.goods;
      // 处理规格参数
      const specs = {};
      this.specs.forEach(({ id,v }) => {
        specs[id] = v;
      });
      // 处理特有规格参数模板
      const specTemplate = {};
      this.specialSpecs.forEach(({ id, options }) => {
        specTemplate[id] = options;
      });
      // 处理sku
      const skus = this.skus
        .filter(s => s.enable)
        .map(({ price, stock, enable, images, indexes, ...rest }) => {
          // 标题,在spu的title基础上,拼接特有规格属性值
          const title = goodsParams.title + " " + Object.values(rest).map(v => v.v).join(" ");
          const obj = {};
          Object.values(rest).forEach(v => {
            obj[v.id] = v.v;
          });
          return {
            price: this.$format(price), // 价格需要格式化
            stock,
            indexes,
            enable,
            title, // 基本属性
            images: images ? images.join(",") : '', // 图片
            ownSpec: JSON.stringify(obj) // 特有规格参数
          };
        });
      Object.assign(goodsParams, {
        cid1,
        cid2,
        cid3, // 商品分类
        skus // sku列表
      });
      goodsParams.spuDetail.genericSpec = JSON.stringify(specs);
      goodsParams.spuDetail.specialSpec = JSON.stringify(specTemplate);

      // 提交到后台
      this.$http({
        method: this.isEdit ? "put" : "post",
        url: "/item/goods",
        data: goodsParams
      })
        .then(() => {
          // 成功,关闭窗口
          this.$emit("close");
          // 提示成功
          this.$message.success("保存成功了");
        })
        .catch(() => {
          this.$message.error("保存失败!");
        });
    }

点击提交,查看提交的数据格式:

整体是一个json格式数据,包含Spu表所有数据:

  • brandId:品牌id
  • cid1、cid2、cid3:商品分类id
  • subTitle:副标题
  • title:标题
  • spuDetail:是一个json对象,代表商品详情表数据
    • afterService:售后服务
    • description:商品描述
    • packingList:包装列表
    • specialSpec:sku规格属性模板
    • genericSpec:通用规格参数
  • skus:spu下的所有sku数组,元素是每个sku对象:
    • title:标题
    • images:图片
    • price:价格
    • stock:库存
    • ownSpec:特有规格参数
    • indexes:特有规格参数的下标

后端代码

实体类

SPU和SpuDetail实体类已经添加过,添加Sku和Stock对象并修改(SpuBo)[#spubo]:

SKU

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;
import java.util.Date;

@Data
public class Sku {
    @TableId
    private Long id;
    private Long spuId;
    private String title;
    private String images;
    private Long price;
    private String ownSpec;// 商品特殊规格的键值对
    private String indexes;// 商品特殊规格的下标
    private Boolean enable;// 是否有效,逻辑删除用
    private Date createTime;// 创建时间
    private Date lastUpdateTime;// 最后修改时间
    @TableField(exist = false)
    private Integer stock;// 库存
}

注意:这里保存了一个库存字段,在数据库中是另外一张表保存的,方便查询。

Stock

package top.codekiller.leyou.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.Data;

@Data
public class Stock {
    @TableId(type = IdType.INPUT)
    private Long skuId;
    private Integer seckillStock;// 秒杀可用库存
    private Integer seckillTotal;// 已秒杀数量
    private Integer stock;// 正常库存

}

Mapper

都是通用Mapper,略

目录结构:

GoodsController

结合浏览器页面控制台,可以发现:

请求方式:POST

请求路径:/goods

请求参数:Spu的json格式的对象,spu中包含spuDetail和Sku集合。这里我们该怎么接收?我们之前定义了一个SpuBo对象,作为业务对象。这里也可以用它,不过需要再扩展spuDetail和skus字段:

修改SpuBo:

package top.codekiller.leyou.pojo.bo;

import lombok.Data;
import top.codekiller.leyou.pojo.Sku;
import top.codekiller.leyou.pojo.Spu;
import top.codekiller.leyou.pojo.SpuDetail;
import java.util.List;

@Data
public class SpuBo extends Spu {
    /**
     * 通过cid获取分类name
     */
    private String cname;

    /**
     * 通过bid获取品牌name
     */
    private String bname;

    /**
     * 商品详情
     */
    private SpuDetail spuDetail;

    /**
     * sku集合
     */
    private List<Sku> skus;
}
    /**
     * 增加商品的spu和sku
     * @param spuBo
     * @return
     */
@PostMapping("goods")
public ResponseEntity<Void> saveGoods(@RequestBody SpuBo spuBo){
    this.goodsService.saveGoods(spuBo);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

注意:通过@RequestBody注解来接收Json请求,还有之前如果设置了全局映射路径为spu要注意下

GoodsService

这里的逻辑比较复杂,我们除了要对SPU新增以外,还要对SpuDetail、Sku、Stock进行保存


/**
* 增加商品的spu和sku
* @param spuBo
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void saveGoods(SpuBo spuBo) {
    //先新增spu
    //防止id注入
    spuBo.setId(null);
    spuBo.setSaleable(true);
    spuBo.setValid(true);
    spuBo.setCreateTime(new Date());
    spuBo.setLastUpdateTime(spuBo.getCreateTime());
    this.spuMapper.insert(spuBo);
    //再去新增spuDetail
    SpuDetail spuDetail=spuBo.getSpuDetail();
    spuDetail.setSpuId(spuBo.getId());
    this.spuDetailMapper.insert(spuDetail);

    List<Sku> skus=spuBo.getSkus();
    skus.forEach(sku->{
        //新增sku
        sku.setId(null);
        sku.setSpuId(spuBo.getId());
        sku.setCreateTime(new Date());
        sku.setLastUpdateTime(sku.getCreateTime());
        this.skuMapper.insert(sku);

        //新增stock
        Stock stock=new Stock();
        stock.setSkuId(sku.getId());
        stock.setStock(sku.getStock());
        this.stockMapper.insert(stock);
    });
}

商品修改

编辑按钮点击事件

在商品详情页,每一个商品后面,都会有一个编辑按钮:

点击这个按钮,就会打开一个商品编辑窗口,我们看下它所绑定的点击事件:(在item/Goods.vue)

对应的方法:

可以看到这里发起了两个请求,在查询商品详情和sku信息。

因为在商品列表页面,只有spu的基本信息:id、标题、品牌、商品分类等。比较复杂的商品详情(spuDetail)和sku信息都没有,编辑页面要回显数据,就需要查询这些内容。

因此,接下来我们就编写后台接口,提供查询服务接口。

查询SpuDetail接口

GoodsController

需要分析的内容:

  • 请求方式:GET
  • 请求路径:/spu/detail/{id}
  • 请求参数:id,应该是spu的id
  • 返回结果:SpuDetail对象
     /**
     * 根据spu的id查询SpuDetail
     * @param spuId
     * @return
     */
@GetMapping("spu/detail/{spuId}")
public ResponseEntity<SpuDetail> querySpuDetailBySpuId(@PathVariable("spuId") Long spuId){
    SpuDetail detail=this.goodsService.querySpuBySupId(spuId);
    if(detail==null){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(detail);
}

GoodsService

     /**
     * 根据spu的id查询SpuDetail
     * @param spuId
     * @return
     */
@Override
public SpuDetail querySpuBySupId(Long spuId) {
    return spuDetailMapper.selectById(spuId);
}

查询Sku

分析

  • 请求方式:Get
  • 请求路径:/sku/list
  • 请求参数:id,应该是spu的id
  • 返回结果:sku的集合

GoodsController

/**
     * 根据spu的id查询sku的集合
     * @param spuId
     * @return
     */
@GetMapping("sku/list")
public ResponseEntity<List<Sku>> querySkuBySpuId(@RequestParam("id")Long spuId){
    List<Sku> skus=this.goodsService.querySkuBySpuId(spuId);
    if(CollectionUtils.isEmpty(skus)){
        return ResponseEntity.notFound().build();
    }
    return ResponseEntity.ok(skus);
}

GoodsService

需要注意的是,为了页面回显方便,我们一并把sku的库存stock也查询出来

@Override
public List<Sku> querySkuBySpuId(Long spuId) {
    return this.skuMapper.querySkuBySpuId(spuId);
}

页面回显

随便点击一个编辑按钮,发现数据回显完成:

页面提交

这里的保存按钮与新增其实是同一个,因此提交的逻辑也是一样的,这里不再赘述。

随便修改点数据,然后点击保存,可以看到浏览器已经发出请求:

后台实现

接下来,我们编写后台,实现修改商品接口。

GoodsController

  • 请求方式:PUT
  • 请求路径:/
  • 请求参数:Spu对象
  • 返回结果:无
    /**
     * 更新商品
     * @param spuBo
     * @return
     */
    @PutMapping("goods")
    public ResponseEntity<Void> updateGoods(@RequestBody SpuBo spuBo){
        this.goodsService.updateGoods(spuBo);
        return ResponseEntity.noContent().build();
    }

GoodsService

spu数据可以修改,但是SKU数据无法修改,因为有可能之前存在的SKU现在已经不存在了,或者以前的sku属性都不存在了。比如以前内存有4G,现在没了。

因此这里直接删除以前的SKU,然后新增即可。

然后新增sku和开始新增商品中新增sku的代码一致,可以将保存sku和库存的方法抽取成一个方法

代码:

/**
     * 更新商品
     * @param spuBo
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateGoods(SpuBo spuBo) {
        //根据spuid查询要删除的sku
        List<Sku> skus=this.skuMapper.selectList(new QueryWrapper<Sku>().lambda().eq(Sku::getSpuId,spuBo.getId()));
        //删除stock
        skus.forEach(sku -> {
            this.stockMapper.deleteById(sku.getId());
        });

        //删除sku
        skus.forEach(sku->{
            this.skuMapper.deleteById(sku.getId());
        });

        //新增sku和stock
        this.saveSkuAndStock(spuBo);

        //更新spu和spuDetails
        spuBo.setCreateTime(null);
        spuBo.setLastUpdateTime(new Date(