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; /// /// 쿠폰 코드 풀의 개별 코드. Code(평문) + CodeHash(SHA-256 hex) 동시 저장. /// 조회/UNIQUE는 CodeHash, 사용자 표시는 Code. /// 상태 머신: Available -> Reserved (결제 진행) -> Issued (결제 완료, 회원 보관함) -> Used (실제 사용). /// 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; } /// 실제 사용자에게 노출되는 코드 평문 (예: "ABCD-1234-EFGH-5678"). public string Code { get; private set; } = default!; /// SHA-256 hex 해시 (대문자, 64자). UNIQUE 인덱스 대상. 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 }; } /// /// CSV import 전용. 외부 시스템에서 마이그레이션할 때 사용. Used 상태로 즉시 생성. /// IssuedAt/UsedAt 은 UTC 로 전달. /// 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 }; } /// /// 결제 진행 중 사전 점유. Available -> Reserved. /// 호출 측에서 SQL UPDATE 조건 절(`WHERE Status=Available`)로 동시성 충돌 방지 권장. /// public void Reserve() { if (Status != CouponCodeStatus.Available) { throw new InvalidOperationException($"Cannot reserve from status {Status}."); } Status = CouponCodeStatus.Reserved; } /// 결제 실패/취소 시 Reserved -> Available 복귀. public void ReleaseReservation() { if (Status != CouponCodeStatus.Reserved) { throw new InvalidOperationException($"Cannot release reservation from status {Status}."); } Status = CouponCodeStatus.Available; } /// 결제 완료 시 Reserved -> Issued. 회원 보관함에 발급됨. 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; } /// 사용자가 보관함에서 "사용" 클릭 시 Issued -> Used. public void MarkUsed() { if (Status != CouponCodeStatus.Issued) { throw new InvalidOperationException($"Cannot mark used from status {Status}."); } Status = CouponCodeStatus.Used; UsedAt = DateTime.UtcNow; } /// 환불 시 Issued -> Available 재사용 가능. Used 상태에서는 환불 거부 정책. public void RestoreToAvailable() { if (Status == CouponCodeStatus.Used) { throw new InvalidOperationException("이미 사용된 쿠폰 코드는 환불 후 재사용 불가합니다."); } Status = CouponCodeStatus.Available; IssuedToMemberID = null; IssuedAt = null; UsedAt = null; } /// /// 관리자 일괄 만료. Available 또는 Issued 코드만 만료 가능. /// Issued 출발 시 IssuedToMemberID / IssuedAt 보존 (복귀 시 회원 정보 복원에 사용). /// Reserved (결제 진행 중) / Used (사용 완료) 코드는 정합성 보호를 위해 만료 거부. /// public void Expire() { if (Status != CouponCodeStatus.Available && Status != CouponCodeStatus.Issued) { throw new InvalidOperationException($"Cannot expire from status {Status}. (Available 또는 Issued 만 만료 가능)"); } Status = CouponCodeStatus.Expired; } /// /// 만료 코드 복귀. Expired 에서만 호출 가능. /// UsedAt 이 있으면 거부 (이중 안전 — 실제로는 Used 가 만료된 적이 없어 항상 null 이어야 함). /// IssuedToMemberID 가 있으면 Issued 로, 없으면 Available 로 복귀. /// 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); } }