项目地址:https://github.com/wanger61/Springboot-
该项目分为买家端和卖家端两部分:
该项目共有5张数据表,分别为:
商品详情表(product_info),商品类目表(product_category),订单主表(order_master),订单详情表(order_detail),卖家信息表(seller_info)
其中的注意点:
- 表名和字段名都应采用xx_xx的格式,对应JavaBean中一一对应为xxXx格式,(如数据表中字段名为order_id,JavaBean中属性名为orderId) 使用Mybatis时可以开启驼峰命名进行映射
- id字段,如果在记录较少的情况下可以使用int和auto_increment, 而在记录较多的情况下应该使用varchar类型(因为auto_increment是有上限的)
- 对于金钱相关的字段需使用decimal类型,否则会有精度上的差异
- 创建数据表时最好添加创建时间和更新时间字段
- 最好给字段添加注释
- 对于图片等数据通常在表中记录其链接地址
- 状态属性字段在数据库中应设置为tinyint类型,并添加注释什么数字对应什么状态(如订单的支付状态,订单状态)
update_time TIMESTAMP NOT NULL DEFAULT current_timestamp ON UPDATE current_timestamp COMMENT '更新时间',`
create_time TIMESTAMP NOT NULL DEFAULT current_timestamp COMMENT '创建时间',
在订单表的设计上,把订单分为订单主表和订单详情表:
其中订单主表记录了总金额,买家信息等;
订单详情表记录了购买了什么商品,商品数量等;
订单主表的一条记录对应着多条订单详情表记录,订单详情表中有order_id字段,记录其所属的订单主表记录
这么设计,在进行支付等操作时只需要查订单主表即可,而在查询订单详情时再根据order_id去查询到底买了什么商品,对表进行了合理的拆分
针对数据库中的5张表,需要创建与之相对应的JavaBean对象,这里可以使用Lombok插件,只需在对象类上添加@Data注解,就会自动生成get/set和toString方法,非常方便
注意点:
- 金额字段必须使用BigDecimal类型
- 对于状态属性最好为其创建枚举类型,再从枚举中获得状态数字与数据表中的字段相对应:
- 最好对状态属性设置默认值
以订单状态为例:
//订单状态枚举类
@Getter
public enum OrderStatusEnum implements CodeEnum {
NEW(0,"新订单"),
FINISHED(1,"完结"),
CANCEL(2,"已取消"),
;
private Integer code;
private String message;
OrderStatusEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
对象属性:
/** 订单状态,默认为0新下单. */
private Integer orderStatus = OrderStatusEnum.NEW.getCode();
/** 支付状态,默认为0未支付. */
private Integer payStatus = PayStatusEnum.WAIT.getCode();
这里采用的是Mybatis的注解版进行开发,同样也是为每张表创建一个Mapper,对其进行CRUD操作
注意点:
- 对于增删改操作,方法的返回类型最好设置为int型(返回修改的记录行数),这样在后续方法调用时就可以根据该值进行判断是否操作成功,没成功及时捕获
- 对于根据多个字段值查询记录的情况,最好写在一个方法里,查询的字段值通过List传入,然后用动态Sql进行遍历。这样一来避免了多次查询数据库。
- 对于插入操作,有自增主键需使用@Options开启自增主键
- 写完后一定要进行单元测试,因为SQL语句很容易写错…
如根据多个类目类型查询类目:
@Select({""})
public List findProductCategoriesByTypes(@Param("types") List types);
自增主键的插入:
@Options(useGeneratedKeys = true,keyProperty = "categoryId")
@Insert("insert into product_category(category_name, category_type) values(#{categoryName}, #{categoryType})")
public int insertNewProductCategory(ProductCategory productCategory);
对于基本的增删改查操作,注入对应Mapper调用其方法即可
对于查询多条记录的操作,在记录很多时需进行分页,这里使用的Mybatis的PageHelper插件。使用时非常简单,只需在调用Mapper方法前调用静态方法PageHelper.startPage(page,size)即可;
如:
@Override
public List findAll(Integer page, Integer size) {
PageHelper.startPage(page,size);
List allProductInfos = productInfoMapper.findAllProductInfos();
return allProductInfos;
}
对于订单的查询,在给前端传数据时,想通过查询直接查出该订单与其对应的订单详情,但没有与之对应的JavaBean,怎么办?
这里重新创建了OrderDTO对象(Data Transport Object)用于数据传输,在OrderMaster的基础上添加List
生成订单id时,需生成一个唯一的主键,这里通过 时间 + 随机数的方式,编写一个KeyUtil类生成主键,并使用synchronized保证时间戳唯一
将前端传来的OrderDTO(含订单详情)转换为OrderMaster和OrderDetail对象时,可以使用BeanUtils进行属性的拷贝。但在拷贝前一定要注意属性是否拷贝完全。
在进行订单总价的计算时,商品的价格一定要从数据库中查出,而不能从前端传来(防止篡改价格)
订单的创建包括查询商品,计算总价,写入订单数据库,扣库存等操作;整个操作应定义为一个事务,因此需要在方法上添加 @Transactional 注解!
在进行扣库存操作时,这里设计了一个购物车对象CartDTO,包括商品id和商品数量, 在扣库存方法中传入,这样就不用在扣库存时再遍历OrderDTO
其实就是根据订单id从数据库中查找订单,或者查询一个用户的所有订单,组装成一个OrderDTO传给前端,这里要注意的查不到订单的处理方式:
这里采用自定义异常的方式,如果查不到就抛出对应的异常,不过在异常的设计上这里编写一个通用的异常,然后在抛出时为其注入对应的异常原因(枚举)
异常的设计:
继承自运行时异常,在构造方法中传入枚举状态赋予错误码(code)
@Getter
public class SellException extends RuntimeException {
private Integer code;
public SellException(ResultEnum resultEnum) {
super(resultEnum.getMessage());
this.code = resultEnum.getCode();
}
public SellException(Integer code, String message) {
super(message);
this.code = code;
}
}
枚举的设计(这里把状态都列出来了)
@Getter
public enum ResultEnum {
SUCCESS(0,"成功"),
PARAM_ERROR(1,"参数不正确"),
PRODUCT_NOT_EXIST(10,"商品不存在"),
PRODUCT_STOCK_ERROR(11,"商品库存不正确"),
ORDER_NOT_EXIST(12,"订单不存在"),
ORDERDETAIL_NOT_EXIST(13,"订单详情不存在"),
ORDER_STATUS_ERROR(14,"订单取消状态不正确"),
ORDER_UPDATE_FAIL(15,"订单取消失败"),
ORDER_DETAIL_EMPTY(16,"取消订单中无商品详情"),
ORDER_PAY_STATUS_ERROR(17,"订单支付状态不正确"),
CART_EMPTY(18,"购物车为空"),
ORDER_OWNER_ERROR(19,"该订单不属于你"),
ORDER_CANCEL_SUCCESS(20,"订单取消成功"),
ORDER_FINISH_SUCCESS(21,"订单完结成功"),
PRODUCT_STATUS_ERROR(22,"商品状态不正确"),
PRODUCT_UPDATE_ERROR(23,"商品更新失败"),
WECHAT_MP_ERROR(3,"微信公众号方面错误"),
WXPAY_NOTIFY_MONEY_VERITY_ERROR(24,"微信支付异步通知金额校验不通过"),
LOGIN_FAIL(25,"登录失败"),
LOGOUT_SUCCESS(26,"登出成功")
;
private Integer code;
private String message;
ResultEnum(Integer code, String message) {
this.code = code;
this.message = message;
}
}
其实都是去修改订单主表中的订单状态或支付状态,逻辑都很类似,不过需要注意的是,在修改状态前必须先对原本的状态进行校验,只有当原本的状态正确时才能进行修改,不正确要及时抛异常
这些方法都应该声明为事务
加减库存时应该考虑多线程的场景,(防止发生超卖等问题)
这部分比较简单,详情见代码
首先是商品功能,需要根据类目把数据库中的商品传出来发给前端。由于这是一个前后端分离项目,因此服务端主要做的就是提供API,并根据开发文档规定的Json格式向前端传数据
不过要注意的是,向前端传送的Json格式跟原本的JavaBean是有出入的,因此需要根据文档规定的Json格式创建对应的VO对象,然后将原本的JavaBean组装成相应VO对象再传输
在Json传输时的注意点:
- 如果属性名和Json的字段名不同,相要在传输Json时将属性名转成对应的字段名,可以在属性上添加@JsonProperty注解,如:
@JsonProperty("name") private String productName;
在传输Json时就会把productName转为name;- 如果在传输Json时不想传输为null的属性值,则在对象上添加注解:
@JsonInclude(JsonInclude.Include.NON_NULL)
或添加全局配置 spring.jackson.default-property-inclusion=non_null- 如果想在传输Json时忽略某些字段或方法,则在字段或方法上添加
@JsonIgnore注解- 如果在传输Json时想对属性进行格式化,规定传输时的格式,则在该属性上添加 @JsonSerialize注解, 并传入相应的JsonSerializer
如传输时想对时间属性进行格式化
/** 创建时间. */
@JsonSerialize(using = Date2LongSerializer.class)
private Date createTime;
在JsonSerializer中重新定义转换格式:
public class Date2LongSerializer extends JsonSerializer {
@Override
public void serialize(Date date, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
jsonGenerator.writeNumber(date.getTime()/1000);
}
}
前端通过表单传输数据时,应当为每个表单都单独创建相应的Form对象,并在对象的字段上添加JSR303校验注解对表单字段进行规范
Controller在接受表单字段时,有固定的格式:
@PostMapping("create")
public ResultVO
而从前端传来的Json数据为字符串,要想把字符串重新转换为Java对象,这里用到了Gson:
Gson gson = new Gson();
List orderDetailList = new ArrayList<>();
try {
orderDetailList = gson.fromJson(orderForm.getItems(), new TypeToken>(){}.getType());
} catch (Exception e){
log.error("对象转换错误, string={}",orderForm.getItems());
throw new SellException(ResultEnum.PARAM_ERROR);
}
orderDTO.setOrderDetailList(orderDetailList);
卖家端管理平台对页面美观没有太大的需求,这里采用Freemarker模板技术,前端代码可以从ibootstrap下载
其中Freemarker通过${ }的方式取值,迭代格式如下:
<#list categoryList as category>
${category.categoryId}
${category.categoryName}
${category.categoryType}
${category.createTime?string('dd.MM.yyyy HH:mm:ss')}
修改
#list>
注意,使用Freemarker需配置 spring.freemarker.suffix=.ftl
在修改和更新操作后,一般先跳到成功或失败页面,几秒后再跳转,跳转的前端代码为:
因为该项目后期可能会改为分布式项目,因此单个服务器上的Session在其他服务器上会失效,所以采用Redis实现分布式Session:
1.登录时先去找数据库里有没有匹配的用户信息,有则把token设置在Redis和Cookie中
//2.设置token至redis
String token = UUID.randomUUID().toString();
Integer expire = RedisConstant.EXPIRE;
redisTemplate.opsForValue().set(String.format(RedisConstant.TOKEN_PREFIX, token), openid, expire, TimeUnit.SECONDS);
//3.设置token至Cookie
Cookie cookie = new Cookie("token",token);
cookie.setPath("/");
cookie.setMaxAge(expire);
httpServletResponse.addCookie(cookie);
Cookie[] cookies = request.getCookies();
if (cookies != null){
for (Cookie cookie: cookies){
if (cookie.getName() == "token"){
//2.清除Redis
redisTemplate.opsForValue().getOperations().delete(String.format(RedisConstant.TOKEN_PREFIX,cookie.getValue()));
//3.清除Cookie
cookie.setMaxAge(0); //设置时间为0使其失效
}
}
}
关于微信相关功能的开发,主要为微信授权和微信支付以及微信模板推送,都根据微信官方文档和第三方sdk文档进行开发
微信授权的主要流程为:
用户点击生成的特定链接——>会向微信平台发起请求——>微信平台收到请求后再转发到服务器对应的Controller,并传来Code参数——>服务器根据传来的Code参数再向微信平台发送请求以获取access_token ——>如果采用SNSAPI_BASE模式,在传来的access_token中即可得到用户的微信openid
微信支付的主要流程为:
用户点击支付——>服务器调用接口生成用户订单——>服务器通过微信平台的统一下单API向微信平台发送请求——>微信平台收到请求后返回预付单信息——>服务器根据返回的预付单信息生成前端界面给用户——>用户点击支付后微信平台向用户返回支付成功信息——>同时异步地通知服务器支付结果——>服务器收到支付结果后进行金额的比对,比对正确后再修改订单的支付状态——>然后再告知微信处理结果
采用Websocket进行买家端和卖家端的消息通信