Compare commits

...

2 Commits
zuncle ... main

Author SHA1 Message Date
win
749464c03e revert: 移除转赠资产禁止兑换积分的限制
经数据核实,转赠后兑换积分属于合法行为(资产转赠后归接收方所有)。
并发漏洞虽然产生了重复转赠/发货记录,但实际经济损失为 0 元:
- 18 个重复发货资产中,没有任何一个真正被两方都发了货
- 没有任何资产被重复兑换积分

保留前两个并发修复(SELECT FOR UPDATE + RowsAffected 检查),
回退第三个业务限制(禁止转赠资产兑换积分)。
2026-03-11 16:51:27 +08:00
win
8229b41382 fix(security): 修复赠送资产薅积分三大漏洞
1. SELECT FOR UPDATE 锁定资产行,防止并发转赠竞态条件
2. 检查 RowsAffected 防止 GORM 静默失败导致空壳发货记录
3. 兑换积分时校验转赠来源,禁止转赠资产兑换积分
4. 转赠来源校验改用写库查询,避免主从延迟绕过
5. 转赠来源查询错误不再静默忽略,失败时返回错误

基于 zuncle 分支修复,额外修正了两个安全隐患:
- RedeemInventoryToPoints/RedeemInventoriesToPoints 中
  转赠记录查询从 readDB 改为 writeDB
- Count()/Find() 返回的 error 不再丢弃
2026-03-11 16:25:11 +08:00

View File

@ -112,27 +112,7 @@ func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, nam
s.logger.Info("SubmitAddressShare: Processing", zap.Int64("invID", claims.InventoryID), zap.Int64("owner", claims.OwnerUserID))
// 1. 基本安全校验
cnt, err := s.readDB.ShippingRecords.WithContext(ctx).Where(
s.readDB.ShippingRecords.InventoryID.Eq(claims.InventoryID),
s.readDB.ShippingRecords.Status.Neq(5), // 排除已取消
).Count()
if err == nil && cnt > 0 {
s.logger.Warn("SubmitAddressShare: Already processed", zap.Int64("invID", claims.InventoryID))
return 0, fmt.Errorf("already_processed")
}
inv, err := s.readDB.UserInventory.WithContext(ctx).Where(s.readDB.UserInventory.ID.Eq(claims.InventoryID)).First()
if err != nil {
s.logger.Error("SubmitAddressShare: Inventory not found", zap.Int64("invID", claims.InventoryID), zap.Error(err))
return 0, err
}
if inv.Status != 1 {
s.logger.Warn("SubmitAddressShare: Inventory unavailable", zap.Int64("invID", claims.InventoryID), zap.Int32("status", inv.Status))
return 0, fmt.Errorf("inventory_unavailable")
}
// 2. 确定资产最终归属地 (实名转赠逻辑)
// 1. 确定资产最终归属地 (实名转赠逻辑)
targetUserID := claims.OwnerUserID
isTransfer := false
if submittedByUserID != nil && *submittedByUserID > 0 && *submittedByUserID != claims.OwnerUserID {
@ -142,7 +122,33 @@ func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, nam
var addrID int64
err = s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
// a. 创建收货地址 (归属于 targetUserID)
// a. 锁定资产行SELECT FOR UPDATE 防止并发转赠)
var inv model.UserInventory
lockResult := tx.Raw("SELECT * FROM user_inventory WHERE id = ? FOR UPDATE", claims.InventoryID).Scan(&inv)
if lockResult.Error != nil {
s.logger.Error("SubmitAddressShare: Lock inventory failed", zap.Int64("invID", claims.InventoryID), zap.Error(lockResult.Error))
return lockResult.Error
}
if inv.ID == 0 {
s.logger.Warn("SubmitAddressShare: Inventory not found", zap.Int64("invID", claims.InventoryID))
return fmt.Errorf("inventory_unavailable")
}
if inv.Status != 1 {
s.logger.Warn("SubmitAddressShare: Inventory unavailable", zap.Int64("invID", claims.InventoryID), zap.Int32("status", inv.Status))
return fmt.Errorf("inventory_unavailable")
}
// b. 在事务内检查发货记录(使用写库,避免主从延迟)
var shipCnt int64
if err := tx.Raw("SELECT COUNT(*) FROM shipping_records WHERE inventory_id = ? AND status != 5", claims.InventoryID).Scan(&shipCnt).Error; err != nil {
return err
}
if shipCnt > 0 {
s.logger.Warn("SubmitAddressShare: Already processed", zap.Int64("invID", claims.InventoryID))
return fmt.Errorf("already_processed")
}
// c. 创建收货地址 (归属于 targetUserID)
arow := &model.UserAddresses{
UserID: targetUserID,
Name: name,
@ -164,7 +170,7 @@ func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, nam
}
addrID = arow.ID
// b. 资产状态更新及所有权转移
// d. 资产状态更新及所有权转移(检查 RowsAffected 防止并发写入)
if isTransfer {
// 记录转赠流水
transferLog := &model.UserInventoryTransfers{
@ -178,28 +184,36 @@ func (s *service) SubmitAddressShare(ctx context.Context, shareToken string, nam
}
// 更新资产所属人
if err := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID).
result := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID).
Updates(map[string]interface{}{
"user_id": targetUserID,
"status": 3,
"updated_at": time.Now(),
"remark": fmt.Sprintf("transferred_from_%d|shipping_requested", claims.OwnerUserID),
}).Error; err != nil {
return err
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("inventory_unavailable")
}
} else {
// 仅更新状态 (原主发货)
if err := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID).
result := tx.Table("user_inventory").Where("id = ? AND user_id = ? AND status = 1", claims.InventoryID, claims.OwnerUserID).
Updates(map[string]interface{}{
"status": 3,
"updated_at": time.Now(),
"remark": "shipping_requested_via_share",
}).Error; err != nil {
return err
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("inventory_unavailable")
}
}
// c. 创建发货记录 (归属于 targetUserID)
// e. 创建发货记录 (归属于 targetUserID)
// 使用资产价值快照,确保价格与分解时一致
price := inv.ValueCents
if price <= 0 && inv.ProductID > 0 {
@ -554,6 +568,7 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
if err != nil {
return 0, err
}
valueCents := inv.ValueCents
valueSource := inv.ValueSource
valueSnapshotAt := inv.ValueSnapshotAt