从SpringBoot到DotNet_3.完成购物模块
一、从购物车模型完成购物车
(一)购物车模型设计
使用 Guid 作为购物车的主键,让 EFCore 自己管理外部引用,使用 ICollection保存商品的信息,这里将商品抽象成LineItem与其他模块进行解耦。
namespace FakeXiecheng.Models;
public class ShoppingCart
{
[Key]
public Guid Id { get; set; }
public string UserId { get; set; }
public ApplicationUser User { get; set; }
public ICollection<LineItem>? ShoppingCartItems { get; set; }
}
// 在 User 中添加对购物车的引用交给 EF 进行管理
public class ApplicationUser : IdentityUser
{
public string? Address { get; set; }
// ++ 引用
public ShoppingCart ShoppingCart { get; set; }
// Order
public virtual ICollection<IdentityUserRole<string>> UserRoles { get; set; }
public virtual ICollection<IdentityUserClaim<string>> Claims { get; set; }
public virtual ICollection<IdentityUserLogin<string>> Logins { get; set; }
public virtual ICollection<IdentityUserToken<string>> Tokens { get; set; }
}
(二)引入中间概念进行解耦
引入LineItem可以被看作对商品本身的一种抽象。LineItem 作为订单中的一个项,代表了用户在购物过程中选择的具体商品或服务,它对商品的抽象化使得系统能够更灵活地处理不同种类的商品,而不必过于依赖于特定的商品属性。
在LineItem中引入了外键TouristRouteId并将引用对象TouristRoute加入到LineItem中,这样在LineItem类中就可以直接通过这个引用对象去访问到商品(TouristRoute)的所有信息,而对于ShoppingCart对象,我们只需要唯一确定这个对象即可,所以没有加入该对象的引用,只使用到了唯一主键。
public class LineItem {
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[ForeignKey("TouristRouteId")]
public Guid TouristRouteId { get; set; }
public TouristRoute TouristRoute { get; set; }
public Guid? ShoppingCartId { get; set; }
//public Guid? OrderId { get; set; }
[Column(TypeName = "decimal(18, 2)")]
public decimal OriginalPrice { get; set; }
[Range(0.0, 1.0)]
public double? DiscountPresent { get; set; }
}

通过引入LineItem,可以将商品的关键信息(如名称、价格、数量等)封装在一个统一的数据结构中,而不是直接操作商品对象。这种抽象化有几个优点:
- 灵活性: 可以轻松地添加新的商品类型,而不必修改订单处理逻辑。LineItem提供了一个通用的接口,使得系统更易于适应变化。
- 可扩展性: 可以在LineItem中包含额外的属性,以支持各种商品特性,如折扣、税费、颜色等。这使得系统能够更容易地适应不同种类的商品。
- 简化逻辑: 将商品抽象成LineItem简化了订单处理逻辑。通过对LineItem的统一处理,可以更方便地计算订单总额、管理库存、生成发票等。
- 与购物车的关联: LineItem通常与购物车紧密关联,用户每次添加商品都会生成一个新的LineItem,这有助于清晰地组织购物车中的商品信息。

// AppdbContext 中引入
public DbSet<ShoppingCart> ShoppingCarts { get; set; }
public DbSet<LineItem> LineItems { get; set; }
进行数据迁移
VS 有自带的 PM 控制台,简单的命令就能完成数据迁移操作。


要实现的接口有下面这些(订单放到下一个模块)

