package realname import ( "context" "errors" "strings" "hfb_sys/backend/internal/logging" "hfb_sys/backend/internal/model" "hfb_sys/backend/internal/modules/invitation" "hfb_sys/backend/pkg/crypto" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) const idNoFingerprintDomain = "realname_id" type Repository struct { db *gorm.DB encryptor crypto.Encryptor idHashKey string } func NewRepository(db *gorm.DB, encryptor crypto.Encryptor, idHashKey string) *Repository { if encryptor == nil { encryptor = &crypto.MockEncryptor{} } return &Repository{db: db, encryptor: encryptor, idHashKey: strings.TrimSpace(idHashKey)} } func (r *Repository) FindByUserID(ctx context.Context, userID uint64) (*model.UserRealname, error) { var record model.UserRealname if err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&record).Error; err != nil { return nil, err } return &record, nil } // IsVerifiedUser 判断当前账号是否已经完成实名认证。 func (r *Repository) IsVerifiedUser(ctx context.Context, userID uint64) (bool, error) { var userStatus string if err := r.db.WithContext(ctx).Model(&model.User{}). Where("id = ?", userID).Pluck("realname_status", &userStatus).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil } return false, err } if userStatus == "verified" { return true, nil } var count int64 err := r.db.WithContext(ctx).Model(&model.UserRealname{}). Where("user_id = ? AND status = ?", userID, StatusVerified). Count(&count).Error return count > 0, err } // ExistsVerifiedIdentity 判断身份证是否已绑定到其他已实名账号。 // 新记录直接查 HMAC 指纹;存量记录没有指纹时按脱敏号取候选并解密精确比对, // 命中后顺手补写指纹,避免每次重复解密。 func (r *Repository) ExistsVerifiedIdentity(ctx context.Context, userID uint64, idNo string) (bool, error) { idNo = normalizeIDNo(idNo) if idNo == "" { return false, nil } idNoHash := FingerprintIDNo(r.idHashKey, idNo) var count int64 if err := r.db.WithContext(ctx).Model(&model.UserRealname{}). Where("status = ? AND id_no_hash = ? AND user_id <> ?", StatusVerified, idNoHash, userID). Count(&count).Error; err != nil { return false, err } if count > 0 { return true, nil } masked := maskIDNo(idNo) var candidates []model.UserRealname if err := r.db.WithContext(ctx). Where("status = ? AND user_id <> ? AND masked_id_no = ?", StatusVerified, userID, masked). Find(&candidates).Error; err != nil { return false, err } for _, candidate := range candidates { plain, err := r.encryptor.Decrypt(candidate.EncryptedIDNo) if err != nil { logging.FromContext(ctx).Warn("实名存量身份证解密失败,无法完成唯一性比对", zap.String("module", "realname"), zap.Uint64("realname_id", candidate.ID), zap.Error(err), ) continue } if normalizeIDNo(plain) != idNo { continue } if candidate.IDNoHash == nil || *candidate.IDNoHash != idNoHash { r.backfillIDNoHash(ctx, candidate.ID, idNoHash) } return true, nil } return false, nil } func (r *Repository) backfillIDNoHash(ctx context.Context, recordID uint64, idNoHash string) { err := r.db.WithContext(ctx).Model(&model.UserRealname{}). Where("id = ? AND (id_no_hash IS NULL OR id_no_hash <> ?)", recordID, idNoHash). Update("id_no_hash", idNoHash).Error if err != nil && !isDuplicateIDNoError(err) { logging.FromContext(ctx).Warn("实名身份证指纹回填失败", zap.String("module", "realname"), zap.Uint64("realname_id", recordID), zap.Error(err), ) } } // SaveResult 保存实名结果并更新用户实名状态。idNoHash 仅认证通过时写入。 func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider string, result ProviderResult, idNoHash string) (*model.UserRealname, error) { record := model.UserRealname{ UserID: userID, Provider: provider, ProviderOrderNo: result.ProviderOrderNo, Status: result.Status, MaskedName: result.MaskedName, EncryptedName: result.EncryptedName, MaskedIDNo: result.MaskedIDNo, EncryptedIDNo: result.EncryptedIDNo, VerifiedAt: result.VerifiedAt, FailReason: result.FailReason, } if result.Status == StatusVerified && strings.TrimSpace(idNoHash) != "" { hash := strings.TrimSpace(idNoHash) record.IDNoHash = &hash } err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.UserRealname findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("user_id = ?", userID).First(&existing).Error switch { case findErr == nil: existing.Provider = record.Provider existing.ProviderOrderNo = record.ProviderOrderNo existing.Status = record.Status existing.MaskedName = record.MaskedName existing.EncryptedName = record.EncryptedName existing.MaskedIDNo = record.MaskedIDNo existing.EncryptedIDNo = record.EncryptedIDNo existing.IDNoHash = record.IDNoHash existing.VerifiedAt = record.VerifiedAt existing.FailReason = record.FailReason if err := tx.Save(&existing).Error; err != nil { if isDuplicateIDNoError(err) { return ErrIDNoAlreadyUsed } return err } case errors.Is(findErr, gorm.ErrRecordNotFound): if err := tx.Create(&record).Error; err != nil { if isDuplicateIDNoError(err) { return ErrIDNoAlreadyUsed } return err } default: return findErr } userStatus := StatusRejected if result.Status == StatusVerified { userStatus = "verified" } if result.Status == StatusPending { userStatus = "pending" } if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("realname_status", userStatus).Error; err != nil { return err } if userStatus == "verified" { _ = invitation.OnUserRealnameVerifiedTx(tx, userID) } return nil }) if err != nil { return nil, err } return r.FindByUserID(ctx, userID) } // FingerprintIDNo 生成身份证号的稳定盲索引。 func FingerprintIDNo(secret, idNo string) string { return crypto.FingerprintHMAC(secret, idNoFingerprintDomain, normalizeIDNo(idNo)) } func normalizeIDNo(idNo string) string { return strings.ToUpper(strings.TrimSpace(idNo)) } func isDuplicateIDNoError(err error) bool { if err == nil { return false } if errors.Is(err, gorm.ErrDuplicatedKey) { return true } text := strings.ToLower(err.Error()) return strings.Contains(text, "1062") && strings.Contains(text, "uk_user_realname_id_no_hash") } // IsIDNoDuplicateError 供其他模块复用身份证唯一键冲突判断。 func IsIDNoDuplicateError(err error) bool { return isDuplicateIDNoError(err) }