提交订单

我们目前已经完成了和客户交互的基本功能,接下来要完成最后在购物车页面添加的结算功能。当用户点击结算按钮会提交购物车,生成订单,而后台服务端要接收订单,最后更新数据库。因此,数据库要能保存订单,还要更新每种商品(电子书)的库存数量。

创建数据模型

因为我们是代码优先再用迁移命令创建数据表的,我们需要先创建一个订单的数据模型类 Order ,再去更新数据库结构。
Listing 9-1 在 Models 文件夹里新建一个 Orders.cs 文件

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace EBooksStore.Models;

public class Order 
{
    public int Id { get; set; }
    [Required(ErrorMessage = "请输入姓名")]
    public string? Name { get; set; }
    [Required(ErrorMessage = "请输入地址")]
    public string? Address { get; set; }
    [Required(ErrorMessage = "请输入城市")]
    public string? City { get; set; }
    [Required(ErrorMessage = "请输入邮政编码")]
    public string? Zip { get; set; }
    [Required(ErrorMessage = "请输入国家")]
    public string? Country { get; set; }

    [InverseProperty(nameof(OrderItem.Order))]
    public IEnumerable<OrderItem>? OrderItems { get; set; }
}

我们给这些字段都加上了 Required 属性限定。最后一行是购物车里的商品。
每个订单都要有商品(电子书),我们需要再创建一个订单物品的数据模型类 OrderItem 。
Listing 9-2 在 Models 文件夹里新建一个 OrderItems.cs 文件

using System.ComponentModel.DataAnnotations.Schema;

namespace EBooksStore.Models
{
    public class OrderItem
    {
        public int Id { get; set; }
        public int OrderId { get; set; }
        [ForeignKey(nameof(OrderId))]
        [InverseProperty(nameof(Order.OrderItems))]
        public Order? Order { get; set; }
        public int EBookId { get; set; }
        [ForeignKey(nameof(EBookId))]
        public EBook? EBook { get; set; }
        public int Quantity { get; set; }
    }
}

[ForeignKey] 的外键属性限定 OrderId 字段是映射到 Order 数据表的主键,而这个表的 Order 叫导航字段,由映射关系链接到数据库表 Order 中一个唯一的记录,我们就可以直接在这个数据模型即 OrderItem 中存取 Order 中对应的记录。[InverseProperty] 反向属性显示指定相关数据表(也叫数据实体)中的导航字段。在这个例子里: Order 里的 OrderItems 和 OrderItem 里的 Order 互为反向属性。它们直接的关系定义:


  1. OrderItem ↔ Order: 一对多关系:一个 Order 对应多个 OrderItem



  2. OrderItem ↔ EBook: 一对一关系:一个 OrderItem 对应 一本 EBook (有些情况是多对一关系)


这种外键加导航字段的方式能够让 Entity Framework Core 自动处理关系,提高数据存储效率。

我们还要在数据库上下文里增加 DbSet Orders 和 DbSet OrderItems。
Listing 9-3 在 Models 文件夹的 StoreDbContext.cs 里添加数据集合类型

using Microsoft.EntityFrameworkCore;

namespace EBooksStore.Models;

public class StoreDbContext(DbContextOptions<StoreDbContext> options) : DbContext(options) 
{
    public DbSet<EBook> EBooks => Set<EBook>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
}

数据模型已经就绪,开始添加数据库迁移命令。在项目文件夹下开启 PowerShell ,在终端里输入如下语句:
Listing 9-4 添加数据库迁移

dotnet ef migrations add AddOrderAndOrderItem
dotnet ef database update

如果还没有注释掉 Program.cs 里的 Seeds.InitPopulate(app) 语句,我们也可以不用输入 dotnet ef database update 命令,因为在这个方法里会检测迁移和应用数据库更新。

注意:如果数据库迁移出现错误,可以用 dotnet ef migrations remove 移除最近一次的迁移记录。
如果实在都搞砸了,也可以删除所有数据表和数据库,再从头开始做初始化迁移。删除数据表和数据库命令(必须在项目文件夹执行):

dotnet ef database drop --force

我们一般是数据迁移完成后,再开始程序逻辑。数据为王嘛。先修改购物车页面的结算按钮的控制器和方法。
Listing 9-4 修改 Views/Cart 文件夹里的 Index.cshtml

...
<div class="col-sm-6">
        <a asp-controller="Home" asp-action="Index" class="btn btn-primary">继续购物</a>
        <a asp-action="Checkout" asp-controller="Order" class="btn btn-primary">结算</a>
        <!-- a 链接到 Order 控制器的 Checkout 方法 -->
    </div>
    <form class="col-sm-6 text-end" asp-action="ClearCart" method="post">
        <button type="submit" class="btn btn-danger">清空购物车</button>
    </form>
