CouponCode.cs 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using System.Security.Cryptography;
  4. using System.Text;
  5. using Domain.Entities.Members;
  6. using Domain.Entities.Store.ValueObject;
  7. namespace Domain.Entities.Store;
  8. /// <summary>
  9. /// 쿠폰 코드 풀의 개별 코드. Code(평문) + CodeHash(SHA-256 hex) 동시 저장.
  10. /// 조회/UNIQUE는 CodeHash, 사용자 표시는 Code.
  11. /// 상태 머신: Available -> Reserved (결제 진행) -> Issued (결제 완료, 회원 보관함) -> Used (실제 사용).
  12. /// </summary>
  13. public class CouponCode
  14. {
  15. [ForeignKey(nameof(CouponID))]
  16. public virtual Coupon? Coupon { get; private set; }
  17. [ForeignKey(nameof(IssuedToMemberID))]
  18. public virtual Member? IssuedToMember { get; private set; }
  19. [Key]
  20. public int ID { get; private set; }
  21. public int CouponID { get; private set; }
  22. /// <summary>실제 사용자에게 노출되는 코드 평문 (예: "ABCD-1234-EFGH-5678").</summary>
  23. public string Code { get; private set; } = default!;
  24. /// <summary>SHA-256 hex 해시 (대문자, 64자). UNIQUE 인덱스 대상.</summary>
  25. public string CodeHash { get; private set; } = default!;
  26. public CouponCodeStatus Status { get; private set; } = CouponCodeStatus.Available;
  27. public int? IssuedToMemberID { get; private set; }
  28. public DateTime? IssuedAt { get; private set; }
  29. public DateTime? UsedAt { get; private set; }
  30. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  31. [Timestamp]
  32. public byte[] RowVersion { get; private set; } = default!;
  33. private CouponCode() { }
  34. public static CouponCode Create(int couponID, string code)
  35. {
  36. if (couponID <= 0)
  37. {
  38. throw new ArgumentOutOfRangeException(nameof(couponID));
  39. }
  40. if (string.IsNullOrWhiteSpace(code))
  41. {
  42. throw new ArgumentException("Code is required.", nameof(code));
  43. }
  44. if (code.Length > 100)
  45. {
  46. throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
  47. }
  48. return new CouponCode
  49. {
  50. CouponID = couponID,
  51. Code = code,
  52. CodeHash = ComputeHash(code),
  53. Status = CouponCodeStatus.Available
  54. };
  55. }
  56. /// <summary>
  57. /// CSV import 전용. 외부 시스템에서 마이그레이션할 때 사용. Used 상태로 즉시 생성.
  58. /// IssuedAt/UsedAt 은 UTC 로 전달.
  59. /// </summary>
  60. public static CouponCode CreateUsedForImport(
  61. int couponID,
  62. string code,
  63. int memberID,
  64. DateTime issuedAtUtc,
  65. DateTime usedAtUtc
  66. ) {
  67. if (couponID <= 0)
  68. {
  69. throw new ArgumentOutOfRangeException(nameof(couponID));
  70. }
  71. if (memberID <= 0)
  72. {
  73. throw new ArgumentOutOfRangeException(nameof(memberID));
  74. }
  75. if (string.IsNullOrWhiteSpace(code))
  76. {
  77. throw new ArgumentException("Code is required.", nameof(code));
  78. }
  79. if (code.Length > 100)
  80. {
  81. throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
  82. }
  83. if (issuedAtUtc > usedAtUtc)
  84. {
  85. throw new ArgumentException("IssuedAt must be <= UsedAt.", nameof(issuedAtUtc));
  86. }
  87. return new CouponCode
  88. {
  89. CouponID = couponID,
  90. Code = code,
  91. CodeHash = ComputeHash(code),
  92. Status = CouponCodeStatus.Used,
  93. IssuedToMemberID = memberID,
  94. IssuedAt = issuedAtUtc,
  95. UsedAt = usedAtUtc
  96. };
  97. }
  98. /// <summary>
  99. /// 결제 진행 중 사전 점유. Available -> Reserved.
  100. /// 호출 측에서 SQL UPDATE 조건 절(`WHERE Status=Available`)로 동시성 충돌 방지 권장.
  101. /// </summary>
  102. public void Reserve()
  103. {
  104. if (Status != CouponCodeStatus.Available)
  105. {
  106. throw new InvalidOperationException($"Cannot reserve from status {Status}.");
  107. }
  108. Status = CouponCodeStatus.Reserved;
  109. }
  110. /// <summary>결제 실패/취소 시 Reserved -> Available 복귀.</summary>
  111. public void ReleaseReservation()
  112. {
  113. if (Status != CouponCodeStatus.Reserved)
  114. {
  115. throw new InvalidOperationException($"Cannot release reservation from status {Status}.");
  116. }
  117. Status = CouponCodeStatus.Available;
  118. }
  119. /// <summary>결제 완료 시 Reserved -> Issued. 회원 보관함에 발급됨.</summary>
  120. public void Issue(int memberID)
  121. {
  122. if (memberID <= 0)
  123. {
  124. throw new ArgumentOutOfRangeException(nameof(memberID));
  125. }
  126. if (Status != CouponCodeStatus.Reserved)
  127. {
  128. throw new InvalidOperationException($"Cannot issue from status {Status}.");
  129. }
  130. Status = CouponCodeStatus.Issued;
  131. IssuedToMemberID = memberID;
  132. IssuedAt = DateTime.UtcNow;
  133. }
  134. /// <summary>사용자가 보관함에서 "사용" 클릭 시 Issued -> Used.</summary>
  135. public void MarkUsed()
  136. {
  137. if (Status != CouponCodeStatus.Issued)
  138. {
  139. throw new InvalidOperationException($"Cannot mark used from status {Status}.");
  140. }
  141. Status = CouponCodeStatus.Used;
  142. UsedAt = DateTime.UtcNow;
  143. }
  144. /// <summary>환불 시 Issued -> Available 재사용 가능. Used 상태에서는 환불 거부 정책.</summary>
  145. public void RestoreToAvailable()
  146. {
  147. if (Status == CouponCodeStatus.Used)
  148. {
  149. throw new InvalidOperationException("이미 사용된 쿠폰 코드는 환불 후 재사용 불가합니다.");
  150. }
  151. Status = CouponCodeStatus.Available;
  152. IssuedToMemberID = null;
  153. IssuedAt = null;
  154. UsedAt = null;
  155. }
  156. /// <summary>
  157. /// 관리자 일괄 만료. Available 또는 Issued 코드만 만료 가능.
  158. /// Issued 출발 시 IssuedToMemberID / IssuedAt 보존 (복귀 시 회원 정보 복원에 사용).
  159. /// Reserved (결제 진행 중) / Used (사용 완료) 코드는 정합성 보호를 위해 만료 거부.
  160. /// </summary>
  161. public void Expire()
  162. {
  163. if (Status != CouponCodeStatus.Available && Status != CouponCodeStatus.Issued)
  164. {
  165. throw new InvalidOperationException($"Cannot expire from status {Status}. (Available 또는 Issued 만 만료 가능)");
  166. }
  167. Status = CouponCodeStatus.Expired;
  168. }
  169. /// <summary>
  170. /// 만료 코드 복귀. Expired 에서만 호출 가능.
  171. /// UsedAt 이 있으면 거부 (이중 안전 — 실제로는 Used 가 만료된 적이 없어 항상 null 이어야 함).
  172. /// IssuedToMemberID 가 있으면 Issued 로, 없으면 Available 로 복귀.
  173. /// </summary>
  174. public void RestoreFromExpired()
  175. {
  176. if (Status != CouponCodeStatus.Expired)
  177. {
  178. throw new InvalidOperationException($"Cannot restore from status {Status}. (Expired 만 복귀 가능)");
  179. }
  180. if (UsedAt.HasValue)
  181. {
  182. throw new InvalidOperationException("이미 사용된 쿠폰 코드는 복귀할 수 없습니다.");
  183. }
  184. Status = IssuedToMemberID.HasValue ? CouponCodeStatus.Issued : CouponCodeStatus.Available;
  185. }
  186. private static string ComputeHash(string code)
  187. {
  188. var bytes = Encoding.UTF8.GetBytes(code);
  189. var hash = SHA256.HashData(bytes);
  190. return Convert.ToHexString(hash);
  191. }
  192. }