电商交易安全:四个必须做的服务端校验

3 阅读7分钟

做电商项目,钱相关的逻辑容不得半点马虎。

之前重构一个数字商品电商平台,涉及USDT支付。金额计算必须精确,容不得半点模糊。

总结下来,有四个服务端校验是必须做的。


一、服务端重算价格

问题

前端传过来的订单总价,能不能直接用?

// 危险:直接用前端传的价格
func CreateOrder(req *CreateOrderReq) error {
    order.TotalPrice = req.TotalPrice  // 直接信任前端
    // ...
}

不能。用户抓包改一下价格,100块的东西传0.01块,你服务器就按0.01块扣。

解决

服务端拿到商品单价,自己算一遍总价,跟前端传的对比。

func (l *CreateProductOrderLogic) CreateProductOrder(req *CreateProductOrderReq) error {
    // 从数据库取商品价格
    product, err := l.svcCtx.Postgres.Products.FindOne(l.ctx, req.ProductId)
    // ...

    // 服务端重算总价,跟客户端传值对比
    totalPrice, _ := decimal.NewFromString(req.TotalPrice)
    calculated := product.Price.Mul(decimal.NewFromInt(int64(req.Quantity)))
    if !calculated.Equal(totalPrice) {
        return l.ErrInvalidParams.Wrap(nil, "total price is not equal")
    }
}

关键点:

  • 价格来源:从数据库取,不是从请求里取
  • 计算方式单价 × 数量,不是信任客户端传的总价
  • 比较方式:用 decimal.Equal(),不是 ==

为什么用decimal不用float64?

// float64精度丢失
price := 0.1 + 0.2
fmt.Println(price)  // 0.30000000000000004

// decimal精确计算
price := decimal.NewFromString("0.1")
price = price.Add(decimal.NewFromString("0.2"))
fmt.Println(price)  // 0.3

金额计算必须用 shopspring/decimalgf/decimal,这是底线。

还要校验什么?

