From bd91c0fad12efebb4a2af914bc39a95f1ee07f0c Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Wed, 11 Mar 2026 14:14:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(transfer):=20=E4=BF=AE=E5=A4=8D=E8=B5=A0?= =?UTF-8?q?=E9=80=81=E8=B5=84=E4=BA=A7=E5=B9=B6=E5=8F=91=E6=BC=8F=E6=B4=9E?= =?UTF-8?q?=E5=8F=8A=E8=BD=AC=E8=B5=A0=E7=A7=AF=E5=88=86=E8=96=85=E5=8F=96?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SubmitAddressShare 事务内 SELECT FOR UPDATE 锁定资产行,防止并发重复提交 - 检查 UPDATE RowsAffected,静默失败时回滚事务 - 防重检查从 readDB 移入事务内写库,消除主从延迟竞态 - RedeemInventoryToPoints/RedeemInventoriesToPoints 添加转赠来源校验, 禁止通过转赠获得的资产兑换积分 --- internal/service/user/address_share.go | 108 ++++++++++++++++++------- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index 85039d8..e3b4511 100755 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -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{}) -- 2.47.2