优化:收货信息设计方案
This commit is contained in:
252
docs/DELIVERY_ADDRESS_DESIGN.md
Normal file
252
docs/DELIVERY_ADDRESS_DESIGN.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 收货信息设计方案
|
||||
|
||||
## 概述
|
||||
|
||||
本文档详细说明了电商系统中收货信息的设计方案,采用**地址快照 + 地址引用混合模式**,确保订单收货信息的完整性和一致性。
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **数据一致性**:用户下单时保存收货地址快照,避免后续地址修改影响历史订单
|
||||
2. **用户体验**:自动读取用户默认地址,减少用户输入
|
||||
3. **灵活性**:支持用户在下单时临时修改收货信息
|
||||
4. **可追溯性**:保留地址ID引用关系,便于数据分析和问题排查
|
||||
|
||||
## 数据库设计
|
||||
|
||||
### 1. 用户地址表 (shop_user_address)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `shop_user_address` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`name` varchar(100) DEFAULT NULL COMMENT '姓名',
|
||||
`phone` varchar(20) DEFAULT NULL COMMENT '手机号码',
|
||||
`country` varchar(50) DEFAULT NULL COMMENT '所在国家',
|
||||
`province` varchar(50) DEFAULT NULL COMMENT '所在省份',
|
||||
`city` varchar(50) DEFAULT NULL COMMENT '所在城市',
|
||||
`region` varchar(50) DEFAULT NULL COMMENT '所在辖区',
|
||||
`address` varchar(500) DEFAULT NULL COMMENT '收货地址',
|
||||
`full_address` varchar(500) DEFAULT NULL COMMENT '完整地址',
|
||||
`lat` varchar(50) DEFAULT NULL COMMENT '纬度',
|
||||
`lng` varchar(50) DEFAULT NULL COMMENT '经度',
|
||||
`gender` int(11) DEFAULT NULL COMMENT '1先生 2女士',
|
||||
`type` varchar(20) DEFAULT NULL COMMENT '家、公司、学校',
|
||||
`is_default` tinyint(1) DEFAULT 0 COMMENT '默认收货地址',
|
||||
`sort_number` int(11) DEFAULT NULL COMMENT '排序号',
|
||||
`user_id` int(11) DEFAULT NULL COMMENT '用户ID',
|
||||
`tenant_id` int(11) DEFAULT NULL COMMENT '租户id',
|
||||
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_is_default` (`is_default`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货地址';
|
||||
```
|
||||
|
||||
### 2. 订单表收货信息字段 (shop_order)
|
||||
|
||||
```sql
|
||||
-- 订单表中的收货信息字段
|
||||
`address_id` int(11) DEFAULT NULL COMMENT '收货地址ID(引用关系)',
|
||||
`address` varchar(500) DEFAULT NULL COMMENT '收货地址快照',
|
||||
`real_name` varchar(100) DEFAULT NULL COMMENT '收货人姓名快照',
|
||||
`address_lat` varchar(50) DEFAULT NULL COMMENT '地址纬度',
|
||||
`address_lng` varchar(50) DEFAULT NULL COMMENT '地址经度',
|
||||
```
|
||||
|
||||
## 业务流程设计
|
||||
|
||||
### 1. 下单时收货地址处理流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户下单] --> B{前端是否传入完整地址?}
|
||||
B -->|是| C[使用前端传入地址]
|
||||
B -->|否| D{是否指定地址ID?}
|
||||
D -->|是| E[根据地址ID获取地址]
|
||||
E --> F{地址是否存在且属于当前用户?}
|
||||
F -->|是| G[使用指定地址]
|
||||
F -->|否| H[获取用户默认地址]
|
||||
D -->|否| H[获取用户默认地址]
|
||||
H --> I{是否有默认地址?}
|
||||
I -->|是| J[使用默认地址]
|
||||
I -->|否| K[获取用户第一个地址]
|
||||
K --> L{是否有地址?}
|
||||
L -->|是| M[使用第一个地址]
|
||||
L -->|否| N[抛出异常:请先添加收货地址]
|
||||
C --> O[创建地址快照]
|
||||
G --> O
|
||||
J --> O
|
||||
M --> O
|
||||
O --> P[保存订单]
|
||||
```
|
||||
|
||||
### 2. 地址优先级
|
||||
|
||||
1. **前端传入的完整地址信息**(最高优先级)
|
||||
2. **指定的地址ID对应的地址**
|
||||
3. **用户默认收货地址**
|
||||
4. **用户的第一个收货地址**
|
||||
5. **无地址时抛出异常**
|
||||
|
||||
## 核心实现
|
||||
|
||||
### 1. 收货地址处理方法
|
||||
|
||||
```java
|
||||
/**
|
||||
* 处理收货地址信息
|
||||
* 优先级:前端传入地址 > 指定地址ID > 用户默认地址
|
||||
*/
|
||||
private void processDeliveryAddress(ShopOrder shopOrder, OrderCreateRequest request, User loginUser) {
|
||||
// 1. 如果前端已经传入了完整的收货地址信息,直接使用
|
||||
if (isAddressInfoComplete(request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果指定了地址ID,获取该地址信息
|
||||
if (request.getAddressId() != null) {
|
||||
ShopUserAddress userAddress = shopUserAddressService.getById(request.getAddressId());
|
||||
if (userAddress != null && userAddress.getUserId().equals(loginUser.getUserId())) {
|
||||
copyAddressToOrder(userAddress, shopOrder, request);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取用户默认收货地址
|
||||
ShopUserAddress defaultAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId());
|
||||
if (defaultAddress != null) {
|
||||
copyAddressToOrder(defaultAddress, shopOrder, request);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 如果没有默认地址,获取用户的第一个地址
|
||||
List<ShopUserAddress> userAddresses = shopUserAddressService.getUserAddresses(loginUser.getUserId());
|
||||
if (!userAddresses.isEmpty()) {
|
||||
copyAddressToOrder(userAddresses.get(0), shopOrder, request);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 如果用户没有任何收货地址,抛出异常
|
||||
throw new BusinessException("请先添加收货地址");
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 地址快照创建
|
||||
|
||||
```java
|
||||
/**
|
||||
* 将用户地址信息复制到订单中(创建快照)
|
||||
*/
|
||||
private void copyAddressToOrder(ShopUserAddress userAddress, ShopOrder shopOrder, OrderCreateRequest request) {
|
||||
// 保存地址ID引用关系
|
||||
shopOrder.setAddressId(userAddress.getId());
|
||||
request.setAddressId(userAddress.getId());
|
||||
|
||||
// 创建地址信息快照
|
||||
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
|
||||
StringBuilder fullAddress = new StringBuilder();
|
||||
if (userAddress.getProvince() != null) fullAddress.append(userAddress.getProvince());
|
||||
if (userAddress.getCity() != null) fullAddress.append(userAddress.getCity());
|
||||
if (userAddress.getRegion() != null) fullAddress.append(userAddress.getRegion());
|
||||
if (userAddress.getAddress() != null) fullAddress.append(userAddress.getAddress());
|
||||
|
||||
shopOrder.setAddress(fullAddress.toString());
|
||||
request.setAddress(fullAddress.toString());
|
||||
}
|
||||
|
||||
// 复制收货人信息
|
||||
if (request.getRealName() == null || request.getRealName().trim().isEmpty()) {
|
||||
shopOrder.setRealName(userAddress.getName());
|
||||
request.setRealName(userAddress.getName());
|
||||
}
|
||||
|
||||
// 复制经纬度信息
|
||||
if (request.getAddressLat() == null && userAddress.getLat() != null) {
|
||||
shopOrder.setAddressLat(userAddress.getLat());
|
||||
request.setAddressLat(userAddress.getLat());
|
||||
}
|
||||
if (request.getAddressLng() == null && userAddress.getLng() != null) {
|
||||
shopOrder.setAddressLng(userAddress.getLng());
|
||||
request.setAddressLng(userAddress.getLng());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成建议
|
||||
|
||||
### 1. 下单页面地址选择
|
||||
|
||||
```javascript
|
||||
// 获取用户地址列表
|
||||
const getUserAddresses = async () => {
|
||||
const response = await api.get('/api/shop/user-address/my');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// 获取默认地址
|
||||
const getDefaultAddress = async () => {
|
||||
const addresses = await getUserAddresses();
|
||||
return addresses.find(addr => addr.isDefault) || addresses[0];
|
||||
};
|
||||
|
||||
// 下单时的地址处理
|
||||
const createOrder = async (orderData) => {
|
||||
// 如果用户没有选择地址,使用默认地址
|
||||
if (!orderData.addressId && !orderData.address) {
|
||||
const defaultAddress = await getDefaultAddress();
|
||||
if (defaultAddress) {
|
||||
orderData.addressId = defaultAddress.id;
|
||||
}
|
||||
}
|
||||
|
||||
return api.post('/api/shop/order/create', orderData);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 地址选择组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="address-selector">
|
||||
<div v-if="selectedAddress" class="selected-address">
|
||||
<div class="address-info">
|
||||
<span class="name">{{ selectedAddress.name }}</span>
|
||||
<span class="phone">{{ selectedAddress.phone }}</span>
|
||||
</div>
|
||||
<div class="address-detail">{{ selectedAddress.fullAddress }}</div>
|
||||
</div>
|
||||
<button @click="showAddressList = true">选择收货地址</button>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 优势分析
|
||||
|
||||
### 1. 数据一致性
|
||||
- 订单创建时保存地址快照,确保历史订单信息不受用户后续地址修改影响
|
||||
- 同时保留地址ID引用,便于数据关联和分析
|
||||
|
||||
### 2. 用户体验
|
||||
- 自动读取用户默认地址,减少用户操作步骤
|
||||
- 支持临时修改收货信息,满足特殊需求
|
||||
- 智能地址选择逻辑,确保总能找到合适的收货地址
|
||||
|
||||
### 3. 系统稳定性
|
||||
- 完善的异常处理机制,避免因地址问题导致下单失败
|
||||
- 详细的日志记录,便于问题排查和系统监控
|
||||
|
||||
### 4. 扩展性
|
||||
- 支持多种地址类型(家、公司、学校等)
|
||||
- 预留经纬度字段,支持地图定位功能
|
||||
- 灵活的排序和默认地址设置
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **地址验证**:建议在前端和后端都进行地址完整性验证
|
||||
2. **默认地址管理**:确保用户只能有一个默认地址
|
||||
3. **地址数量限制**:建议限制用户地址数量,避免数据冗余
|
||||
4. **隐私保护**:敏感信息如手机号需要适当脱敏处理
|
||||
5. **性能优化**:对于高频查询的地址信息,可考虑适当缓存
|
||||
|
||||
## 总结
|
||||
|
||||
本设计方案通过地址快照机制确保了订单数据的一致性,通过智能地址选择提升了用户体验,通过完善的异常处理保证了系统稳定性。该方案已在 `OrderBusinessService` 中实现,可以直接投入使用。
|
||||
@@ -5,10 +5,7 @@ import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import com.gxwebsoft.shop.config.OrderConfigProperties;
|
||||
import com.gxwebsoft.shop.dto.OrderCreateRequest;
|
||||
import com.gxwebsoft.shop.entity.ShopGoods;
|
||||
import com.gxwebsoft.shop.entity.ShopGoodsSku;
|
||||
import com.gxwebsoft.shop.entity.ShopOrder;
|
||||
import com.gxwebsoft.shop.entity.ShopOrderGoods;
|
||||
import com.gxwebsoft.shop.entity.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,6 +44,9 @@ public class OrderBusinessService {
|
||||
@Resource
|
||||
private OrderConfigProperties orderConfig;
|
||||
|
||||
@Resource
|
||||
private ShopUserAddressService shopUserAddressService;
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*
|
||||
@@ -63,19 +63,22 @@ public class OrderBusinessService {
|
||||
// 2. 构建订单对象
|
||||
ShopOrder shopOrder = buildShopOrder(request, loginUser);
|
||||
|
||||
// 3. 应用业务规则
|
||||
// 3. 处理收货地址信息
|
||||
processDeliveryAddress(shopOrder, request, loginUser);
|
||||
|
||||
// 4. 应用业务规则
|
||||
applyBusinessRules(shopOrder, loginUser);
|
||||
|
||||
// 4. 保存订单
|
||||
// 5. 保存订单
|
||||
boolean saved = shopOrderService.save(shopOrder);
|
||||
if (!saved) {
|
||||
throw new BusinessException("订单保存失败");
|
||||
}
|
||||
|
||||
// 5. 保存订单商品
|
||||
// 6. 保存订单商品
|
||||
saveOrderGoods(request, shopOrder);
|
||||
|
||||
// 6. 创建微信支付订单
|
||||
// 7. 创建微信支付订单
|
||||
try {
|
||||
return shopOrderService.createWxOrder(shopOrder);
|
||||
} catch (Exception e) {
|
||||
@@ -274,6 +277,107 @@ public class OrderBusinessService {
|
||||
return shopOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理收货地址信息
|
||||
* 优先级:前端传入地址 > 指定地址ID > 用户默认地址
|
||||
*/
|
||||
private void processDeliveryAddress(ShopOrder shopOrder, OrderCreateRequest request, User loginUser) {
|
||||
try {
|
||||
// 1. 如果前端已经传入了完整的收货地址信息,直接使用
|
||||
if (isAddressInfoComplete(request)) {
|
||||
log.info("使用前端传入的收货地址信息,用户ID:{}", loginUser.getUserId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 如果指定了地址ID,获取该地址信息
|
||||
if (request.getAddressId() != null) {
|
||||
ShopUserAddress userAddress = shopUserAddressService.getById(request.getAddressId());
|
||||
if (userAddress != null && userAddress.getUserId().equals(loginUser.getUserId())) {
|
||||
copyAddressToOrder(userAddress, shopOrder, request);
|
||||
log.info("使用指定地址ID:{},用户ID:{}", request.getAddressId(), loginUser.getUserId());
|
||||
return;
|
||||
}
|
||||
log.warn("指定的地址ID不存在或不属于当前用户,地址ID:{},用户ID:{}",
|
||||
request.getAddressId(), loginUser.getUserId());
|
||||
}
|
||||
|
||||
// 3. 获取用户默认收货地址
|
||||
ShopUserAddress defaultAddress = shopUserAddressService.getDefaultAddress(loginUser.getUserId());
|
||||
if (defaultAddress != null) {
|
||||
copyAddressToOrder(defaultAddress, shopOrder, request);
|
||||
log.info("使用用户默认收货地址,地址ID:{},用户ID:{}", defaultAddress.getId(), loginUser.getUserId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 如果没有默认地址,获取用户的第一个地址
|
||||
List<ShopUserAddress> userAddresses = shopUserAddressService.getUserAddresses(loginUser.getUserId());
|
||||
if (!userAddresses.isEmpty()) {
|
||||
ShopUserAddress firstAddress = userAddresses.get(0);
|
||||
copyAddressToOrder(firstAddress, shopOrder, request);
|
||||
log.info("使用用户第一个收货地址,地址ID:{},用户ID:{}", firstAddress.getId(), loginUser.getUserId());
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 如果用户没有任何收货地址,抛出异常
|
||||
throw new BusinessException("请先添加收货地址");
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("处理收货地址信息失败,用户ID:{}", loginUser.getUserId(), e);
|
||||
throw new BusinessException("处理收货地址信息失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查前端传入的地址信息是否完整
|
||||
*/
|
||||
private boolean isAddressInfoComplete(OrderCreateRequest request) {
|
||||
return request.getAddress() != null && !request.getAddress().trim().isEmpty() &&
|
||||
request.getRealName() != null && !request.getRealName().trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户地址信息复制到订单中(创建快照)
|
||||
*/
|
||||
private void copyAddressToOrder(ShopUserAddress userAddress, ShopOrder shopOrder, OrderCreateRequest request) {
|
||||
// 保存地址ID引用关系
|
||||
shopOrder.setAddressId(userAddress.getId());
|
||||
request.setAddressId(userAddress.getId());
|
||||
|
||||
// 创建地址信息快照
|
||||
if (request.getAddress() == null || request.getAddress().trim().isEmpty()) {
|
||||
// 构建完整地址
|
||||
StringBuilder fullAddress = new StringBuilder();
|
||||
if (userAddress.getProvince() != null) fullAddress.append(userAddress.getProvince());
|
||||
if (userAddress.getCity() != null) fullAddress.append(userAddress.getCity());
|
||||
if (userAddress.getRegion() != null) fullAddress.append(userAddress.getRegion());
|
||||
if (userAddress.getAddress() != null) fullAddress.append(userAddress.getAddress());
|
||||
|
||||
shopOrder.setAddress(fullAddress.toString());
|
||||
request.setAddress(fullAddress.toString());
|
||||
}
|
||||
|
||||
// 复制收货人信息
|
||||
if (request.getRealName() == null || request.getRealName().trim().isEmpty()) {
|
||||
shopOrder.setRealName(userAddress.getName());
|
||||
request.setRealName(userAddress.getName());
|
||||
}
|
||||
|
||||
// 复制经纬度信息
|
||||
if (request.getAddressLat() == null && userAddress.getLat() != null) {
|
||||
shopOrder.setAddressLat(userAddress.getLat());
|
||||
request.setAddressLat(userAddress.getLat());
|
||||
}
|
||||
if (request.getAddressLng() == null && userAddress.getLng() != null) {
|
||||
shopOrder.setAddressLng(userAddress.getLng());
|
||||
request.setAddressLng(userAddress.getLng());
|
||||
}
|
||||
|
||||
log.debug("地址信息快照创建完成 - 地址ID:{},收货人:{},地址:{}",
|
||||
userAddress.getId(), userAddress.getName(), shopOrder.getAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用业务规则
|
||||
*/
|
||||
|
||||
@@ -39,4 +39,20 @@ public interface ShopUserAddressService extends IService<ShopUserAddress> {
|
||||
*/
|
||||
ShopUserAddress getByIdRel(Integer id);
|
||||
|
||||
/**
|
||||
* 获取用户默认收货地址
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return ShopUserAddress
|
||||
*/
|
||||
ShopUserAddress getDefaultAddress(Integer userId);
|
||||
|
||||
/**
|
||||
* 获取用户所有收货地址
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @return List<ShopUserAddress>
|
||||
*/
|
||||
List<ShopUserAddress> getUserAddresses(Integer userId);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.gxwebsoft.shop.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.gxwebsoft.shop.mapper.ShopUserAddressMapper;
|
||||
import com.gxwebsoft.shop.service.ShopUserAddressService;
|
||||
@@ -44,4 +45,24 @@ public class ShopUserAddressServiceImpl extends ServiceImpl<ShopUserAddressMappe
|
||||
return param.getOne(baseMapper.selectListRel(param));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShopUserAddress getDefaultAddress(Integer userId) {
|
||||
LambdaQueryWrapper<ShopUserAddress> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(ShopUserAddress::getUserId, userId)
|
||||
.eq(ShopUserAddress::getIsDefault, true)
|
||||
.orderByDesc(ShopUserAddress::getCreateTime)
|
||||
.last("LIMIT 1");
|
||||
return getOne(wrapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ShopUserAddress> getUserAddresses(Integer userId) {
|
||||
LambdaQueryWrapper<ShopUserAddress> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(ShopUserAddress::getUserId, userId)
|
||||
.orderByDesc(ShopUserAddress::getIsDefault)
|
||||
.orderByAsc(ShopUserAddress::getSortNumber)
|
||||
.orderByDesc(ShopUserAddress::getCreateTime);
|
||||
return list(wrapper);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user