(三)获得用户购物车信息
1.初始化购物车
在获得购物车信息之前,我们要保证购物车这个引用被初始化过,否则就可能会有空指针的异常抛出,所以我们可以在用户进行注册的时候 public async Task<IActionResult> Register([FromBody] RegisterDto registerDto)进行购物车的初始化;
// AuthenticateController.cs
// 3 初始化购物车
// 注入 private readonly ITouristRouteRepository _touristRouteRepository;
var shoppingCart = new ShoppingCart()
{
Id = Guid.NewGuid(),
UserId = user.Id
};
await _touristRouteRepository.CreateShoppingCart(shoppingCart);
await _touristRouteRepository.SaveAsync();
在 Repository中实现创建购物车的方法,将AuthenticateController初始化好的对象作为参数传入。
Task CreateShoppingCart(ShoppingCart shoppingCart);
// impl
public async Task CreateShoppingCart(ShoppingCart shoppingCart)
{
await _dbContext.ShoppingCarts.AddAsync(shoppingCart);
}
2.获得购物车信息
想要获得用户的购物车信息,就要现在 Http 得到上下文中获得用户的 ID,而我们采取 Token 授权登录的形式只需要在 Claim 中取出用户的 ID 即可。
(1)创建相应的 Dto 和映射关系
namespace FakeXiecheng.Dtos;
public class LineItemDto
{
public int Id { get; set; }
public Guid TouristRouteId { get; set; }
public TouristRouteDto TouristRoute { get; set; }
public Guid? ShoppingCartId { get; set; }
//public Guid? OrderId { get; set; }
public decimal OriginalPrice { get; set; }
public double? DiscountPresent { get; set; }
}
public class ShoppingCartDto
{
public Guid Id { get; set; }
public string UserId { get; set; }
public ICollection<LineItemDto> ShoppingCartItems { get; set; }
}
public class ShoppingCartProfile: Profile
{
public ShoppingCartProfile()
{
CreateMap<ShoppingCart, ShoppingCartDto>();
CreateMap<LineItem, LineItemDto>();
}
}
(2)编写控制器 ShoppingCartController
[Route("api/[controller]")]
[ApiController]
public class ShoppingCartController : ControllerBase
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITouristRouteRepository _touristRouteRepository;
private readonly IMapper _mapper;
public ShoppingCartController(
IHttpContextAccessor httpContextAccessor,
ITouristRouteRepository touristRouteRepository,
IMapper mapper
)
{
_httpContextAccessor = httpContextAccessor;
_touristRouteRepository = touristRouteRepository;
_mapper = mapper;
}
[HttpGet]
public async Task<IActionResult> GetShoppingCart()
{
// 1 获得当前用户
var userId = _httpContextAccessor
.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value;
// 2 使用userid获得购物车
var shoppingCart = await _touristRouteRepository.GetShoppingCartByUserId(userId);
return Ok(_mapper.Map<ShoppingCartDto>(shoppingCart));
}
}
(3)完善数据仓库
Task<ShoppingCart> GetShoppingCartByUserId(string userId);
// impl
public async Task<ShoppingCart> GetShoppingCartByUserId(string userId)
{
return await _dbContext.ShoppingCarts
.Include(s => s.User)
.Include(s => s.ShoppingCartItems).ThenInclude(li => li.TouristRoute)
.Where(s => s.UserId == userId)
.FirstOrDefaultAsync();
}
(4)登录测试
从用户注册开始,在用户注册后初始化购物车,用户登录返回 JWT,查询购物车携带 JWT,获得用户对应的购物车信息。



(四)向购物车中添加商品
当用户想添加商品到购物车,要先将商品(TouristRoute)打包成 LineItem来解耦各个模块。
[HttpPost("items")]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> AddShoppingCartItem(
[FromBody] AddShoppingCartItemDto addShoppingCartItemDto
)
{
// 1 获得当前用户
var userId = _httpContextAccessor
.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value;
// 2 使用userid获得购物车
var shoppingCart = await _touristRouteRepository
.GetShoppingCartByUserId(userId);
// 3 创建lineItem
var touristRoute = await _touristRouteRepository
.GetTouristRouteAsync(addShoppingCartItemDto.TouristRouteId);
if (touristRoute == null)
{
return NotFound("旅游路线不存在");
}
var lineItem = new LineItem()
{
TouristRouteId = addShoppingCartItemDto.TouristRouteId,
ShoppingCartId = shoppingCart.Id,
OriginalPrice = touristRoute.OriginalPrice,
DiscountPresent = touristRoute.DiscountPresent
};
// 4 添加lineitem,并保存数据库
await _touristRouteRepository.AddShoppingCartItem(lineItem);
await _touristRouteRepository.SaveAsync();
return Ok(_mapper.Map<ShoppingCartDto>(shoppingCart));
}
Task AddShoppingCartItem(LineItem lineItem);
/// <summary>
/// 添加 Lineitem
/// </summary>
/// <param name="lineItem"></param>
/// <returns></returns>
public async Task AddShoppingCartItem(LineItem lineItem)
{
await _dbContext.LineItems.AddAsync(lineItem);
}
可以使用新注册的用户进行测试(之前的用户可能没有初始化购物车导致空指针):注册 -> 登录获得 token -> 携带 token 请求加入购物车

(五)删除 / 批量删除购物车商品
1.删除单个
使用 LinItem 的 ID 作为参数,如果在 db 中查找到则将这个对象作为参数传到数据仓库进行操作。
[HttpDelete("items/{itemId}")]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> DeleteShoppingCartItem([FromRoute] int itemId)
{
// 1 获取lineitem数据
var lineItem = await _touristRouteRepository
.GetShoppingCartItemByItemId(itemId);
if (lineItem == null)
{
return NotFound("购物车商品找不到");
}
_touristRouteRepository.DeleteShoppingCartItem(lineItem);
await _touristRouteRepository.SaveAsync();
return NoContent();
}
在下面之所以操作的是LineItem这个数据实体而不是ShoppingCart是因为在设计的时候引入的LineItem使用ShoppingCartId作为外键与ShoppingCart关联。

