SinoSigStore-second

技术栈

一、业务一:查出所有产品列表的三级分类并返回一个数据树形结构

对应着数据库中的表:pms_category

pms_category
实现效果
    public List<CategoryEntity> listWithTree() {
        //1.查出所有的分类
        List<CategoryEntity> entities = categoryDao.selectList(null);

        //2.组装成父子的树形结构
        //2.1找所有的一级结构
        List<CategoryEntity> levelMenus = entities.stream().filter(categoryEntity ->
                categoryEntity.getParentCid() == 0
        ).map((menu) -> {
            menu.setChildren(getChildrens(menu, entities));
            return menu;
        }).sorted((menu1, menu2) -> {
            return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
        }).collect(Collectors.toList());
        return levelMenus;
    }

    //递归查找所有菜单的子菜单
    //传入当前分类和总分类
    private List<CategoryEntity> getChildrens(CategoryEntity root, List<CategoryEntity> all) {

        List<CategoryEntity> children = all.stream().filter(CategoryEntity -> {
            return CategoryEntity.getParentCid() == root.getCatId();
        }).map(categoryEntity -> {
            //找到子菜单
            categoryEntity.setChildren(getChildrens(categoryEntity, all));
            return categoryEntity;
        }).sorted((menu1, menu2) -> {
            //菜单的排序
            return (menu1.getSort() == null ? 0 : menu1.getSort()) - (menu2.getSort() == null ? 0 : menu2.getSort());
        }).collect(Collectors.toList());
        return children;
    }

二、配置网关

image.png

比如说在前端点击一个查询服务,他会触发请求,但是这个请求却是提交给了renren-fast/...但是我们实际上是想调用我们的产品模块的方法,而产品的接口配置的是10000,如下所示,定义了API的基准路径地址,难道要改成10000吗,当然不是,以后还有10001,10002....所以我们统一提交给网关,交给网关来进行路由。改成网关的地址:'http://localhost:88/api',这里所有前台发来的请求都加上了/api;

image.png
image.png

但是在将路径修改为"http://localhost:88/api"后,我们要加载验证码的请求,确实发送给网关了,但是网关并处理不了这个问题,还应该路由给指定的要解决问题的服务,所以讲网关注册进注册中心,然后由网关来进行路由到指定的处理地址。

spring:
  cloud:
    gateway:
      routes:
        - id: admin_route
        uri: lb://renren-fast
        predicates:
          - Path=/api/**
##前端项目,/api
image.png

如上所示,在网关中进行配置,将所有前台发来的请求路由到renren-fast,但是这样仍然会存在问题,因为他会路由到renren-fast也就是8001,但是实际请求的路径并不是这样的,所以这里就需要我们网关对路径进行重写。官网参考如下:

https://www.springcloud.cc/spring-cloud-greenwich.html#_spring_cloud_gateway

官网示例
spring:
  cloud:
    gateway:
      routes:
        - id: admin_route
          uri: lb://renren-fast
          predicates:
            - Path=/api/**
          filters:
            - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}

##前端项目发来的请求统一加前缀 ,/api
##将地址:http://localhost:88/api/captcha.jpg? 
##路由成 :http://localhost:8080/renren-fast/captcha.jpg

这样就可以实现访问了,验证码就出来了。

三、解决跨域请求问题

如下所示,网关处理好了,但是在登陆时仍然出现了报错,如图中报错显示,在从8001到88访问的时候被CORS(跨域)策略阻塞了,浏览器会默认拒绝跨域。

问题页面

跨域:指的是浏览器不能执行其他网站的脚本,他是本浏览器的同源策略造成的,是浏览器对javaScript施加的安全限制。
同源策略:是指协议,域名,端口都要相同,其中有一个不同就会产生跨域。

跨域举例
跨域流程
跨域解决方案一

如上,可以将前端vue和后台都部署到同一台nginx下,这样浏览器访问就不需要访问项目的真实路径,而只需要访问nginx的路径就可以了。

跨域解决方案二

如上,可以对跨域进行这些配置,但是不能每个跨域都来进行这样的配置,太麻烦,所以写一个Filter,所有的请求进来之后,放行,执行完之后返回给浏览器,在响应中添加上这几个字段。在网关里面统一配置跨域就可以了。

package com.fzq.gateway.config;

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

/**
 * @author fuzq
 * @create
 */
@Configuration
public class XigumallCorsConfiguration {

    @Bean
    public CorsWebFilter corsWebFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        //所有跟跨域有关的配置都写在以下里面
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        //配置跨域
        corsConfiguration.addAllowedHeader("*");//允许哪些头通过
        corsConfiguration.addAllowedMethod("*");//允许哪些请求方式进行跨域
        corsConfiguration.addAllowedOrigin("*");//允许哪些请求来源进行跨域
        corsConfiguration.setAllowCredentials(true);//是否允许携带cookie进行跨域
        source.registerCorsConfiguration("/**", corsConfiguration);//任意路径都进行,注册跨域的配置
        return new CorsWebFilter(source);
    }
}

四、树形展示三级分类数据,并设置append,remove按钮,并添加修改功能,三级分类的增删改查

前端负责收集数据,组装好各种数据,发送后台,后台将前端组装好的数据转换成自己想要的数据,然后调用Service,整个期间用的最多的方式是Http,而且发送的是post方式,将所有的数据直接转为json,然后后台通过@RequestBody注解将json转换成自己需要的格式