价格对了还不够,还要校验:

  • 商品是否在售(OnSale
  • 库存是否充足(Total - Sold >= Quantity
  • 配送选项是否合法(用户传的key是否在商品的配送选项里)

这些看似简单,但每一条都是被羊毛党薅出来的教训。


二、自定义金额校验器

问题

金额输入从接口进来是字符串(JSON的number类型对大数字有精度问题),怎么校验?

  • 最多几位小数?
  • 最小金额是多少?
  • 最大金额是多少?

用validator默认的标签做不到"精度+范围"一体化校验。

解决

自定义一个 decimal validator:

// 使用方式:`validate:"decimal=2:0.01:10000"`
// 含义:最多2位小数,最小0.01,最大10000

type WithdrawReq struct {
    Amount string `json:"amount" validate:"required,decimal=2:0.01:10000"`
}

实现:

func (v *Validator) Decimal(fl validator.FieldLevel) bool {
    var value decimal.Decimal

    // 支持string和float类型
    switch kind := fl.Field().Kind(); kind {
    case reflect.Float32, reflect.Float64:
        value = decimal.NewFromFloat(fl.Field().Float())
    case reflect.String:
        var err error
        if value, err = decimal.NewFromString(fl.Field().String()); err != nil {
            return false
        }
    default:
        return false
    }

    if fl.Param() == "" {
        return true
    }

    // 解析参数:精度:最小值:最大值
    params := strings.Split(fl.Param(), ":")
    if len(params) > 3 {
        params = params[:3]
    }

    // 精度校验
    if len(params) > 0 {
        precision, _ := strconv.Atoi(params[0])
        if precision >= 1 && value.Exponent() < -int32(precision) {
            return false
        }
    }
    // 最小值
    if len(params) > 1 && params[1] != "" {
        min, _ := decimal.NewFromString(params[1])
        if value.LessThan(min) {
            return false
        }
    }
    // 最大值
    if len(params) > 2 && params[2] != "" {
        max, _ := decimal.NewFromString(params[2])
        if value.GreaterThan(max) {
            return false
        }
    }

    return true
}

精度校验的原理:decimal.Exponent() 返回的是10的幂次的相反数。比如 1.23 的 Exponent 是 -2,表示有2位小数。如果 Exponent() < -precision,说明小数位数超了。

举个例子:

  • 1.234 → Exponent = -3,precision=2 → -3 < -2 → 校验失败
  • 1.23 → Exponent = -2,precision=2 → -2 < -2 → 校验通过
  • 1.2 → Exponent = -1,precision=2 → -1 < -2 → 校验通过

为什么要精度校验?

USDT最小精度是小数点后8位。如果用户传了9位小数,说明要么是爬虫精度溢出,要么是恶意输入。


三、事务内延迟删除缓存

问题

数据库更新后要删除缓存,最常见的做法:

// ❌ 事务内直接删缓存
err := db.TransactCtx(ctx, func(ctx context.Context, s sqlx.Session) error {
    // 更新数据库
    _, err := UpdateAccount(ctx, userId, amount)
    // 直接删缓存
    redis.Del("account:" + userId)
    return nil
})

问题:事务提交前就删缓存

在事务提交前缓存就已经被删除了,下次读请求读不到缓存,查数据库拿到了旧数据,又写回缓存。脏数据就这么产生了。

事务操作                           查询请求
更新数据库:余额 10050
删除缓存  ← 此时事务还未提交,数据库里的值已经是50了
                                   读请求:缓存没有 → 查数据库
                                   (此时事务未提交,MySQL默认隔离级别下
                                   读请求看不到未提交的值)
                                   写入缓存:100
                                   (所以这笔是100,取决于隔离级别)
事务A提交
结果:数据库 50,缓存 100

这里的核心问题是:在事务提交前就删缓存,导致并发读请求可能用旧数据污染缓存

真正危险的场景是:

事务A                              读请求B
更新数据库:余额 10050
删除缓存
                                   读请求:缓存没有 → 查数据库 → 读到旧值 100
                                   写入缓存:100
事务A提交
结果:数据库 50,缓存 100 ❌

然后事务A继续:余额 500
但读请求命中了缓存 100,还是按 100 继续操作

解决

事务内只注册删除回调,事务提交后统一执行。

func (l *WithdrawLogic) Withdraw(req *types.WithdrawReq) (resp *types.WithdrawResp, err error) {
    cacheDeletes := make([]func() error, 0)

    if err := l.svcCtx.Postgres.Conn.TransactCtx(l.ctx, func(ctx context.Context, s sqlx.Session) error {
        // 冻结资金,注册延迟删除回调
        account, err := l.svcCtx.Postgres.Accounts.WithSession(s).AvailableToFrozen(l.ctx, userId, amount,
            postgres.WithCacheLazyDelete[postgres.Accounts](func(del func() error) {
                cacheDeletes = append(cacheDeletes, del)
            }),
        )
        // 创建提现单,同样注册
        withdrawal, err := l.svcCtx.Postgres.Withdrawals.WithSession(s).Insert(l.ctx, setters,
            postgres.WithCacheLazyDelete[postgres.Withdrawals](func(del func() error) {
                cacheDeletes = append(cacheDeletes, del)
            }),
        )
        // 创建两条流水,同样注册
        _, err = l.svcCtx.Postgres.AccountFlows.WithSession(s).Insert(...)
        _, err = l.svcCtx.Postgres.AccountFlows.WithSession(s).Insert(...)

        // 事务提交前统一执行缓存删除,缩短缓存删除与事务提交之间的窗口期
        if err := mr.Finish(cacheDeletes...); err != nil {
            return l.ErrServiceUnavailable.Wrap(err, "finish cache delete error")
        }
        return nil
    }); err != nil {
        return nil, err
    }

    // 事务提交完成后再删一次缓存
    if err := mr.Finish(cacheDeletes...); err != nil {
        // 删除失败,因为数据库已更新完成,不适合直接抛出异常,可考虑异步队列重试删除
        l.Errorf("finish cache delete error: %+v", err)
    }

    return resp, nil
}

关键点:将缓存删除在作为事务提交前的最后一步操作来执行,最大程度缩短缓存删除与事务提交之间的窗口。在事务提交完成之后再删除一次缓存,防止在窗口期的读请求向缓存中写入了脏数据。

核心逻辑在哪?

缓存延迟删除的实现在 model 基础层里:

func (m *baseModel[T]) delCache(affected []*T, cfg *execConfig[T]) error {
    delCache := func() error {
        if cfg.cacheDel == nil {
            return nil
        }
        return mr.Finish(dels...)  // 并发删除所有缓存key
    }

    if len(cfg.cacheLazyDel) == 0 {
        return delCache()  // 没有延迟注册,走立即删除
    }
    // 有延迟注册:把删除函数通过回调传出去
    for _, lazy := range cfg.cacheLazyDel {
        lazy(delCache)
    }
    return nil  // 只注册,不执行删除
}

逻辑拆解:

  1. 数据操作完成后,delCache() 被调用
  2. 如果有 cacheLazyDel,不直接删缓存,而是把删除函数通过回调传出去
  3. 外层(业务逻辑)收集所有回调,在事务内统一调用
  4. 关键:缓存删除在事务提交前执行。如果删除失败,事务回滚,不存在"数据库回滚但缓存已删"的状态

mr.Finish 并发删除

err := mr.Finish(cacheDeletes...)

go-zero 的 mr.Finish 并发执行所有缓存删除。Accounts、Withdrawals、AccountFlows 三个表的缓存同时删除,不用串行等待。

延迟删缓存能100%避免不一致吗?

不能。

即使把缓存删除放在事务最后,缓存删除到事务提交之间仍有一个微小窗口。事务提交之后的缓存删除操作也不能保证一定执行成功。更重要的是,任何缓存策略都无法100%保证缓存和数据库一致,这是分布式系统的本质限制。

真正的兜底不是消除不一致,而是让不一致不影响结果正确性

真正的兜底:数据库原子操作

缓存不一致的影响是什么?读到了旧数据。但如果写入时不依赖缓存值,就不会出问题。

举例:用户余额扣减

// ❌ 危险:依赖缓存/数据库读取的值
account := GetAccountFromCache(userId)  // 读到旧值 100
if account.Available >= amount {
    account.Available -= amount  // 计算:100 - 50 = 50
    UpdateAccount(userId, account.Available)  // 写入 50
}
// 问题:并发时两个请求都读到 100,都扣 50,结果应该是 0,实际是 50

// ✅ 安全:原子操作,不依赖读取的值
UPDATE accounts 
SET available = available - $1 
WHERE user_id = $2 AND available >= $1
// 数据库自己保证原子性,不会超扣

项目里的实际实现:

余额冻结

func (m *customAccountsModel) AvailableToFrozen(ctx context.Context, userId int64, amount decimal.Decimal, options ...ExecOption[Accounts]) (account *Accounts, err error) {
    affected, err := m.UpdateBy(ctx, []FieldSetter{}, append(options,
        // WHERE条件里带了余额检查,防止超扣
        withWhere[Accounts]("user_id = $1 AND available >= $2 AND status = ANY($3)", userId, amount, pq.Array([]model.AccountStatus{model.AccountStatusActive})),
        // SET里用原子操作,不是读取后计算
        withSet[Accounts]("available = available - $2, frozen = frozen + $2"),
    )...)
    if len(affected) == 0 {
        return nil, ErrNotFound  // 余额不足或账户状态异常
    }
    return affected[0], nil
}

生成的SQL:

UPDATE accounts 
SET available = available - 100, frozen = frozen + 100 
WHERE user_id = 123 AND available >= 100 AND status = ANY(ARRAY[1])

关键点:

  • available = available - $2 是原子操作
  • available >= $2 在WHERE里做乐观锁检查
  • 返回 affected 为空说明条件不满足(余额不足或状态不对)

库存锁定

func (m *customInventoriesModel) LockStock(ctx context.Context, productId int64, quantity uint64, options ...ExecOption[Inventories]) (locked bool, err error) {
    affected, err := m.UpdateBy(ctx, []FieldSetter{}, append(options,
        // WHERE条件保证不会超卖
        withWhere[Inventories]("product_id = $1 and total >= sold + $2", productId, quantity),
        // 原子递增
        withSet[Inventories]("sold = sold + $2"),
    )...)
    return len(affected) > 0, nil
}

生成的SQL:

UPDATE inventories 
SET sold = sold + 10 
WHERE product_id = 456 AND total >= sold + 10

total >= sold + quantity 保证库存足够才更新,否则返回 0 行,上层判断 locked = false

总结:缓存是辅助,数据库才是真理

读请求 → 先查缓存 → 缓存没有 → 查数据库 → 写缓存 → 返回
写请求 → 原子更新数据库 → 删除缓存

即使缓存读到旧数据,也不影响数据正确性,因为:

  • 写入时不依赖缓存值
  • 数据库原子操作保证不会超扣/超卖
  • 下次写操作会删除缓存,最终一致

所以延迟删缓存 + 原子操作 = 数据库永远正确,缓存只是加速读的优化手段


四个校验的关系

用户请求 → ① decimal精度校验(validator层)
         → ② 服务端重算价格(业务层)
         → ③ 事务+延迟删缓存(数据层)
         → ④ 数据库原子操作(数据库层)
  • ① 是入口防线,拦截非法输入
  • ② 是核心防线,防止数据篡改
  • ③ 是数据防线,降低缓存不一致概率
  • ④ 是兜底,确保数据正确性

四个校验覆盖从请求到落地的全链路。


总结

校验防什么关键技术
服务端重算价格前端篡改价格decimal精确计算 + 服务端取价
decimal精度校验非法金额输入自定义validator + Exponent精度判断
事务内延迟删缓存数据不一致回调注册 + 事务内统一执行
数据库原子操作并发超扣/超卖available = available - $1 + WHERE条件检查

前三个是缓存层面的优化,第四个才是数据正确性的兜底。

缓存不一致不可避免,但只要写入时用原子操作,不依赖缓存值,数据库永远是对的。缓存只是加速读的手段,不是数据源。