| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- using System.ComponentModel.DataAnnotations;
- using System.ComponentModel.DataAnnotations.Schema;
- using Domain.Entities.Members.ValueObject;
- using Domain.Entities.Store;
- using Domain.Entities.Store.ValueObject;
- namespace Domain.Entities.Members;
- /// <summary>
- /// 계정 귀속 아이템 (쿠폰 코드와 별개). 상점에서 ItemKind 상품 구매 시 발급된다. 양도 불가(MemberID 직접 귀속).
- /// • 지위 아이템(Badge=2/NicknameEffect=3/CommentEffect=4): 장착형 — Equip/Unequip. 슬롯당 1개.
- /// • 소모품(ChatBanLift=5 …): 사용(소모) 시 효과 적용 — MarkUsed/Revoke.
- /// 선물이면 GiftFromMemberID 에 발신자, 지속일(DurationDays) 스냅샷으로 ExpiresAt 를 계산한다(null=영구).
- /// </summary>
- public class MemberItem
- {
- [ForeignKey(nameof(MemberID))]
- public virtual Member Member { get; private set; } = null!;
- [ForeignKey(nameof(ProductID))]
- public virtual Product? Product { get; private set; }
- [Key]
- public int ID { get; private set; }
- public int MemberID { get; private set; }
- public int ProductID { get; private set; }
- /// <summary>발급 근거 주문 항목 (환불 시 회수 연동).</summary>
- public int OrderItemID { get; private set; }
- /// <summary>선물이면 발신자 회원 ID. 본인 구매면 null.</summary>
- public int? GiftFromMemberID { get; private set; }
- /// <summary>아이템 종류 (구매 시점 Product.ItemKind 스냅샷).</summary>
- public ItemKind Kind { get; private set; }
- /// <summary>지위 아이템 장착 슬롯 (Badge=1/NicknameEffect=2/CommentEffect=3). 소모품은 null.</summary>
- public EquipSlot? EquipSlot { get; private set; }
- /// <summary>지위 아이템 장착 여부. 슬롯당 1개만 true (필터 유니크 인덱스로 강제).</summary>
- public bool IsEquipped { get; private set; }
- /// <summary>효과 지속일 스냅샷 (ExpBooster 활성 창 등). 0 = 즉시 소모형/영구.</summary>
- public int DurationDays { get; private set; }
- public DateTime AcquiredAt { get; private set; } = DateTime.UtcNow;
- /// <summary>만료 일시 (DurationDays 스냅샷). null = 영구. 조회 시 만료분 필터.</summary>
- public DateTime? ExpiresAt { get; private set; }
- /// <summary>소모/활성 시각 — NULL 이면 미사용(사용 가능). 지위 아이템은 사용하지 않음(항상 null).</summary>
- public DateTime? UsedAt { get; private set; }
- /// <summary>환불/회수 시각 — NULL 이 아니면 무효.</summary>
- public DateTime? RevokedAt { get; private set; }
- [Timestamp]
- public byte[] RowVersion { get; private set; } = default!;
- private MemberItem() { }
- public static MemberItem Create(int memberID, int productID, int orderItemID, ItemKind kind, int durationDays, int? giftFromMemberID = null)
- {
- if (memberID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(memberID));
- }
- if (productID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(productID));
- }
- if (orderItemID <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(orderItemID));
- }
- if (giftFromMemberID is <= 0)
- {
- throw new ArgumentOutOfRangeException(nameof(giftFromMemberID));
- }
- var days = durationDays < 0 ? 0 : durationDays;
- var now = DateTime.UtcNow;
- return new MemberItem
- {
- MemberID = memberID,
- ProductID = productID,
- OrderItemID = orderItemID,
- GiftFromMemberID = giftFromMemberID,
- Kind = kind,
- EquipSlot = kind.ToEquipSlot(),
- DurationDays = days,
- AcquiredAt = now,
- // 지위 아이템: DurationDays>0 이면 획득 시점 기준 만료 스냅샷(null=영구). 소모품은 사용 후 창(IsBoostActiveAt)이라 미적용.
- ExpiresAt = kind.IsStatusItem() && days > 0 ? now.AddDays(days) : null
- };
- }
- /// <summary>지위 아이템 여부 — 장착 슬롯 보유.</summary>
- public bool IsStatusItem => EquipSlot is not null;
- /// <summary>지정 시각 기준 만료 여부 (ExpiresAt 존재 && 경과).</summary>
- public bool IsExpiredAt(DateTime nowUtc) => ExpiresAt is not null && ExpiresAt.Value <= nowUtc;
- /// <summary>사용 가능 여부 — 미사용 && 미회수. (소모품 기준)</summary>
- public bool IsAvailable => UsedAt is null && RevokedAt is null;
- /// <summary>경험치 부스터 활성 여부 (사용 후 DurationDays 창 이내).</summary>
- public bool IsBoostActiveAt(DateTime nowUtc)
- {
- if (Kind != ItemKind.ExpBooster || RevokedAt is not null || UsedAt is null)
- {
- return false;
- }
- return UsedAt.Value.AddDays(DurationDays) > nowUtc;
- }
- /// <summary>지위 아이템 장착. 소모품/회수/만료 아이템은 예외. 성공 후 호출측이 슬롯 유일성을 보장한다.</summary>
- public void Equip(DateTime nowUtc)
- {
- if (EquipSlot is null)
- {
- throw new InvalidOperationException("장착할 수 없는 아이템입니다.");
- }
- if (RevokedAt is not null)
- {
- throw new InvalidOperationException("회수된 아이템입니다.");
- }
- if (IsExpiredAt(nowUtc))
- {
- throw new InvalidOperationException("만료된 아이템입니다.");
- }
- IsEquipped = true;
- }
- /// <summary>지위 아이템 장착 해제. 이미 미장착이면 무해(idempotent).</summary>
- public void Unequip()
- {
- IsEquipped = false;
- }
- /// <summary>소모/활성 처리. 이미 사용/회수 시 예외.</summary>
- public void MarkUsed(DateTime nowUtc)
- {
- if (RevokedAt is not null)
- {
- throw new InvalidOperationException("회수된 아이템입니다.");
- }
- if (UsedAt is not null)
- {
- throw new InvalidOperationException("이미 사용한 아이템입니다.");
- }
- UsedAt = nowUtc;
- }
- /// <summary>환불/회수 — 미사용 아이템만 회수 가능. 지위 아이템은 장착 해제 후 회수. 성공 시 true.</summary>
- public bool Revoke(DateTime nowUtc)
- {
- if (RevokedAt is not null || UsedAt is not null)
- {
- return false;
- }
- IsEquipped = false;
- RevokedAt = nowUtc;
- return true;
- }
- }
|