①.展示三级分类

肯定是得不到自己想要的结果的,因为前台发来的所有请求全部路由到renren-fast了,但是我们想要请求的实际是product模块,所以就要对网关添加一个断言,也将product模块注册进注册中心。如下,因为网关是从上向下执行的,所有断言的顺序是要注意的。

spring:
  cloud:
    gateway:
      routes:
        - id: product_route
          uri: lb://xigumall-product
          predicates:
            - Path=/api/product/**
          ##http://localhost:8888/api/product/category/list/tree 重写成 http://localhost:10000/product/category/list/tree
          filters:
            - RewritePath=/api/(?<segment>.*),/$\{segment}

        - id: admin_route
          uri: lb://renren-fast
            ##前端项目发来的请求统一加前缀 ,/api
          predicates:
            - Path=/api/**
          filters:
            ##http://localhost:88/api/captcha.jpg? 重写成 http://localhost:8080/renren-fast/captcha.jpg
            - RewritePath=/api/(?<segment>.*),/renren-fast/$\{segment}
拿到自己想要的数据

对应的前端页面如下:

<template>
  <el-tree :data="menus" :props="defaultProps" @node-click="handleNodeClick"></el-tree>
</template>
<script>
export default {
  data() {
    return {
      menus: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  methods: {
    handleNodeClick(data) {
      console.log(data);
    },
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({data}) => {
        console.log("成功获取到菜单数据...", data.data);
        this.menus = data.data;
      });
    }
  },
  created() {
    this.getMenus();
  }
};
</script>
<style>
</style>

②.设置append,delete按钮

append按钮只有在有子目录时才会有,delete按钮只有在没有子目录时才会有
可以通过v-if来对后台传来的数据进行实现

<template>
  <el-tree :data="menus" :props="defaultProps" :expand-on-click-node="false" :show-checkbox="true" node-key="catId">
    <span class="custom-tree-node" slot-scope="{ node, data }">
      <span>{{ node.label }}</span>
      <span>
        <el-button v-if="node.level <=2" type="text" size="mini" @click="() => append(data)">Append</el-button>
        <el-button v-if="node.childNodes.length==0" type="text" size="mini" @click="() => remove(node, data)">Delete</el-button>
      </span>
    </span>
  </el-tree>
</template>

③.逻辑删除而并不是物理删除(逻辑删除只是设置一个标识位,而并没有进行真正的删除)

逻辑删除这里用的是mybatis-plus实现的,首先在yml中进行逻辑删除字段的配置如下,然后对实体类映射数据库的那个字段上进行标记用@TableLogic注解进行标记。可以看出showStatus显示与不显示的值跟全局设置是相反的,所以可以进行单独的配置,value是显示,devalue是不显示。

yml配置
@TableLogic

④.点击删除按钮调用后台进行删除

TODO就像是作为一个备忘录一样,防止忘记。

待办事项列表TODO的用法
#前台remove按钮调用方法
remove(node, data) {
      var ids = [data.catId];
      this.$http({
        url: this.$http.adornUrl("/product/category/delete"),
        method: "post",
        data: this.$http.adornData(ids, false)
      }).then(({ data }) => {
        console.log("删除成功");
      });
      console.log("remove", node, data);
    }
controller
service
serviceImpl

然后对前台页面进行优化,首先点击删除之后要有一个提示框,来提示是否删除,其次删除成功之后,弹出一条消息删除成功。还有就是删除成功之后要调用方法重新获取tree树,也就是getMenus方法,最后要把删除的那个菜单的父菜单是默认开着的,不要每次删除完之后自己关闭了。

<template>
  <el-tree
    :data="menus"
    :props="defaultProps"
    :expand-on-click-node="false"
    :show-checkbox="true"
    node-key="catId"
    :default-expanded-keys="expandedKey"
  >
    <span class="custom-tree-node" slot-scope="{ node, data }">
      <span>{{ node.label }}</span>
      <span>
        <el-button v-if="node.level <=2" type="text" size="mini" @click="() => append(data)">Append</el-button>
        <el-button
          v-if="node.childNodes.length==0"
          type="text"
          size="mini"
          @click="() => remove(node, data)"
        >Delete</el-button>
      </span>
    </span>
  </el-tree>
</template>
<script>
export default {
  data() {
    return {
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  methods: {
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        this.menus = data.data;
      });
    },
    append(data) {
      console.log("append", data);
    },
    remove(node, data) {
      var ids = [data.catId];
      this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false)
          }).then(({ data }) => {
            //删除成功消息提示
            this.$message({
              showClose: true,
              message: "删除成功",
              type: "success"
            });
          });
          console.log("remove", node, data);
          //刷新出新的菜单
          this.getMenus();
          //设置需要默认展开的菜单
          this.expandedKey=[node.parent.data.catId]
        })
        .catch(() => {});
    }
  },
  created() {
    this.getMenus();
  }
};
</script>
<style>
</style>

⑤.设置append按钮弹出弹窗进行菜单新增

在<el-tree>下面放一个<el-dialog>,在弹窗中填写新的菜单,大体和delete相同。然后调用后端的save方法,进行存储,实现代码如下:

<template>
  <div>
    <el-tree
      :data="menus"
      :props="defaultProps"
      :expand-on-click-node="false"
      :show-checkbox="true"
      node-key="catId"
      :default-expanded-keys="expandedKey"
    >
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <el-button
            v-if="node.level <=2"
            type="text"
            size="mini"
            @click="() => append(data)"
          >Append</el-button>
          <el-button
            v-if="node.childNodes.length==0"
            type="text"
            size="mini"
            @click="() => remove(node, data)"
          >Delete</el-button>
        </span>
      </span>
    </el-tree>
    <el-dialog title="提示" :visible.sync="dialogVisible" width="30%">
      <el-form :model="category">
        <el-form-item label="分类名称">
          <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="addCategory">确 定</el-button>
      </span>
    </el-dialog>
  </div>
</template>
<script>
export default {
  data() {
    return {
      category: {
        name: " ",
        parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0
      },
      //对话框设置属性,默认关闭false
      dialogVisible: false,
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  methods: {
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        this.menus = data.data;
      });
    },
    append(data) {
      this.dialogVisible = "true";
      this.category.parentCid = data.catId;
      this.category.catLevel = data.catLevel * 1 + 1;
      console.log("append", data);
    },
    //添加三级分类的方法
    addCategory() {
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.category, false)
      }).then(({ data }) => {
        //删除成功消息提示
        this.$message({
          showClose: true,
          message: "菜单保存成功",
          type: "success"
        });
        //关闭对话框
        this.dialogVisible = false;
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.category.parentCid];
      });
    },
    remove(node, data) {
      var ids = [data.catId];
      this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false)
          }).then(({ data }) => {
            //删除成功消息提示
            this.$message({
              showClose: true,
              message: "删除成功",
              type: "success"
            });
          });
          console.log("remove", node, data);
          //刷新出新的菜单
          this.getMenus();
          //设置需要默认展开的菜单
          this.expandedKey = [node.parent.data.catId];
        })
        .catch(() => {});
    }
  },
  created() {
    this.getMenus();
  }
};
</script>
<style>
</style>

⑥.设置修改功能,修改菜单的属性

在这里修改功能复用的是append的dialog组件,但是添加一个标记判断是append还是edit,然后注意结构表达式,给后台数据只需要十个中的五个,那样就不需要全部传递给后台了。其次,在点击修改按钮时,对页面的内容进行回显,回显的数据也是通过调用后台,将数据查询出来。目前前台页面如下:

<template>
  <div>
    <el-tree
      :data="menus"
      :props="defaultProps"
      :expand-on-click-node="false"
      :show-checkbox="true"
      node-key="catId"
      :default-expanded-keys="expandedKey"
    >
      <span class="custom-tree-node" slot-scope="{ node, data }">
        <span>{{ node.label }}</span>
        <span>
          <el-button
            v-if="node.level <=2"
            type="text"
            size="mini"
            @click="() => append(data)"
          >Append</el-button>
          <el-button type="text" size="mini" @click="() => edit(data)">edit</el-button>
          <el-button
            v-if="node.childNodes.length==0"
            type="text"
            size="mini"
            @click="() => remove(node, data)"
          >Delete</el-button>
        </span>
      </span>
    </el-tree>
    <el-dialog :title="title" :visible.sync="dialogVisible" width="30%">
      <el-form :model="category">
        <el-form-item label="分类名称">
          <el-input v-model="category.name" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="图标">
          <el-input v-model="category.icon" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="计量单位">
          <el-input v-model="category.productUnit" autocomplete="off"></el-input>
        </el-form-item>
      </el-form>
      <span slot="footer" class="dialog-footer">
        <el-button @click="dialogVisible = false">取 消</el-button>
        <el-button type="primary" @click="submitData">确 定</el-button>
      </span>
    </el-dialog>
  </div>
</template>
<script>
export default {
  data() {
    return {
      title: "",
      dialogType: "", //edit,add
      category: {
        name: "",
        parentCid: 0,
        catLevel: 0,
        showStatus: 1,
        sort: 0,
        icon: "",
        productUnit: "",
        catId: null
      },
      //对话框设置属性,默认关闭false
      dialogVisible: false,
      menus: [],
      expandedKey: [],
      defaultProps: {
        children: "children",
        label: "name"
      }
    };
  },
  methods: {
    getMenus() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        this.menus = data.data;
      });
    },
    edit(data) {
      this.dialogVisible = true;
      this.dialogType = "edit";
      this.title = "修改分类";

      //发送请求获取当前节点最新的数据
      this.$http({
        url: this.$http.adornUrl(`/product/category/info/${data.catId}`),
        method: "get"
      }).then(({ data }) => {
        //请求成功
        console.log("要回显的数据", data);
        this.category.name = data.data.name;
        this.category.catId = data.data.catId;
        this.category.icon = data.data.icon;
        this.category.productUnit = data.data.productUnit;
        this.category.parentCid = data.data.parentCid;
      });
    },
    append(data) {
      this.dialogVisible = "true";
      this.title = "添加分类";
      this.dialogType = "add";
      this.category.parentCid = data.catId;
      this.category.catLevel = data.catLevel * 1 + 1;
      this.category.catId = null;
      this.category.icon = "";
      this.category.name = "";
      this.category.productUnit = "";
      this.category.sort = 0;
      this.category.showStatus = 1;
      console.log("append", data);
    },
    submitData() {
      if (this.dialogType == "add") {
        this.addCategory();
      }
      if (this.dialogType == "edit") {
        this.editCategory();
      }
    },
    //添加三级分类的方法
    addCategory() {
      this.$http({
        url: this.$http.adornUrl("/product/category/save"),
        method: "post",
        data: this.$http.adornData(this.category, false)
      }).then(({ data }) => {
        //删除成功消息提示
        this.$message({
          showClose: true,
          message: "菜单保存成功",
          type: "success"
        });
        //关闭对话框
        this.dialogVisible = false;
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.category.parentCid];
      });
    },
    //修改三级分类的方法
    editCategory() {
      //解构表达式,将category解构,只拿自己需要的数据
      var { catId, name, icon, productUnit } = this.category;
      // var data = {catId: catId,name: name,icon: icon,productUnit: productUnit};
      //如果key,value是一个名字,和上面的效果相同
      var data = {
        catId,
        name,
        icon,
        productUnit
      };
      this.$http({
        url: this.$http.adornUrl("/product/category/update"),
        method: "post",
        data: this.$http.adornData({ catId, name, icon, productUnit }, false)
      }).then(({ data }) => {
        //修改成功消息提示
        this.$message({
          showClose: true,
          message: "菜单修改成功",
          type: "success"
        });
        //关闭对话框
        this.dialogVisible = false;
        //刷新出新的菜单
        this.getMenus();
        //设置需要默认展开的菜单
        this.expandedKey = [this.category.parentCid];
      });
    },
    remove(node, data) {
      var ids = [data.catId];
      this.$confirm(`是否删除【${data.name}】菜单?`, "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        type: "warning"
      })
        .then(() => {
          this.$http({
            url: this.$http.adornUrl("/product/category/delete"),
            method: "post",
            data: this.$http.adornData(ids, false)
          }).then(({ data }) => {
            //删除成功消息提示
            this.$message({
              showClose: true,
              message: "删除成功",
              type: "success"
            });
          });
          console.log("remove", node, data);
          //刷新出新的菜单
          this.getMenus();
          //设置需要默认展开的菜单
          this.expandedKey = [node.parent.data.catId];
        })
        .catch(() => {});
    }
  },
  created() {
    this.getMenus();
  }
};
</script>
<style>
</style>

7.拖拽功能

首先就是在el-tree里面加了几个配置,draggable来开启拖拽功能,还有:allow-drop="allowDrop",写一个allowDrop方法来进行判断是否能进行拖拽。

然后将拖拽后的结果数据进行收集,收集的数据主要是拖拽之后,菜单的父菜单、在菜单中的顺序、层级都要发生变化等。

最后将修改的数据发送给后台进行修改,因为要修改的节点有很多,所以发送到后台的是一个category类型的数组,另外,页面操作时可能会不小心拖拽到,所以设置一个拖拽的开关按钮,设置思路就是,给<el-tree>的drageable绑定一个true或者false的属性,然后添加一个switch开关组件,组件可以控制drageable的属性。最后细节处理,频繁的拖动菜单,改变顺序就会导致一直和数据库进行交互,所以可以设置一个批量保存按钮,对拖拽结果进行保存。

二、品牌管理设置新增功能,将文件上传到OSS

对应着数据库中的表:pms_brand

pms_brand
实现效果
点击新增弹窗

大体的页面,利用逆向工程就可以自动生成,主要的业务就是新增品牌,在点击新增时,可以引用另一个vue,而不会立即加载,从而会影响页面加载时间,特变要注意的是文件上传,也就是弹框中的品牌logo地址

1、文件上传到阿里oss

文件上传

单体应用如图可以直接将文件上传到项目的某一个位置下,下次如果还想要在用,就可以直接拿下来,分布式情况下,会部署多台服务器,如果一次上传由于负载均衡上传到了服务器1,下次想要的如果负载均衡到了其他的服务器,其他服务器是没有这个文件的,所以就会有问题,这样我们浏览器,无论调用哪个微服务,都会将文件上传到同一个文件系统中,也在一个地方读,这样就不会存在找不到的情况,自己搭建服务器的话比较复杂,而且维护成本高,而且要买服务器买流量,因为我们项目访问量不是很大,所以干脆选择了使用阿里云的云存储OSS(Object Storage Service),比较方便,按量收费。阿里官网参考

OSS的几个专业术语:

  • 存储空间(Bucket):一般一个项目的文件存储在一个bucket中

  • 对象(Object):也就是我们存储大的一个个文件、内容

  • 地域(Region):在创建bucket时会选择区域,也就是说的地域

  • 访问域名(Endpoint):也就是空间中存储文件的访问域名

  • 访问密钥(AccessKey):如果需要给这个存储空间上传,那就需要密钥和密码。

将文件上传到OSS有以下两种方式:

传递给服务器,然后传递给OSS

因为要考虑到OSS的账号密码问题,所以不会用户直接将文件上传给OSS,那样的话在请求json里面就要带有OSS的账户密码,会容易泄露阿里云操作的账号密码,而是如上先把文件上传给我们的应用服务器,具体的业务流程也就是,上传给网关,然后网关找到商品服务,然后商品服务拿到文件的流数据,上传给OSS,这种方式不好,因为还要过一下自己的服务器,完全没有必要,

所以采用如下这种方式,把操作OSS服务器的账号密码还是存在自己的服务器里面,浏览器想要给阿里云上传数据,首先要从服务器中获取要到一个policy(上传策略),服务器利用阿里云账号密码生成一个防伪签名,防伪签名里包括访问阿里云的授权令牌, 以及要上传给阿里云的哪个地址等信息,前端拿到这些后,带着这串防伪签名以及要上传的文件交给阿里云,阿里云可以验证这次防伪签名,然后进行上传。这样文件就不用先经过服务器再给阿里云,不然单是文件上传就占用了很大的带宽,影响服务器处理别的请求。

服务端获取到签名后传递给OSS

参考网址

OSS在java中的使用

EndPoint
Accesskey
使用子账户Accesskey
将ID和Secret复制到代码中
添加权限,因为默认的没有任何权限

1、pom添加依赖

<dependency>
       <groupId>com.alibaba.cloud</groupId>
       <artifactId>spring-cloud-starter-alibaba-oss</artifactId>
</dependency>

2、修改yml配置

spring:
  datasource:
    username: root
    password: root
    url: jdbc:mysql://192.168.0.105:3306/gulimall_pms
    driver-class-name: com.mysql.cj.jdbc.Driver
  cloud:
    nacos:
      discovery:
        server-addr: localhost:8848
    alicloud:
      access-key: **************
      secret-key: **************
      oss:
        endpoint: oss-cn-beijing.aliyuncs.com

3、上代码

@Resource
OSSClient ossClient;
@Test
void uploadPicture() throws FileNotFoundException {
        InputStream inputStream = new FileInputStream("/Users/fushier/Desktop/021.jpg");
        ossClient.putObject("picturelx", "021.jpg", inputStream);
        ossClient.shutdown();
        System.out.println("上传完成");
    }

上面这种方式是要先传递给应用服务器,在传递给OSS服务器,是不提倡的,后来会有很多的第三方服务进行调用,比如对象存储,发送短信,查物流等等,所以专门创建一个微服务来整合第三方的功能

以下来完成将文件上传到OSS的功能,直接将参考地址的代码拿过来,服务端获取签名后上传OSS示例参考网址,修改为自己所需的如下,其中同样设置一次跨域请求,所以要在OSS中进行设置,最后前端上传文件,先发一个跨域请求,请求通过后,在后端获取签名,最后将文件上传到OSS。

package com.fzq.xigumall.thirdparty.controller;

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

import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * @author fuzq
 * @create
 */
