219 lines
6.6 KiB
Go
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)
|
|
}
|