敏感字段插件使用 AES-GCM 随机密文保存手机号等敏感数据,并使用 HMAC-SHA256 盲索引支持精确查询。
本文以手机号为例,完整说明模型、注册、插入、查询、返回控制和安全更新方式。
可直接执行的 MySQL 示例文件:sensitive_example.sql。
推荐使用两个数据库字段:
phone_cipher VARCHAR(255) NOT NULL COMMENT '手机号密文',
phone_index VARCHAR(64) NOT NULL COMMENT '手机号查询索引',
UNIQUE KEY uk_phone_index (phone_index)两个字段用途不同:
phone_cipher保存可以解密的 AES-GCM 随机密文。phone_index保存不可逆的 HMAC 查询索引,用于手机号等值查询和唯一约束。
随机密文不能直接执行等值查询,因此需要额外的 phone_index。
在 gormplus.Generate 配置中声明表名和业务字段:
cfg := &gormplus.GeneratorConfig{
// 其他数据库和输出路径配置……
SensitiveFields: []gormplus.GeneratorSensitiveField{{
Table: "user",
Field: "phone",
Type: "phone",
CipherField: "phone_cipher",
IndexField: "phone_index",
EncryptAtRest: false,
}},
}
if err := gormplus.Generate(cfg); err != nil {
return err
}YAML 配置:
sensitive_fields:
- table: user
field: phone
type: phone
cipher_field: phone_cipher
index_field: phone_index
encrypt_at_rest: false生成器会在实体中增加业务字段:
Phone string `gorm:"-" json:"phone" gormplus:"type:phone;cipher:phone_cipher;index:phone_index;encrypt:false"`同时会把数据库存储字段自动设置为 json:"-":
PhoneCipher string `gorm:"column:phone_cipher" json:"-"`
PhoneIndex string `gorm:"column:phone_index" json:"-"`因此 phoneCipher 和 phoneIndex 不会出现在返回给前端的 JSON 中。
生成 API、Proto、DTO、VO 和 Mapper 时也会自动过滤这两个内部字段,对外只生成 phone。Repository 和 DAO 仍会保留 PhoneCipher、PhoneIndex,因为数据库持久化及索引查询需要它们。
其中 gorm:"-" 表示该字段只用于业务输入输出,不会在数据库增加 phone 列。数据库仍然只有 phone_cipher 和 phone_index。
type User struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
// Phone 是业务输入和返回字段,不直接映射数据库。
Phone string `gorm:"-" json:"phone"`
// 下面两个字段只供插件和数据库使用,不返回给前端。
PhoneCipher string `gorm:"column:phone_cipher" json:"-"`
PhoneIndex string `gorm:"column:phone_index;uniqueIndex" json:"-"`
Nickname string `gorm:"column:nickname" json:"nickname"`
}
func (User) TableName() string {
return "user"
}不要删除 Phone 上的 gorm:"-",否则 GORM 会尝试把手机号明文写入数据库。
使用代码生成器产生 gormplus tag 后,运行时只需配置主密钥:
sensitive, err := gormplus.RegisterSensitive(db, gormplus.SensitiveConfig{
Key: []byte(os.Getenv("SENSITIVE_MASTER_KEY")),
})
if err != nil {
return fmt.Errorf("注册敏感字段插件失败: %w", err)
}插件会从生成实体的 tag 自动读取 Phone、phone_cipher 和 phone_index。
EncryptAtRest 是字段级开关,默认为 false。数据库保存规范化后的明文,但接口仍然默认返回脱敏值:
gormplus.SensitiveConfig{
Key: secretKey,
Fields: []gormplus.SensitiveFieldConfig{{
PlainField: "Phone",
CipherField: "PhoneCipher",
IndexField: "PhoneIndex",
IndexColumn: "phone_index",
EncryptAtRest: false,
ReturnMode: gormplus.SensitiveReturnMasked,
}},
}数据库存储:13800138000
接口默认返回:138****8000
明文权限返回:13800138000
需要数据库加密时开启:
gormplus.SensitiveConfig{
Key: secretKey,
Fields: []gormplus.SensitiveFieldConfig{{
PlainField: "Phone",
CipherField: "PhoneCipher",
IndexField: "PhoneIndex",
IndexColumn: "phone_index",
EncryptAtRest: true,
ReturnMode: gormplus.SensitiveReturnMasked,
}},
}数据库存储:AES-GCM 随机密文
接口默认返回:138****8000
明文权限返回:解密后的 13800138000
该开关只影响当前字段的数据库存储形式,不影响 PhoneEq、查询索引和返回脱敏逻辑。关闭加密时建议把 cipher_field 配成中性列名,例如 phone_value;继续使用 phone_cipher 也可以正常工作,但名称容易产生误解。
不同字段可以使用不同策略:
Fields: []gormplus.SensitiveFieldConfig{
{
PlainField: "Phone",
CipherField: "PhoneCipher",
IndexField: "PhoneIndex",
IndexColumn: "phone_index",
EncryptAtRest: false, // 手机号数据库存明文,接口默认脱敏
ReturnMode: gormplus.SensitiveReturnMasked,
},
{
PlainField: "IDCard",
CipherField: "IDCardCipher",
IndexField: "IDCardIndex",
IndexColumn: "id_card_index",
EncryptAtRest: true, // 身份证数据库存密文
ReturnMode: gormplus.SensitiveReturnMasked,
},
}已有数据的生产环境不能直接切换该开关。明文与密文模式互相切换时必须迁移存储列,否则旧数据会按错误模式读取。
没有使用生成器或需要覆盖默认规则时,可以显式配置 PhoneField("Phone"):
sensitive, err := gormplus.RegisterSensitive(db, gormplus.SensitiveConfig{
// 从 KMS、Vault 或安全环境变量读取,不要写死在源码中。
Key: []byte(os.Getenv("SENSITIVE_MASTER_KEY")),
Fields: []gormplus.SensitiveFieldConfig{
gormplus.PhoneField("Phone"),
},
})
if err != nil {
return fmt.Errorf("注册敏感字段插件失败: %w", err)
}主密钥不能少于 16 字节。插件会从主密钥自动派生相互独立的加密密钥和查询索引密钥。
PhoneField("Phone") 默认约定:
业务字段:Phone
密文字段:PhoneCipher
索引字段:PhoneIndex
索引列名:phone_index
默认返回:138****8000
手机号写入前会自动移除空格、横线、括号和 +86。
插入时只给 Phone 传原始手机号:
user := User{
Phone: "13800138000",
Nickname: "张三",
}
if err := db.WithContext(ctx).Create(&user).Error; err != nil {
return err
}插件会在创建 SQL 执行前自动设置:
PhoneCipher = AES-GCM("13800138000")
PhoneIndex = HMAC-SHA256("13800138000")
不要自行给 PhoneCipher、PhoneIndex 赋值。
不能这样查询:
// 错误:数据库没有手机号明文列。
db.Where("phone = ?", phone).First(&user)
// 错误:AES-GCM 每次加密结果不同,不能通过新密文匹配旧密文。
db.Where("phone_cipher = ?", encryptedPhone).First(&user)应通过插件生成查询索引:
var user User
err := sensitive.
WhereEqual(db.WithContext(ctx), "Phone", "13800138000").
First(&user).Error
if err != nil {
return err
}插件实际生成类似条件:
WHERE phone_index = ?默认查询结果:
fmt.Println(user.Phone) // 138****8000Phone 使用了 gorm:"-",不是数据库列,因此不会出现在 dao.SysUserEntity 中。DAO 中只会出现真实数据库字段 PhoneCipher 和 PhoneIndex,这是正常行为。
不能强行生成并使用:
// 不存在,也不能直接把手机号明文用于 SQL 条件。
dao.SysUserEntity.Phone.Eq(phone)使用插件的 PhoneEq 可以一行完成手机号规范化、HMAC 索引计算和 PhoneIndex 查询。下面是完整的 Gin Handler 示例,查询结果默认返回脱敏手机号:
func (a *SysUser) GetTest1Api(ctx *gin.Context) {
phone := ctx.Query("phone")
if phone == "" {
a.Fail(ctx, "手机号不能为空", nil)
return
}
list, err := a.SysUserRepository.FindList(
ctx,
gormplus.QueryOpt().Where(
a.SensitivePlugin.PhoneEq(dao.SysUserEntity.PhoneIndex, phone),
).Build(),
)
if err != nil || len(list) == 0 {
a.Fail(ctx, "查询失败", err)
return
}
a.Success(ctx, list)
}如果当前接口已完成明文查看权限校验,可以把查询 Context 改为:
list, err := a.SysUserRepository.FindList(
gormplus.WithSensitivePlaintext(ctx),
gormplus.QueryOpt().Where(
a.SensitivePlugin.PhoneEq(dao.SysUserEntity.PhoneIndex, phone),
).Build(),
)其中 a.SensitivePlugin 是注册插件时保存的实例:
sensitive, err := gormplus.RegisterSensitive(db, gormplus.SensitiveConfig{
Key: []byte(os.Getenv("SENSITIVE_MASTER_KEY")),
})
if err != nil {
return err
}
svcCtx.SensitivePlugin = sensitive也可以分两步构造条件:
phoneIndex := a.SensitivePlugin.IndexValue("Phone", phone)
list, err := a.SysUserRepository.FindList(
ctx,
gormplus.QueryOpt().Where(
dao.SysUserEntity.PhoneIndex.Eq(phoneIndex),
).Build(),
)普通列表查询不需要额外处理,查询后插件会逐条设置 Phone:
func (a *SysUser) GetListApi(ctx *gin.Context) {
list, err := a.SysUserRepository.FindList(ctx)
if err != nil || len(list) == 0 {
a.Fail(ctx, "查询失败", err)
return
}
// Phone 默认返回类似 138****8000 的脱敏值。
a.Success(ctx, list)
}如果当前接口已经完成查看手机号明文的权限校验,可以使用 WithSensitivePlaintext:
func (a *SysUser) GetTest2Api(ctx *gin.Context) {
list, err := a.SysUserRepository.FindList(
gormplus.WithSensitivePlaintext(ctx),
)
if err != nil || len(list) == 0 {
a.Fail(ctx, "查询失败", err)
return
}
// Phone 返回完整明文,例如 13800138000。
a.Success(ctx, list)
}gorm-plus 根包导出了插件类型和三种返回模式:
type SensitivePlugin = plugin.SensitivePlugin
const (
SensitiveReturnMasked = plugin.SensitiveReturnMasked // 返回脱敏值
SensitiveReturnPlain = plugin.SensitiveReturnPlain // 返回完整明文
SensitiveReturnCipher = plugin.SensitiveReturnCipher // 返回数据库密文
)注册成功后返回的 sensitive 类型就是 *gormplus.SensitivePlugin,建议保存在 ServiceContext 中供 Repository 查询使用:
type ServiceContext struct {
DB *gorm.DB
SensitivePlugin *gormplus.SensitivePlugin
}
sensitive, err := gormplus.RegisterSensitive(db, gormplus.SensitiveConfig{
Key: []byte(os.Getenv("SENSITIVE_MASTER_KEY")),
})
if err != nil {
return err
}
svcCtx := &ServiceContext{
DB: db,
SensitivePlugin: sensitive,
}SensitiveReturnMasked、SensitiveReturnPlain、SensitiveReturnCipher 主要用于字段级高级配置:
sensitive, err := gormplus.RegisterSensitive(db, gormplus.SensitiveConfig{
Key: []byte(os.Getenv("SENSITIVE_MASTER_KEY")),
Fields: []gormplus.SensitiveFieldConfig{{
PlainField: "Phone",
CipherField: "PhoneCipher",
IndexField: "PhoneIndex",
IndexColumn: "phone_index",
EncryptAtRest: false,
ReturnMode: gormplus.SensitiveReturnMasked,
Mask: gormplus.SensitiveMaskConfig{
Prefix: 3,
Suffix: 4,
Replacement: "*",
},
}},
})对应结果:
| 返回模式 | Phone 内容 |
适用场景 |
|---|---|---|
SensitiveReturnMasked |
138****8000 |
普通列表、详情,推荐默认值 |
SensitiveReturnPlain |
13800138000 |
已完成敏感数据查看权限校验 |
SensitiveReturnCipher |
数据库存储原值 | 内部审计、迁移等特殊场景;关闭加密时该值就是明文 |
如果配置了 ReturnModeResolver,还可以根据请求 Context 动态返回:
ReturnModeResolver: func(ctx context.Context) gormplus.SensitiveReturnMode {
if canViewPlainPhone(ctx) {
return gormplus.SensitiveReturnPlain
}
return gormplus.SensitiveReturnMasked
},大多数业务不需要显式填写 ReturnMode,直接使用下面的 Context 方法更简单。
err := db.WithContext(ctx).First(&user, userID).Error
fmt.Println(user.Phone) // 138****8000也可以明确指定脱敏返回:
ctx = gormplus.WithSensitiveMasked(ctx)
err := db.WithContext(ctx).First(&user, userID).Error必须先由服务端完成权限判断:
if !canViewPlainPhone(ctx) {
return errors.New("没有查看完整手机号的权限")
}
plainCtx := gormplus.WithSensitivePlaintext(ctx)
err := db.WithContext(plainCtx).First(&user, userID).Error
fmt.Println(user.Phone) // 13800138000不要直接信任前端传入的 showPlaintext=true,必须结合当前登录用户权限判断。
cipherCtx := gormplus.WithSensitiveCiphertext(ctx)
err := db.WithContext(cipherCtx).First(&user, userID).Error
fmt.Println(user.Phone) // AES-GCM 密文一般业务接口建议返回脱敏值,而不是数据库密文。
详情接口和更新接口不要直接复用 User 数据库实体。
详情响应:
type UserDetailVO struct {
ID int64 `json:"id"`
Phone string `json:"phone"`
Nickname string `json:"nickname"`
}更新请求使用指针表示“是否修改手机号”:
type UpdateUserReq struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
// nil 表示不修改手机号;非 nil 时必须是新的手机号明文。
Phone *string `json:"phone,optional"`
}详情返回:
{
"id": 1001,
"phone": "138****8000",
"nickname": "张三"
}前端只提交修改过的普通字段,不提交 phone:
{
"id": 1001,
"nickname": "李四"
}后端只更新普通字段:
err := db.WithContext(ctx).
Model(&User{}).
Where("id = ?", req.ID).
Update("nickname", req.Nickname).Error此时 phone_cipher 和 phone_index 都不会变化。
前端提交新的手机号明文:
{
"id": 1001,
"nickname": "李四",
"phone": "13900139000"
}后端先更新普通字段,再单独更新手机号:
func UpdateUser(ctx context.Context, db *gorm.DB, req *UpdateUserReq) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).
Where("id = ?", req.ID).
Update("nickname", req.Nickname).Error; err != nil {
return err
}
if req.Phone == nil {
return nil
}
if !isValidPhone(*req.Phone) {
return errors.New("手机号格式错误")
}
// 使用独立对象,Phone 中只放新的原始明文。
update := User{
ID: req.ID,
Phone: *req.Phone,
}
return tx.Model(&update).
Select("PhoneCipher", "PhoneIndex").
Updates(&update).Error
})
}插件会同时更新新手机号的密文和查询索引。更新后,新手机号可以查询到,旧手机号无法再查询到。
不要对详情查询得到的对象直接执行:
// 错误:user.Phone 可能是 138****8000、明文或数据库密文。
db.Save(&user)也不要把详情响应直接绑定到数据库实体后保存:
// 错误:可能把 138****8000 当作新手机号再次加密。
var user User
_ = httpx.Parse(r, &user)
db.Save(&user)否则可能出现:
138****8000
→ 被当作新手机号
→ 再次加密
→ 数据库中的真实手机号被破坏
正确规则:
详情字段只负责展示
更新请求未传手机号表示保持不变
更新请求传手机号时必须是新的原始明文
密文和脱敏值永远不能作为更新输入
func (l *UpdateUserLogic) UpdateUser(req *types.UpdateUserReq) error {
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
updates := map[string]any{
"nickname": req.Nickname,
}
if err := tx.Model(&User{}).
Where("id = ?", req.ID).
Updates(updates).Error; err != nil {
return err
}
if req.Phone == nil {
return nil
}
phoneUpdate := User{ID: req.ID, Phone: *req.Phone}
return tx.Model(&phoneUpdate).
Select("PhoneCipher", "PhoneIndex").
Updates(&phoneUpdate).Error
})
}- 当前支持
string类型敏感字段的精确等值查询。 - 不支持直接对随机密文执行
LIKE、后四位或号段查询。 - 需要后四位查询时应设计独立索引,不能复用完整手机号索引。
- 主密钥应由 KMS、Vault 或安全环境变量提供,不应提交到 Git。
- 查看明文的接口应进行权限校验并记录审计日志。
- 日志中禁止打印手机号明文、密文、主密钥和查询索引。
- 更新手机号前应进行格式校验,必要时增加短信验证。