@RestController
public class OssController {

    @Resource
    OSS ossClient;

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

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

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

    @RequestMapping(value = "/oss/policy")
    public R policy() {
        String host = "https://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
        // callbackUrl为 上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实信息。
        String callbackUrl = "http://88.88.88.88:8888";
        String format = new SimpleDateFormat("yyyy-mm-dd").format(new Date());
        String dir = format + "/"; // 用户上传文件时指定的前缀。
        Map<String, String> respMap = new LinkedHashMap<String, String>();


        try {
            long expireTime = 30;
            long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
            Date expiration = new Date(expireEndTime);
            // PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
            PolicyConditions policyConds = new PolicyConditions();
            policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
            policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

            String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
            byte[] binaryData = postPolicy.getBytes("utf-8");
            String encodedPolicy = BinaryUtil.toBase64String(binaryData);
            String postSignature = ossClient.calculatePostSignature(postPolicy);

            respMap.put("accessid", accessId);
            respMap.put("policy", encodedPolicy);
            respMap.put("signature", postSignature);
            respMap.put("dir", dir);
            respMap.put("host", host);
            respMap.put("expire", String.valueOf(expireEndTime / 1000));
            // respMap.put("expire", formatISO8601Date(expiration));
        } catch (Exception e) {
            // Assert.fail(e.getMessage());
            System.out.println(e.getMessage());
        } finally {
            ossClient.shutdown();
        }
        return R.ok().put("data", respMap);
    }
}