</div>
...

创建控制器和视图

我们在 Controllers 文件夹里添加 Order 控制器类。
Listing 9-5 在 Controllers 文件夹新建一个文件 OrderController.cs

using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class OrderController : Controller
{
    public IActionResult Checkout()
    {
        return View();
    }
}

在 Views 文件夹新建文件夹 Order,我们为 Order 控制器添加视图。
Listing 9-6 在 Views/Order 文件夹新建一个文件 Checkout.cshtml

@model Order
@{
    ViewData["Title"] = "结算";
}

<h2>结算</h2>
<p>请输入送货信息,我们会尽快发货。</p>
<form asp-action="Checkout" method="post">
    <h3>收货信息</h3>
    <div class="form-group">
        <label asp-for="Name">姓名</label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Address">地址</label>
        <input asp-for="Address" class="form-control" />
        <span asp-validation-for="Address" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="City">城市</label>
        <input asp-for="City" class="form-control" />
        <span asp-validation-for="City" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Zip">邮编</label>
        <input asp-for="Zip" class="form-control" />
        <span asp-validation-for="Zip" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Country">国家</label>
        <input asp-for="Country" class="form-control" />
        <span asp-validation-for="Country" class="text-danger"></span>
    </div>
    <div class="text-center my-3">
        <button type="submit" class="btn btn-primary">提交订单</button>
    </div>
</form>

现在我们运行应用,在购物车页面点击结算按钮,将会打开结算页面,如图:

Figure-9-1

Figure 9-1

完成订单过程

对数据库数据操作比较好的方法,还是采用仓库模式。我们将定义一个仓库接口以及实现方法,再用依赖注入(DI)到应用中。

Listing 9-7 在 Models/Interfaces 文件夹里新建一个文件 IOrderRepository.cs

namespace EBooksStore.Models.Interfaces;
public interface IOrderRepository
{
    IQueryable<Order> Orders { get; }
    void SaveOrder(Order order);
    void SaveOrder(Order order, List<OrderItem> orderItems)
}

再创建这个接口的实现类。这里我们创建 2 个同名的 SaveOrder 方法,每个方法最后都是要保存数据库更新,具体的数据库交易由数据库上下文类里封装好了,我们只管用C# 方法就好。
Listing 9-8 在 Models 文件夹里新建一个文件 OrderRepository.cs

using EBooksStore.Models.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace EBooksStore.Models;

public class OrderRepository(StoreDbContext context) : IOrderRepository
{
    private readonly StoreDbContext _context = context;
    
    public IQueryable<Order> Orders => _context.Orders;

    public void SaveOrder(Order order)
    {
        if (order.Id == 0)
        {
            _context.Orders.Add(order);
        }
        _context.SaveChanges();
    }

    public void SaveOrder(Order order, List<OrderItem> orderItems)
    {
        if (order.Id == 0)
        {
            _context.Orders.Add(order);
        }
        foreach (var item in orderItems)
        {
            item.OrderId = order.Id;
            _context.OrderItems.Add(item);
        }
        _context.SaveChanges();
    }
}

在 Program.cs 里添加 <IOrderRepository, OrderRepository> 依赖注入。
Listing 9-9 在 Program.cs 里添加依赖注入

...
builder.Services.AddScoped<IStoreRepository, StoreRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
...

现在 OrderRepository 已经可以被注入了,那 OrderItem 怎么产生? OrderItem 可以从 CartLine 获得,而 CartLine 在我们的会话里,所以我们可以直接从那里取得,也可以写一个服务,让它从会话里取得 CarLine。我们这里暂时用最简单粗暴的直接取用法把。LOL。
Listing 9-10 修改 Controllers 文件夹里的 OrderController.cs

using EBooksStore.Extensions;
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class OrderController(IOrderRepository orderRepository) : Controller
{
    private readonly IOrderRepository _orderRepository = orderRepository;

    public IActionResult Checkout() => View(new Order());
    
    [HttpPost]
    public IActionResult Checkout(Order order)
    {
        // 直接从会话里取得行
        var cart = HttpContext.Session.Get<List<CartLine>>("CartLines");
        if (cart == null || cart.Count == 0)
        {
            ModelState.AddModelError("", "您的购物车是空的!");
        }
        else
        {
            List<OrderItem> orderItems = [];
            foreach (var line in cart)
            {
                orderItems.Add(new OrderItem
                {
                    EBookId = line.EBook.Id,
                    Quantity = line.Quantity
                });
            }
            if (ModelState.IsValid)
            {
                order.OrderItems = orderItems;
                // 用2个参数的签名的方法
                _orderRepository.SaveOrder(order, orderItems);
                // 清空会话数据
                HttpContext.Session.Set("CartLines", new List<CartLine>());
                HttpContext.Session.Set("DisabledCartItems", new List<int>());
                return RedirectToAction();
            }
        }
        
        return View(order);
    }

}

