Files
hfb_sys/backend/internal/modules/realname/repository.go
T

219 lines
6.6 KiB
Go

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)
}