實戰:電商網站開發
專案架構(三層式 + Repository Pattern)
💡 比喻:餐廳運作
- Controller(外場服務生):接受客人點餐、送菜上桌
- Service(廚房主廚):處理商業邏輯,決定怎麼煮
- Repository(倉庫管理員):負責進出貨,拿食材
每一層各司其職,服務生不需要知道食材從哪來,主廚不需要知道客人坐哪桌。
專案資料夾結構
EShop/
├── Controllers/ ← 外場(接收 HTTP 請求)
│ ├── ProductController.cs
│ ├── CartController.cs
│ └── OrderController.cs
├── Services/ ← 廚房(商業邏輯)
│ ├── IProductService.cs
│ ├── ProductService.cs
│ ├── ICartService.cs
│ ├── CartService.cs
│ ├── IOrderService.cs
│ └── OrderService.cs
├── Repositories/ ← 倉庫(資料存取)
│ ├── IRepository.cs
│ ├── ProductRepository.cs
│ └── OrderRepository.cs
├── Models/ ← 食材(資料模型)
│ ├── Product.cs
│ ├── CartItem.cs
│ └── Order.cs
├── ViewModels/ ← 菜單(呈現用的模型)
│ ├── ProductListVM.cs
│ └── CheckoutVM.cs
└── Data/ ← 冰箱(資料庫設定)
└── AppDbContext.cs
Repository Pattern 基本實作
// 定義泛型 Repository 介面 // Define generic repository interface
public interface IRepository<T> where T : class // 泛型 Repository 介面
{
Task<List<T>> GetAllAsync(); // 取得所有資料
Task<T?> GetByIdAsync(int id); // 依 ID 取得單筆
Task AddAsync(T entity); // 新增一筆資料
Task UpdateAsync(T entity); // 更新一筆資料
Task DeleteAsync(int id); // 刪除一筆資料
Task<bool> ExistsAsync(int id); // 檢查是否存在
}
// 實作泛型 Repository // Implement generic repository
public class Repository<T> : IRepository<T> where T : class // 泛型 Repository 實作
{
protected readonly AppDbContext _context; // 資料庫上下文
protected readonly DbSet<T> _dbSet; // 資料表集合
public Repository(AppDbContext context) // 建構函式注入 DbContext
{
_context = context; // 儲存 DbContext 參考
_dbSet = context.Set<T>(); // 取得對應的 DbSet
}
public async Task<List<T>> GetAllAsync() // 取得所有資料的方法
{
return await _dbSet.ToListAsync(); // 從資料庫取得全部
}
public async Task<T?> GetByIdAsync(int id) // 依 ID 查詢的方法
{
return await _dbSet.FindAsync(id); // 用主鍵查詢
}
public async Task AddAsync(T entity) // 新增資料的方法
{
await _dbSet.AddAsync(entity); // 加入追蹤
await _context.SaveChangesAsync(); // 儲存到資料庫
}
public async Task UpdateAsync(T entity) // 更新資料的方法
{
_dbSet.Update(entity); // 標記為已修改
await _context.SaveChangesAsync(); // 儲存變更
}
public async Task DeleteAsync(int id) // 刪除資料的方法
{
var entity = await _dbSet.FindAsync(id); // 先找到該筆資料
if (entity != null) // 如果找到了
{
_dbSet.Remove(entity); // 標記為刪除
await _context.SaveChangesAsync(); // 執行刪除
}
}
public async Task<bool> ExistsAsync(int id) // 檢查是否存在
{
return await _dbSet.FindAsync(id) != null; // 回傳是否找得到
}
}
商品 CRUD 完整實作
Model 定義
// 商品 Model // Product model
public class Product // 商品類別
{
public int Id { get; set; } // 主鍵
[Required(ErrorMessage = "商品名稱必填")] // 驗證:必填
[StringLength(100)] // 驗證:最長 100 字
public string Name { get; set; } = ""; // 商品名稱
public string? Description { get; set; } // 商品描述(可為空)
[Required] // 驗證:必填
[Range(1, 999999, ErrorMessage = "價格需在 1~999999 之間")] // 驗證:範圍
public decimal Price { get; set; } // 售價
[Range(0, int.MaxValue)] // 驗證:不可負數
public int Stock { get; set; } // 庫存量
public string? ImageUrl { get; set; } // 商品圖片網址
public int CategoryId { get; set; } // 分類 ID(外鍵)
public Category? Category { get; set; } // 導覽屬性:分類
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; // 建立時間
public bool IsActive { get; set; } = true; // 是否上架
}
Service 層
// 商品 Service 介面 // Product service interface
public interface IProductService // 定義商品服務的合約
{
Task<List<Product>> GetProductsAsync(string? category, string? search, int page, int pageSize); // 取得商品清單
Task<Product?> GetProductAsync(int id); // 取得單一商品
Task<Product> CreateProductAsync(Product product); // 建立商品
Task UpdateProductAsync(Product product); // 更新商品
Task DeleteProductAsync(int id); // 刪除商品
Task<int> GetTotalCountAsync(string? category, string? search); // 取得總筆數
}
// 商品 Service 實作 // Product service implementation
public class ProductService : IProductService // 實作商品服務
{
private readonly IRepository<Product> _repo; // 商品 Repository
public ProductService(IRepository<Product> repo) // 建構函式注入 Repository
{
_repo = repo; // 儲存 Repository 參考
}
public async Task<List<Product>> GetProductsAsync( // 取得商品清單方法
string? category, string? search, int page, int pageSize) // 支援篩選和分頁
{
var query = _context.Products // 從商品資料表開始
.Include(p => p.Category) // 載入分類資訊
.Where(p => p.IsActive) // 只顯示上架商品
.AsQueryable(); // 轉為可查詢物件
if (!string.IsNullOrEmpty(category)) // 如果有指定分類
{
query = query.Where(p => p.Category!.Slug == category); // 篩選該分類
}
if (!string.IsNullOrEmpty(search)) // 如果有搜尋關鍵字
{
query = query.Where(p => p.Name.Contains(search) // 搜尋名稱
|| p.Description!.Contains(search)); // 或搜尋描述
}
return await query // 執行查詢
.OrderByDescending(p => p.CreatedAt) // 依建立時間降序
.Skip((page - 1) * pageSize) // 跳過前幾筆(分頁)
.Take(pageSize) // 取指定筆數
.ToListAsync(); // 轉為 List 回傳
}
public async Task<Product> CreateProductAsync(Product product) // 建立商品方法
{
product.CreatedAt = DateTime.UtcNow; // 設定建立時間
await _repo.AddAsync(product); // 透過 Repository 新增
return product; // 回傳建立好的商品
}
public async Task UpdateProductAsync(Product product) // 更新商品方法
{
var existing = await _repo.GetByIdAsync(product.Id); // 先取得現有資料
if (existing == null) // 如果找不到
throw new KeyNotFoundException($"找不到商品 ID: {product.Id}"); // 拋出例外
existing.Name = product.Name; // 更新名稱
existing.Price = product.Price; // 更新價格
existing.Stock = product.Stock; // 更新庫存
existing.Description = product.Description; // 更新描述
existing.CategoryId = product.CategoryId; // 更新分類
await _repo.UpdateAsync(existing); // 儲存更新
}
public async Task DeleteProductAsync(int id) // 刪除商品方法
{
await _repo.DeleteAsync(id); // 透過 Repository 刪除
}
public async Task<Product?> GetProductAsync(int id) // 取得單一商品
{
return await _repo.GetByIdAsync(id); // 透過 Repository 查詢
}
public async Task<int> GetTotalCountAsync(string? category, string? search) // 取得總筆數
{
var query = _context.Products.Where(p => p.IsActive).AsQueryable(); // 查詢上架商品
if (!string.IsNullOrEmpty(category)) // 如果有分類條件
query = query.Where(p => p.Category!.Slug == category); // 加上分類篩選
if (!string.IsNullOrEmpty(search)) // 如果有搜尋條件
query = query.Where(p => p.Name.Contains(search)); // 加上搜尋篩選
return await query.CountAsync(); // 回傳總筆數
}
}
Controller 層
// 商品 Controller // Product controller
public class ProductController : Controller // 繼承 Controller 基底類別
{
private readonly IProductService _service; // 商品服務
public ProductController(IProductService service) // 建構函式注入服務
{
_service = service; // 儲存服務參考
}
// GET /Products?category=fruit&search=蘋果&page=1 // 商品列表頁
public async Task<IActionResult> Index( // 列表 Action
string? category, string? search, int page = 1) // 接收篩選參數
{
int pageSize = 12; // 每頁 12 筆
var products = await _service.GetProductsAsync( // 取得商品
category, search, page, pageSize); // 傳入篩選條件
var total = await _service.GetTotalCountAsync(category, search); // 取得總筆數
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize); // 計算總頁數
ViewBag.CurrentPage = page; // 目前頁碼
ViewBag.Category = category; // 目前分類
ViewBag.Search = search; // 目前搜尋詞
return View(products); // 回傳 View
}
// GET /Products/Details/5 // 商品詳情頁
public async Task<IActionResult> Details(int id) // 詳情 Action
{
var product = await _service.GetProductAsync(id); // 取得商品
if (product == null) return NotFound(); // 找不到回 404
return View(product); // 回傳 View
}
// POST /Products/Create // 建立商品
[HttpPost] // 限定 POST 方法
[ValidateAntiForgeryToken] // 防止 CSRF 攻擊
[Authorize(Roles = "Admin")] // 限定 Admin 角色
public async Task<IActionResult> Create(Product product) // 建立 Action
{
if (!ModelState.IsValid) return View(product); // 驗證失敗回表單
await _service.CreateProductAsync(product); // 建立商品
TempData["Success"] = "商品建立成功!"; // 設定成功訊息
return RedirectToAction(nameof(Index)); // 導回列表頁
}
}
購物車功能(Session + DB)
💡 比喻:實體購物車 vs 線上清單 在超市推購物車(Session)= 還沒結帳,離開就沒了。 加入線上願望清單(DB)= 登入後永遠都在。 我們兩個都做,未登入用 Session,登入後同步到 DB。
// 購物車項目 Model // Cart item model
public class CartItem // 購物車項目類別
{
public int Id { get; set; } // 主鍵
public int ProductId { get; set; } // 商品 ID
public string ProductName { get; set; } = ""; // 商品名稱(快照)
public decimal UnitPrice { get; set; } // 單價(快照)
public int Quantity { get; set; } // 數量
public string? UserId { get; set; } // 會員 ID(登入後才有)
public string SessionId { get; set; } = ""; // Session ID(未登入用)
public decimal Subtotal => UnitPrice * Quantity; // 小計(計算屬性)
}
// 購物車 Service // Cart service
public class CartService : ICartService // 實作購物車服務
{
private readonly AppDbContext _context; // 資料庫上下文
private readonly IHttpContextAccessor _http; // HTTP 上下文存取器
public CartService(AppDbContext context, IHttpContextAccessor http) // 建構函式
{
_context = context; // 儲存 DbContext
_http = http; // 儲存 HTTP 存取器
}
private string GetCartId() // 取得購物車識別 ID
{
var session = _http.HttpContext!.Session; // 取得 Session
var cartId = session.GetString("CartId"); // 嘗試讀取 CartId
if (string.IsNullOrEmpty(cartId)) // 如果沒有 CartId
{
cartId = Guid.NewGuid().ToString(); // 產生新的 GUID
session.SetString("CartId", cartId); // 存入 Session
}
return cartId; // 回傳 CartId
}
public async Task AddToCartAsync(int productId, int quantity = 1) // 加入購物車方法
{
var product = await _context.Products.FindAsync(productId); // 查詢商品
if (product == null) throw new KeyNotFoundException("商品不存在"); // 找不到就報錯
if (product.Stock < quantity) throw new InvalidOperationException("庫存不足"); // 庫存不夠也報錯
var cartId = GetCartId(); // 取得購物車 ID
var existingItem = await _context.CartItems // 查詢購物車中是否已有此商品
.FirstOrDefaultAsync(c => c.SessionId == cartId // 比對 Session ID
&& c.ProductId == productId); // 比對商品 ID
if (existingItem != null) // 如果已經在購物車中
{
existingItem.Quantity += quantity; // 增加數量
}
else // 如果是新商品
{
_context.CartItems.Add(new CartItem // 建立新的購物車項目
{
ProductId = productId, // 設定商品 ID
ProductName = product.Name, // 記錄商品名稱快照
UnitPrice = product.Price, // 記錄當前價格快照
Quantity = quantity, // 設定數量
SessionId = cartId // 設定 Session ID
});
}
await _context.SaveChangesAsync(); // 儲存到資料庫
}
public async Task<List<CartItem>> GetCartItemsAsync() // 取得購物車內容
{
var cartId = GetCartId(); // 取得購物車 ID
return await _context.CartItems // 查詢購物車項目
.Where(c => c.SessionId == cartId) // 篩選此購物車
.ToListAsync(); // 回傳清單
}
public async Task<decimal> GetTotalAsync() // 計算購物車總金額
{
var items = await GetCartItemsAsync(); // 取得所有項目
return items.Sum(i => i.Subtotal); // 加總所有小計
}
public async Task MergeCartAsync(string userId) // 登入後合併購物車
{
var cartId = GetCartId(); // 取得 Session 購物車 ID
var sessionItems = await _context.CartItems // 取得 Session 中的項目
.Where(c => c.SessionId == cartId && c.UserId == null) // 未登入的項目
.ToListAsync(); // 轉為清單
foreach (var item in sessionItems) // 逐一處理每個項目
{
item.UserId = userId; // 綁定會員 ID
}
await _context.SaveChangesAsync(); // 儲存變更
}
}
會員系統(Identity Framework)
// 在 Program.cs 中設定 Identity // Configure Identity in Program.cs
builder.Services.AddIdentity<IdentityUser, IdentityRole>(options => // 加入 Identity 服務
{
options.Password.RequireDigit = true; // 密碼需包含數字
options.Password.RequiredLength = 8; // 密碼最少 8 字元
options.Password.RequireUppercase = false; // 不強制大寫(對中文使用者友善)
options.Password.RequireNonAlphanumeric = false; // 不強制特殊字元
options.User.RequireUniqueEmail = true; // Email 不可重複
options.SignIn.RequireConfirmedEmail = false; // 先不要求 Email 驗證
})
.AddEntityFrameworkStores<AppDbContext>() // 使用 EF Core 儲存
.AddDefaultTokenProviders(); // 加入預設 Token 產生器
// 設定 Cookie // Configure authentication cookie
builder.Services.ConfigureApplicationCookie(options => // 設定驗證 Cookie
{
options.LoginPath = "/Account/Login"; // 未登入導向登入頁
options.LogoutPath = "/Account/Logout"; // 登出路徑
options.AccessDeniedPath = "/Account/AccessDenied"; // 權限不足頁面
options.ExpireTimeSpan = TimeSpan.FromDays(7); // Cookie 有效期 7 天
});
// 註冊 ViewModel // Register view model
public class RegisterVM // 註冊用的 ViewModel
{
[Required(ErrorMessage = "Email 必填")] // 驗證:必填
[EmailAddress(ErrorMessage = "Email 格式不正確")] // 驗證:Email 格式
public string Email { get; set; } = ""; // Email 欄位
[Required(ErrorMessage = "密碼必填")] // 驗證:必填
[MinLength(8, ErrorMessage = "密碼至少 8 個字元")] // 驗證:最少 8 字
[DataType(DataType.Password)] // 指定為密碼類型
public string Password { get; set; } = ""; // 密碼欄位
[Compare("Password", ErrorMessage = "密碼不一致")] // 驗證:與密碼比對
[DataType(DataType.Password)] // 指定為密碼類型
public string ConfirmPassword { get; set; } = ""; // 確認密碼欄位
}
訂單流程(狀態機設計)
💡 比喻:快遞包裹追蹤 你的包裹狀態:已下單 → 已付款 → 出貨中 → 配送中 → 已送達。 每個狀態只能往下一步走,不能跳步也不能倒退(除了取消)。
// 訂單狀態列舉 // Order status enum
public enum OrderStatus // 訂單狀態
{
Pending, // 待付款:剛建立訂單
Paid, // 已付款:付款成功
Processing, // 處理中:賣家準備出貨
Shipped, // 已出貨:交給物流
Delivered, // 已送達:買家收到
Cancelled, // 已取消:訂單取消
Refunded // 已退款:退款完成
}
// 訂單狀態機 // Order state machine
public class OrderStateMachine // 訂單狀態機類別
{
// 定義合法的狀態轉換 // Define valid state transitions
private static readonly Dictionary<OrderStatus, List<OrderStatus>> _transitions = // 狀態轉換表
new() // 初始化狀態轉換規則
{
[OrderStatus.Pending] = new() { OrderStatus.Paid, OrderStatus.Cancelled }, // 待付款可以→已付款或取消
[OrderStatus.Paid] = new() { OrderStatus.Processing, OrderStatus.Refunded }, // 已付款可以→處理中或退款
[OrderStatus.Processing] = new() { OrderStatus.Shipped, OrderStatus.Refunded }, // 處理中可以→已出貨或退款
[OrderStatus.Shipped] = new() { OrderStatus.Delivered }, // 已出貨只能→已送達
[OrderStatus.Delivered] = new() { OrderStatus.Refunded }, // 已送達可以→退款
[OrderStatus.Cancelled] = new(), // 已取消:終態,不能再變
[OrderStatus.Refunded] = new(), // 已退款:終態,不能再變
};
public static bool CanTransition(OrderStatus from, OrderStatus to) // 檢查能否轉換狀態
{
return _transitions.ContainsKey(from) // 確認來源狀態存在
&& _transitions[from].Contains(to); // 確認目標狀態合法
}
public static void Transition(Order order, OrderStatus newStatus) // 執行狀態轉換
{
if (!CanTransition(order.Status, newStatus)) // 檢查是否可以轉換
{
throw new InvalidOperationException( // 不合法就拋出例外
$"無法從 {order.Status} 變更為 {newStatus}"); // 說明錯誤原因
}
order.Status = newStatus; // 更新訂單狀態
order.UpdatedAt = DateTime.UtcNow; // 記錄更新時間
}
}
// 建立訂單的 Service 方法 // Order creation service method
public async Task<Order> CreateOrderAsync(string userId) // 建立訂單
{
var cartItems = await _cartService.GetCartItemsAsync(); // 取得購物車項目
if (!cartItems.Any()) // 如果購物車是空的
throw new InvalidOperationException("購物車是空的"); // 拋出例外
var order = new Order // 建立新訂單
{
UserId = userId, // 設定會員 ID
OrderDate = DateTime.UtcNow, // 設定訂單日期
Status = OrderStatus.Pending, // 初始狀態:待付款
Items = cartItems.Select(ci => new OrderItem // 將購物車轉為訂單明細
{
ProductId = ci.ProductId, // 商品 ID
ProductName = ci.ProductName, // 商品名稱快照
Quantity = ci.Quantity, // 數量
UnitPrice = ci.UnitPrice // 單價快照
}).ToList(), // 轉為清單
TotalAmount = cartItems.Sum(ci => ci.Subtotal) // 計算訂單總金額
};
_context.Orders.Add(order); // 加入訂單
await _context.SaveChangesAsync(); // 儲存到資料庫
await _cartService.ClearCartAsync(); // 清空購物車
return order; // 回傳訂單
}
金流串接概念(綠界 ECPay 範例)
// 綠界金流串接服務 // ECPay integration service
public class EcpayService // 綠界金流服務類別
{
private readonly IConfiguration _config; // 設定檔
public EcpayService(IConfiguration config) // 建構函式注入設定
{
_config = config; // 儲存設定參考
}
public Dictionary<string, string> BuildPaymentForm(Order order) // 建立付款表單參數
{
var merchantId = _config["ECPay:MerchantID"]; // 取得特店編號
var hashKey = _config["ECPay:HashKey"]; // 取得 HashKey
var hashIv = _config["ECPay:HashIV"]; // 取得 HashIV
var parameters = new Dictionary<string, string> // 建立參數字典
{
["MerchantID"] = merchantId!, // 特店編號
["MerchantTradeNo"] = $"ORDER{order.Id:D10}", // 交易編號(補零到 10 位)
["MerchantTradeDate"] = DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"), // 交易時間
["PaymentType"] = "aio", // 付款類型:all in one
["TotalAmount"] = ((int)order.TotalAmount).ToString(), // 總金額(整數)
["TradeDesc"] = "小農直售平台訂單", // 交易描述
["ItemName"] = string.Join("#", order.Items // 商品名稱用 # 分隔
.Select(i => $"{i.ProductName} x{i.Quantity}")), // 格式:名稱 x 數量
["ReturnURL"] = _config["ECPay:ReturnURL"]!, // 付款結果通知網址
["OrderResultURL"] = _config["ECPay:OrderResultURL"]!, // 付款完成導回網址
["ChoosePayment"] = "ALL" // 付款方式:全部開放
};
// 產生檢查碼 // Generate check mac value
var checkMac = GenerateCheckMacValue(parameters, hashKey!, hashIv!); // 計算檢查碼
parameters["CheckMacValue"] = checkMac; // 加入檢查碼
return parameters; // 回傳完整參數
}
private string GenerateCheckMacValue( // 產生綠界檢查碼
Dictionary<string, string> parameters, // 參數字典
string hashKey, string hashIv) // 金鑰
{
var raw = string.Join("&", parameters // 將參數排序並組合
.OrderBy(p => p.Key) // 依 Key 排序
.Select(p => $"{p.Key}={p.Value}")); // 組合 Key=Value
raw = $"HashKey={hashKey}&{raw}&HashIV={hashIv}"; // 前後加上金鑰
raw = WebUtility.UrlEncode(raw).ToLower(); // URL 編碼後轉小寫
using var sha256 = SHA256.Create(); // 建立 SHA256 雜湊
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(raw)); // 計算雜湊值
return BitConverter.ToString(hash).Replace("-", "").ToUpper(); // 轉為大寫 16 進位
}
}
部署到 Railway
// appsettings.Production.json 設定 // Production settings
// Railway 會提供 DATABASE_URL 環境變數 // Railway provides DATABASE_URL env var
// 在 Program.cs 中讀取 // Read in Program.cs
var connectionString = Environment.GetEnvironmentVariable("DATABASE_URL") // 讀取環境變數
?? builder.Configuration.GetConnectionString("DefaultConnection"); // 或用設定檔
builder.Services.AddDbContext<AppDbContext>(options => // 設定 DbContext
{
if (connectionString!.StartsWith("postgres://")) // 如果是 PostgreSQL 格式
{
options.UseNpgsql(ConvertPostgresUrl(connectionString)); // 轉換並使用 Npgsql
}
else // 如果是一般格式
{
options.UseSqlServer(connectionString); // 使用 SQL Server
}
});
// 轉換 Railway 的 PostgreSQL URL // Convert Railway PostgreSQL URL
static string ConvertPostgresUrl(string url) // URL 格式轉換方法
{
var uri = new Uri(url); // 解析 URL
var userInfo = uri.UserInfo.Split(':'); // 分離帳號密碼
return $"Host={uri.Host};" + // 主機
$"Port={uri.Port};" + // 連接埠
$"Database={uri.AbsolutePath.TrimStart('/')};" + // 資料庫名稱
$"Username={userInfo[0]};" + // 使用者名稱
$"Password={userInfo[1]};" + // 密碼
$"SSL Mode=Require;Trust Server Certificate=true"; // SSL 設定
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:Controller 直接操作 DbContext
// ❌ 錯誤:Controller 直接查資料庫 // Mistake: Controller directly queries DB
public class BadController : Controller // 不好的 Controller
{
private readonly AppDbContext _context; // 直接注入 DbContext
public async Task<IActionResult> Index() // 直接在 Controller 查詢
{
var products = await _context.Products.ToListAsync(); // 這裡沒有商業邏輯層
return View(products); // 小專案可以但不好擴充
}
}
// ✅ 正確:透過 Service 層 // Correct: go through Service layer
public class GoodController : Controller // 正確的 Controller
{
private readonly IProductService _service; // 注入服務介面
public async Task<IActionResult> Index() // 透過 Service 取資料
{
var products = await _service.GetProductsAsync(null, null, 1, 12); // 呼叫 Service 方法
return View(products); // 回傳 View
}
}
❌ 錯誤 2:購物車沒有存價格快照
// ❌ 錯誤:結帳時才去查價格 // Mistake: query price at checkout time
public decimal CalculateTotal(List<CartItem> items) // 計算總金額
{
decimal total = 0; // 初始化總額
foreach (var item in items) // 逐一計算
{
var product = _context.Products.Find(item.ProductId); // 查詢當前價格
total += product!.Price * item.Quantity; // 用當前價格計算
// 問題:如果商品在購物車期間漲價,客人會嚇到! // Bug: price may have changed!
}
return total; // 回傳可能不正確的金額
}
// ✅ 正確:加入購物車時就記錄價格 // Correct: record price when adding to cart
public async Task AddToCartAsync(int productId, int qty) // 加入購物車
{
var product = await _context.Products.FindAsync(productId); // 查詢商品
_context.CartItems.Add(new CartItem // 建立購物車項目
{
ProductId = productId, // 商品 ID
UnitPrice = product!.Price, // 記錄當時的價格快照
ProductName = product.Name, // 記錄當時的名稱快照
Quantity = qty // 數量
});
await _context.SaveChangesAsync(); // 儲存
}
❌ 錯誤 3:訂單狀態沒有檢查就直接改
// ❌ 錯誤:不檢查就直接改狀態 // Mistake: changing status without validation
public async Task UpdateStatus(int orderId, OrderStatus newStatus) // 更新訂單狀態
{
var order = await _context.Orders.FindAsync(orderId); // 查詢訂單
order!.Status = newStatus; // 直接改(已送達可以變回待付款?!)
await _context.SaveChangesAsync(); // 儲存不合理的狀態
}
// ✅ 正確:用狀態機檢查 // Correct: use state machine
public async Task UpdateStatus(int orderId, OrderStatus newStatus) // 更新訂單狀態
{
var order = await _context.Orders.FindAsync(orderId); // 查詢訂單
OrderStateMachine.Transition(order!, newStatus); // 透過狀態機檢查並更新
await _context.SaveChangesAsync(); // 儲存合法的狀態變更
}
📋 本章重點
| 層級 | 負責的事 | 不該做的事 |
|---|---|---|
| Controller | 接收請求、回傳結果 | 不該有商業邏輯 |
| Service | 商業邏輯、規則驗證 | 不該直接操作 HTTP |
| Repository | 資料存取(CRUD) | 不該有業務規則 |
🎯 下一步:用相同的架構來做部落格 CMS 系統!