跨域设置
规则配置

三、双端校验,前台数据校验,后台JSR303(Java Specification Requests的缩写,意思是Java 规范提案第303)

实现页面

前台form表单中会有一个rules属性,绑定校验规则。校验规则中首先要确定所有的字段都不可以为空,检索首字母有且只有一个字母,用正则表达式来实现,排序必须填写数字,并且必须是一个整数且大于等于0。前端校验数据到这就算完成了,但是后端还要进行校验,因为不校验同样很危险,可以绕过前台页面,比如说用postman直接进行提交数据,就会想提交什么数据都行了,所以后台同样要进行校验。

image.png
dataRule: {
        name: [{ required: true, message: "品牌名不能为空", trigger: "blur" }],
        logo: [
          { required: true, message: "品牌logo地址不能为空", trigger: "blur" }
        ],
        descript: [
          { required: true, message: "介绍不能为空", trigger: "blur" }
        ],
        showStatus: [
          {
            required: true,
            message: "显示状态[0-不显示;1-显示]不能为空",
            trigger: "blur"
          }
        ],
        firstLetter: [
          {
            validator: (rule, value, callback) => {
              if (value == "") {
                callback(new Error("首字母必须填写"));
              } else if (!/^[a-zA-Z]$/.test(value)) {
                callback(new Error("首字母必须a-z或者A-Z之间"));
              } else {
                callback();
              }
            },
            trigger: "blur"
          }
        ],
        sort: [
          {
            validator: (rule, value, callback) => {
              if (value == "") {
                callback(new Error("排序字段必须填写"));
              } else if (!Number.isInteger(value) || value<0) {
                callback(new Error("排序必须是一个大于等于0的整数"));
              } else {
                callback();
              }
            },
            trigger: "blur"
          }
        ]
      }

