fix(transfer): 修复赠送资产并发漏洞及转赠积分薅取问题
- SubmitAddressShare 事务内 SELECT FOR UPDATE 锁定资产行,防止并发重复提交 - 检查 UPDATE RowsAffected,静默失败时回滚事务 - 防重检查从 readDB 移入事务内写库,消除主从延迟竞态 - RedeemInventoryToPoints/RedeemInventoriesToPoints 添加转赠来源校验, 禁止通过转赠获得的资产兑换积分
This commit is contained in:
parent
91dd42ca1c
commit
bd91c0fad1
@ -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,16 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 校验转赠来源:通过转赠获得的资产不允许兑换积分(防薅积分漏洞)
|
||||
transferCnt, _ := s.readDB.UserInventoryTransfers.WithContext(ctx).Where(
|
||||
s.readDB.UserInventoryTransfers.InventoryID.Eq(inventoryID),
|
||||
s.readDB.UserInventoryTransfers.ToUserID.Eq(userID),
|
||||
).Count()
|
||||
if transferCnt > 0 {
|
||||
return 0, fmt.Errorf("transfer_inventory_cannot_redeem")
|
||||
}
|
||||
|
||||
valueCents := inv.ValueCents
|
||||
valueSource := inv.ValueSource
|
||||
valueSnapshotAt := inv.ValueSnapshotAt
|
||||
@ -634,6 +658,30 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i
|
||||
return 0, fmt.Errorf("no_valid_inventory")
|
||||
}
|
||||
|
||||
// 3.5 排除通过转赠获得的资产(防薅积分漏洞)
|
||||
invIDs := make([]int64, 0, len(invList))
|
||||
for _, inv := range invList {
|
||||
invIDs = append(invIDs, inv.ID)
|
||||
}
|
||||
transferredInvs, _ := s.readDB.UserInventoryTransfers.WithContext(ctx).
|
||||
Where(s.readDB.UserInventoryTransfers.InventoryID.In(invIDs...)).
|
||||
Where(s.readDB.UserInventoryTransfers.ToUserID.Eq(userID)).
|
||||
Find()
|
||||
transferredSet := make(map[int64]struct{}, len(transferredInvs))
|
||||
for _, t := range transferredInvs {
|
||||
transferredSet[t.InventoryID] = struct{}{}
|
||||
}
|
||||
filteredInvList := make([]*model.UserInventory, 0, len(invList))
|
||||
for _, inv := range invList {
|
||||
if _, isTransferred := transferredSet[inv.ID]; !isTransferred {
|
||||
filteredInvList = append(filteredInvList, inv)
|
||||
}
|
||||
}
|
||||
if len(filteredInvList) == 0 {
|
||||
return 0, fmt.Errorf("transfer_inventory_cannot_redeem")
|
||||
}
|
||||
invList = filteredInvList
|
||||
|
||||
// 4. 按资产快照计算总积分,缺失快照时回退商品价格并回写
|
||||
productIDs := make([]int64, 0, len(invList))
|
||||
productIDSet := make(map[int64]struct{})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user