using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Security.Cryptography; using Domain.Entities.Members.ValueObject; using Domain.Entities.Wallets; using NanoidDotNet; /// /// 사용자단 일반 회원 정보 /// namespace Domain.Entities.Members; public class Member { [Key] public int ID { get; private set; } // 회원 등급 정보 [ForeignKey(nameof(MemberGradeID))] public virtual MemberGrade? MemberGrade { get; private set; } // 알림/약관 동의 여부 public virtual MemberApprove MemberApprove { get; private set; } = null!; // 회원 집계 정보 public virtual MemberStats MemberStats { get; private set; } = null!; // 지갑 public virtual Wallet? Wallet { get; private set; } // 채널 public virtual Channel? Channel { get; private set; } public int? MemberGradeID { get; private set; } public string SID { get; private set; } = Nanoid.Generate(size: 8); // 무작위 문자열 public string Email { get; private set; } = default!; public string? Name { get; private set; } public string? FullName { get; private set; } public string? FirstName { get; private set; } public string? LastName { get; private set; } public string? Password { get; private set; } = string.Empty; // 관리자 수정 시 필요 public string? PasswordHash { get; private set; } public string? Intro { get; private set; } public string? Summary { get; private set; } public string? Phone { get; private set; } public DateOnly? Birthday { get; private set; } public Gender? Gender { get; private set; } public string? Thumb { get; private set; } public string? BannerUrl { get; private set; } public string? Icon { get; private set; } /// /// PG customerKey (토스페이먼츠 — 예측 가능 값 금지, 회원별 UUID). /// 결제 주문 생성 시 없으면 발급·저장한다. 마이그레이션은 후속 처리. /// public Guid? PaymentCustomerKey { get; private set; } public bool IsEmailVerified { get; private set; } = false; public bool IsAuthCertified { get; private set; } = false; public bool IsDenied { get; private set; } = false; public bool IsAdmin { get; private set; } = false; public bool IsWithdraw { get; private set; } = false; public bool IsCreator { get; private set; } = false; public string? DeviceInfo { get; private set; } public string? SignupIP { get; private set; } public string? LastLoginIp { get; private set; } public string? IpAddress { get; private set; } public string? UserAgent { get; private set; } public DateTime? LastLoginAt { get; private set; } public DateTime? LastEmailChangedAt { get; private set; } public DateTime? LastNameChangedAt { get; private set; } public DateTime? LastSummaryChangedAt { get; private set; } public DateTime? LastIntroChangedAt { get; private set; } public DateTime? LastThumbChangedAt { get; private set; } public DateTime? EmailVerifiedAt { get; private set; } public DateTime? AuthCertifiedAt { get; private set; } public DateTime PasswordUpdatedAt { get; private set; } public DateTime CreatedAt { get; private set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; private set; } public DateTime? DeletedAt { get; private set; } public DateTime? DeniedAt { get; private set; } private Member() { } private Member(string email) { if (string.IsNullOrWhiteSpace(email)) { throw new ArgumentException("Email is required.", nameof(email)); } if (email.Length > 60) { throw new ArgumentOutOfRangeException(nameof(email)); } Email = email; PasswordUpdatedAt = DateTime.UtcNow; } public static Member Create(string email) { return new(email); } public static Member Create(string email, string password) { var member = new Member(email); member.SetPassword(password); return member; } public void SetPassword(string password) { PasswordHash = HashPassword(password); PasswordUpdatedAt = DateTime.UtcNow; } public bool VerifyPassword(string password) { if (string.IsNullOrEmpty(PasswordHash)) { return false; } return VerifyHashedPassword(PasswordHash, password); } public void SetEmail(string email) { Email = email; IsEmailVerified = false; LastEmailChangedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void SetName(string? name) { Name = name; LastNameChangedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void SetSummary(string? summary) { Summary = summary; LastSummaryChangedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void SetIntro(string? intro) { Intro = intro; LastIntroChangedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void SetThumb(string? thumb) { Thumb = thumb; LastThumbChangedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void SetBannerUrl(string? bannerUrl) { BannerUrl = bannerUrl; UpdatedAt = DateTime.UtcNow; } public void SetCreator(bool isCreator) { IsCreator = isCreator; UpdatedAt = DateTime.UtcNow; } /// PG customerKey 확보 — 없으면 이 시점에 UUID 발급 (호출측이 SaveChanges 로 영속화) public Guid EnsurePaymentCustomerKey() { if (PaymentCustomerKey is null) { PaymentCustomerKey = Guid.NewGuid(); UpdatedAt = DateTime.UtcNow; } return PaymentCustomerKey.Value; } public void MarkEmailVerified() { IsEmailVerified = true; EmailVerifiedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void Withdraw() { IsWithdraw = true; DeletedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow; } public void RecordLogin(string? ipAddress, string? userAgent = null) { // DB 컬럼 길이 방어 — Cloudflare 더블 경유 등으로 비정상 길이 값이 와도 throw 대신 truncate. // (IpAddress/LastLoginIp nvarchar(45), UserAgent nvarchar(255)) if (ipAddress is not null && ipAddress.Length > 45) { ipAddress = ipAddress[..45]; } if (userAgent is not null && userAgent.Length > 255) { userAgent = userAgent[..255]; } LastLoginAt = DateTime.UtcNow; LastLoginIp = ipAddress; if (!string.IsNullOrWhiteSpace(ipAddress)) { IpAddress = ipAddress; } if (!string.IsNullOrWhiteSpace(userAgent)) { UserAgent = userAgent; } UpdatedAt = DateTime.UtcNow; } // PBKDF2 (RFC 2898) - 순수 C# 비밀번호 해싱 // public: Admin 회원 생성/수정의 bulk ExecuteUpdate 에서 평문 대신 해시를 저장하기 위해 노출 public static string HashPassword(string password) { var salt = RandomNumberGenerator.GetBytes(16); var hash = Rfc2898DeriveBytes.Pbkdf2( password, salt, iterations: 100_000, HashAlgorithmName.SHA256, outputLength: 32 ); // salt + hash → Base64 var combined = new byte[salt.Length + hash.Length]; Buffer.BlockCopy(salt, 0, combined, 0, salt.Length); Buffer.BlockCopy(hash, 0, combined, salt.Length, hash.Length); return Convert.ToBase64String(combined); } // 비밀번호 검증 private static bool VerifyHashedPassword(string hashedPassword, string password) { var combined = Convert.FromBase64String(hashedPassword); if (combined.Length != 48) // 16(salt) + 32(hash) { return false; } var salt = combined[..16]; var storedHash = combined[16..]; var hash = Rfc2898DeriveBytes.Pbkdf2( password, salt, iterations: 100_000, HashAlgorithmName.SHA256, outputLength: 32 ); return CryptographicOperations.FixedTimeEquals(storedHash, hash); } }