| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- using System.ComponentModel.DataAnnotations;
- using System.ComponentModel.DataAnnotations.Schema;
- using System.Security.Cryptography;
- using System.Text;
- using Domain.Entities.Members;
- using Domain.Entities.Store.ValueObject;
- namespace Domain.Entities.Store;
- /// <summary>
- /// 쿠폰 코드 풀의 개별 코드. Code(평문) + CodeHash(SHA-256 hex) 동시 저장.
- /// 조회/UNIQUE는 CodeHash, 사용자 표시는 Code.
- /// 상태 머신: Available -> Reserved (결제 진행) -> Issued (결제 완료, 회원 보관함) -> Used (실제 사용).
- /// </summary>
- public class CouponCode
- {
- [ForeignKey(nameof(CouponID))]
- public virtual Coupon? Coupon { get; private set; }
- [ForeignKey(nameof(IssuedToMemberID))]
- public virtual Member? IssuedToMember { get; private set; }
- [Key]
- public int ID { get; private set; }
- public int CouponID { get; private set; }
- /// <summary>실제 사용자에게 노출되는 코드 평문 (예: "ABCD-1234-EFGH-5678").</summary>
- public string Code { get; private set; } = default!;
- /// <summary>SHA-256 hex 해시 (대문자, 64자). UNIQUE 인덱스 대상.</summary>
- public string CodeHash { get; private set; } = default!;
- public CouponCodeStatus Status { get; private set; } = CouponCodeStatus.Available;
- public int? IssuedToMemberID { get; private set; }
- public DateTime? IssuedAt { get; private set; }
- public DateTime? UsedAt { get; private set; }
- public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
- [Timestamp]
- public byte[] RowVersion { get; private set; } = default!;
- private CouponCode() { }
- public static CouponCode Create(int couponID, string code)
- {
- if (couponID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(couponID));
- }
- if (string.IsNullOrWhiteSpace(code))
- {
- throw new ArgumentException("Code is required.", nameof(code));
- }
- if (code.Length > 100)
- {
- throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
- }
- return new CouponCode
- {
- CouponID = couponID,
- Code = code,
- CodeHash = ComputeHash(code),
- Status = CouponCodeStatus.Available
- };
- }
- /// <summary>
- /// CSV import 전용. 외부 시스템에서 마이그레이션할 때 사용. Used 상태로 즉시 생성.
- /// IssuedAt/UsedAt 은 UTC 로 전달.
- /// </summary>
- public static CouponCode CreateUsedForImport(
- int couponID,
- string code,
- int memberID,
- DateTime issuedAtUtc,
- DateTime usedAtUtc
- ) {
- if (couponID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(couponID));
- }
- if (memberID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(memberID));
- }
- if (string.IsNullOrWhiteSpace(code))
- {
- throw new ArgumentException("Code is required.", nameof(code));
- }
- if (code.Length > 100)
- {
- throw new ArgumentOutOfRangeException(nameof(code), "Code must be <= 100 chars.");
- }
- if (issuedAtUtc > usedAtUtc)
- {
- throw new ArgumentException("IssuedAt must be <= UsedAt.", nameof(issuedAtUtc));
- }
- return new CouponCode
- {
- CouponID = couponID,
- Code = code,
- CodeHash = ComputeHash(code),
- Status = CouponCodeStatus.Used,
- IssuedToMemberID = memberID,
- IssuedAt = issuedAtUtc,
- UsedAt = usedAtUtc
- };
- }
- /// <summary>
- /// 결제 진행 중 사전 점유. Available -> Reserved.
- /// 호출 측에서 SQL UPDATE 조건 절(`WHERE Status=Available`)로 동시성 충돌 방지 권장.
- /// </summary>
- public void Reserve()
- {
- if (Status != CouponCodeStatus.Available)
- {
- throw new InvalidOperationException($"Cannot reserve from status {Status}.");
- }
- Status = CouponCodeStatus.Reserved;
- }
- /// <summary>결제 실패/취소 시 Reserved -> Available 복귀.</summary>
- public void ReleaseReservation()
- {
- if (Status != CouponCodeStatus.Reserved)
- {
- throw new InvalidOperationException($"Cannot release reservation from status {Status}.");
- }
- Status = CouponCodeStatus.Available;
- }
- /// <summary>결제 완료 시 Reserved -> Issued. 회원 보관함에 발급됨.</summary>
- public void Issue(int memberID)
- {
- if (memberID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(memberID));
- }
- if (Status != CouponCodeStatus.Reserved)
- {
- throw new InvalidOperationException($"Cannot issue from status {Status}.");
- }
- Status = CouponCodeStatus.Issued;
- IssuedToMemberID = memberID;
- IssuedAt = DateTime.UtcNow;
- }
- /// <summary>사용자가 보관함에서 "사용" 클릭 시 Issued -> Used.</summary>
- public void MarkUsed()
- {
- if (Status != CouponCodeStatus.Issued)
- {
- throw new InvalidOperationException($"Cannot mark used from status {Status}.");
- }
- Status = CouponCodeStatus.Used;
- UsedAt = DateTime.UtcNow;
- }
- /// <summary>환불 시 Issued -> Available 재사용 가능. Used 상태에서는 환불 거부 정책.</summary>
- public void RestoreToAvailable()
- {
- if (Status == CouponCodeStatus.Used)
- {
- throw new InvalidOperationException("이미 사용된 쿠폰 코드는 환불 후 재사용 불가합니다.");
- }
- Status = CouponCodeStatus.Available;
- IssuedToMemberID = null;
- IssuedAt = null;
- UsedAt = null;
- }
- /// <summary>
- /// 관리자 일괄 만료. Available 또는 Issued 코드만 만료 가능.
- /// Issued 출발 시 IssuedToMemberID / IssuedAt 보존 (복귀 시 회원 정보 복원에 사용).
- /// Reserved (결제 진행 중) / Used (사용 완료) 코드는 정합성 보호를 위해 만료 거부.
- /// </summary>
- public void Expire()
- {
- if (Status != CouponCodeStatus.Available && Status != CouponCodeStatus.Issued)
- {
- throw new InvalidOperationException($"Cannot expire from status {Status}. (Available 또는 Issued 만 만료 가능)");
- }
- Status = CouponCodeStatus.Expired;
- }
- /// <summary>
- /// 만료 코드 복귀. Expired 에서만 호출 가능.
- /// UsedAt 이 있으면 거부 (이중 안전 — 실제로는 Used 가 만료된 적이 없어 항상 null 이어야 함).
- /// IssuedToMemberID 가 있으면 Issued 로, 없으면 Available 로 복귀.
- /// </summary>
- public void RestoreFromExpired()
- {
- if (Status != CouponCodeStatus.Expired)
- {
- throw new InvalidOperationException($"Cannot restore from status {Status}. (Expired 만 복귀 가능)");
- }
- if (UsedAt.HasValue)
- {
- throw new InvalidOperationException("이미 사용된 쿠폰 코드는 복귀할 수 없습니다.");
- }
- Status = IssuedToMemberID.HasValue ? CouponCodeStatus.Issued : CouponCodeStatus.Available;
- }
- private static string ComputeHash(string code)
- {
- var bytes = Encoding.UTF8.GetBytes(code);
- var hash = SHA256.HashData(bytes);
- return Convert.ToHexString(hash);
- }
- }
|