Task<LineItem> GetShoppingCartItemByItemId(int lineItemId);
void DeleteShoppingCartItem(LineItem lineItem);
public async Task<LineItem> GetShoppingCartItemByItemId(int lineItemId)
{
return await _dbContext.LineItems
.Where(li => li.Id == lineItemId)
.FirstOrDefaultAsync();
}
public async void DeleteShoppingCartItem(LineItem lineItem)
{
_dbContext.LineItems.Remove(lineItem);
}
通过下面的 ER 图,可以更清晰的理解这种解耦:购物车仅作为概念存在,从而联系 Users 和 LineItems,TouristeRoutes 中的数据被整理成 LineItems。


2.批量删除
批量删除向服务器传输的参数应该是一个列表,要想将 URL 地址中的字符串转换成列表的形式需要使用前面引入的ArrayModelBinder
[HttpDelete("items/({itemIDs})")]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> RemoveShoppingCartItems(
[ModelBinder(BinderType = typeof(ArrayModelBinder))]
[FromRoute] IEnumerable<int> itemIDs
)
{
var lineitems = await _touristRouteRepository
.GeshoppingCartsByIdListAsync(itemIDs);
_touristRouteRepository.DeleteShoppingCartItems(lineitems);
await _touristRouteRepository.SaveAsync();
return NoContent();
}
使用.ToListAsync();从数据库中取出参数列表所需要的数据。
Task<IEnumerable<LineItem>> GeshoppingCartsByIdListAsync(IEnumerable<int> ids);
void DeleteShoppingCartItems(IEnumerable<LineItem> lineItems);
public async Task<IEnumerable<LineItem>> GeshoppingCartsByIdListAsync(
IEnumerable<int> ids)
{
return await _dbContext.LineItems
.Where(li => ids.Contains(li.Id))
.ToListAsync();
}
public void DeleteShoppingCartItems(IEnumerable<LineItem> lineItems)
{
_dbContext.LineItems.RemoveRange(lineItems);
}
单次删除删除了ItemId为1的数据,批量删除传输的参数为(2,3)所以这三条数据全部被删除 。


二、完成订单模块
订单模块是商业系统的核心功能之一。它负责处理客户下单、支付、库存管理以及物流等关键业务流程。这些流程直接影响到企业的盈利能力和客户满意度。
订单所需要的 API 如下,部分 API 不适用 Rest 的风格:

(一)引入有限状态自动机
在订单实体中,除了有购物车的一些基本信息之外,还应该有
创建时间;
用于存储与订单支付相关的元数据TransactionMetadata :其中包含有关第三方支付服务的回调信息。当订单通过第三方支付服务完成交易后,该服务向您的服务器发送回调请求,以通知您订单的支付状态或其他相关信息。在此处使用字符串存储元数据是因为字符串类型的数据在处理和存储时通常更加简单,无需担心数据的序列化和反序列化,因为字符串可以直接存储和检索;
订单状态:使用六个状态来表示一个订单可能出现的所有生命周期,此外使用状态机表示订单还有多条优点:
-
清晰的状态转换: 使用状态机可以明确定义订单在不同阶段的状态以及状态之间的转换规则。这使得整个订单生命周期变得清晰可见,有助于开发人员和维护人员更好地理解订单的状态变化。
-
减少错误: 通过使用状态机,可以强制实施订单状态转换的规则和约束。这有助于减少错误状态转换的可能性,提高系统的可靠性和稳定性。
-
易于扩展: 如果需要新增或调整订单的状态,使用状态机可以相对容易地进行扩展。您只需更新状态机的定义和转换规则,而不必修改整个系统的代码。
-
方便的状态查询: 通过状态机,您可以轻松地查询订单的当前状态以及可能的下一步状态。这使得系统可以根据订单状态采取适当的操作,如发送通知、更新库存等。
-
更好的可维护性: 使用状态机将订单状态的管理和处理抽象出来,使得系统更具可维护性。状态机的结构清晰,易于理解和修改,有助于降低系统的复杂性。
关于状态机的知识请看:设计模式:一目了然的状态机图 - 白露~ - 博客园 (cnblogs.com)
下面是订单状态的转换图:

