fix(realname): 增加身份证唯一性校验,防止同一证件重复实名
This commit is contained in:
@@ -70,6 +70,8 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
|
|||||||
REALNAME_PROVIDER=mock
|
REALNAME_PROVIDER=mock
|
||||||
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
|
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
|
||||||
REALNAME_CLOUDMARKET_APPCODE=
|
REALNAME_CLOUDMARKET_APPCODE=
|
||||||
|
# 身份证唯一性盲索引密钥;留空回退 FIELD_ENCRYPTION_KEY
|
||||||
|
REALNAME_ID_HASH_KEY=
|
||||||
|
|
||||||
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
||||||
# 支付配置加密密钥(必须为 16、24 或 32 字节;生产环境不要留空)
|
# 支付配置加密密钥(必须为 16、24 或 32 字节;生产环境不要留空)
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ ALIYUN_SMS_LOGIN_TEMPLATE_CODE=
|
|||||||
REALNAME_PROVIDER=cloudmarket
|
REALNAME_PROVIDER=cloudmarket
|
||||||
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
|
REALNAME_CLOUDMARKET_URL=https://eid.shumaidata.com/eid/check
|
||||||
REALNAME_CLOUDMARKET_APPCODE=
|
REALNAME_CLOUDMARKET_APPCODE=
|
||||||
|
# 身份证唯一性盲索引密钥;留空回退 FIELD_ENCRYPTION_KEY
|
||||||
|
REALNAME_ID_HASH_KEY=
|
||||||
|
|
||||||
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
# 支付商户、网关和回调等业务配置通过后台支付配置页维护。
|
||||||
# 支付配置加密密钥(必须为 16、24 或 32 字节,生产环境必填)
|
# 支付配置加密密钥(必须为 16、24 或 32 字节,生产环境必填)
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ type RealnameConfig struct {
|
|||||||
Provider string
|
Provider string
|
||||||
CloudMarketURL string
|
CloudMarketURL string
|
||||||
CloudMarketAppCode string
|
CloudMarketAppCode string
|
||||||
|
// IDHashKey 用于计算身份证号盲索引;留空时回退到 FIELD_ENCRYPTION_KEY。
|
||||||
|
IDHashKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
@@ -129,6 +131,7 @@ func Load() Config {
|
|||||||
Provider: getEnv("REALNAME_PROVIDER", "mock"),
|
Provider: getEnv("REALNAME_PROVIDER", "mock"),
|
||||||
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://eid.shumaidata.com/eid/check"),
|
CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://eid.shumaidata.com/eid/check"),
|
||||||
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
|
CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""),
|
||||||
|
IDHashKey: getEnv("REALNAME_ID_HASH_KEY", ""),
|
||||||
},
|
},
|
||||||
Log: LogConfig{
|
Log: LogConfig{
|
||||||
Level: getEnv("LOG_LEVEL", "info"),
|
Level: getEnv("LOG_LEVEL", "info"),
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type UserRealname struct {
|
type UserRealname struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"`
|
UserID uint64 `gorm:"not null;uniqueIndex" json:"user_id"`
|
||||||
Provider string `gorm:"size:32;not null" json:"provider"`
|
Provider string `gorm:"size:32;not null" json:"provider"`
|
||||||
ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"`
|
ProviderOrderNo string `gorm:"size:128;not null;default:''" json:"provider_order_no"`
|
||||||
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'pending'" json:"status"`
|
||||||
MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"`
|
MaskedName string `gorm:"size:64;not null;default:''" json:"masked_name"`
|
||||||
EncryptedName string `gorm:"size:255;not null;default:''" json:"encrypted_name"`
|
EncryptedName string `gorm:"size:255;not null;default:''" json:"encrypted_name"`
|
||||||
MaskedIDNo string `gorm:"size:64;not null;default:''" json:"masked_id_no"`
|
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"`
|
EncryptedIDNo string `gorm:"size:255;not null;default:''" json:"encrypted_id_no"`
|
||||||
VerifiedAt *time.Time `json:"verified_at"`
|
// IDNoHash 是身份证号的不可逆指纹,仅认证通过时写入;唯一索引保证同一身份证只能绑定一个账号。
|
||||||
FailReason string `gorm:"size:255;not null;default:''" json:"fail_reason"`
|
IDNoHash *string `gorm:"size:64;uniqueIndex:uk_user_realname_id_no_hash" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
VerifiedAt *time.Time `json:"verified_at"`
|
||||||
UpdatedAt time.Time `json:"updated_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 {
|
func (UserRealname) TableName() string {
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ func writeAdminUserError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "姓名或身份证号格式不正确")
|
response.BadRequest(c, "姓名或身份证号格式不正确")
|
||||||
case errors.Is(err, ErrRealnameAlreadyVerified):
|
case errors.Is(err, ErrRealnameAlreadyVerified):
|
||||||
response.Error(c, http.StatusConflict, "realname_already_verified", "该用户已完成实名认证,如需修改请先撤销实名")
|
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):
|
case errors.Is(err, ErrRealnameNotRevocable):
|
||||||
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
response.Error(c, http.StatusConflict, "realname_not_revocable", "该用户当前没有可撤销的实名信息")
|
||||||
case errors.Is(err, ErrInvalidWalletDirection):
|
case errors.Is(err, ErrInvalidWalletDirection):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"hfb_sys/backend/internal/auditlog"
|
"hfb_sys/backend/internal/auditlog"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/invitation"
|
"hfb_sys/backend/internal/modules/invitation"
|
||||||
|
"hfb_sys/backend/internal/modules/realname"
|
||||||
"hfb_sys/backend/internal/modules/rentergrowth"
|
"hfb_sys/backend/internal/modules/rentergrowth"
|
||||||
"hfb_sys/backend/internal/modules/wallet"
|
"hfb_sys/backend/internal/modules/wallet"
|
||||||
"hfb_sys/backend/pkg/crypto"
|
"hfb_sys/backend/pkg/crypto"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
encryptor crypto.Encryptor
|
encryptor crypto.Encryptor
|
||||||
|
realnameIDHashKey string
|
||||||
statusChangeNotifier func(userID uint64)
|
statusChangeNotifier func(userID uint64)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +37,11 @@ func NewRepository(db *gorm.DB, encryptors ...crypto.Encryptor) *Repository {
|
|||||||
return &Repository{db: db, encryptor: encryptor}
|
return &Repository{db: db, encryptor: encryptor}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRealnameIDHashKey 设置身份证盲索引密钥,后台人工实名同样执行唯一性校验。
|
||||||
|
func (r *Repository) SetRealnameIDHashKey(key string) {
|
||||||
|
r.realnameIDHashKey = strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
|
// SetStatusChangeNotifier 设置状态变更后的会话撤销通知。
|
||||||
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
|
func (r *Repository) SetStatusChangeNotifier(notifier func(userID uint64)) {
|
||||||
r.statusChangeNotifier = notifier
|
r.statusChangeNotifier = notifier
|
||||||
@@ -317,6 +324,7 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
idNoHash := realname.FingerprintIDNo(r.realnameIDHashKey, idNo)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
record := model.UserRealname{
|
record := model.UserRealname{
|
||||||
@@ -328,6 +336,7 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
|
|||||||
EncryptedName: encryptedName,
|
EncryptedName: encryptedName,
|
||||||
MaskedIDNo: maskManualIDNo(idNo),
|
MaskedIDNo: maskManualIDNo(idNo),
|
||||||
EncryptedIDNo: encryptedIDNo,
|
EncryptedIDNo: encryptedIDNo,
|
||||||
|
IDNoHash: &idNoHash,
|
||||||
VerifiedAt: &now,
|
VerifiedAt: &now,
|
||||||
FailReason: "",
|
FailReason: "",
|
||||||
}
|
}
|
||||||
@@ -340,28 +349,50 @@ func (r *Repository) ManualRealname(ctx context.Context, adminID uint64, userID
|
|||||||
if user.RealnameStatus == "verified" {
|
if user.RealnameStatus == "verified" {
|
||||||
return ErrRealnameAlreadyVerified
|
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
|
beforeRealnameStatus := user.RealnameStatus
|
||||||
user.RealnameStatus = "verified"
|
user.RealnameStatus = "verified"
|
||||||
if err := tx.Save(&user).Error; err != nil {
|
if err := tx.Save(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
var existingRealname model.UserRealname
|
||||||
Columns: []clause.Column{{Name: "user_id"}},
|
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
Where("user_id = ?", userID).First(&existingRealname).Error
|
||||||
"provider": record.Provider,
|
switch {
|
||||||
"provider_order_no": record.ProviderOrderNo,
|
case findErr == nil:
|
||||||
"status": record.Status,
|
existingRealname.Provider = record.Provider
|
||||||
"masked_name": record.MaskedName,
|
existingRealname.ProviderOrderNo = record.ProviderOrderNo
|
||||||
"encrypted_name": record.EncryptedName,
|
existingRealname.Status = record.Status
|
||||||
"masked_id_no": record.MaskedIDNo,
|
existingRealname.MaskedName = record.MaskedName
|
||||||
"encrypted_id_no": record.EncryptedIDNo,
|
existingRealname.EncryptedName = record.EncryptedName
|
||||||
"verified_at": record.VerifiedAt,
|
existingRealname.MaskedIDNo = record.MaskedIDNo
|
||||||
"fail_reason": record.FailReason,
|
existingRealname.EncryptedIDNo = record.EncryptedIDNo
|
||||||
"updated_at": now,
|
existingRealname.IDNoHash = record.IDNoHash
|
||||||
}),
|
existingRealname.VerifiedAt = record.VerifiedAt
|
||||||
}).Create(&record).Error; err != nil {
|
existingRealname.FailReason = record.FailReason
|
||||||
return err
|
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)
|
_ = 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) {
|
func TestAdjustWalletDebitAndAudit(t *testing.T) {
|
||||||
db := setupAdminUserTestDB(t)
|
db := setupAdminUserTestDB(t)
|
||||||
user := model.User{
|
user := model.User{
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/modules/realname"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -12,6 +14,7 @@ var (
|
|||||||
ErrInvalidUser = errors.New("invalid user")
|
ErrInvalidUser = errors.New("invalid user")
|
||||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
||||||
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
ErrRealnameAlreadyVerified = errors.New("realname already verified")
|
||||||
|
ErrRealnameIDNoUsed = realname.ErrIDNoAlreadyUsed
|
||||||
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
ErrRealnameNotRevocable = errors.New("realname not revocable")
|
||||||
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
|
ErrInvalidWalletDirection = errors.New("invalid wallet direction")
|
||||||
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
ErrInvalidWalletAmount = errors.New("invalid wallet amount")
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ func writeRealnameError(c *gin.Context, err error) {
|
|||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
case errors.Is(err, ErrInvalidRealnameInput):
|
case errors.Is(err, ErrInvalidRealnameInput):
|
||||||
response.BadRequest(c, "姓名或身份证号格式不正确")
|
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):
|
case errors.Is(err, ErrProviderConfigInvalid):
|
||||||
response.ServiceUnavailable(c, "实名认证服务未配置")
|
response.ServiceUnavailable(c, "实名认证服务未配置")
|
||||||
case errors.Is(err, ErrProviderRateLimited):
|
case errors.Is(err, ErrProviderRateLimited):
|
||||||
|
|||||||
@@ -2,21 +2,32 @@ package realname
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
"hfb_sys/backend/internal/modules/invitation"
|
"hfb_sys/backend/internal/modules/invitation"
|
||||||
|
"hfb_sys/backend/pkg/crypto"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const idNoFingerprintDomain = "realname_id"
|
||||||
|
|
||||||
type Repository struct {
|
type Repository struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
encryptor crypto.Encryptor
|
||||||
|
idHashKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRepository(db *gorm.DB) *Repository {
|
func NewRepository(db *gorm.DB, encryptor crypto.Encryptor, idHashKey string) *Repository {
|
||||||
return &Repository{db: db}
|
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) {
|
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
|
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{
|
record := model.UserRealname{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
@@ -40,24 +133,42 @@ func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider str
|
|||||||
VerifiedAt: result.VerifiedAt,
|
VerifiedAt: result.VerifiedAt,
|
||||||
FailReason: result.FailReason,
|
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 {
|
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
var existing model.UserRealname
|
||||||
Columns: []clause.Column{{Name: "user_id"}},
|
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
Where("user_id = ?", userID).First(&existing).Error
|
||||||
"provider": record.Provider,
|
switch {
|
||||||
"provider_order_no": record.ProviderOrderNo,
|
case findErr == nil:
|
||||||
"status": record.Status,
|
existing.Provider = record.Provider
|
||||||
"masked_name": record.MaskedName,
|
existing.ProviderOrderNo = record.ProviderOrderNo
|
||||||
"encrypted_name": record.EncryptedName,
|
existing.Status = record.Status
|
||||||
"masked_id_no": record.MaskedIDNo,
|
existing.MaskedName = record.MaskedName
|
||||||
"encrypted_id_no": record.EncryptedIDNo,
|
existing.EncryptedName = record.EncryptedName
|
||||||
"verified_at": record.VerifiedAt,
|
existing.MaskedIDNo = record.MaskedIDNo
|
||||||
"fail_reason": record.FailReason,
|
existing.EncryptedIDNo = record.EncryptedIDNo
|
||||||
"updated_at": time.Now(),
|
existing.IDNoHash = record.IDNoHash
|
||||||
}),
|
existing.VerifiedAt = record.VerifiedAt
|
||||||
}).Create(&record).Error; err != nil {
|
existing.FailReason = record.FailReason
|
||||||
return err
|
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
|
userStatus := StatusRejected
|
||||||
@@ -80,3 +191,28 @@ func (r *Repository) SaveResult(ctx context.Context, userID uint64, provider str
|
|||||||
}
|
}
|
||||||
return r.FindByUserID(ctx, userID)
|
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 (
|
var (
|
||||||
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
ErrDependencyUnavailable = errors.New("dependency unavailable")
|
||||||
ErrInvalidRealnameInput = errors.New("invalid realname input")
|
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")
|
ErrProviderConfigInvalid = errors.New("realname provider config invalid")
|
||||||
ErrProviderRateLimited = errors.New("realname provider rate limited")
|
ErrProviderRateLimited = errors.New("realname provider rate limited")
|
||||||
ErrProviderQuotaExhausted = errors.New("realname provider quota exhausted")
|
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
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
idNo = strings.TrimSpace(idNo)
|
idNo = normalizeIDNo(idNo)
|
||||||
if !validName(name) || !validIDNo(idNo) {
|
if !validName(name) || !validIDNo(idNo) {
|
||||||
return nil, ErrInvalidRealnameInput
|
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{
|
result, err := s.provider.Start(ctx, StartRequest{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -77,7 +93,7 @@ func (s *Service) Start(ctx context.Context, userID uint64, name string, idNo st
|
|||||||
}
|
}
|
||||||
return nil, err
|
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) {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
"hfb_sys/backend/internal/handler"
|
"hfb_sys/backend/internal/handler"
|
||||||
@@ -78,6 +79,10 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
// 业务字段加密器(实名/收款账号/分站短信密钥):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。
|
// 业务字段加密器(实名/收款账号/分站短信密钥):主密钥 + legacy 回退,用于密钥轮换期间透明解出旧密文。
|
||||||
// 生产环境必须配置 FIELD_ENCRYPTION_KEY(ValidateProductionSecurity 已校验)。
|
// 生产环境必须配置 FIELD_ENCRYPTION_KEY(ValidateProductionSecurity 已校验)。
|
||||||
fieldEncryptor := NewFieldEncryptor(cfg, logger)
|
fieldEncryptor := NewFieldEncryptor(cfg, logger)
|
||||||
|
realnameIDHashKey := strings.TrimSpace(cfg.Realname.IDHashKey)
|
||||||
|
if realnameIDHashKey == "" {
|
||||||
|
realnameIDHashKey = cfg.FieldEncryptionKey
|
||||||
|
}
|
||||||
|
|
||||||
var userRepo *auth.UserRepository
|
var userRepo *auth.UserRepository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
@@ -113,6 +118,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
var adminUserRepo *adminuser.Repository
|
var adminUserRepo *adminuser.Repository
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
adminUserRepo = adminuser.NewRepository(deps.DB, fieldEncryptor)
|
adminUserRepo = adminuser.NewRepository(deps.DB, fieldEncryptor)
|
||||||
|
adminUserRepo.SetRealnameIDHashKey(realnameIDHashKey)
|
||||||
}
|
}
|
||||||
adminUserService := adminuser.NewService(adminUserRepo)
|
adminUserService := adminuser.NewService(adminUserRepo)
|
||||||
adminUserHandler := adminuser.NewHandler(adminUserService)
|
adminUserHandler := adminuser.NewHandler(adminUserService)
|
||||||
@@ -137,7 +143,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
userHandler := user.NewHandler(userRepo)
|
userHandler := user.NewHandler(userRepo)
|
||||||
var realnameRepo *realname.Repository
|
var realnameRepo *realname.Repository
|
||||||
if deps.DB != nil {
|
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)
|
realnameService := realname.NewService(realnameRepo, newRealnameProvider(cfg, fieldEncryptor, logger), logger)
|
||||||
realnameHandler := realname.NewHandler(realnameService)
|
realnameHandler := realname.NewHandler(realnameService)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -134,3 +134,13 @@ AppCode 与阿里云市场的订购关系绑定,**换商品必须换 AppCode**
|
|||||||
|
|
||||||
该接口不返回服务商流水号,因此 `user_realname.provider_order_no` 对 cloudmarket 渠道为空。
|
该接口不返回服务商流水号,因此 `user_realname.provider_order_no` 对 cloudmarket 渠道为空。
|
||||||
姓名与身份证号由本项目自行脱敏入库,并用 `pkg/crypto` 加密保存完整值。
|
姓名与身份证号由本项目自行脱敏入库,并用 `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` 取出候选并解密做精确比对,命中后自动补写当前指纹,兼容历史上已加密保存的实名数据。
|
||||||
|
|||||||
Reference in New Issue
Block a user