From 2029c1f4fb1f9d9f53c829c8f00b5501c7a2424b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 29 Oct 2026 12:06:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(realname):=20=E5=A2=9E=E5=8A=A0=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E8=AF=81=E5=94=AF=E4=B8=80=E6=80=A7=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E9=98=B2=E6=AD=A2=E5=90=8C=E4=B8=80=E8=AF=81=E4=BB=B6?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=AE=9E=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/.env.example | 2 + backend/.env.prod.example | 2 + backend/internal/config/config.go | 3 + backend/internal/model/user_realname.go | 28 +-- backend/internal/modules/adminuser/handler.go | 2 + .../internal/modules/adminuser/repository.go | 63 ++++-- .../modules/adminuser/repository_test.go | 22 ++ backend/internal/modules/adminuser/service.go | 3 + backend/internal/modules/realname/handler.go | 4 + .../internal/modules/realname/repository.go | 178 +++++++++++++-- backend/internal/modules/realname/service.go | 20 +- .../internal/modules/realname/service_test.go | 208 ++++++++++++++++++ backend/internal/router/router.go | 8 +- .../000089_user_realname_identity_unique.sql | 17 ++ backend/pkg/crypto/fingerprint.go | 21 ++ docs/实名认证.md | 10 + 16 files changed, 538 insertions(+), 53 deletions(-) create mode 100644 backend/internal/modules/realname/service_test.go create mode 100644 backend/migrations/000089_user_realname_identity_unique.sql create mode 100644 backend/pkg/crypto/fingerprint.go diff --git a/backend/.env.example b/backend/.env.example index 6d6b394..de9b04b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -70,6 +70,8 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE= REALNAME_PROVIDER=mock REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check REALNAME_CLOUDMARKET_APPCODE= +# 身份证唯一性盲索引密钥;留空回退 FIELD_ENCRYPTION_KEY +REALNAME_ID_HASH_KEY= # 支付商户、网关和回调等业务配置通过后台支付配置页维护。 # 支付配置加密密钥(必须为 16、24 或 32 字节;生产环境不要留空) diff --git a/backend/.env.prod.example b/backend/.env.prod.example index f36104f..9bd14b4 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -89,6 +89,8 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE= REALNAME_PROVIDER=cloudmarket REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check REALNAME_CLOUDMARKET_APPCODE= +# 身份证唯一性盲索引密钥;留空回退 FIELD_ENCRYPTION_KEY +REALNAME_ID_HASH_KEY= # 支付商户、网关和回调等业务配置通过后台支付配置页维护。 # 支付配置加密密钥(必须为 16、24 或 32 字节,生产环境必填) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f0490ba..5778573 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -56,6 +56,8 @@ type RealnameConfig struct { Provider string CloudMarketURL string CloudMarketAppCode string + // IDHashKey 用于计算身份证号盲索引;留空时回退到 FIELD_ENCRYPTION_KEY。 + IDHashKey string } type LogConfig struct { @@ -129,6 +131,7 @@ func Load() Config { Provider: getEnv("REALNAME_PROVIDER", "mock"), CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://eid.shumaidata.com/eid/check"), CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""), + IDHashKey: getEnv("REALNAME_ID_HASH_KEY", ""), }, Log: LogConfig{ Level: getEnv("LOG_LEVEL", "info"), diff --git a/backend/internal/model/user_realname.go b/backend/internal/model/user_realname.go index 9ee0f0d..702f74c 100644 --- a/backend/internal/model/user_realname.go +++ b/backend/internal/model/user_realname.go @@ -3,19 +3,21 @@ package model import "time" type UserRealname struct { - ID uint64 `gorm:"primaryKey" json:"id"` - UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"` - Provider string `gorm:"size:32;not null" json:"provider"` - ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"` - Status string `gorm:"size:32;not null;default:'pending'" json:"status"` - MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"` - EncryptedName string `gorm:"size:255;not null;default:''" json:"encrypted_name"` - MaskedIDNo string `gorm:"size:64;not null;default:''" json:"masked_id_no"` - EncryptedIDNo string `gorm:"size:255;not null;default:''" json:"encrypted_id_no"` - VerifiedAt *time.Time `json:"verified_at"` - FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"` + Provider string `gorm:"size:32;not null" json:"provider"` + ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"` + Status string `gorm:"size:32;not null;default:'pending'" json:"status"` + MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"` + EncryptedName string `gorm:"size:255;not null;default:''" json:"encrypted_name"` + MaskedIDNo string `gorm:"size:64;not null;default:'';index:idx_user_realname_masked_id_no" json:"masked_id_no"` + EncryptedIDNo string `gorm:"size:255;not null;default:''" json:"encrypted_id_no"` + // IDNoHash 是身份证号的不可逆指纹,仅认证通过时写入;唯一索引保证同一身份证只能绑定一个账号。 + IDNoHash *string `gorm:"size:64;uniqueIndex:uk_user_realname_id_no_hash" json:"-"` + VerifiedAt *time.Time `json:"verified_at"` + FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (UserRealname) TableName() string { diff --git a/backend/internal/modules/adminuser/handler.go b/backend/internal/modules/adminuser/handler.go index 3ab00b7..a5c9d80 100644 --- a/backend/internal/modules/adminuser/handler.go +++ b/backend/internal/modules/adminuser/handler.go @@ -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): diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 1fb4461..59e0ab3 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -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) diff --git a/backend/internal/modules/adminuser/repository_test.go b/backend/internal/modules/adminuser/repository_test.go index 4b3802e..cc2d4e3 100644 --- a/backend/internal/modules/adminuser/repository_test.go +++ b/backend/internal/modules/adminuser/repository_test.go @@ -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{ diff --git a/backend/internal/modules/adminuser/service.go b/backend/internal/modules/adminuser/service.go index 79718b8..14b43f7 100644 --- a/backend/internal/modules/adminuser/service.go +++ b/backend/internal/modules/adminuser/service.go @@ -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") diff --git a/backend/internal/modules/realname/handler.go b/backend/internal/modules/realname/handler.go index 1a30c32..fbd46f2 100644 --- a/backend/internal/modules/realname/handler.go +++ b/backend/internal/modules/realname/handler.go @@ -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): diff --git a/backend/internal/modules/realname/repository.go b/backend/internal/modules/realname/repository.go index 5ff8c7c..49c833f 100644 --- a/backend/internal/modules/realname/repository.go +++ b/backend/internal/modules/realname/repository.go @@ -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) +} diff --git a/backend/internal/modules/realname/service.go b/backend/internal/modules/realname/service.go index 763b76e..55977ea 100644 --- a/backend/internal/modules/realname/service.go +++ b/backend/internal/modules/realname/service.go @@ -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) { diff --git a/backend/internal/modules/realname/service_test.go b/backend/internal/modules/realname/service_test.go new file mode 100644 index 0000000..21b80cd --- /dev/null +++ b/backend/internal/modules/realname/service_test.go @@ -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) + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 0668d71..a3a0002 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -2,6 +2,7 @@ package router import ( "context" + "strings" "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/handler" @@ -78,6 +79,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { // 业务字段加密器(实名/收款账号/分站短信密钥):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。 // 生产环境必须配置 FIELD_ENCRYPTION_KEY(ValidateProductionSecurity 已校验)。 fieldEncryptor := NewFieldEncryptor(cfg, logger) + realnameIDHashKey := strings.TrimSpace(cfg.Realname.IDHashKey) + if realnameIDHashKey == "" { + realnameIDHashKey = cfg.FieldEncryptionKey + } var userRepo *auth.UserRepository if deps.DB != nil { @@ -113,6 +118,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { var adminUserRepo *adminuser.Repository if deps.DB != nil { adminUserRepo = adminuser.NewRepository(deps.DB, fieldEncryptor) + adminUserRepo.SetRealnameIDHashKey(realnameIDHashKey) } adminUserService := adminuser.NewService(adminUserRepo) adminUserHandler := adminuser.NewHandler(adminUserService) @@ -137,7 +143,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { userHandler := user.NewHandler(userRepo) var realnameRepo *realname.Repository if deps.DB != nil { - realnameRepo = realname.NewRepository(deps.DB) + realnameRepo = realname.NewRepository(deps.DB, fieldEncryptor, realnameIDHashKey) } realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, fieldEncryptor, logger), logger) realnameHandler := realname.NewHandler(realnameService) diff --git a/backend/migrations/000089_user_realname_identity_unique.sql b/backend/migrations/000089_user_realname_identity_unique.sql new file mode 100644 index 0000000..7b9f1cd --- /dev/null +++ b/backend/migrations/000089_user_realname_identity_unique.sql @@ -0,0 +1,17 @@ +-- +goose Up + +-- 实名唯一性:同一身份证号最多绑定一个已实名账号。 +-- encrypted_id_no 使用随机 nonce,同一身份证每次密文不同,不能直接做唯一键; +-- 因此新增由明文身份证号 + 服务端密钥计算的 HMAC 指纹,仅认证通过时写入。 +-- 存量记录先保持 NULL,查询时按 masked_id_no + 解密比对兼容,命中后按需补写。 +ALTER TABLE user_realname + ADD COLUMN id_no_hash CHAR(64) NULL COMMENT '身份证号 HMAC-SHA256 指纹,仅认证通过时写入' AFTER encrypted_id_no, + ADD KEY idx_user_realname_masked_id_no (masked_id_no), + ADD UNIQUE KEY uk_user_realname_id_no_hash (id_no_hash); + +-- +goose Down + +ALTER TABLE user_realname + DROP INDEX uk_user_realname_id_no_hash, + DROP INDEX idx_user_realname_masked_id_no, + DROP COLUMN id_no_hash; diff --git a/backend/pkg/crypto/fingerprint.go b/backend/pkg/crypto/fingerprint.go new file mode 100644 index 0000000..dcc7a8b --- /dev/null +++ b/backend/pkg/crypto/fingerprint.go @@ -0,0 +1,21 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" +) + +// FingerprintHMAC 生成用于唯一性盲索引的稳定 HMAC-SHA256 指纹。 +// secret 为空时退化为带域前缀的 SHA-256,仅用于本地/测试环境。 +func FingerprintHMAC(secret, domain, value string) string { + payload := strings.TrimSpace(domain) + ":" + strings.TrimSpace(value) + if strings.TrimSpace(secret) == "" { + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/docs/实名认证.md b/docs/实名认证.md index 1c67774..8493cb7 100644 --- a/docs/实名认证.md +++ b/docs/实名认证.md @@ -134,3 +134,13 @@ AppCode 与阿里云市场的订购关系绑定,**换商品必须换 AppCode** 该接口不返回服务商流水号,因此 `user_realname.provider_order_no` 对 cloudmarket 渠道为空。 姓名与身份证号由本项目自行脱敏入库,并用 `pkg/crypto` 加密保存完整值。 + +## 身份证唯一性 + +同一身份证号最多绑定一个已实名账号: + +- `user_realname.id_no_hash` 保存身份证号的 HMAC-SHA256 盲索引,`uk_user_realname_id_no_hash` 唯一索引兜底并发请求。 +- 指纹密钥优先使用 `REALNAME_ID_HASH_KEY`,未配置时回退到 `FIELD_ENCRYPTION_KEY`。更换密钥不会丢失唯一性:旧指纹查不到时会退回解密候选比对,并在命中时补写新指纹。 +- 已实名用户再次调用实名校验会返回 `realname_already_verified`。 +- 同一身份证绑定第二个账号会返回 `realname_id_no_used`;后台人工实名同样执行该校验。 +- 存量记录没有指纹或指纹使用旧密钥时,按 `masked_id_no` 取出候选并解密做精确比对,命中后自动补写当前指纹,兼容历史上已加密保存的实名数据。