fix(realname): 增加身份证唯一性校验,防止同一证件重复实名

This commit is contained in:
yml2213
2026-10-29 12:06:53 +08:00
parent 86fbc05e9b
commit 2029c1f4fb
16 changed files with 538 additions and 53 deletions
@@ -246,6 +246,8 @@ func writeAdminUserError(c *gin.Context, err error) {
response.BadRequest(c, "姓名或身份证号格式不正确")
case errors.Is(err, ErrRealnameAlreadyVerified):
response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名")
case errors.Is(err, ErrRealnameIDNoUsed):
response.Error(c, http.StatusConflict, "realname_id_no_used", "该身份证号已绑定其他实名账号,不能重复认证")
case errors.Is(err, ErrRealnameNotRevocable):
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
case errors.Is(err, ErrInvalidWalletDirection):
@@ -11,6 +11,7 @@ import (
"hfb_sys/backend/internal/auditlog"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/invitation"
"hfb_sys/backend/internal/modules/realname"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/internal/modules/wallet"
"hfb_sys/backend/pkg/crypto"
@@ -22,6 +23,7 @@ import (
type Repository struct {
db *gorm.DB
encryptor crypto.Encryptor
realnameIDHashKey string
statusChangeNotifier func(userID uint64)
}
@@ -35,6 +37,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
return &Repository{db: db, encryptor: encryptor}
}
// SetRealnameIDHashKey 设置身份证盲索引密钥,后台人工实名同样执行唯一性校验。
func (r *Repository) SetRealnameIDHashKey(key string) {
r.realnameIDHashKey = strings.TrimSpace(key)
}
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
r.statusChangeNotifier = notifier
@@ -317,6 +324,7 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
if err != nil {
return nil, err
}
idNoHash := realname.FingerprintIDNo(r.realnameIDHashKey, idNo)
now := time.Now()
record := model.UserRealname{
@@ -328,6 +336,7 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
EncryptedName: encryptedName,
MaskedIDNo: maskManualIDNo(idNo),
EncryptedIDNo: encryptedIDNo,
IDNoHash: &idNoHash,
VerifiedAt: &now,
FailReason: "",
}
@@ -340,28 +349,50 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
if user.RealnameStatus == "verified" {
return ErrRealnameAlreadyVerified
}
identityRepo := realname.NewRepository(tx, r.encryptor, r.realnameIDHashKey)
used, err := identityRepo.ExistsVerifiedIdentity(ctx, userID, idNo)
if err != nil {
return err
}
if used {
return realname.ErrIDNoAlreadyUsed
}
beforeRealnameStatus := user.RealnameStatus
user.RealnameStatus = "verified"
if err := tx.Save(&user).Error; err != nil {
return err
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{
"provider": record.Provider,
"provider_order_no": record.ProviderOrderNo,
"status": record.Status,
"masked_name": record.MaskedName,
"encrypted_name": record.EncryptedName,
"masked_id_no": record.MaskedIDNo,
"encrypted_id_no": record.EncryptedIDNo,
"verified_at": record.VerifiedAt,
"fail_reason": record.FailReason,
"updated_at": now,
}),
}).Create(&record).Error; err != nil {
return err
var existingRealname model.UserRealname
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ?", userID).First(&existingRealname).Error
switch {
case findErr == nil:
existingRealname.Provider = record.Provider
existingRealname.ProviderOrderNo = record.ProviderOrderNo
existingRealname.Status = record.Status
existingRealname.MaskedName = record.MaskedName
existingRealname.EncryptedName = record.EncryptedName
existingRealname.MaskedIDNo = record.MaskedIDNo
existingRealname.EncryptedIDNo = record.EncryptedIDNo
existingRealname.IDNoHash = record.IDNoHash
existingRealname.VerifiedAt = record.VerifiedAt
existingRealname.FailReason = record.FailReason
if err := tx.Save(&existingRealname).Error; err != nil {
if realname.IsIDNoDuplicateError(err) {
return realname.ErrIDNoAlreadyUsed
}
return err
}
case errors.Is(findErr, gorm.ErrRecordNotFound):
if err := tx.Create(&record).Error; err != nil {
if realname.IsIDNoDuplicateError(err) {
return realname.ErrIDNoAlreadyUsed
}
return err
}
default:
return findErr
}
_ = invitation.OnUserRealnameVerifiedTx(tx, user.ID)
@@ -204,6 +204,28 @@ func TestManualRealnameRejectsVerifiedUser(t *testing.T) {
}
}
func TestManualRealnameRejectsIdentityUsedByAnotherUser(t *testing.T) {
db := setupAdminUserTestDB(t)
first := model.User{Phone: "13900000011", Nickname: "用户一", Status: "active"}
second := model.User{Phone: "13900000012", Nickname: "用户二", Status: "active"}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("创建用户一失败:%v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建用户二失败:%v", err)
}
repo := NewRepository(db)
repo.SetRealnameIDHashKey("test-realname-id-hash-key")
req := ManualRealnameRequest{Name: "周奕男", IDNo: "130682200604080615"}
if _, err := repo.ManualRealname(t.Context(), 99, first.ID, req, AuditMeta{}); err != nil {
t.Fatalf("用户一人工实名失败:%v", err)
}
if _, err := repo.ManualRealname(t.Context(), 99, second.ID, req, AuditMeta{}); !errors.Is(err, ErrRealnameIDNoUsed) {
t.Fatalf("同一身份证人工实名第二账号 error = %v, want ErrRealnameIDNoUsed", err)
}
}
func TestAdjustWalletDebitAndAudit(t *testing.T) {
db := setupAdminUserTestDB(t)
user := model.User{
@@ -5,6 +5,8 @@ import (
"errors"
"regexp"
"strings"
"hfb_sys/backend/internal/modules/realname"
)
var (
@@ -12,6 +14,7 @@ var (
ErrInvalidUser = errors.New("invalid user")
ErrInvalidRealnameInput = errors.New("invalid realname input")
ErrRealnameAlreadyVerified = errors.New("realname already verified")
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
ErrRealnameNotRevocable = errors.New("realname not revocable")
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
ErrInvalidWalletAmount = errors.New("invalid wallet amount")