还是按照前面的习惯,我们新建一个 HttpPost 方法,这个方法的参数是我们绑定的 Order 对象,我们用两个参数的签名的 SaveOrder 方法写入数据库并提交。ModelState 是
ASP.NET Core 的固定用法,用它来保存绑定的数据模型的信息和有效性等。我们可以在代码中加入自定义错误信息。只有保证数据模型状态合法,我们才会保存数据库更新。最基本和有效的保证数据合法的逻辑,我们还是要在和客户交互的页面进行。
为了保证用户输入数据的初步合法性,这里是我们至少要让数据符合我们在 Models 文件里定义的 Order 类的属性限定。所有这些限定都会在 ModelState 里验证。我们也有必要告诉警告用户输入数据的问题,所以我们要在视图里加入合法性检测的 Tag Helper 页面元素。
Listing 9-11 在 Views/Order 文件里的 Checkout.cshtml 里添加检查

...
<h2>结算</h2>
<p>请输入送货信息,我们会尽快发货。</p>

<div asp-validation-summary="All" class="text-danger"></div>

<form asp-action="Checkout" method="post">
    <h3>收货信息</h3>
...

这里我们选择把所有的验证信息都反馈给用户。

Figure-9-2

Figure 9-2

每次订单完成以后,我们还要给用户一个感谢信息,不能简单的回到主页。我们在 Order 控制器里创建一个感谢信息的 Action 方法,再在 Views/Order 文件夹里创建一个视图。
Listing 9-12 修改 OrderController.cs 文件,加入新的方法

using EBooksStore.Extensions;
using EBooksStore.Models;
using EBooksStore.Models.Interfaces;
using Microsoft.AspNetCore.Mvc;

namespace EBooksStore.Controllers;

public class OrderController(IOrderRepository orderRepository) : Controller
{
    private readonly IOrderRepository _orderRepository = orderRepository;

    public IActionResult Checkout() => View(new Order());
    
    [HttpPost]
    public IActionResult Checkout(Order order)
    {
        var cart = HttpContext.Session.Get<List<CartLine>>("CartLines");
        if (cart == null || cart.Count == 0)
        {
            ModelState.AddModelError("", "您的购物车是空的!");
        }
        else
        {
            List<OrderItem> orderItems = [];
            foreach (var line in cart)
            {
                orderItems.Add(new OrderItem
                {
                    EBookId = line.EBook.Id,
                    Quantity = line.Quantity
                });
            }
            if (ModelState.IsValid)
            {
                order.OrderItems = orderItems;
                _orderRepository.SaveOrder(order, orderItems);
                HttpContext.Session.Set("CartLines", new List<CartLine>());
                HttpContext.Session.Set("DisabledCartItems", new List<int>());
                // 转到 OrderSummary 方法
                return RedirectToAction(nameof(OrderSummary), new { orderName = order.Name, orderId = order.Id });
            }
        }

        return View(order);
    }

    // 感谢页面方法
    public IActionResult OrderSummary(string orderName, int orderId)
    {
        string orderSummary = $"{orderName}! 感谢您的购买!n您的订单号是:{orderId}";
        return View((object)orderSummary);
    }
}

感谢页面的 Action 方法用订单姓名和订单号构造字符串对象作为数据模型。
Listing 9-13 在 Views/Order 文件夹里新建 OrderSummary.cshtml 文件

@model string
@{
    ViewData["Title"] = "订单摘要";
    Layout = "~/Views/Shared/_CartLayout.cshtml";
}

<h2>订单摘要</h2>
<p>@Model</p>
<p>您的订单已经提交,我们会尽快发货。</p>

<div class="text-center my-3">
    <a asp-controller="Home" asp-action="Index" class="btn btn-primary">返回首页</a>
</div>

我们试着完成订单后,会转到类似下面的页面:

Figure-9-3

Figure 9-3

我们完成了网店的基本功能,特别是面对用户的基本功能。如果可以接着打磨它,完全可以作为生成版本使用。但是,生产版本基本还是要整合其他应用,特别是付款方面的;我们的这个简陋的东西权当作引子,相信各位可能做出功能更全面,更实用的应用。
特别提醒:在 OrderRepository 的 SaveOrder 方法里应用数据库更新时,基本要用 try catch 等类似的处理异常的过程,以防止不可预知的错误。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注