后台校验采用JSR303进行校验,主要分为以下几步

  • 1、添加数据校验规则,比如在实体类某个字段添加注解@notNull、@notBlank,@email........注解都在javax.validation.constraints 。并且可以定义自己的message提示。也可以自定义注解配置,使用注解@Pattern(regexp="" )
Bean中添加jsr303注解
自定义的pattern注解
  • 2、要在Controller的传入参数添加注解@Valid,告诉springmvc开启校验,不然只在Bean标记也是没用的,效果:校验错误以后会有默认的响应。
controller添加@Valid注解
存储错误消息提示的文件
  • 3、在controller的传入参数bean后紧跟一个BindingResult,就可以获得到校验的结果。
BindingResult
  • 4、分组校验
    1)、比如说在某一个实体bean的品牌属性添加注解@NotBlank(message="品牌必须提交",groups={AddGroup.class,UpdateGroup.class})如上意思就是在品牌无论是提交还是修改时,品牌都必须不能为空。其中AddGroup和UpdateGroup是两个接口,可以声明在公共微服务中,方便调用。如果开启了分组校验功能,那么其他的校验规则必须指定分组,不然会不起作用。
    2)、Controller进行修改,将以前的@valid注解修改为@Validated(接口)
Validated
  • 5、自定义校验
    1)、编写一个自定义的校验注解
