fix(realname): 增加身份证唯一性校验,防止同一证件重复实名
This commit is contained in:
@@ -75,6 +75,10 @@ func writeRealnameError(c *gin.Context, err error) {
|
||||
response.ServiceUnavailable(c, "数据库未连接")
|
||||
case errors.Is(err, ErrInvalidRealnameInput):
|
||||
response.BadRequest(c, "姓名或身份证号格式不正确")
|
||||
case errors.Is(err, ErrAlreadyVerified):
|
||||
response.Error(c, http.StatusConflict, "realname_already_verified", "当前账号已完成实名认证,无需重复认证")
|
||||
case errors.Is(err, ErrIDNoAlreadyUsed):
|
||||
response.Error(c, http.StatusConflict, "realname_id_no_used", "该身份证号已绑定其他实名账号,不能重复认证")
|
||||
case errors.Is(err, ErrProviderConfigInvalid):
|
||||
response.ServiceUnavailable(c, "实名认证服务未配置")
|
||||
case errors.Is(err, ErrProviderRateLimited):
|
||||
|
||||
@@ -2,21 +2,32 @@ package realname
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"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
|
||||
db *gorm.DB
|
||||
encryptor crypto.Encryptor
|
||||
idHashKey string
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
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) {
|
||||
@@ -27,7 +38,89 @@ func (r *Repository) FindByUserID(ctx context.Context, userID uint64) (*model.Us
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider string, result ProviderResult) (*model.UserRealname, error) {
|
||||
// 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,
|
||||
@@ -40,24 +133,42 @@ func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider str
|
||||
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 {
|
||||
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": time.Now(),
|
||||
}),
|
||||
}).Create(&record).Error; err != nil {
|
||||
return err
|
||||
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
|
||||
@@ -80,3 +191,28 @@ func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider str
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ const (
|
||||
var (
|
||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||
ErrAlreadyVerified = errors.New("realname already verified")
|
||||
ErrIDNoAlreadyUsed = errors.New("id no already used")
|
||||
ErrProviderConfigInvalid = errors.New("realname provider config invalid")
|
||||
ErrProviderRateLimited = errors.New("realname provider rate limited")
|
||||
ErrProviderQuotaExhausted = errors.New("realname provider quota exhausted")
|
||||
@@ -52,10 +54,24 @@ func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo st
|
||||
return nil, ErrDependencyUnavailable
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
idNo = strings.TrimSpace(idNo)
|
||||
idNo = normalizeIDNo(idNo)
|
||||
if !validName(name) || !validIDNo(idNo) {
|
||||
return nil, ErrInvalidRealnameInput
|
||||
}
|
||||
verified, err := s.repo.IsVerifiedUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if verified {
|
||||
return nil, ErrAlreadyVerified
|
||||
}
|
||||
used, err := s.repo.ExistsVerifiedIdentity(ctx, userID, idNo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if used {
|
||||
return nil, ErrIDNoAlreadyUsed
|
||||
}
|
||||
result, err := s.provider.Start(ctx, StartRequest{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
@@ -77,7 +93,7 @@ func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo st
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.SaveResult(ctx, userID, s.provider.Name(), result)
|
||||
return s.repo.SaveResult(ctx, userID, s.provider.Name(), result, FingerprintIDNo(s.repo.idHashKey, idNo))
|
||||
}
|
||||
|
||||
func (s *Service) Status(ctx context.Context, userID uint64) (PublicStatus, error) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package realname
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hfb_sys/backend/internal/database"
|
||||
"hfb_sys/backend/internal/model"
|
||||
"hfb_sys/backend/pkg/crypto"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupRealnameTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := database.NewTestDB()
|
||||
if err := database.AutoMigrateIfMissing(db,
|
||||
&model.User{},
|
||||
&model.UserRealname{},
|
||||
&model.UserInvitation{},
|
||||
); err != nil {
|
||||
t.Fatalf("迁移实名测试表失败: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func newRealnameTestService(t *testing.T, db *gorm.DB) *Service {
|
||||
t.Helper()
|
||||
encryptor := &crypto.MockEncryptor{}
|
||||
repo := NewRepository(db, encryptor, "test-realname-id-hash-key")
|
||||
return NewService(repo, NewMockProvider(encryptor), zap.NewNop())
|
||||
}
|
||||
|
||||
func TestRealnameIdentityMustBeUnique(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
user1 := model.User{Phone: "13700000001", Status: "active"}
|
||||
user2 := model.User{Phone: "13700000002", Status: "active"}
|
||||
if err := db.Create(&user1).Error; err != nil {
|
||||
t.Fatalf("创建用户1失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&user2).Error; err != nil {
|
||||
t.Fatalf("创建用户2失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "110101199001010011"
|
||||
if _, err := svc.Start(t.Context(), user1.ID, "张三", idNo); err != nil {
|
||||
t.Fatalf("用户1首次实名失败: %v", err)
|
||||
}
|
||||
if _, err := svc.Start(t.Context(), user1.ID, "张三", idNo); !errors.Is(err, ErrAlreadyVerified) {
|
||||
t.Fatalf("同一用户重复实名 error = %v, want ErrAlreadyVerified", err)
|
||||
}
|
||||
if _, err := svc.Start(t.Context(), user2.ID, "张三", idNo); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("同一身份证绑定第二账号 error = %v, want ErrIDNoAlreadyUsed", err)
|
||||
}
|
||||
|
||||
var record model.UserRealname
|
||||
if err := db.Where("user_id = ?", user1.ID).First(&record).Error; err != nil {
|
||||
t.Fatalf("读取用户1实名记录失败: %v", err)
|
||||
}
|
||||
if record.IDNoHash == nil || *record.IDNoHash == "" {
|
||||
t.Fatal("实名通过后必须写入身份证指纹")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameIdentityCheckSupportsLegacyEncryptedRecord(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
legacyUser := model.User{Phone: "13700000011", Status: "active"}
|
||||
otherUser := model.User{Phone: "13700000012", Status: "active"}
|
||||
if err := db.Create(&legacyUser).Error; err != nil {
|
||||
t.Fatalf("创建存量用户失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&otherUser).Error; err != nil {
|
||||
t.Fatalf("创建新用户失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "11010119900101002X"
|
||||
now := time.Now()
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: legacyUser.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusVerified,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
VerifiedAt: &now,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建存量实名记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), otherUser.ID, "李四", idNo); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("存量实名身份证重复绑定 error = %v, want ErrIDNoAlreadyUsed", err)
|
||||
}
|
||||
|
||||
var legacy model.UserRealname
|
||||
if err := db.Where("user_id = ?", legacyUser.ID).First(&legacy).Error; err != nil {
|
||||
t.Fatalf("读取存量实名记录失败: %v", err)
|
||||
}
|
||||
if legacy.IDNoHash == nil || *legacy.IDNoHash == "" {
|
||||
t.Fatal("命中存量记录后应补写身份证指纹")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveResultRejectsDuplicateIdentityAtDBLevel(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
encryptor := &crypto.MockEncryptor{}
|
||||
repo := NewRepository(db, encryptor, "test-realname-id-hash-key")
|
||||
|
||||
user1 := model.User{Phone: "13700000031", Status: "active"}
|
||||
user2 := model.User{Phone: "13700000032", Status: "active"}
|
||||
if err := db.Create(&user1).Error; err != nil {
|
||||
t.Fatalf("创建用户1失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&user2).Error; err != nil {
|
||||
t.Fatalf("创建用户2失败: %v", err)
|
||||
}
|
||||
const idNo = "110101199001010049"
|
||||
now := time.Now()
|
||||
result := ProviderResult{
|
||||
ProviderOrderNo: "mock_unique",
|
||||
Status: StatusVerified,
|
||||
MaskedName: "赵*",
|
||||
EncryptedName: "赵六",
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
VerifiedAt: &now,
|
||||
}
|
||||
hash := FingerprintIDNo(repo.idHashKey, idNo)
|
||||
if _, err := repo.SaveResult(t.Context(), user1.ID, "mock", result, hash); err != nil {
|
||||
t.Fatalf("用户1保存实名失败: %v", err)
|
||||
}
|
||||
if _, err := repo.SaveResult(t.Context(), user2.ID, "mock", result, hash); !errors.Is(err, ErrIDNoAlreadyUsed) {
|
||||
t.Fatalf("数据库唯一索引未拦截重复身份证: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameRejectedRecordCanRetryOnSameAccount(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
user := model.User{Phone: "13700000025", Status: "active", RealnameStatus: "rejected"}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
const idNo = "110101199001010022"
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: user.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusRejected,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
FailReason: "旧记录",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建驳回记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), user.ID, "孙七", idNo); err != nil {
|
||||
t.Fatalf("驳回后重新实名失败: %v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&model.UserRealname{}).Where("user_id = ?", user.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计实名记录失败: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("实名记录条数 = %d, want 1", count)
|
||||
}
|
||||
var record model.UserRealname
|
||||
if err := db.Where("user_id = ?", user.ID).First(&record).Error; err != nil {
|
||||
t.Fatalf("读取实名记录失败: %v", err)
|
||||
}
|
||||
if record.Status != StatusVerified || record.IDNoHash == nil {
|
||||
t.Fatalf("重新实名后状态/指纹 = %s/%v", record.Status, record.IDNoHash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealnameRejectedRecordDoesNotBlockAnotherAccount(t *testing.T) {
|
||||
db := setupRealnameTestDB(t)
|
||||
svc := newRealnameTestService(t, db)
|
||||
|
||||
rejectedUser := model.User{Phone: "13700000021", Status: "active"}
|
||||
otherUser := model.User{Phone: "13700000022", Status: "active"}
|
||||
if err := db.Create(&rejectedUser).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
if err := db.Create(&otherUser).Error; err != nil {
|
||||
t.Fatalf("创建用户失败: %v", err)
|
||||
}
|
||||
|
||||
const idNo = "110101199001010030"
|
||||
if err := db.Create(&model.UserRealname{
|
||||
UserID: rejectedUser.ID,
|
||||
Provider: "mock",
|
||||
Status: StatusRejected,
|
||||
MaskedIDNo: maskIDNo(idNo),
|
||||
EncryptedIDNo: idNo,
|
||||
FailReason: "姓名不一致",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("创建驳回记录失败: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.Start(t.Context(), otherUser.ID, "王五", idNo); err != nil {
|
||||
t.Fatalf("驳回记录不应占用身份证,实名失败: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user