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 1/4] =?UTF-8?q?fix(transfer):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B5=A0=E9=80=81=E8=B5=84=E4=BA=A7=E5=B9=B6=E5=8F=91=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E=E5=8F=8A=E8=BD=AC=E8=B5=A0=E7=A7=AF=E5=88=86=E8=96=85?= =?UTF-8?q?=E5=8F=96=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{}) From 9cf9f798bb9e9d63c7c09b1aa2e6c9d1a60adba6 Mon Sep 17 00:00:00 2001 From: win Date: Wed, 11 Mar 2026 16:25:11 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B5=A0=E9=80=81=E8=B5=84=E4=BA=A7=E8=96=85=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E4=B8=89=E5=A4=A7=E6=BC=8F=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. SELECT FOR UPDATE 锁定资产行,防止并发转赠竞态条件 2. 检查 RowsAffected 防止 GORM 静默失败导致空壳发货记录 3. 兑换积分时校验转赠来源,禁止转赠资产兑换积分 4. 转赠来源校验改用写库查询,避免主从延迟绕过 5. 转赠来源查询错误不再静默忽略,失败时返回错误 基于 zuncle 分支修复,额外修正了两个安全隐患: - RedeemInventoryToPoints/RedeemInventoriesToPoints 中 转赠记录查询从 readDB 改为 writeDB - Count()/Find() 返回的 error 不再丢弃 --- internal/service/user/address_share.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index e3b4511..987a4df 100755 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -570,10 +570,11 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv } // 校验转赠来源:通过转赠获得的资产不允许兑换积分(防薅积分漏洞) - transferCnt, _ := s.readDB.UserInventoryTransfers.WithContext(ctx).Where( - s.readDB.UserInventoryTransfers.InventoryID.Eq(inventoryID), - s.readDB.UserInventoryTransfers.ToUserID.Eq(userID), - ).Count() + // 使用写库查询,避免主从延迟导致校验被绕过 + var transferCnt int64 + if err := s.repo.GetDbW().Raw("SELECT COUNT(*) FROM user_inventory_transfers WHERE inventory_id = ? AND to_user_id = ?", inventoryID, userID).Scan(&transferCnt).Error; err != nil { + return 0, err + } if transferCnt > 0 { return 0, fmt.Errorf("transfer_inventory_cannot_redeem") } @@ -659,14 +660,15 @@ func (s *service) RedeemInventoriesToPoints(ctx context.Context, userID int64, i } // 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() + var transferredInvs []*model.UserInventoryTransfers + if err := s.repo.GetDbW().Raw("SELECT * FROM user_inventory_transfers WHERE inventory_id IN ? AND to_user_id = ?", invIDs, userID).Scan(&transferredInvs).Error; err != nil { + return 0, err + } transferredSet := make(map[int64]struct{}, len(transferredInvs)) for _, t := range transferredInvs { transferredSet[t.InventoryID] = struct{}{} From 98694b4e69ff39ee53ba3d811b15290e2d19c09c Mon Sep 17 00:00:00 2001 From: win Date: Wed, 11 Mar 2026 16:51:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?revert:=20=E7=A7=BB=E9=99=A4=E8=BD=AC?= =?UTF-8?q?=E8=B5=A0=E8=B5=84=E4=BA=A7=E7=A6=81=E6=AD=A2=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=A7=AF=E5=88=86=E7=9A=84=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 经数据核实,转赠后兑换积分属于合法行为(资产转赠后归接收方所有)。 并发漏洞虽然产生了重复转赠/发货记录,但实际经济损失为 0 元: - 18 个重复发货资产中,没有任何一个真正被两方都发了货 - 没有任何资产被重复兑换积分 保留前两个并发修复(SELECT FOR UPDATE + RowsAffected 检查), 回退第三个业务限制(禁止转赠资产兑换积分)。 --- internal/service/user/address_share.go | 35 -------------------------- 1 file changed, 35 deletions(-) diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index 987a4df..4405f2e 100755 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -569,16 +569,6 @@ func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inv return 0, err } - // 校验转赠来源:通过转赠获得的资产不允许兑换积分(防薅积分漏洞) - // 使用写库查询,避免主从延迟导致校验被绕过 - var transferCnt int64 - if err := s.repo.GetDbW().Raw("SELECT COUNT(*) FROM user_inventory_transfers WHERE inventory_id = ? AND to_user_id = ?", inventoryID, userID).Scan(&transferCnt).Error; err != nil { - return 0, err - } - if transferCnt > 0 { - return 0, fmt.Errorf("transfer_inventory_cannot_redeem") - } - valueCents := inv.ValueCents valueSource := inv.ValueSource valueSnapshotAt := inv.ValueSnapshotAt @@ -659,31 +649,6 @@ 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) - } - var transferredInvs []*model.UserInventoryTransfers - if err := s.repo.GetDbW().Raw("SELECT * FROM user_inventory_transfers WHERE inventory_id IN ? AND to_user_id = ?", invIDs, userID).Scan(&transferredInvs).Error; err != nil { - return 0, err - } - 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{}) From fac825245b8ab7142491d84105daf214407b1931 Mon Sep 17 00:00:00 2001 From: Zuncle <34310384@qq.com> Date: Sun, 15 Mar 2026 13:18:37 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B5=A0=E9=80=81?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9C=B0=E5=9D=80=E5=BD=92=E5=B1=9E=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=8C=E5=BC=BA=E5=88=B6=E7=99=BB=E5=BD=95=E5=90=8E?= =?UTF-8?q?=E6=89=8D=E8=83=BD=E5=A1=AB=E5=86=99=E6=94=B6=E8=B4=A7=E5=9C=B0?= =?UTF-8?q?=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接收者未登录时提交地址会错误保存到赠送者名下,现改为: - API层:登录态从可选改为必选,未登录返回401 - Service层:始终用提交者ID作为地址归属人 --- .../api/user/address_share_submit_public.go | 18 +++++++++++------- internal/service/user/address_share.go | 10 +++++----- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/api/user/address_share_submit_public.go b/internal/api/user/address_share_submit_public.go index f3004ce..0b1858c 100755 --- a/internal/api/user/address_share_submit_public.go +++ b/internal/api/user/address_share_submit_public.go @@ -45,16 +45,20 @@ func (h *handler) SubmitAddressShare() core.HandlerFunc { return } - // 尝试获取登录用户信息 (可选) + // 登录态验证 - 必须登录才能提交(确保地址归属正确) var submitUserID *int64 authHeader := ctx.GetHeader("Authorization") - if authHeader != "" { - // 如果有 Authorization 尝试解析 - if claims, err := jwtoken.New(configs.Get().JWT.PatientSecret).Parse(authHeader); err == nil { - uid := int64(claims.SessionUserInfo.Id) - submitUserID = &uid - } + if authHeader == "" { + ctx.AbortWithError(core.Error(http.StatusUnauthorized, 10027, "请先登录后再提交收货地址")) + return } + claims, claimsErr := jwtoken.New(configs.Get().JWT.PatientSecret).Parse(authHeader) + if claimsErr != nil { + ctx.AbortWithError(core.Error(http.StatusUnauthorized, 10027, "登录已过期,请重新登录")) + return + } + uid := int64(claims.SessionUserInfo.Id) + submitUserID = &uid ip := ctx.Request().RemoteAddr // 统一使用 ctx.RequestContext() 包含 context 内容 diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go index 4405f2e..fdb20d2 100755 --- a/internal/service/user/address_share.go +++ b/internal/service/user/address_share.go @@ -113,12 +113,12 @@ 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. 确定资产最终归属地 (实名转赠逻辑) - targetUserID := claims.OwnerUserID - isTransfer := false - if submittedByUserID != nil && *submittedByUserID > 0 && *submittedByUserID != claims.OwnerUserID { - targetUserID = *submittedByUserID - isTransfer = true + // 必须登录才能提交,submittedByUserID 由 API 层保证非空 + if submittedByUserID == nil || *submittedByUserID <= 0 { + return 0, fmt.Errorf("login_required") } + targetUserID := *submittedByUserID + isTransfer := targetUserID != claims.OwnerUserID var addrID int64 err = s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {