| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- using System.ComponentModel.DataAnnotations;
- using System.ComponentModel.DataAnnotations.Schema;
- using Domain.Entities.Store.ValueObject;
- namespace Domain.Entities.Store;
- /// <summary>
- /// 상점 판매 상품. 실물(Physical) 또는 무형 쿠폰(Digital).
- /// GameID 는 선택 — null 이면 플랫폼 직접 판매(소모품 ItemKind 상품 등).
- /// </summary>
- public class Product
- {
- [ForeignKey(nameof(GameID))]
- public virtual Game? Game { get; private set; }
- [Key]
- public int ID { get; private set; }
- /// <summary>제휴 게임 (선택). null 이면 플랫폼 직접 판매(소모품 등) — 게임 수익배분·정산 없음.</summary>
- public int? GameID { get; private set; }
- public string Name { get; private set; } = default!;
- public string? Description { get; private set; }
- public string? Thumbnail { get; private set; }
- public ProductType Type { get; private set; }
- /// <summary>정가 (P 단위, 내부는 정수). PgCharged 잔액에서 차감.</summary>
- public int Price { get; private set; }
- /// <summary>할인 모드 (None/Percent/Amount).</summary>
- public DiscountType DiscountType { get; private set; } = DiscountType.None;
- /// <summary>할인 값. Percent: 0..99, Amount: 0..Price, None: 0.</summary>
- public int DiscountValue { get; private set; }
- /// <summary>재고. -1 = 무제한 (Digital 상품 + 사전 입력 쿠폰 코드 풀로 관리하는 경우).</summary>
- public int Stock { get; private set; }
- /// <summary>1회 주문 시 최소 구매 수량 (기본 1, 하한 1).</summary>
- public int MinPurchase { get; private set; } = 1;
- /// <summary>1회 주문 시 최대 구매 수량 (기본 999, 하드 상한 999).</summary>
- public int MaxPurchase { get; private set; } = 999;
- /// <summary>구매 시 후원 채널 선택 필수 여부. true 면 채널 미선택 주문을 거부한다.</summary>
- public bool RequireDonationChannel { get; private set; }
- /// <summary>소모품 아이템 종류. null 이면 일반 상품(실물/쿠폰). 값이 있으면 구매 시 계정 귀속 MemberItem 발급.</summary>
- public ItemKind? ItemKind { get; private set; }
- /// <summary>소모품 효과 지속일 (ExpBooster 활성 창 등). 0 = 즉시 소모형. ItemKind 상품에만 유효.</summary>
- public int DurationDays { get; private set; }
- public bool IsActive { get; private set; } = true;
- public int Order { get; private set; }
- public DateTime? SaleStartAt { get; private set; }
- public DateTime? SaleEndAt { get; private set; }
- public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
- public DateTime? UpdatedAt { get; private set; }
- [Timestamp]
- public byte[] RowVersion { get; private set; } = default!;
- private Product() { }
- public static Product Create(
- int? gameID,
- string name,
- ProductType type,
- int price,
- DiscountType discountType,
- int discountValue,
- int stock,
- string? description = null,
- string? thumbnail = null,
- int order = 0,
- int minPurchase = 1,
- int maxPurchase = 999,
- DateTime? saleStartAt = null,
- DateTime? saleEndAt = null,
- bool requireDonationChannel = false,
- ItemKind? itemKind = null,
- int durationDays = 0
- ) {
- Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
- return new Product
- {
- GameID = gameID,
- Name = name,
- Type = type,
- Price = price,
- DiscountType = discountType,
- DiscountValue = discountType == DiscountType.None ? 0 : discountValue,
- Stock = stock,
- Description = description,
- Thumbnail = thumbnail,
- Order = order,
- MinPurchase = minPurchase,
- MaxPurchase = maxPurchase,
- SaleStartAt = saleStartAt,
- SaleEndAt = saleEndAt,
- RequireDonationChannel = requireDonationChannel,
- ItemKind = itemKind,
- DurationDays = itemKind is null ? 0 : (durationDays < 0 ? 0 : durationDays)
- };
- }
- public void Update(
- int? gameID,
- string name,
- ProductType type,
- int price,
- DiscountType discountType,
- int discountValue,
- int stock,
- string? description,
- string? thumbnail,
- int order,
- int minPurchase,
- int maxPurchase,
- DateTime? saleStartAt,
- DateTime? saleEndAt,
- bool isActive,
- bool requireDonationChannel
- ) {
- Validate(gameID, name, price, discountType, discountValue, stock, minPurchase, maxPurchase, saleStartAt, saleEndAt);
- GameID = gameID;
- Name = name;
- Type = type;
- Price = price;
- DiscountType = discountType;
- DiscountValue = discountType == DiscountType.None ? 0 : discountValue;
- Stock = stock;
- Description = description;
- Thumbnail = thumbnail;
- Order = order;
- MinPurchase = minPurchase;
- MaxPurchase = maxPurchase;
- SaleStartAt = saleStartAt;
- SaleEndAt = saleEndAt;
- IsActive = isActive;
- RequireDonationChannel = requireDonationChannel;
- UpdatedAt = DateTime.UtcNow;
- }
- /// <summary>소모품 아이템 정보 설정 (Admin). ItemKind null 이면 일반 상품으로 되돌린다.</summary>
- public void SetItemInfo(ItemKind? itemKind, int durationDays)
- {
- ItemKind = itemKind;
- DurationDays = itemKind is null ? 0 : (durationDays < 0 ? 0 : durationDays);
- UpdatedAt = DateTime.UtcNow;
- }
- /// <summary>
- /// 할인이 반영된 최종 판매가 (P).
- /// 결제/주문 금액 계산은 반드시 이 값을 사용 (정가 Price 가 아님).
- /// </summary>
- public int GetSalePrice()
- {
- return DiscountType switch
- {
- DiscountType.Percent => Math.Max(0, Price - (Price * DiscountValue / 100)),
- DiscountType.Amount => Math.Max(0, Price - DiscountValue),
- _ => Price
- };
- }
- /// <summary>
- /// 결제 시 재고 차감. -1(무제한)이면 차감하지 않음.
- /// 동시성: EF Core RowVersion 으로 충돌 감지 → DbUpdateConcurrencyException.
- /// </summary>
- public void DecreaseStock(int quantity)
- {
- if (quantity <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
- }
- if (Stock == -1)
- {
- return; // 무제한
- }
- if (Stock < quantity)
- {
- throw new InvalidOperationException("재고가 부족합니다.");
- }
- Stock -= quantity;
- UpdatedAt = DateTime.UtcNow;
- }
- /// <summary>환불 시 재고 복구. -1 이면 변경 없음.</summary>
- public void RestoreStock(int quantity)
- {
- if (quantity <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
- }
- if (Stock == -1)
- {
- return;
- }
- Stock += quantity;
- UpdatedAt = DateTime.UtcNow;
- }
- public void Deactivate()
- {
- IsActive = false;
- UpdatedAt = DateTime.UtcNow;
- }
- private static void Validate(int? gameID, string name, int price, DiscountType discountType, int discountValue, int stock, int minPurchase, int maxPurchase, DateTime? saleStartAt, DateTime? saleEndAt)
- {
- if (gameID.HasValue && gameID.Value <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(gameID), "GameID must be positive when specified.");
- }
- if (string.IsNullOrWhiteSpace(name))
- {
- throw new ArgumentException("Name is required.", nameof(name));
- }
- if (name.Length > 200)
- {
- throw new ArgumentOutOfRangeException(nameof(name));
- }
- if (price < 0)
- {
- throw new ArgumentOutOfRangeException(nameof(price), "Price must be non-negative.");
- }
- if (discountType == DiscountType.Percent && (discountValue < 0 || discountValue > 99))
- {
- throw new ArgumentOutOfRangeException(nameof(discountValue), "Percent discount must be 0..99.");
- }
- if (discountType == DiscountType.Amount && (discountValue < 0 || discountValue > price))
- {
- throw new ArgumentOutOfRangeException(nameof(discountValue), "Amount discount must be 0..Price.");
- }
- if (stock < -1)
- {
- throw new ArgumentOutOfRangeException(nameof(stock), "Stock must be -1 (unlimited) or >= 0.");
- }
- if (minPurchase < 1)
- {
- throw new ArgumentOutOfRangeException(nameof(minPurchase), "MinPurchase must be >= 1.");
- }
- if (maxPurchase < minPurchase)
- {
- throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be >= MinPurchase.");
- }
- if (maxPurchase > 999)
- {
- throw new ArgumentOutOfRangeException(nameof(maxPurchase), "MaxPurchase must be <= 999.");
- }
- if (saleStartAt.HasValue && saleEndAt.HasValue && saleStartAt.Value >= saleEndAt.Value)
- {
- throw new ArgumentException("SaleStartAt must be before SaleEndAt.");
- }
- }
- }
|