了解了基本设计思路之后就可以进行实体的设计,使用枚举类来代表订单状态机的状态。
public enum OrderStateEnum
{
Pending, // 订单已生成
Processing, // 支付处理中
Completed, // 交易成功
Declined, // 交易失败
Cancelled, // 订单取消
Refund, // 已退款
}
public class Order
{
[Key]
public Guid Id { get; set; }
public string UserId { get; set; }
public ApplicationUser User { get; set; }
public ICollection<LineItem> OrderItems { get; set; }
public OrderStateEnum State { get; set; }
public DateTime CreateDateUTC { get; set; }
public string TransactionMetadata { get; set; }
}
Oreder实体中添加了对于User的引用,在ApplicationUser中也务必加入对Order的引用(下面三个从Identity框架继承来的字段可按需保留)。
public class ApplicationUser : IdentityUser
{
public string? Address { get; set; }
public ShoppingCart ShoppingCart { get; set; }
public ICollection<Order> Orders { get; set; }
public virtual ICollection<IdentityUserRole<string>> UserRoles { get; set; }
// public virtual ICollection<IdentityUserClaim<string>> Claims { get; set; }
// public virtual ICollection<IdentityUserLogin<string>> Logins { get; set; }
// public virtual ICollection<IdentityUserToken<string>> Tokens { get; set; }
}
使用 PM / 终端进行数据迁移。


(二)使用 Stateless 框架搭建订单系统
根据状态机的有关知识可以列出订单系统的状态机表:

OrderStateEnum这个类中的字段可以对应到状态机表的第一列,同样触发状态转换的动作也可以被抽象成一个枚举类,而转化的条件多样,我们需要在控制器中进行判断;
使用Stateless框架就能实现各个状态之间的转化 C#状态机Stateless - 波多尔斯基 - 博客园 (cnblogs.com)
注意选择对应的版本

使用OrderStateTriggerEnum枚举类表示动作,引入Satseless作为框架支持,创建一个状态机对象,并指明其状态变化所需要的动作,在订单的构造器中调用初始化。
namespace FakeXiecheng.Models;
public enum OrderStateEnum
{
Pending, // 订单已生成
Processing, // 支付处理中
Completed, // 交易成功
Declined, // 交易失败
Cancelled, // 订单取消
Refund, // 已退款
}
public enum OrderStateTriggerEnum
{
PlaceOrder, // 支付
Approve, // 收款成功
Reject, // 收款失败
Cancel, // 取消
Return // 退货
}
public class Order
{
public Order()
{
StateMachineInit();
}
[Key]
public Guid Id { get; set; }
public string UserId { get; set; }
public ApplicationUser User { get; set; }
public ICollection<LineItem> OrderItems { get; set; }
public OrderStateEnum State { get; set; }
public DateTime CreateDateUTC { get; set; }
public string TransactionMetadata { get; set; }
StateMachine<OrderStateEnum, OrderStateTriggerEnum> _machine;
private void StateMachineInit()
{
_machine = new StateMachine<OrderStateEnum, OrderStateTriggerEnum>
(OrderStateEnum.Pending);
_machine.Configure(OrderStateEnum.Pending)
.Permit(OrderStateTriggerEnum.PlaceOrder, OrderStateEnum.Processing)
.Permit(OrderStateTriggerEnum.Cancel, OrderStateEnum.Cancelled);
_machine.Configure(OrderStateEnum.Processing)
.Permit(OrderStateTriggerEnum.Approve, OrderStateEnum.Completed)
.Permit(OrderStateTriggerEnum.Reject, OrderStateEnum.Declined);
_machine.Configure(OrderStateEnum.Declined)
.Permit(OrderStateTriggerEnum.PlaceOrder, OrderStateEnum.Processing);
_machine.Configure(OrderStateEnum.Completed)
.Permit(OrderStateTriggerEnum.Return, OrderStateEnum.Refund);
}
}
(三)购物车下单、结算
由于在Order中有枚举类型Saate不能通过 AutoMapper 自动映射,所以需要在 Profile 中进行手动映射。
namespace FakeXiecheng.Dtos;
public class OrderDto
{
public Guid Id { get; set; }
public string UserId { get; set; }
public ICollection<LineItemDto> OrderItems { get; set; }
public string State { get; set; }
public DateTime CreateDateUTC { get; set; }
public string TransactionMetadata { get; set; }
}
namespace FakeXiecheng.Profiles;
public class OrderProfile:Profile
{
public OrderProfile()
{
CreateMap<Order, OrderDto>()
.ForMember(
dest => dest.State,
opt =>
{
opt.MapFrom(src => src.State.ToString());
}
);
}
}
下单的过程实际上就是用户将商品加入到购物车 -> 点击结算 -> 清空用户的购物车 Items -> 生成新 Order。
[HttpPost("checkout")]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> Checkout()
{
// 1 获得当前用户
var userId = _httpContextAccessor
.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value;
// 2 使用userid获得购物车
var shoppingCart = await _touristRouteRepository.GetShoppingCartByUserId(userId);
// 3 创建订单
var order = new Order()
{
Id = Guid.NewGuid(),
UserId = userId,
State = OrderStateEnum.Pending,
OrderItems = shoppingCart.ShoppingCartItems,
CreateDateUTC = DateTime.UtcNow,
TransactionMetadata = "defaultMetaData"
};
shoppingCart.ShoppingCartItems = null;
// 4 保存数据
await _touristRouteRepository.AddOrderAsync(order);
await _touristRouteRepository.SaveAsync();
// 5 return
return Ok(_mapper.Map<OrderDto>(order));
}
将在控制器生成的新订单作为参数传入到数据仓库中。
// namespace Service
Task AddOrderAsync(Order order);
public async Task AddOrderAsync(Order order)
{
await _dbContext.Orders.AddAsync(order);
}
登录获取 Token,携带 Token 请求结算的接口,

