Browse Source

重写安全解冻方法:修正分支逻辑和业务规则

- 将需要补足和完全补足合并为一个分支(shortageQuantity > 0)
- 修正业务逻辑:解冻只从其他冻结堆补足,不从可用物品扣除
- 移除错误的物品交易日志,解冻操作只产生解冻日志
- 完善事件触发和异常处理机制
- 添加安全解冻逻辑设计文档
- 创建完整的测试验证两个分支的正确性
dongasai 6 months ago
parent
commit
09ba13199a

+ 134 - 277
app/Module/GameItems/Logics/ItemFreeze.php

@@ -335,119 +335,111 @@ class ItemFreeze
         $originalFrozenQuantity = $freezeLog->quantity;
         $currentQuantity = $frozenItem->quantity;
 
-        if ($currentQuantity <= 0) {
-            // 冻结堆已被完全消耗,无法解冻
-            throw new Exception("冻结日志 {$freezeLogId} 对应的冻结物品已被完全消耗,无法解冻");
-        }
-
         // 检查是否需要补足差额
         $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
 
+        if ($currentQuantity <= 0) {
+            // 冻结堆已被完全消耗,需要从其他冻结堆中完全补足
+            $shortageQuantity = $originalFrozenQuantity; // 需要补足全部原始数量
+        }
+
         if ($shortageQuantity > 0) {
-            // 冻结堆被部分消耗,需要从用户其他可用物品中补足
-            $availableQuantity = self::getAvailableQuantity(
+            // 冻结堆被部分消耗,需要从用户其他冻结物品中解冻来补足
+            $otherFrozenQuantity = self::getOtherFrozenQuantity(
                 $frozenItem->user_id,
                 $frozenItem->item_id,
-                $frozenItem->instance_id
+                $frozenItem->instance_id,
+                $freezeLogId
             );
 
-            if ($availableQuantity < $shortageQuantity) {
+            if ($otherFrozenQuantity < $shortageQuantity) {
                 throw new Exception(
                     "解冻失败:原始冻结数量 {$originalFrozenQuantity},当前冻结剩余 {$currentQuantity}," .
-                    "需要补足 {$shortageQuantity},但用户可用数量只有 {$availableQuantity}"
+                    "需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}"
                 );
             }
 
-            // 从用户可用物品中扣除差额,补足到冻结堆(使用锁定避免并发问题)
-            $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
+            // 从用户其他冻结物品中解冻来补足(使用锁定避免并发问题)
+            $otherFrozenItems = ItemUser::where('user_id', $frozenItem->user_id)
                 ->where('item_id', $frozenItem->item_id)
-                ->where('is_frozen', false)
-                ->whereNull('instance_id')
+                ->where('instance_id', $frozenItem->instance_id)
+                ->where('is_frozen', true)
+                ->where('frozen_log_id', '!=', $freezeLogId)
                 ->where('quantity', '>', 0)
                 ->orderBy('expire_at')
                 ->lockForUpdate() // 锁定记录,避免并发修改
                 ->get();
 
-            // 重新验证可用数量(锁定后可能已变化)
-            $actualAvailableQuantity = $availableItems->sum('quantity');
-            if ($actualAvailableQuantity < $shortageQuantity) {
+            // 重新验证其他冻结数量(锁定后可能已变化)
+            $actualOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
+            if ($actualOtherFrozenQuantity < $shortageQuantity) {
                 throw new Exception(
-                    "解冻失败:需要补足 {$shortageQuantity},但锁定后用户可用数量只有 {$actualAvailableQuantity}"
+                    "解冻失败:需要补足 {$shortageQuantity},但锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}"
                 );
             }
 
             $remainingShortage = $shortageQuantity;
-            $transferDetails = []; // 记录转移详情
+            $unfreezeDetails = []; // 记录解冻详情
 
-            foreach ($availableItems as $availableItem) {
+            foreach ($otherFrozenItems as $otherFrozenItem) {
                 if ($remainingShortage <= 0) break;
 
-                $deductQuantity = min($availableItem->quantity, $remainingShortage);
-                $oldQuantity = $availableItem->quantity;
-                $newQuantity = $oldQuantity - $deductQuantity;
+                $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
+                $oldQuantity = $otherFrozenItem->quantity;
+                $newQuantity = $oldQuantity - $unfreezeQuantity;
 
-                // 更新可用物品数量
-                $availableItem->quantity = $newQuantity;
-                $availableItem->save();
+                // 从其他冻结堆中解冻指定数量
+                $otherFrozenItem->quantity = $newQuantity;
+                $otherFrozenItem->save();
 
-                // 记录转移详情
-                $transferDetails[] = [
-                    'from_user_item_id' => $availableItem->id,
-                    'transferred_quantity' => $deductQuantity,
+                // 记录解冻详情
+                $unfreezeDetails[] = [
+                    'from_frozen_item_id' => $otherFrozenItem->id,
+                    'from_freeze_log_id' => $otherFrozenItem->frozen_log_id,
+                    'unfrozen_quantity' => $unfreezeQuantity,
                     'old_quantity' => $oldQuantity,
                     'new_quantity' => $newQuantity,
                 ];
 
-                // 记录交易日志(从可用物品扣除
-                \App\Module\GameItems\Logics\Item::logTransaction(
+                // 记录解冻日志(从其他冻结堆解冻
+                ItemFreezeLog::createLog(
                     $frozenItem->user_id,
                     $frozenItem->item_id,
-                    null,
-                    -$deductQuantity,
-                    TRANSACTION_TYPE::TRADE_OUT, // 使用交易失去类型
-                    'unfreeze_compensation',
-                    $freezeLogId,
-                    ["解冻补足转移:从可用物品转移 {$deductQuantity} 个到冻结堆"]
+                    $frozenItem->instance_id,
+                    $unfreezeQuantity,
+                    FREEZE_ACTION_TYPE::UNFREEZE,
+                    "解冻补足:从冻结堆 {$otherFrozenItem->frozen_log_id} 解冻 {$unfreezeQuantity} 个用于补足解冻日志 {$freezeLogId}",
+                    $freezeLog->source_id,
+                    $freezeLog->source_type,
+                    $freezeLog->operator_id
                 );
 
-                // 触发物品数量变更事件(可用物品减少)
+                // 触发物品数量变更事件(其他冻结堆减少)
                 event(new ItemQuantityChanged(
                     $frozenItem->user_id,
                     $frozenItem->item_id,
-                    null,
+                    $frozenItem->instance_id,
                     $oldQuantity,
                     $newQuantity,
-                    $availableItem->id,
-                    false, // 旧冻结状态:未冻结
-                    false, // 新冻结状态:未冻结
+                    $otherFrozenItem->id,
+                    true, // 旧冻结状态:已冻结
+                    true, // 新冻结状态:仍冻结(如果数量>0)或未冻结(如果数量=0)
                     [
-                        'action' => 'unfreeze_compensation_transfer_out',
-                        'freeze_log_id' => $freezeLogId,
-                        'transferred_quantity' => $deductQuantity,
+                        'action' => 'unfreeze_compensation_from_other_frozen',
+                        'target_freeze_log_id' => $freezeLogId,
+                        'unfrozen_quantity' => $unfreezeQuantity,
                     ]
                 ));
 
-                $remainingShortage -= $deductQuantity;
+                $remainingShortage -= $unfreezeQuantity;
             }
 
-            // 将补足的数量加到冻结堆
+            // 将补足的数量加到目标冻结堆
             $oldFrozenQuantity = $frozenItem->quantity;
             $frozenItem->quantity = $originalFrozenQuantity;
             $frozenItem->save();
 
-            // 记录交易日志(向冻结堆补足)
-            \App\Module\GameItems\Logics\Item::logTransaction(
-                $frozenItem->user_id,
-                $frozenItem->item_id,
-                $frozenItem->instance_id,
-                $shortageQuantity,
-                TRANSACTION_TYPE::TRADE_IN, // 使用交易获得类型
-                'unfreeze_compensation',
-                $freezeLogId,
-                ["解冻补足转移:向冻结堆补足 {$shortageQuantity} 个,转移详情:" . json_encode($transferDetails)]
-            );
-
-            // 触发物品数量变更事件(冻结堆增加)
+            // 触发物品数量变更事件(目标冻结堆增加)
             event(new ItemQuantityChanged(
                 $frozenItem->user_id,
                 $frozenItem->item_id,
@@ -458,10 +450,10 @@ class ItemFreeze
                 true, // 旧冻结状态:已冻结
                 true, // 新冻结状态:已冻结
                 [
-                    'action' => 'unfreeze_compensation_transfer_in',
+                    'action' => 'unfreeze_compensation_target_increase',
                     'freeze_log_id' => $freezeLogId,
                     'compensated_quantity' => $shortageQuantity,
-                    'transfer_details' => $transferDetails,
+                    'unfreeze_details' => $unfreezeDetails,
                 ]
             ));
         }
@@ -817,253 +809,102 @@ class ItemFreeze
         $originalFrozenQuantity = $freezeLog->quantity;
         $currentQuantity = $frozenItem->quantity;
 
-        if ($currentQuantity <= 0) {
-            // 冻结堆已被完全消耗,尝试从用户其他可用物品中补足
-            $availableQuantity = self::getAvailableQuantity(
+        // 计算需要补足的数量
+        $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
+
+        if ($shortageQuantity > 0) {
+            // 需要补足分支:从其他冻结堆中解冻来补足
+            $otherFrozenQuantity = self::getOtherFrozenQuantity(
                 $frozenItem->user_id,
                 $frozenItem->item_id,
-                $frozenItem->instance_id
+                $frozenItem->instance_id,
+                $freezeLogId
             );
 
-            if ($availableQuantity < $originalFrozenQuantity) {
+            if ($otherFrozenQuantity < $shortageQuantity) {
                 throw new Exception(
-                    "安全解冻失败:冻结物品已被完全消耗,需要补足 {$originalFrozenQuantity},但用户可用数量只有 {$availableQuantity}"
+                    "安全解冻失败:需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}"
                 );
             }
 
-            // 从用户可用物品中补足全部数量(使用锁定避免并发问题)
-            $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
+            // 从其他冻结物品中解冻来补足
+            $otherFrozenItems = ItemUser::where('user_id', $frozenItem->user_id)
                 ->where('item_id', $frozenItem->item_id)
-                ->where('is_frozen', false)
-                ->whereNull('instance_id')
+                ->where('instance_id', $frozenItem->instance_id)
+                ->where('is_frozen', true)
+                ->where('frozen_log_id', '!=', $freezeLogId)
                 ->where('quantity', '>', 0)
                 ->orderBy('expire_at')
-                ->lockForUpdate() // 锁定记录,避免并发修改
+                ->lockForUpdate()
                 ->get();
 
-            // 重新验证可用数量(锁定后可能已变化)
-            $actualAvailableQuantity = $availableItems->sum('quantity');
-            if ($actualAvailableQuantity < $originalFrozenQuantity) {
+            // 重新验证其他冻结数量(锁定后可能已变化)
+            $actualOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
+            if ($actualOtherFrozenQuantity < $shortageQuantity) {
                 throw new Exception(
-                    "安全解冻失败:冻结物品已被完全消耗,需要补足 {$originalFrozenQuantity},但锁定后用户可用数量只有 {$actualAvailableQuantity}"
+                    "安全解冻失败:需要补足 {$shortageQuantity},但锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}"
                 );
             }
 
-            $remainingQuantity = $originalFrozenQuantity;
-            $transferDetails = []; // 记录转移详情
+            $remainingShortage = $shortageQuantity;
+            $unfreezeDetails = [];
 
-            foreach ($availableItems as $availableItem) {
-                if ($remainingQuantity <= 0) break;
+            foreach ($otherFrozenItems as $otherFrozenItem) {
+                if ($remainingShortage <= 0) break;
 
-                $deductQuantity = min($availableItem->quantity, $remainingQuantity);
-                $oldQuantity = $availableItem->quantity;
-                $newQuantity = $oldQuantity - $deductQuantity;
+                $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
+                $oldQuantity = $otherFrozenItem->quantity;
+                $newQuantity = $oldQuantity - $unfreezeQuantity;
 
-                // 更新可用物品数量
-                $availableItem->quantity = $newQuantity;
-                $availableItem->save();
+                // 从其他冻结堆中减少数量
+                $otherFrozenItem->quantity = $newQuantity;
+                $otherFrozenItem->save();
 
-                // 记录转移详情
-                $transferDetails[] = [
-                    'from_user_item_id' => $availableItem->id,
-                    'transferred_quantity' => $deductQuantity,
+                // 记录解冻详情
+                $unfreezeDetails[] = [
+                    'from_frozen_item_id' => $otherFrozenItem->id,
+                    'from_freeze_log_id' => $otherFrozenItem->frozen_log_id,
+                    'unfrozen_quantity' => $unfreezeQuantity,
                     'old_quantity' => $oldQuantity,
                     'new_quantity' => $newQuantity,
                 ];
 
-                // 记录交易日志(从可用物品扣除
-                \App\Module\GameItems\Logics\Item::logTransaction(
+                // 记录解冻日志(从其他冻结堆解冻
+                ItemFreezeLog::createLog(
                     $frozenItem->user_id,
                     $frozenItem->item_id,
-                    null,
-                    -$deductQuantity,
-                    TRANSACTION_TYPE::TRADE_OUT,
-                    'safe_unfreeze_compensation',
-                    $freezeLogId,
-                    ["安全解冻补足转移:从可用物品转移 {$deductQuantity} 个到冻结堆"]
+                    $frozenItem->instance_id,
+                    $unfreezeQuantity,
+                    FREEZE_ACTION_TYPE::UNFREEZE,
+                    "安全解冻补足:从冻结堆 {$otherFrozenItem->frozen_log_id} 解冻 {$unfreezeQuantity} 个用于补足解冻日志 {$freezeLogId}",
+                    $freezeLog->source_id,
+                    $freezeLog->source_type,
+                    $freezeLog->operator_id
                 );
 
-                // 触发物品数量变更事件(可用物品减少)
+                // 触发物品数量变更事件
                 event(new ItemQuantityChanged(
                     $frozenItem->user_id,
                     $frozenItem->item_id,
-                    null,
+                    $frozenItem->instance_id,
                     $oldQuantity,
                     $newQuantity,
-                    $availableItem->id,
-                    false, // 旧冻结状态:未冻结
-                    false, // 新冻结状态:未冻结
+                    $otherFrozenItem->id,
+                    true, // 旧冻结状态:已冻结
+                    $newQuantity > 0, // 新冻结状态:如果数量>0仍冻结,否则解冻
                     [
-                        'action' => 'safe_unfreeze_compensation_transfer_out',
-                        'freeze_log_id' => $freezeLogId,
-                        'transferred_quantity' => $deductQuantity,
+                        'action' => 'safe_unfreeze_compensation_from_other_frozen',
+                        'target_freeze_log_id' => $freezeLogId,
+                        'unfrozen_quantity' => $unfreezeQuantity,
                     ]
                 ));
 
-                $remainingQuantity -= $deductQuantity;
+                $remainingShortage -= $unfreezeQuantity;
             }
 
-            // 恢复冻结堆到原始数量
-            $oldFrozenQuantity = $frozenItem->quantity;
+            // 将补足的数量加到目标冻结堆
             $frozenItem->quantity = $originalFrozenQuantity;
             $frozenItem->save();
-
-            // 记录交易日志(向冻结堆补足)
-            \App\Module\GameItems\Logics\Item::logTransaction(
-                $frozenItem->user_id,
-                $frozenItem->item_id,
-                $frozenItem->instance_id,
-                $originalFrozenQuantity,
-                TRANSACTION_TYPE::TRADE_IN,
-                'safe_unfreeze_compensation',
-                $freezeLogId,
-                ["安全解冻补足转移:向冻结堆补足 {$originalFrozenQuantity} 个,转移详情:" . json_encode($transferDetails)]
-            );
-
-            // 触发物品数量变更事件(冻结堆增加)
-            event(new ItemQuantityChanged(
-                $frozenItem->user_id,
-                $frozenItem->item_id,
-                $frozenItem->instance_id,
-                $oldFrozenQuantity,
-                $originalFrozenQuantity,
-                $frozenItem->id,
-                true, // 旧冻结状态:已冻结
-                true, // 新冻结状态:已冻结
-                [
-                    'action' => 'safe_unfreeze_compensation_transfer_in',
-                    'freeze_log_id' => $freezeLogId,
-                    'compensated_quantity' => $originalFrozenQuantity,
-                    'transfer_details' => $transferDetails,
-                ]
-            ));
-
-            $shortageQuantity = $originalFrozenQuantity;
-        } else {
-            // 检查是否需要补足差额
-            $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
-
-            if ($shortageQuantity > 0) {
-                // 冻结堆被部分消耗,需要从用户其他可用物品中补足
-                $availableQuantity = self::getAvailableQuantity(
-                    $frozenItem->user_id,
-                    $frozenItem->item_id,
-                    $frozenItem->instance_id
-                );
-
-                if ($availableQuantity < $shortageQuantity) {
-                    throw new Exception(
-                        "安全解冻失败:需要补足 {$shortageQuantity},但用户可用数量只有 {$availableQuantity}"
-                    );
-                }
-
-                // 从用户可用物品中扣除差额,补足到冻结堆(使用锁定避免并发问题)
-                $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
-                    ->where('item_id', $frozenItem->item_id)
-                    ->where('is_frozen', false)
-                    ->whereNull('instance_id')
-                    ->where('quantity', '>', 0)
-                    ->orderBy('expire_at')
-                    ->lockForUpdate() // 锁定记录,避免并发修改
-                    ->get();
-
-                // 重新验证可用数量(锁定后可能已变化)
-                $actualAvailableQuantity = $availableItems->sum('quantity');
-                if ($actualAvailableQuantity < $shortageQuantity) {
-                    throw new Exception(
-                        "安全解冻失败:需要补足 {$shortageQuantity},但锁定后用户可用数量只有 {$actualAvailableQuantity}"
-                    );
-                }
-
-                $remainingShortage = $shortageQuantity;
-                $transferDetails = []; // 记录转移详情
-
-                foreach ($availableItems as $availableItem) {
-                    if ($remainingShortage <= 0) break;
-
-                    $deductQuantity = min($availableItem->quantity, $remainingShortage);
-                    $oldQuantity = $availableItem->quantity;
-                    $newQuantity = $oldQuantity - $deductQuantity;
-
-                    // 更新可用物品数量
-                    $availableItem->quantity = $newQuantity;
-                    $availableItem->save();
-
-                    // 记录转移详情
-                    $transferDetails[] = [
-                        'from_user_item_id' => $availableItem->id,
-                        'transferred_quantity' => $deductQuantity,
-                        'old_quantity' => $oldQuantity,
-                        'new_quantity' => $newQuantity,
-                    ];
-
-                    // 记录交易日志(从可用物品扣除)
-                    \App\Module\GameItems\Logics\Item::logTransaction(
-                        $frozenItem->user_id,
-                        $frozenItem->item_id,
-                        null,
-                        -$deductQuantity,
-                        TRANSACTION_TYPE::TRADE_OUT,
-                        'safe_unfreeze_partial_compensation',
-                        $freezeLogId,
-                        ["安全解冻部分补足转移:从可用物品转移 {$deductQuantity} 个到冻结堆"]
-                    );
-
-                    // 触发物品数量变更事件(可用物品减少)
-                    event(new ItemQuantityChanged(
-                        $frozenItem->user_id,
-                        $frozenItem->item_id,
-                        null,
-                        $oldQuantity,
-                        $newQuantity,
-                        $availableItem->id,
-                        false, // 旧冻结状态:未冻结
-                        false, // 新冻结状态:未冻结
-                        [
-                            'action' => 'safe_unfreeze_partial_compensation_transfer_out',
-                            'freeze_log_id' => $freezeLogId,
-                            'transferred_quantity' => $deductQuantity,
-                        ]
-                    ));
-
-                    $remainingShortage -= $deductQuantity;
-                }
-
-                // 将补足的数量加到冻结堆
-                $oldFrozenQuantity = $frozenItem->quantity;
-                $frozenItem->quantity = $originalFrozenQuantity;
-                $frozenItem->save();
-
-                // 记录交易日志(向冻结堆补足)
-                \App\Module\GameItems\Logics\Item::logTransaction(
-                    $frozenItem->user_id,
-                    $frozenItem->item_id,
-                    $frozenItem->instance_id,
-                    $shortageQuantity,
-                    TRANSACTION_TYPE::TRADE_IN,
-                    'safe_unfreeze_partial_compensation',
-                    $freezeLogId,
-                    ["安全解冻部分补足转移:向冻结堆补足 {$shortageQuantity} 个,转移详情:" . json_encode($transferDetails)]
-                );
-
-                // 触发物品数量变更事件(冻结堆增加)
-                event(new ItemQuantityChanged(
-                    $frozenItem->user_id,
-                    $frozenItem->item_id,
-                    $frozenItem->instance_id,
-                    $oldFrozenQuantity,
-                    $originalFrozenQuantity,
-                    $frozenItem->id,
-                    true, // 旧冻结状态:已冻结
-                    true, // 新冻结状态:已冻结
-                    [
-                        'action' => 'safe_unfreeze_partial_compensation_transfer_in',
-                        'freeze_log_id' => $freezeLogId,
-                        'compensated_quantity' => $shortageQuantity,
-                        'transfer_details' => $transferDetails,
-                    ]
-                ));
-            }
         }
 
         // 创建解冻日志
@@ -1073,7 +914,7 @@ class ItemFreeze
             $frozenItem->instance_id,
             $originalFrozenQuantity,
             FREEZE_ACTION_TYPE::UNFREEZE,
-            "安全解冻操作,原冻结日志ID: {$freezeLogId},原始冻结数量: {$originalFrozenQuantity}" .
+            "安全解冻操作,原冻结日志ID: {$freezeLogId}" .
             ($shortageQuantity > 0 ? ",补足差额: {$shortageQuantity}" : ""),
             $freezeLog->source_id,
             $freezeLog->source_type,
@@ -1085,7 +926,7 @@ class ItemFreeze
         $frozenItem->frozen_log_id = null;
         $frozenItem->save();
 
-        // 触发物品变更事件
+        // 触发解冻完成事件
         event(new ItemQuantityChanged(
             $frozenItem->user_id,
             $frozenItem->item_id,
@@ -1096,21 +937,16 @@ class ItemFreeze
             true,  // 旧冻结状态:已冻结
             false, // 新冻结状态:未冻结
             [
-                'freeze_action' => 'safe_unfreeze',
+                'action' => 'safe_unfreeze_completed',
                 'unfreeze_log_id' => $unfreezeLog->id,
                 'original_freeze_log_id' => $freezeLogId,
-                'original_frozen_quantity' => $originalFrozenQuantity,
                 'shortage_compensated' => $shortageQuantity,
-                'source_id' => $freezeLog->source_id,
-                'source_type' => $freezeLog->source_type,
-                'operator_id' => $freezeLog->operator_id,
+                'compensation_details' => $unfreezeDetails ?? [],
             ]
         ));
 
         return [
             'success' => true,
-            'status' => 'unfrozen',
-            'message' => "成功解冻物品",
             'user_id' => $frozenItem->user_id,
             'item_id' => $frozenItem->item_id,
             'instance_id' => $frozenItem->instance_id,
@@ -1118,6 +954,7 @@ class ItemFreeze
             'shortage_compensated' => $shortageQuantity,
             'user_item_id' => $frozenItem->id,
             'unfreeze_log_id' => $unfreezeLog->id,
+            'compensation_details' => $unfreezeDetails ?? [],
         ];
     }
 
@@ -1174,4 +1011,24 @@ class ItemFreeze
 
         return $statistics;
     }
+
+    /**
+     * 获取用户其他冻结物品的数量(排除指定的冻结日志)
+     *
+     * @param int $userId 用户ID
+     * @param int $itemId 物品ID
+     * @param string|null $instanceId 实例ID
+     * @param int $excludeFreezeLogId 要排除的冻结日志ID
+     * @return int 其他冻结物品的总数量
+     */
+    private static function getOtherFrozenQuantity(int $userId, int $itemId, ?string $instanceId, int $excludeFreezeLogId): int
+    {
+        return ItemUser::where('user_id', $userId)
+            ->where('item_id', $itemId)
+            ->where('instance_id', $instanceId)
+            ->where('is_frozen', true)
+            ->where('frozen_log_id', '!=', $excludeFreezeLogId)
+            ->where('quantity', '>', 0)
+            ->sum('quantity');
+    }
 }

+ 234 - 0
docs/安全解冻逻辑设计.md

@@ -0,0 +1,234 @@
+# 安全解冻逻辑设计文档
+
+## 概述
+
+安全解冻是物品冻结系统中的核心功能,用于处理冻结物品被部分或完全消耗后的解冻操作。与普通解冻不同,安全解冻需要处理复杂的补足逻辑。
+
+## 业务场景
+
+### 典型场景
+1. **用户冻结100个物品**(用于交易订单)
+2. **冻结堆被部分消耗60个**(其他操作消耗了冻结物品)
+3. **用户要求解冻**(取消交易订单)
+4. **系统需要解冻100个物品**(恢复原始冻结数量)
+
+### 问题分析
+- 冻结堆当前只有40个,但用户期望解冻100个
+- 需要从其他地方补足60个差额
+- **关键原则**:解冻是状态变更,不是物品消耗
+
+## 核心设计原则
+
+### 1. 解冻 ≠ 消耗
+- **解冻**:改变物品的冻结状态,从"冻结"变为"可用"
+- **消耗**:减少物品的总数量
+- **补足来源**:只能从其他冻结堆中解冻,不能从可用物品中扣除
+
+### 2. 数量守恒
+- 解冻前后,用户的物品总数量不变
+- 只是冻结状态发生变化
+
+### 3. 日志类型
+- **解冻操作**:只产生解冻日志(ItemFreezeLog)
+- **不产生**:物品交易日志(ItemTransactionLog)
+
+## 安全解冻逻辑流程
+
+### 输入参数
+- `freezeLogId`: 要解冻的冻结日志ID
+
+### 处理流程
+
+#### 第一步:验证和获取基础信息
+```php
+1. 检查事务状态
+2. 查找冻结日志记录
+3. 验证冻结日志的有效性
+4. 查找对应的冻结物品堆
+```
+
+#### 第二步:计算补足需求
+```php
+$originalFrozenQuantity = $freezeLog->quantity;  // 原始冻结数量
+$currentQuantity = $frozenItem->quantity;        // 当前剩余数量
+$shortageQuantity = $originalFrozenQuantity - $currentQuantity; // 需要补足的数量
+```
+
+#### 第三步:分支处理
+
+##### 分支A:无需补足(shortageQuantity == 0)
+```php
+if ($shortageQuantity == 0) {
+    // 冻结堆完整,直接解冻
+    return 正常解冻流程();
+}
+```
+
+##### 分支B:需要补足(shortageQuantity > 0)
+```php
+if ($shortageQuantity > 0) {
+    // 包含两种情况:
+    // 1. 部分消耗:currentQuantity > 0,需要补足部分差额
+    // 2. 完全消耗:currentQuantity <= 0,需要补足全部数量
+    // 两种情况的处理逻辑相同:都是从其他冻结堆中解冻来补足
+    return 补足解冻流程();
+}
+```
+
+### 补足解冻详细流程
+
+#### 1. 查找补足来源
+```php
+// 查找用户其他冻结物品(排除当前冻结堆)
+$otherFrozenItems = ItemUser::where('user_id', $userId)
+    ->where('item_id', $itemId)
+    ->where('instance_id', $instanceId)
+    ->where('is_frozen', true)
+    ->where('frozen_log_id', '!=', $freezeLogId)  // 排除当前冻结堆
+    ->where('quantity', '>', 0)
+    ->orderBy('expire_at')  // 优先使用即将过期的
+    ->lockForUpdate()       // 锁定防并发
+    ->get();
+```
+
+#### 2. 验证补足能力
+```php
+$totalOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
+if ($totalOtherFrozenQuantity < $shortageQuantity) {
+    throw new Exception("其他冻结数量不足以补足");
+}
+```
+
+#### 3. 执行补足操作
+```php
+foreach ($otherFrozenItems as $otherFrozenItem) {
+    $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
+    
+    // 从其他冻结堆中减少数量
+    $otherFrozenItem->quantity -= $unfreezeQuantity;
+    $otherFrozenItem->save();
+    
+    // 记录解冻日志
+    ItemFreezeLog::createLog(
+        $userId, $itemId, $instanceId, $unfreezeQuantity,
+        FREEZE_ACTION_TYPE::UNFREEZE,
+        "补足解冻:从冻结堆{$otherFrozenItem->frozen_log_id}解冻{$unfreezeQuantity}个用于补足解冻日志{$freezeLogId}"
+    );
+    
+    // 触发事件
+    event(new ItemQuantityChanged(...));
+    
+    $remainingShortage -= $unfreezeQuantity;
+    if ($remainingShortage <= 0) break;
+}
+```
+
+#### 4. 恢复目标冻结堆
+```php
+// 将目标冻结堆恢复到原始数量
+$frozenItem->quantity = $originalFrozenQuantity;
+$frozenItem->save();
+```
+
+#### 5. 执行最终解冻
+```php
+// 创建解冻日志
+$unfreezeLog = ItemFreezeLog::createLog(
+    $userId, $itemId, $instanceId, $originalFrozenQuantity,
+    FREEZE_ACTION_TYPE::UNFREEZE,
+    "安全解冻操作,原冻结日志ID: {$freezeLogId},补足差额: {$shortageQuantity}"
+);
+
+// 解冻物品
+$frozenItem->is_frozen = false;
+$frozenItem->frozen_log_id = null;
+$frozenItem->save();
+
+// 触发解冻事件
+event(new ItemQuantityChanged(...));
+```
+
+## 异常处理
+
+### 1. 冻结日志不存在
+```php
+throw new Exception("冻结日志 {$freezeLogId} 不存在");
+```
+
+### 2. 冻结物品不存在
+```php
+throw new Exception("未找到冻结日志 {$freezeLogId} 对应的冻结物品");
+```
+
+### 3. 其他冻结数量不足
+```php
+throw new Exception("需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}");
+```
+
+### 4. 锁定后数量变化
+```php
+throw new Exception("锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}");
+```
+
+## 返回结果
+
+### 成功返回
+```php
+return [
+    'success' => true,
+    'user_id' => $userId,
+    'item_id' => $itemId,
+    'instance_id' => $instanceId,
+    'unfrozen_quantity' => $originalFrozenQuantity,      // 实际解冻数量
+    'shortage_compensated' => $shortageQuantity,         // 补足的差额
+    'user_item_id' => $frozenItem->id,
+    'unfreeze_log_id' => $unfreezeLog->id,
+    'compensation_details' => $unfreezeDetails,          // 补足详情
+];
+```
+
+## 事件触发
+
+### 1. 补足过程事件
+- **事件类型**:ItemQuantityChanged
+- **触发时机**:每次从其他冻结堆减少数量时
+- **事件数据**:包含补足操作的详细信息
+
+### 2. 解冻完成事件
+- **事件类型**:ItemQuantityChanged
+- **触发时机**:最终解冻操作完成时
+- **事件数据**:包含解冻操作的完整信息
+
+## 并发安全
+
+### 锁定机制
+- 使用 `lockForUpdate()` 锁定相关记录
+- 锁定后重新验证数量,防止并发修改
+- 确保补足操作的原子性
+
+### 事务保护
+- 整个操作在数据库事务中执行
+- 任何步骤失败都会回滚所有变更
+- 保证数据一致性
+
+## 与普通解冻的区别
+
+| 特性 | 普通解冻 | 安全解冻 |
+|------|----------|----------|
+| 处理场景 | 冻结堆完整 | 冻结堆被消耗 |
+| 补足机制 | 无需补足 | 从其他冻结堆补足 |
+| 异常处理 | 严格抛异常 | 智能处理各种情况 |
+| 返回信息 | 基础信息 | 详细补足信息 |
+| 性能开销 | 较低 | 较高(需要查找和处理其他冻结堆) |
+
+## 使用建议
+
+### 何时使用安全解冻
+1. **用户主动取消操作**:如取消交易订单
+2. **系统自动回滚**:如订单超时自动取消
+3. **异常恢复场景**:如系统故障后的数据恢复
+
+### 何时使用普通解冻
+1. **确定冻结堆完整**:如刚冻结后立即解冻
+2. **性能敏感场景**:如高频操作
+3. **简单业务逻辑**:如临时冻结后快速解冻

+ 226 - 0
tests/test_safe_unfreeze_rewrite.php

@@ -0,0 +1,226 @@
+<?php
+
+/**
+ * 手动测试脚本:验证重写的安全解冻方法
+ * 
+ * 测试安全解冻的两个分支:无需补足和需要补足
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use App\Module\GameItems\Services\ItemService;
+use App\Module\GameItems\Enums\FREEZE_REASON_TYPE;
+use Illuminate\Support\Facades\DB;
+
+// 启动Laravel应用
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+echo "=== 安全解冻方法重写验证测试 ===\n\n";
+
+try {
+    DB::beginTransaction();
+    
+    $userId = 1001;
+    $itemId = 1;
+    
+    echo "1. 准备测试数据\n";
+    
+    // 添加物品
+    $addResult = ItemService::addItem($userId, $itemId, 150);
+    echo "   添加150个物品成功\n";
+    
+    // 冻结第一批物品(用于测试无需补足)
+    $freezeResult1 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12345,
+            'source_type' => 'test_order_1',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId1 = $freezeResult1['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结30个物品成功,日志ID: {$freezeLogId1}\n";
+    
+    // 冻结第二批物品(用于测试需要补足)
+    $freezeResult2 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        50, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12346,
+            'source_type' => 'test_order_2',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId2 = $freezeResult2['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结50个物品成功,日志ID: {$freezeLogId2}\n";
+    
+    // 冻结第三批物品(用于作为补足来源)
+    $freezeResult3 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        40, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12347,
+            'source_type' => 'test_order_3',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId3 = $freezeResult3['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结40个物品成功,日志ID: {$freezeLogId3}\n";
+    
+    // 检查当前状态
+    echo "\n2. 检查冻结后的物品状态\n";
+    $userItems = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItems as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        $freezeLogId = $item->frozen_log_id ?? 'N/A';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+    }
+    
+    echo "\n3. 测试分支A:无需补足的安全解冻\n";
+    
+    // 第一个冻结堆没有被消耗,直接解冻
+    try {
+        $safeUnfreezeResult1 = ItemService::safeUnfreezeItem($freezeLogId1);
+        echo "   安全解冻成功: " . json_encode($safeUnfreezeResult1, JSON_UNESCAPED_UNICODE) . "\n";
+        echo "   补足差额: " . ($safeUnfreezeResult1['shortage_compensated'] ?? 0) . "\n";
+        
+        if ($safeUnfreezeResult1['shortage_compensated'] == 0) {
+            echo "   ✅ 分支A测试通过:无需补足\n";
+        } else {
+            echo "   ❌ 分支A测试失败:不应该有补足\n";
+        }
+    } catch (Exception $e) {
+        echo "   ❌ 分支A测试失败: " . $e->getMessage() . "\n";
+    }
+    
+    echo "\n4. 测试分支B:需要补足的安全解冻\n";
+    
+    // 部分消耗第二个冻结堆
+    $consumeResult = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        [
+            'include_frozen' => true,
+            'source_type' => 'test_consume',
+            'source_id' => 67890
+        ]
+    );
+    echo "   消耗30个冻结物品成功\n";
+    
+    // 检查消耗后状态
+    echo "   消耗后物品状态:\n";
+    $userItemsAfter = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsAfter as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        $freezeLogId = $item->frozen_log_id ?? 'N/A';
+        echo "     物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+    }
+    
+    // 现在第二个冻结堆应该只有20个(50-30),需要从第三个冻结堆补足30个
+    try {
+        $safeUnfreezeResult2 = ItemService::safeUnfreezeItem($freezeLogId2);
+        echo "   安全解冻成功: " . json_encode($safeUnfreezeResult2, JSON_UNESCAPED_UNICODE) . "\n";
+        echo "   补足差额: " . ($safeUnfreezeResult2['shortage_compensated'] ?? 0) . "\n";
+        
+        if ($safeUnfreezeResult2['shortage_compensated'] == 30) {
+            echo "   ✅ 分支B测试通过:正确补足30个\n";
+        } else {
+            echo "   ❌ 分支B测试失败:应该补足30个,实际补足 " . ($safeUnfreezeResult2['shortage_compensated'] ?? 0) . "\n";
+        }
+        
+        if ($safeUnfreezeResult2['unfrozen_quantity'] == 50) {
+            echo "   ✅ 解冻数量正确:50个\n";
+        } else {
+            echo "   ❌ 解冻数量错误:应该50个,实际 " . ($safeUnfreezeResult2['unfrozen_quantity'] ?? 0) . "\n";
+        }
+    } catch (Exception $e) {
+        echo "   ❌ 分支B测试失败: " . $e->getMessage() . "\n";
+    }
+    
+    echo "\n5. 检查最终物品状态\n";
+    $userItemsFinal = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    $totalAvailable = 0;
+    $totalFrozen = 0;
+    
+    foreach ($userItemsFinal as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        $freezeLogId = $item->frozen_log_id ?? 'N/A';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+        
+        if ($item->is_frozen) {
+            $totalFrozen += $item->quantity;
+        } else {
+            $totalAvailable += $item->quantity;
+        }
+    }
+    
+    echo "   总计:可用 {$totalAvailable},冻结 {$totalFrozen}\n";
+    
+    // 验证数量守恒
+    $totalQuantity = $totalAvailable + $totalFrozen;
+    if ($totalQuantity == 120) { // 150 - 30(消耗) = 120
+        echo "   ✅ 数量守恒正确:总数量120(150-30消耗)\n";
+    } else {
+        echo "   ❌ 数量守恒错误:总数量应该120,实际 {$totalQuantity}\n";
+    }
+    
+    echo "\n6. 测试完全消耗的情况\n";
+    
+    // 完全消耗第三个冻结堆
+    $consumeResult2 = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        10, // 第三个冻结堆现在应该只有10个(40-30补足)
+        [
+            'include_frozen' => true,
+            'source_type' => 'test_consume_all',
+            'source_id' => 67891
+        ]
+    );
+    echo "   完全消耗第三个冻结堆成功\n";
+    
+    // 尝试解冻第三个冻结堆(应该失败,因为没有其他冻结堆可以补足)
+    try {
+        $safeUnfreezeResult3 = ItemService::safeUnfreezeItem($freezeLogId3);
+        echo "   ❌ 意外成功: " . json_encode($safeUnfreezeResult3, JSON_UNESCAPED_UNICODE) . "\n";
+    } catch (Exception $e) {
+        echo "   ✅ 预期失败: " . $e->getMessage() . "\n";
+    }
+    
+    echo "\n=== 测试完成 ===\n";
+    echo "安全解冻方法重写验证完成,两个分支逻辑正确\n";
+    
+    DB::rollback();
+    echo "已回滚测试数据\n";
+    
+} catch (Exception $e) {
+    DB::rollback();
+    echo "测试失败: " . $e->getMessage() . "\n";
+    echo "堆栈跟踪: " . $e->getTraceAsString() . "\n";
+}

+ 169 - 0
tests/test_unfreeze_logic_fix.php

@@ -0,0 +1,169 @@
+<?php
+
+/**
+ * 手动测试脚本:验证解冻逻辑修复
+ * 
+ * 测试解冻时是否从其他冻结堆中解冻,而不是从可用物品中扣除
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use App\Module\GameItems\Services\ItemService;
+use App\Module\GameItems\Enums\FREEZE_REASON_TYPE;
+use Illuminate\Support\Facades\DB;
+
+// 启动Laravel应用
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+echo "=== 解冻逻辑修复验证测试 ===\n\n";
+
+try {
+    DB::beginTransaction();
+    
+    $userId = 1001;
+    $itemId = 1;
+    
+    echo "1. 准备测试数据\n";
+    
+    // 添加物品
+    $addResult = ItemService::addItem($userId, $itemId, 100);
+    echo "   添加100个物品成功\n";
+    
+    // 冻结第一批物品
+    $freezeResult1 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12345,
+            'source_type' => 'test_order_1',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId1 = $freezeResult1['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结30个物品成功,日志ID: {$freezeLogId1}\n";
+    
+    // 冻结第二批物品
+    $freezeResult2 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        40, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12346,
+            'source_type' => 'test_order_2',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId2 = $freezeResult2['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结40个物品成功,日志ID: {$freezeLogId2}\n";
+    
+    // 检查当前状态
+    echo "\n2. 检查冻结后的物品状态\n";
+    $userItems = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItems as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        $freezeLogId = $item->frozen_log_id ?? 'N/A';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+    }
+    
+    // 部分消耗第一个冻结堆
+    echo "\n3. 部分消耗第一个冻结堆\n";
+    $consumeResult = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        20, 
+        [
+            'include_frozen' => true,
+            'source_type' => 'test_consume',
+            'source_id' => 67890
+        ]
+    );
+    echo "   消耗20个冻结物品成功\n";
+    
+    // 检查消耗后状态
+    echo "\n4. 检查消耗后的物品状态\n";
+    $userItemsAfter = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsAfter as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        $freezeLogId = $item->frozen_log_id ?? 'N/A';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+    }
+    
+    // 现在状态应该是:
+    // - 第一个冻结堆:10个(30-20)
+    // - 第二个冻结堆:40个
+    // - 可用物品:30个
+    
+    echo "\n5. 测试解冻第一个冻结堆(需要从第二个冻结堆补足)\n";
+    
+    try {
+        $unfreezeResult = ItemService::unfreezeItem($freezeLogId1);
+        echo "   解冻成功: " . json_encode($unfreezeResult, JSON_UNESCAPED_UNICODE) . "\n";
+        echo "   补足差额: " . ($unfreezeResult['shortage_compensated'] ?? 0) . "\n";
+        
+        // 检查解冻后状态
+        echo "\n6. 检查解冻后的物品状态\n";
+        $userItemsFinal = DB::table('item_users')
+            ->where('user_id', $userId)
+            ->where('item_id', $itemId)
+            ->get();
+        
+        $totalAvailable = 0;
+        $totalFrozen = 0;
+        
+        foreach ($userItemsFinal as $item) {
+            $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+            $freezeLogId = $item->frozen_log_id ?? 'N/A';
+            echo "     物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}, 冻结日志: {$freezeLogId}\n";
+            
+            if ($item->is_frozen) {
+                $totalFrozen += $item->quantity;
+            } else {
+                $totalAvailable += $item->quantity;
+            }
+        }
+        
+        echo "   总计:可用 {$totalAvailable},冻结 {$totalFrozen}\n";
+        
+        // 验证逻辑是否正确
+        echo "\n7. 验证解冻逻辑\n";
+        if ($totalAvailable == 60) { // 30个原始可用 + 30个解冻后的
+            echo "   ✅ 解冻逻辑正确:可用物品数量为60(30原始+30解冻)\n";
+        } else {
+            echo "   ❌ 解冻逻辑错误:可用物品数量应该为60,实际为 {$totalAvailable}\n";
+        }
+        
+        if ($totalFrozen == 20) { // 40个第二个冻结堆 - 20个用于补足
+            echo "   ✅ 补足逻辑正确:剩余冻结物品数量为20(40-20补足)\n";
+        } else {
+            echo "   ❌ 补足逻辑错误:剩余冻结物品数量应该为20,实际为 {$totalFrozen}\n";
+        }
+        
+    } catch (Exception $e) {
+        echo "   解冻失败: " . $e->getMessage() . "\n";
+    }
+    
+    echo "\n=== 测试完成 ===\n";
+    
+    DB::rollback();
+    echo "已回滚测试数据\n";
+    
+} catch (Exception $e) {
+    DB::rollback();
+    echo "测试失败: " . $e->getMessage() . "\n";
+    echo "堆栈跟踪: " . $e->getTraceAsString() . "\n";
+}