MemberItem.cs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using Domain.Entities.Members.ValueObject;
  4. using Domain.Entities.Store;
  5. using Domain.Entities.Store.ValueObject;
  6. namespace Domain.Entities.Members;
  7. /// <summary>
  8. /// 계정 귀속 아이템 (쿠폰 코드와 별개). 상점에서 ItemKind 상품 구매 시 발급된다. 양도 불가(MemberID 직접 귀속).
  9. /// • 지위 아이템(Badge=2/NicknameEffect=3/CommentEffect=4): 장착형 — Equip/Unequip. 슬롯당 1개.
  10. /// • 소모품(ChatBanLift=5 …): 사용(소모) 시 효과 적용 — MarkUsed/Revoke.
  11. /// 선물이면 GiftFromMemberID 에 발신자, 지속일(DurationDays) 스냅샷으로 ExpiresAt 를 계산한다(null=영구).
  12. /// </summary>
  13. public class MemberItem
  14. {
  15. [ForeignKey(nameof(MemberID))]
  16. public virtual Member Member { get; private set; } = null!;
  17. [ForeignKey(nameof(ProductID))]
  18. public virtual Product? Product { get; private set; }
  19. [Key]
  20. public int ID { get; private set; }
  21. public int MemberID { get; private set; }
  22. public int ProductID { get; private set; }
  23. /// <summary>발급 근거 주문 항목 (환불 시 회수 연동).</summary>
  24. public int OrderItemID { get; private set; }
  25. /// <summary>선물이면 발신자 회원 ID. 본인 구매면 null.</summary>
  26. public int? GiftFromMemberID { get; private set; }
  27. /// <summary>아이템 종류 (구매 시점 Product.ItemKind 스냅샷).</summary>
  28. public ItemKind Kind { get; private set; }
  29. /// <summary>지위 아이템 장착 슬롯 (Badge=1/NicknameEffect=2/CommentEffect=3). 소모품은 null.</summary>
  30. public EquipSlot? EquipSlot { get; private set; }
  31. /// <summary>지위 아이템 장착 여부. 슬롯당 1개만 true (필터 유니크 인덱스로 강제).</summary>
  32. public bool IsEquipped { get; private set; }
  33. /// <summary>효과 지속일 스냅샷 (ExpBooster 활성 창 등). 0 = 즉시 소모형/영구.</summary>
  34. public int DurationDays { get; private set; }
  35. public DateTime AcquiredAt { get; private set; } = DateTime.UtcNow;
  36. /// <summary>만료 일시 (DurationDays 스냅샷). null = 영구. 조회 시 만료분 필터.</summary>
  37. public DateTime? ExpiresAt { get; private set; }
  38. /// <summary>소모/활성 시각 — NULL 이면 미사용(사용 가능). 지위 아이템은 사용하지 않음(항상 null).</summary>
  39. public DateTime? UsedAt { get; private set; }
  40. /// <summary>환불/회수 시각 — NULL 이 아니면 무효.</summary>
  41. public DateTime? RevokedAt { get; private set; }
  42. [Timestamp]
  43. public byte[] RowVersion { get; private set; } = default!;
  44. private MemberItem() { }
  45. public static MemberItem Create(int memberID, int productID, int orderItemID, ItemKind kind, int durationDays, int? giftFromMemberID = null)
  46. {
  47. if (memberID <= 0)
  48. {
  49. throw new ArgumentOutOfRangeException(nameof(memberID));
  50. }
  51. if (productID <= 0)
  52. {
  53. throw new ArgumentOutOfRangeException(nameof(productID));
  54. }
  55. if (orderItemID <= 0)
  56. {
  57. throw new ArgumentOutOfRangeException(nameof(orderItemID));
  58. }
  59. if (giftFromMemberID is <= 0)
  60. {
  61. throw new ArgumentOutOfRangeException(nameof(giftFromMemberID));
  62. }
  63. var days = durationDays < 0 ? 0 : durationDays;
  64. var now = DateTime.UtcNow;
  65. return new MemberItem
  66. {
  67. MemberID = memberID,
  68. ProductID = productID,
  69. OrderItemID = orderItemID,
  70. GiftFromMemberID = giftFromMemberID,
  71. Kind = kind,
  72. EquipSlot = kind.ToEquipSlot(),
  73. DurationDays = days,
  74. AcquiredAt = now,
  75. // 지위 아이템: DurationDays>0 이면 획득 시점 기준 만료 스냅샷(null=영구). 소모품은 사용 후 창(IsBoostActiveAt)이라 미적용.
  76. ExpiresAt = kind.IsStatusItem() && days > 0 ? now.AddDays(days) : null
  77. };
  78. }
  79. /// <summary>지위 아이템 여부 — 장착 슬롯 보유.</summary>
  80. public bool IsStatusItem => EquipSlot is not null;
  81. /// <summary>지정 시각 기준 만료 여부 (ExpiresAt 존재 && 경과).</summary>
  82. public bool IsExpiredAt(DateTime nowUtc) => ExpiresAt is not null && ExpiresAt.Value <= nowUtc;
  83. /// <summary>사용 가능 여부 — 미사용 && 미회수. (소모품 기준)</summary>
  84. public bool IsAvailable => UsedAt is null && RevokedAt is null;
  85. /// <summary>경험치 부스터 활성 여부 (사용 후 DurationDays 창 이내).</summary>
  86. public bool IsBoostActiveAt(DateTime nowUtc)
  87. {
  88. if (Kind != ItemKind.ExpBooster || RevokedAt is not null || UsedAt is null)
  89. {
  90. return false;
  91. }
  92. return UsedAt.Value.AddDays(DurationDays) > nowUtc;
  93. }
  94. /// <summary>지위 아이템 장착. 소모품/회수/만료 아이템은 예외. 성공 후 호출측이 슬롯 유일성을 보장한다.</summary>
  95. public void Equip(DateTime nowUtc)
  96. {
  97. if (EquipSlot is null)
  98. {
  99. throw new InvalidOperationException("장착할 수 없는 아이템입니다.");
  100. }
  101. if (RevokedAt is not null)
  102. {
  103. throw new InvalidOperationException("회수된 아이템입니다.");
  104. }
  105. if (IsExpiredAt(nowUtc))
  106. {
  107. throw new InvalidOperationException("만료된 아이템입니다.");
  108. }
  109. IsEquipped = true;
  110. }
  111. /// <summary>지위 아이템 장착 해제. 이미 미장착이면 무해(idempotent).</summary>
  112. public void Unequip()
  113. {
  114. IsEquipped = false;
  115. }
  116. /// <summary>소모/활성 처리. 이미 사용/회수 시 예외.</summary>
  117. public void MarkUsed(DateTime nowUtc)
  118. {
  119. if (RevokedAt is not null)
  120. {
  121. throw new InvalidOperationException("회수된 아이템입니다.");
  122. }
  123. if (UsedAt is not null)
  124. {
  125. throw new InvalidOperationException("이미 사용한 아이템입니다.");
  126. }
  127. UsedAt = nowUtc;
  128. }
  129. /// <summary>환불/회수 — 미사용 아이템만 회수 가능. 지위 아이템은 장착 해제 후 회수. 성공 시 true.</summary>
  130. public bool Revoke(DateTime nowUtc)
  131. {
  132. if (RevokedAt is not null || UsedAt is not null)
  133. {
  134. return false;
  135. }
  136. IsEquipped = false;
  137. RevokedAt = nowUtc;
  138. return true;
  139. }
  140. }