结算之后购物车中的 Item 应该被清空并在 Order 中生成新的 Item。


反应到数据库中
ShoppingCart 类中的 ShoppingCartItems 属性是一个 ICollection<LineItem> 类型的集合,用于表示购物车中的购物项列表。但在数据库中,ShoppingCart 表只有字段 Id 和 UserId,并没有直接包含购物项的信息。
实际上,在数据库中并没有直接存储购物车的内容。购物车的内容是通过关联 LineItem 表来实现的,其中的每一条记录都关联了具体的购物车和购物项。购物车中的购物项信息实际上存储在 LineItem 表中,通过外键关联到对应的购物车。清空购物车就是在购物车对应的 LineItem 表中删除所有与该购物车相关联的购物项记录。


虽然清空购物车的操作可能会将 ShoppingCartId 字段置为 NULL 或者删除相关记录,但这并不意味着购物记录就被完全清除。在购物车中的每个购物项都会有一条对应的 LineItem 记录,它会保留有关购买的商品信息、价格信息以及与购物车的关联信息(即 ShoppingCartId)。即使购物车被清空,这些 LineItem 记录仍然存在,并且保留了与用户购买相关的详细信息。

(四)获得用户订单
根据用户的 ID 获得订单,从 Http 上下文中获得 Token 中的信息,根据用户 ID 获得订单即可。
using AutoMapper;
using FakeXiecheng.Dtos;
using FakeXiecheng.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace FakeXiecheng.Controllers;
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITouristRouteRepository _touristRouteRepository;
private readonly IMapper _mapper;
public OrdersController(
IHttpContextAccessor httpContextAccessor,
ITouristRouteRepository touristRouteRepository,
IMapper mapper
)
{
_httpContextAccessor = httpContextAccessor;
_touristRouteRepository = touristRouteRepository;
_mapper = mapper;
}
[HttpGet]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> GetOrders()
{
// 1. 获得当前用户
var userId = _httpContextAccessor
.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value;
// 2. 使用用户id来获取订单历史记录
var orders = await _touristRouteRepository.GetOrdersByUserId(userId);
return Ok(_mapper.Map<IEnumerable<OrderDto>>(orders));
}
Task<IEnumerable<Order>> GetOrdersByUserId(string userId);
public async Task<IEnumerable<Order>> GetOrdersByUserId(string userId)
{
return await _dbContext.Orders.Where(o => o.UserId == userId).ToListAsync();
}
获得用户的详情订单,将订单的 ID 作为参数传到数据仓库,获得详情信息需要连接OrderItems和TouristRoute来保证数据的完整性。
[HttpGet("{orderId}")]
[Authorize(AuthenticationSchemes = "Bearer")]
public async Task<IActionResult> GerOrderById([FromRoute] Guid orderId)
{
// 1. 获得当前用户
var userId = _httpContextAccessor
.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value;
var order = await _touristRouteRepository.GetOrderById(orderId);
return Ok(_mapper.Map<OrderDto>(order));
}
}
Task<Order> GetOrderById(Guid orderId);
public async Task<Order> GetOrderById(Guid orderId)
{
return await _dbContext.Orders
.Include(o => o.OrderItems).ThenInclude(oi => oi.TouristRoute)
.Where(o => o.Id == orderId)
.FirstOrDefaultAsync();
}
获得用户历史订单:


订单详情:


浙公网安备 33010602011771号