/**
 * @author fuzq
 * @create
 */
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(
        validatedBy = {ListValueConstraintValidator.class}
)
public @interface ListValue {
    String message() default "{com.fzq.common.valid.ListValue.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    int[] vals() default {};
}

2)、编写一个自定义的校验器ConstraintValidator

public class ListValueConstraintValidator implements ConstraintValidator<ListValue, Integer> {

    private Set<Integer> set = new HashSet<>();

    //初始化方法
    @Override
    public void initialize(ListValue constraintAnnotation) {
        int[] vals = constraintAnnotation.vals();
        for (int val : vals) {
            set.add(val);
        }
    }
    //判断是否校验成功

    /**
     * @param value   需要校验的值
     * @param context
     * @return
     */
    @Override
    public boolean isValid(Integer value, ConstraintValidatorContext context) {
        return set.contains(value);
    }
}
}

3)、关联自定义的校验器和自定义的校验注解

关联自定义的校验器

四、异常的统一处理策略

统一异常处理可以使用spring提供的@ControllerAdvice,但是前提是把上面提到的在Controller方法中加的BindingResult去掉,这样异常就会抛出来,然后被异常处理类捕获到。如下所示将controller包中的异常处理抽取出来统一进行处理,当发生MethodArgumentNotValidException异常(数据校验异常)时就都会调用下面的这个方法了。其中项目中用到的错误编码定义规则为5位数字,最后三位表示错误码,其中:10:通用、11:商品、12:订单、13:购物车、14:物流,因为后期各个微服务都会用到这个错误码,所以可以定义一个枚举类放在公共包里,然后可以直接调用

package com.fzq.xigumall.exception;

/**
 * @author fuzq
 * @create
 */
public enum BizCodeEnume {

    UNKNOW_EXCEPTION(10000, "系统未知异常"),
    VALID_EXCEPTION(10001, "系统未知异常");

    private int code;
    private String msg;

    BizCodeEnume(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }
    public int getCode() {
        return code;
    }
    public String getMsg() {
        return msg;
    }
}

package com.fzq.xigumall.exception;

import com.fzq.common.utils.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

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

/**
 * @author fuzq
 * @create
 */
@Slf4j
//@ControllerAdvice(basePackages = "com.fzq.xigumall.product.controller")
//@ResponseBody
//@RestControllerAdvice注解=@ControllerAdvice+@ResponseBody
@RestControllerAdvice(basePackages = "com.fzq.xigumall.product.controller")
public class XigumallExceptionControllerAdvice {

    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public R handleVaildException(MethodArgumentNotValidException e) {
        log.error("数据校验出现问题" + e.getMessage(), e.getClass());
        BindingResult bindingResult = e.getBindingResult();

        Map<String, String> errorMap = new HashMap<>();
        bindingResult.getFieldErrors().forEach((fieldError -> {
            errorMap.put(fieldError.getField(), fieldError.getDefaultMessage());
        }));
        return R.error(BizCodeEnume.VALID_EXCEPTION.getCode(), BizCodeEnume.VALID_EXCEPTION.getMsg()).put("data", errorMap);
    }
}

五、SPU与SKU

SPU:Standard Product Unit(标准化产品单元)

是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性

SKU:Stock Keeping Unit(库存量单位)

即库存进出计量的基本单位,可以是以件,盒,托盘等为单位。SKU这是对于大型连锁超市DC(配送中心)物流管理的一个必要的方法。现在已经被引申为产品统一编号的简称,每种产品均对应有唯一的SKU号。

举例来说苹果XS MAX是一个SPU,如下这些规格与包装这些属性都是属于SPU的,都可以称为基本属性,而颜色版本这些决定SKU的这些属性成为销售属性。在java中可以类比为SPU是一个类,而SKU是属性。

规格与包装属于基本属性,同一个SPU下的所有基本属性都是相同的
颜色内存是SKU,称为销售属性

基本属性【规格参数】与销售属性:

