using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.RegularExpressions; namespace Domain.Entities.Members; public class Channel { [ForeignKey(nameof(MemberID))] public virtual Member Member { get; private set; } = null!; [Key] public int ID { get; private set; } public int MemberID { get; private set; } public string SID { get; private set; } = default!; public string Name { get; private set; } = default!; public string? Handle { get; private set; } public string YouTubeUrl { get; private set; } = default!; public string? YouTubeChannelID { get; private set; } public string? Description { get; private set; } public string? ThumbnailUrl { get; private set; } public string? BannerUrl { get; private set; } public long SubscriberCount { get; private set; } public long VideoCount { get; private set; } public long ViewCount { get; private set; } public string? Email { get; private set; } public DateTime? YouTubePublishedAt { get; private set; } /// /// YouTube API 로 채널 데이터를 마지막으로 갱신한 시각(UTC). /// YouTube API Services ToS 의 30일 데이터 갱신·삭제 정책 enforcement 용. /// null 이거나 UtcNow - 30d 이전이면 WipeYouTubeData() 대상. /// public DateTime? YouTubeLastSyncedAt { get; private set; } /// 후원(Donation) 수수료(%) — antooza 가 가져가는 비율. PlatformFeeRate=20% 이면 채널주는 80% 보유. public decimal PlatformFeeRate { get; private set; } = 0; /// 상점 매출 보상률(%) — 채널이 가져가는 비율. StoreCommissionRate=20% 이면 채널주에게 20% 입금, antooza가 80% 보유. public decimal StoreCommissionRate { get; private set; } = 0; public bool IsVerified { get; private set; } = false; public bool IsActive { get; private set; } = true; /// 채널 후원 코드(4~7자 영문+숫자, 대문자 저장). 한 번 발급되면 변경 불가. 후원 채널 빠른 검색·게임 in-app 결제 식별용. public string? DonationCode { get; private set; } public string WidgetToken { get; private set; } = GenerateWidgetToken(); public DateTime? UpdatedAt { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; private Channel() { } private Channel(int memberID, string sid, string name, string youtubeUrl) { if (memberID <= 0) { throw new ArgumentOutOfRangeException(nameof(memberID)); } if (string.IsNullOrWhiteSpace(sid)) { throw new ArgumentException("SID is required.", nameof(sid)); } if (sid.Length != 24) { throw new ArgumentOutOfRangeException(nameof(sid)); } if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Name is required.", nameof(name)); } if (name.Length > 200) { throw new ArgumentOutOfRangeException(nameof(name)); } if (string.IsNullOrWhiteSpace(youtubeUrl)) { throw new ArgumentException("YouTubeUrl is required.", nameof(youtubeUrl)); } if (youtubeUrl.Length > 255) { throw new ArgumentOutOfRangeException(nameof(youtubeUrl)); } MemberID = memberID; SID = sid; Name = name; YouTubeUrl = youtubeUrl; } public static Channel Create(int memberID, string sid, string name, string youtubeUrl) { return new(memberID, sid, name, youtubeUrl); } public void Update(string name, string? handle, string youtubeUrl, decimal platformFeeRate, decimal storeCommissionRate, bool isVerified, bool isActive) { if (string.IsNullOrWhiteSpace(name)) { throw new ArgumentException("Name is required.", nameof(name)); } if (name.Length > 200) { throw new ArgumentOutOfRangeException(nameof(name)); } var normalizedHandle = NormalizeHandleInternal(handle); if (normalizedHandle is not null && normalizedHandle.Length > 30) { throw new ArgumentOutOfRangeException(nameof(handle)); } if (string.IsNullOrWhiteSpace(youtubeUrl)) { throw new ArgumentException("YouTubeUrl is required.", nameof(youtubeUrl)); } if (youtubeUrl.Length > 255) { throw new ArgumentOutOfRangeException(nameof(youtubeUrl)); } if (platformFeeRate < 0 || platformFeeRate > 100) { throw new ArgumentOutOfRangeException(nameof(platformFeeRate)); } if (storeCommissionRate < 0 || storeCommissionRate > 100) { throw new ArgumentOutOfRangeException(nameof(storeCommissionRate)); } Name = name; Handle = normalizedHandle; YouTubeUrl = youtubeUrl; PlatformFeeRate = platformFeeRate; StoreCommissionRate = storeCommissionRate; IsVerified = isVerified; IsActive = isActive; UpdatedAt = DateTime.UtcNow; } public void UpdateHandle(string? handle) { var normalizedHandle = NormalizeHandleInternal(handle); if (normalizedHandle is not null && normalizedHandle.Length > 30) { throw new ArgumentOutOfRangeException(nameof(handle)); } Handle = normalizedHandle; UpdatedAt = DateTime.UtcNow; } public void UpdateYouTubeInfo( string youtubeChannelID, string name, string? handle, string? description, string? thumbnailUrl, string? bannerUrl, long subscriberCount, long videoCount, long viewCount, string? email, DateTime? youtubePublishedAt ) { YouTubeChannelID = youtubeChannelID; Name = name; Handle = NormalizeHandleInternal(handle); Description = description; ThumbnailUrl = thumbnailUrl; BannerUrl = bannerUrl; SubscriberCount = subscriberCount; VideoCount = videoCount; ViewCount = viewCount; Email = email; IsActive = true; YouTubePublishedAt = youtubePublishedAt; YouTubeLastSyncedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } /// /// YouTube API Services ToS 의 30일 데이터 갱신·삭제 정책에 따라 /// 마지막 동기화 시점이 30일을 초과한 경우 YouTube 측 데이터를 영구 폐기한다. /// 채널 자체는 유지하되, YouTube 출처 필드만 null·0 처리. /// 호출 후 LastSyncedAt 도 초기화하여 재갱신 시점까지 wipe 상태 유지. /// public void WipeYouTubeData() { YouTubeChannelID = null; Description = null; ThumbnailUrl = null; BannerUrl = null; SubscriberCount = 0; VideoCount = 0; ViewCount = 0; Email = null; YouTubePublishedAt = null; YouTubeLastSyncedAt = null; UpdatedAt = DateTime.UtcNow; } /// /// 핸들 저장 canonical form 정규화: 앞의 '@' 접두사 제거 (YouTube customUrl은 '@foo' 형태로 오지만 DB에는 'foo'로 저장) /// private static string? NormalizeHandleInternal(string? handle) { if (string.IsNullOrWhiteSpace(handle)) { return null; } var trimmed = handle.TrimStart('@'); return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; } public void Deactivate() { IsActive = false; UpdatedAt = DateTime.UtcNow; } public void ResetWidgetToken() { WidgetToken = GenerateWidgetToken(); UpdatedAt = DateTime.UtcNow; } /// /// Admin 전용 후원 코드 설정/변경/해제. /// 일반 사용자(Studio) 의 IssueDonationCode 와 달리 변경 제약이 없다. /// null/공백 → 코드 해제(NULL). 값 있으면 4~7자 영문+숫자 정규식 검증 후 대문자로 저장. /// public void SetDonationCodeByAdmin(string? code) { if (string.IsNullOrWhiteSpace(code)) { DonationCode = null; UpdatedAt = DateTime.UtcNow; return; } var trimmed = code.Trim(); if (!Regex.IsMatch(trimmed, "^[A-Za-z0-9]{4,7}$")) { throw new ArgumentException("DonationCode must be 4~7 alphanumeric characters.", nameof(code)); } DonationCode = trimmed.ToUpperInvariant(); UpdatedAt = DateTime.UtcNow; } /// /// 후원 코드 1회성 발급. 이미 발급된 경우 InvalidOperationException. /// 형식: 4~7자 영문+숫자 (대문자로 정규화 저장). /// public void IssueDonationCode(string code) { if (DonationCode is not null) { throw new InvalidOperationException("DonationCode already issued."); } if (string.IsNullOrEmpty(code)) { throw new ArgumentException("DonationCode is required.", nameof(code)); } if (!Regex.IsMatch(code, "^[A-Za-z0-9]{4,7}$")) { throw new ArgumentException("DonationCode must be 4~7 alphanumeric characters.", nameof(code)); } DonationCode = code.ToUpperInvariant(); UpdatedAt = DateTime.UtcNow; } private static string GenerateWidgetToken() { return Guid.NewGuid().ToString("N"); } }