Channel.cs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. using System.ComponentModel.DataAnnotations;
  2. using System.ComponentModel.DataAnnotations.Schema;
  3. using System.Text.RegularExpressions;
  4. namespace Domain.Entities.Members;
  5. public class Channel
  6. {
  7. [ForeignKey(nameof(MemberID))]
  8. public virtual Member Member { get; private set; } = null!;
  9. [Key]
  10. public int ID { get; private set; }
  11. public int MemberID { get; private set; }
  12. public string SID { get; private set; } = default!;
  13. public string Name { get; private set; } = default!;
  14. public string? Handle { get; private set; }
  15. public string YouTubeUrl { get; private set; } = default!;
  16. public string? YouTubeChannelID { get; private set; }
  17. public string? Description { get; private set; }
  18. public string? ThumbnailUrl { get; private set; }
  19. public string? BannerUrl { get; private set; }
  20. public long SubscriberCount { get; private set; }
  21. public long VideoCount { get; private set; }
  22. public long ViewCount { get; private set; }
  23. public string? Email { get; private set; }
  24. public DateTime? YouTubePublishedAt { get; private set; }
  25. /// <summary>
  26. /// YouTube API 로 채널 데이터를 마지막으로 갱신한 시각(UTC).
  27. /// YouTube API Services ToS 의 30일 데이터 갱신·삭제 정책 enforcement 용.
  28. /// null 이거나 UtcNow - 30d 이전이면 WipeYouTubeData() 대상.
  29. /// </summary>
  30. public DateTime? YouTubeLastSyncedAt { get; private set; }
  31. /// <summary>후원(Donation) 수수료(%) — dpot 이 가져가는 비율. PlatformFeeRate=20% 이면 채널주는 80% 보유.</summary>
  32. public decimal PlatformFeeRate { get; private set; } = 0;
  33. /// <summary>상점 매출 보상률(%) — 채널이 가져가는 비율. StoreCommissionRate=20% 이면 채널주에게 20% 입금, dpot이 80% 보유.</summary>
  34. public decimal StoreCommissionRate { get; private set; } = 0;
  35. public bool IsVerified { get; private set; } = false;
  36. public bool IsActive { get; private set; } = true;
  37. /// <summary>채널 후원 코드(4~7자 영문+숫자, 대문자 저장). 한 번 발급되면 변경 불가. 후원 채널 빠른 검색·게임 in-app 결제 식별용.</summary>
  38. public string? DonationCode { get; private set; }
  39. public string WidgetToken { get; private set; } = GenerateWidgetToken();
  40. public DateTime? UpdatedAt { get; private set; }
  41. public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
  42. private Channel() { }
  43. private Channel(int memberID, string sid, string name, string youtubeUrl)
  44. {
  45. if (memberID <= 0)
  46. {
  47. throw new ArgumentOutOfRangeException(nameof(memberID));
  48. }
  49. if (string.IsNullOrWhiteSpace(sid))
  50. {
  51. throw new ArgumentException("SID is required.", nameof(sid));
  52. }
  53. if (sid.Length != 24)
  54. {
  55. throw new ArgumentOutOfRangeException(nameof(sid));
  56. }
  57. if (string.IsNullOrWhiteSpace(name))
  58. {
  59. throw new ArgumentException("Name is required.", nameof(name));
  60. }
  61. if (name.Length > 200)
  62. {
  63. throw new ArgumentOutOfRangeException(nameof(name));
  64. }
  65. if (string.IsNullOrWhiteSpace(youtubeUrl))
  66. {
  67. throw new ArgumentException("YouTubeUrl is required.", nameof(youtubeUrl));
  68. }
  69. if (youtubeUrl.Length > 255)
  70. {
  71. throw new ArgumentOutOfRangeException(nameof(youtubeUrl));
  72. }
  73. MemberID = memberID;
  74. SID = sid;
  75. Name = name;
  76. YouTubeUrl = youtubeUrl;
  77. }
  78. public static Channel Create(int memberID, string sid, string name, string youtubeUrl)
  79. {
  80. return new(memberID, sid, name, youtubeUrl);
  81. }
  82. public void Update(string name, string? handle, string youtubeUrl, decimal platformFeeRate, decimal storeCommissionRate, bool isVerified, bool isActive)
  83. {
  84. if (string.IsNullOrWhiteSpace(name))
  85. {
  86. throw new ArgumentException("Name is required.", nameof(name));
  87. }
  88. if (name.Length > 200)
  89. {
  90. throw new ArgumentOutOfRangeException(nameof(name));
  91. }
  92. var normalizedHandle = NormalizeHandleInternal(handle);
  93. if (normalizedHandle is not null && normalizedHandle.Length > 30)
  94. {
  95. throw new ArgumentOutOfRangeException(nameof(handle));
  96. }
  97. if (string.IsNullOrWhiteSpace(youtubeUrl))
  98. {
  99. throw new ArgumentException("YouTubeUrl is required.", nameof(youtubeUrl));
  100. }
  101. if (youtubeUrl.Length > 255)
  102. {
  103. throw new ArgumentOutOfRangeException(nameof(youtubeUrl));
  104. }
  105. if (platformFeeRate < 0 || platformFeeRate > 100)
  106. {
  107. throw new ArgumentOutOfRangeException(nameof(platformFeeRate));
  108. }
  109. if (storeCommissionRate < 0 || storeCommissionRate > 100)
  110. {
  111. throw new ArgumentOutOfRangeException(nameof(storeCommissionRate));
  112. }
  113. Name = name;
  114. Handle = normalizedHandle;
  115. YouTubeUrl = youtubeUrl;
  116. PlatformFeeRate = platformFeeRate;
  117. StoreCommissionRate = storeCommissionRate;
  118. IsVerified = isVerified;
  119. IsActive = isActive;
  120. UpdatedAt = DateTime.UtcNow;
  121. }
  122. public void UpdateHandle(string? handle)
  123. {
  124. var normalizedHandle = NormalizeHandleInternal(handle);
  125. if (normalizedHandle is not null && normalizedHandle.Length > 30)
  126. {
  127. throw new ArgumentOutOfRangeException(nameof(handle));
  128. }
  129. Handle = normalizedHandle;
  130. UpdatedAt = DateTime.UtcNow;
  131. }
  132. public void UpdateYouTubeInfo(
  133. string youtubeChannelID,
  134. string name,
  135. string? handle,
  136. string? description,
  137. string? thumbnailUrl,
  138. string? bannerUrl,
  139. long subscriberCount,
  140. long videoCount,
  141. long viewCount,
  142. string? email,
  143. DateTime? youtubePublishedAt
  144. ) {
  145. YouTubeChannelID = youtubeChannelID;
  146. Name = name;
  147. Handle = NormalizeHandleInternal(handle);
  148. Description = description;
  149. ThumbnailUrl = thumbnailUrl;
  150. BannerUrl = bannerUrl;
  151. SubscriberCount = subscriberCount;
  152. VideoCount = videoCount;
  153. ViewCount = viewCount;
  154. Email = email;
  155. IsActive = true;
  156. YouTubePublishedAt = youtubePublishedAt;
  157. YouTubeLastSyncedAt = DateTime.UtcNow;
  158. UpdatedAt = DateTime.UtcNow;
  159. }
  160. /// <summary>
  161. /// YouTube API Services ToS 의 30일 데이터 갱신·삭제 정책에 따라
  162. /// 마지막 동기화 시점이 30일을 초과한 경우 YouTube 측 데이터를 영구 폐기한다.
  163. /// 채널 자체는 유지하되, YouTube 출처 필드만 null·0 처리.
  164. /// 호출 후 LastSyncedAt 도 초기화하여 재갱신 시점까지 wipe 상태 유지.
  165. /// </summary>
  166. public void WipeYouTubeData()
  167. {
  168. YouTubeChannelID = null;
  169. Description = null;
  170. ThumbnailUrl = null;
  171. BannerUrl = null;
  172. SubscriberCount = 0;
  173. VideoCount = 0;
  174. ViewCount = 0;
  175. Email = null;
  176. YouTubePublishedAt = null;
  177. YouTubeLastSyncedAt = null;
  178. UpdatedAt = DateTime.UtcNow;
  179. }
  180. /// <summary>
  181. /// 핸들 저장 canonical form 정규화: 앞의 '@' 접두사 제거 (YouTube customUrl은 '@foo' 형태로 오지만 DB에는 'foo'로 저장)
  182. /// </summary>
  183. private static string? NormalizeHandleInternal(string? handle)
  184. {
  185. if (string.IsNullOrWhiteSpace(handle))
  186. {
  187. return null;
  188. }
  189. var trimmed = handle.TrimStart('@');
  190. return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
  191. }
  192. public void Deactivate()
  193. {
  194. IsActive = false;
  195. UpdatedAt = DateTime.UtcNow;
  196. }
  197. public void ResetWidgetToken()
  198. {
  199. WidgetToken = GenerateWidgetToken();
  200. UpdatedAt = DateTime.UtcNow;
  201. }
  202. /// <summary>
  203. /// Admin 전용 후원 코드 설정/변경/해제.
  204. /// 일반 사용자(Studio) 의 IssueDonationCode 와 달리 변경 제약이 없다.
  205. /// null/공백 → 코드 해제(NULL). 값 있으면 4~7자 영문+숫자 정규식 검증 후 대문자로 저장.
  206. /// </summary>
  207. public void SetDonationCodeByAdmin(string? code)
  208. {
  209. if (string.IsNullOrWhiteSpace(code))
  210. {
  211. DonationCode = null;
  212. UpdatedAt = DateTime.UtcNow;
  213. return;
  214. }
  215. var trimmed = code.Trim();
  216. if (!Regex.IsMatch(trimmed, "^[A-Za-z0-9]{4,7}$"))
  217. {
  218. throw new ArgumentException("DonationCode must be 4~7 alphanumeric characters.", nameof(code));
  219. }
  220. DonationCode = trimmed.ToUpperInvariant();
  221. UpdatedAt = DateTime.UtcNow;
  222. }
  223. /// <summary>
  224. /// 후원 코드 1회성 발급. 이미 발급된 경우 InvalidOperationException.
  225. /// 형식: 4~7자 영문+숫자 (대문자로 정규화 저장).
  226. /// </summary>
  227. public void IssueDonationCode(string code)
  228. {
  229. if (DonationCode is not null)
  230. {
  231. throw new InvalidOperationException("DonationCode already issued.");
  232. }
  233. if (string.IsNullOrEmpty(code))
  234. {
  235. throw new ArgumentException("DonationCode is required.", nameof(code));
  236. }
  237. if (!Regex.IsMatch(code, "^[A-Za-z0-9]{4,7}$"))
  238. {
  239. throw new ArgumentException("DonationCode must be 4~7 alphanumeric characters.", nameof(code));
  240. }
  241. DonationCode = code.ToUpperInvariant();
  242. UpdatedAt = DateTime.UtcNow;
  243. }
  244. private static string GenerateWidgetToken()
  245. {
  246. return Guid.NewGuid().ToString("N");
  247. }
  248. }