每个分类下的商品共享规格参数,与销售属性。只是有些商品不一定要用这个分类下全部属性:
属性是以三级分类组织起来的(见下图)
规格参数也是基本属性,他们具有自己的分组
规格参数中有些是可以提供检索的(比如说内存...)
属性的分组也是以三级分类组织起来的
属性名确定的,但是值是每一个商品来决定的

属性三级分类

以下来看三级分类的字段是catelog_id,属性表(规格参数和销售属性)是attr_id,属性分组是attr_group_id

image.png
image.png

参考接口文档地址

六、前端组件抽取作为公共组件

在后面的业务开发中有很多地方都会用到一个左面树形菜单,右面是表格这样的布局(左边6右边18),所以可以直接将前面写好的树形三级分类作为组件抽取出来作为公用的,然后每个vue都可以直接调用,而不用在重写如下,抽取到common/category.vue如下,抽取了之后,因为在属性分组里要求树里的每个分类都是可以被点击进行属性添加的,所以要公共组件点击后,告诉调用他的组件,这里可以在公共组件el-tree设置节点点击触发方法nodeclick,nodeclick方法向父组件发送事件及数据,父组件接收子组件发来的事件及数据,然后调用自己的方法执行

前端抽取公共组件
属性分组页面效果
el-tree设置节点点击触发方法nodeclick
nodeclick方法向父组件发送事件及数据
父组件接收子组件发来的事件及数据,然后调用自己的方法执行
点击新增会触发方法addOrUpdateHandle

方法会调用当前对象也就是attrgroup.vue的所有组件,其中有一个叫addOrUpdate的,并调用它的init方法。

image.png
如图在这里为组件起的名字,ref="addOrUpdate"

七、查找分类下的属性分组功能新增修改删除功能

实现界面

上图所示,属性分组栏,要展示出三级分类,前面已经实现,然后要点击每一个分类,在点击之后,如果选中了第三级菜单(此项目最高最高只有三级),就会将其相关的属性查询出来,如果没有选中的话则不会显示,另外右侧的显示栏,设置一个查询按钮,可以根据分组id进行查询或者根据组名进行模糊查询。本业务核心代码在下,其次对于右侧的属性可以进行新增、修改、删除,其中新增的时候是调用一个子组件,子组件中进行实现了dialog,要注意的是,新增后要注意会出现选择分类,分类的时候会显示三级目录,其中三级目录要将所有的三级目录名字查询出来,并显示他的子目录,最后没有子目录的时候就不需要显示儿子了,下面通过@jsonInclude注解来解决,另外在进行修改时要注意当前所属分类要进行回显,具体仍要显示为三级分类的形式,所以就要根据属性id将三级分类的cat_id查询出来然后进行回显。

@Override
    public PageUtils queryPage(Map<String, Object> params, Long catelogId) {
        String key = (String) params.get("key");
        //select * from pms_attr_group where catelog_id=? and (attr_group_id=key or attr_group_name like %key%)
        QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>();
        if (!StringUtils.isEmpty(key)) {
            wrapper.and((obj) -> {
                obj.eq("attr_group_id", key).or().like("attr_group_name", key);
            });
        }
        //如果没有选择任何属性的情况下
        if (catelogId == 0) {
            IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
                    wrapper);
            return new PageUtils(page);
        } else {
            wrapper.eq("catelog_id", catelogId);
            IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params),
                    wrapper);
            return new PageUtils(page);
        }

    }

假如某一个对象,当其中某一个属性为空的时候就不哟啊传递了,可以在实体类中添加注解@JsonInclude如下:

表示children这个字段为空的时候就不返回
dialog修改页面实现效果

八、商品系统下品牌管理继续进行完善

其中搜索框可以直接根据关键字查询到对应的品牌id,也可以针对于品牌名进行模糊查询。然后业务上来看品牌和分类是多对多的关系,数据库中有一张品牌和分类的关系表pms_category_brand_relation,前台可以操作关联分类,对分类和品牌名进行关联并可以进行新增关联,对于某个品牌已经关联好的分类,要根据brand_id在这个关系表里查询出对应的catelog_id和catelog_name进行回显。观察这张表,可以发现,这张表里是有冗余设计的,假如说在categry表中某一个分类名发生了修改,而这张表并不知道,所以要将数据同步过来,保证数据的一致性。(p75节)

品牌和分类关系对应表:pms_category_brand_relation
品牌关联分类
新增关联

九、规格参数及销售属性的查询及修改以及VO类的使用

规格参数提交一个新增功能,前台提交给后台的数据除了规格参数的相关信息之外,还有一些这个属性是属于哪个分组的一些数据,但是后台没有与之对应的PO实体类,所以就用到了VO,除了将规格参数的相关信息保存到数据库对应表之外,还要将关联分组的信息保存到属性和分组关系表中。在点击规格参数修改时要显示当前规格参数的分组和分类的回显。

规格参数列表同样也是默认的全部查询出来,也可以查询某一个分类下的规格参数,每一个规格参数也要显示其所属于哪一个分类,哪一个分组,所以单凭规格参数表是不够的,所以新建一个VO类,来保存规格参数表pms_attr的字段以及另外的实体类中没有的字段,所属分类、所属分组。销售属性大体上是和规格参数一样的,在这里针对于销售属性可以和规格参数共用同一个方法,前台传递过来数据可以直接在路径上加上类型,然后后台进行判断,因为销售属性是没有分组的,所以当销售属性时就不用在分组属性关联关系表中进行保存就可以了。

为了项目更清晰的分层,将每种不同的对象按照功能进行划分,比如java中几种常见的对象

  • 1.PO(persistant object持久对象)
    PO就是对应数据库某个表的一条记录,多个记录可以用PO的集合。PO中应该不包含任何数据库的操作。一般就是entity
  • 2.DO(domain object领域对象)
    就是从现实世界中抽取出来的有形或无形的业务实体
  • 3.TO(Transfer object数据传输对象)
    不同的应用程序之间的传输对象,后来在微服务之间一般都是TO用于传输
  • 4.DTO(Data Transfer Object数据传输对象)
    和TO类似
  • 5.VO(value object值对象)
    通常用于业务层之间的数据传递,和PO一样也是仅仅包含数据而已,但应是抽象出的业务对象,可以和表对应,也可以不,根据业务的需要。用new关键字创建,由GC回收。
    也可以成为view object(视图对象);
    接受页面传递来的数据,封装对象,将业务处理完成的对象,封装成页面要用的数据
  • 6.BO(business object业务对象)
    比如说一个简历有教育经历、工作经历、社会关系中,可以把教育经历对应成一个PO,工作经历对应一个PO,社会关系对应一个PO。把他们结合起来就是一个BO业务对象。
  • 7.POJO(plain ordinary java object简单无规则java对象)
    以上所有的对象都是POJO
  • 8.DAO(data access object数据访问对象)
    用来访问数据库的对象称为DAO
查询结果列表
规格参数修改

十、属性分组关联属性删除关联功能

在点击属性分组时要将所有的分组名称查询出来,各个分组下又包含了不同的属性,可以为属性分组进行新增关联,删除关联。在点击某一个分类时会将这个分类下所有的属性查询出来,在为属性分组关联属性时,会显示一些当前属性分组可以关联的属性,显示的这些属性必须是本分类下的,而且必须是本分类下没有被其他分类关联的,实现步骤就是首先1)查询出当前分类下的其他分组、2)查询出这些分组关联的属性、3)从当前分类的所有属性中移除这些属性并且必须是基本属性,并且还要将自己已经关联的属性移除。

属性分组主页面
属性关联

十一、发布商品功能

发布商品设计的业务相对复杂一些,最后上传的是一个很长的json数据,并且要调用其他的服务:
1)首先要获取会员系统的所有会员等级,因为不同的等级要设置的价格是不一样的;
2)调用一个接口就是要获得所有当前分类下的所有品牌,展现在选择品牌栏里;
3)然后录入规格参数,就要查出这个三级分类下所有的属性分组以及每个属性分组下的所有属性,在录入规格参数时已经设定好一些可选值,会自动显示出来,并且在规格参数添加的时候就已经设定好了单选还是多选,在这里就可以进行选择。

发布商品
sms_member_price
image.png
发布商品折扣设置

最终要要保存一个很长的json串,保存大量的商品信息,涉及数据库中的表有十张左右:

首先是spu的信息表,其中涉及商品的id,商品属于的三级分类,商品名称,商品描述等等,还有一张表专门保存描述图片,一张表保存spu图片信息,三张表之间是通过商品是的spu_id进行关联。另外,发布的商品中也会输入一些规格参数,也要保存到spu规格参数表中,除此以外还要保存spu的积分信息,每个spu都对应着积分,因为积分商城是以积分作为计量单位,积分表保存在用户服务中,所以要远程调用其他微服务,其中要保存所需要花去的购物积分,还有对应用等级提升的成长积分。

其次保存sku的信息,sku的名称,价格,介绍,所属分类,品牌,标题,副标题等等。sku同样也有一张表专门用于保存sku图片信息,除此以为还要保存sku的销售属性。

pms_spu_info保存spu的基本信息

pms_spu_info

pms_spu_info_desc保存spu的描述图片

pms_spu_info_desc

pms_spu_images保存spu的图片信息

pms_spu_images

pms_product_attr_value保存spu规格参数

pms_product_attr_value

sms_spu_bounds 保存spu的积分信息

sms_spu_bounds

pms_product_attr_value保存当前商品对应的所有sku信息
6.1)sku的基本信息:pms_sku_info

pms_sku_info

6.2)sku的图片信息:pms_sku_images

pms_sku_images

6.3)sku的销售属性:pms_sku_sale_attr_value

pms_sku_sale_attr_value

十二、SPU和SKU的检索

SPU检索的时候要传入如图几个条件,分类、品牌、以及检索关键字等,SKU检索的时候要带上商品分类,品牌,价格区间,以及检索关键字,大体和SPU相似。

SPU检索
SKU检索

十三、库存系统

如下所示,库存系统会展示仓库信息,以及商品库存等信息,商品的库存不是我们直接手动添加上去的,而是通过下面的采购单,采购需求完成之后,商品的库存就会自动刷新上去,其中采购需求的生成有两种方式,其一是我们手动自己添加,其二是仓库自动对库存进行检测预警,自动生成采购单。采购需求生成之后还可以合并成一个采购单。其中合成的采购单必须要是新生成的且未被领取的采购单,如果没有选中某一个采购单,也可以新建一个新的采购单,采购单分配给相应的采购人员进行领取。然后采购人员有一个app可以进行领取采购单,采购单领取后,采购单的状态就发生了变化,变为已领取。

库存系统查询仓库
商品库存功能
采购需求
新增采购需求
采购流程
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350