ItemFreeze.php 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036
  1. <?php
  2. namespace App\Module\GameItems\Logics;
  3. use App\Module\GameItems\Enums\FREEZE_ACTION_TYPE;
  4. use App\Module\GameItems\Enums\FREEZE_REASON_TYPE;
  5. use App\Module\GameItems\Enums\TRANSACTION_TYPE;
  6. use App\Module\GameItems\Events\ItemQuantityChanged;
  7. use App\Module\GameItems\Models\Item;
  8. use App\Module\GameItems\Models\ItemFreezeLog;
  9. use App\Module\GameItems\Models\ItemUser;
  10. use Exception;
  11. use Illuminate\Support\Collection;
  12. use Illuminate\Support\Facades\Log;
  13. use UCore\Db\Helper;
  14. use UCore\Helper\Logger;
  15. /**
  16. * 物品冻结逻辑类
  17. *
  18. * 处理物品冻结和解冻的核心业务逻辑,包括:
  19. * - 统一属性物品的冻结/解冻(拆堆模式)
  20. * - 单独属性物品的冻结/解冻
  21. * - 冻结状态验证和数量查询
  22. * - 批量冻结操作
  23. */
  24. class ItemFreeze
  25. {
  26. /**
  27. * 冻结统一属性物品(拆堆模式)
  28. *
  29. * 实现逻辑:
  30. * 1. 查找用户可用的物品堆叠(is_frozen=false)
  31. * 2. 从可用堆叠中扣除冻结数量
  32. * 3. 创建新的冻结堆叠记录(is_frozen=true)
  33. * 4. 记录冻结日志
  34. *
  35. * @param int $userId 用户ID
  36. * @param int $itemId 物品ID
  37. * @param int $quantity 冻结数量
  38. * @param FREEZE_REASON_TYPE $reason 冻结原因
  39. * @param int|null $sourceId 来源ID
  40. * @param string|null $sourceType 来源类型
  41. * @param int|null $operatorId 操作员ID
  42. * @return array 冻结结果
  43. * @throws Exception
  44. */
  45. public static function freezeNormalItem(
  46. int $userId,
  47. int $itemId,
  48. int $quantity,
  49. FREEZE_REASON_TYPE $reason,
  50. ?int $sourceId = null,
  51. ?string $sourceType = null,
  52. ?int $operatorId = null
  53. ): array {
  54. // 检查事务
  55. Helper::check_tr();
  56. // 验证冻结操作的合法性
  57. if (!self::validateFreezeOperation($userId, $itemId, $quantity)) {
  58. throw new Exception("用户 {$userId} 的物品 {$itemId} 可用数量不足,无法冻结 {$quantity} 个");
  59. }
  60. // 获取用户可用的物品堆叠(按过期时间排序,优先冻结即将过期的)
  61. // 使用lockForUpdate锁定记录,防止并发修改
  62. $availableItems = ItemUser::where('user_id', $userId)
  63. ->where('item_id', $itemId)
  64. ->where('is_frozen', false)
  65. ->whereNull('instance_id')
  66. ->where('quantity', '>', 0)
  67. ->orderBy('expire_at')
  68. ->lockForUpdate() // 锁定记录,防止并发修改
  69. ->get();
  70. // 重新验证可用数量(锁定后可能已变化)
  71. $actualAvailableQuantity = $availableItems->sum('quantity');
  72. if ($actualAvailableQuantity < $quantity) {
  73. throw new Exception("用户 {$userId} 的物品 {$itemId} 可用数量不足,需要冻结 {$quantity},锁定后实际 {$actualAvailableQuantity}");
  74. }
  75. $remainingQuantity = $quantity;
  76. $frozenItems = [];
  77. $changedItems = []; // 记录所有变更的物品,包括数量减少和冻结创建
  78. foreach ($availableItems as $userItem) {
  79. if ($remainingQuantity <= 0) {
  80. break;
  81. }
  82. $availableQuantity = $userItem->quantity;
  83. $freezeQuantity = min($remainingQuantity, $availableQuantity);
  84. // 创建冻结日志
  85. $freezeLog = ItemFreezeLog::createLog(
  86. $userId,
  87. $itemId,
  88. null,
  89. $freezeQuantity,
  90. FREEZE_ACTION_TYPE::FREEZE,
  91. $reason->getName($reason->value),
  92. $sourceId,
  93. $sourceType,
  94. $operatorId
  95. );
  96. if ($freezeQuantity == $availableQuantity) {
  97. Logger::debug('不拆堆');
  98. // 全部冻结,直接标记为冻结状态
  99. $userItem->is_frozen = true;
  100. $userItem->frozen_log_id = $freezeLog->id;
  101. $userItem->save();
  102. $frozenItems[] = [
  103. 'user_item_id' => $userItem->id,
  104. 'quantity' => $freezeQuantity,
  105. 'freeze_log_id' => $freezeLog->id,
  106. ];
  107. // 记录冻结状态变更(数量不变,但冻结状态从false变为true)
  108. $changedItems[] = [
  109. 'type' => 'freeze_status_change',
  110. 'user_item_id' => $userItem->id,
  111. 'old_quantity' => $availableQuantity,
  112. 'new_quantity' => $availableQuantity,
  113. 'old_frozen_status' => false,
  114. 'new_frozen_status' => true,
  115. 'freeze_log_id' => $freezeLog->id,
  116. ];
  117. } else {
  118. // 部分冻结,需要拆堆
  119. Logger::debug('拆堆');
  120. // 减少原堆叠数量
  121. $userItem->quantity = $availableQuantity - $freezeQuantity;
  122. $userItem->save();
  123. // 记录原堆叠数量减少(非冻结物品减少)
  124. $changedItems[] = [
  125. 'type' => 'quantity_decrease',
  126. 'user_item_id' => $userItem->id,
  127. 'old_quantity' => $availableQuantity,
  128. 'new_quantity' => $availableQuantity - $freezeQuantity,
  129. 'old_frozen_status' => false,
  130. 'new_frozen_status' => false, // 冻结状态不变,仍为未冻结
  131. 'freeze_log_id' => null,
  132. ];
  133. // 创建新的冻结堆叠
  134. $frozenItem = new ItemUser([
  135. 'user_id' => $userId,
  136. 'item_id' => $itemId,
  137. 'instance_id' => null,
  138. 'quantity' => $freezeQuantity,
  139. 'expire_at' => $userItem->expire_at,
  140. 'is_frozen' => true,
  141. 'frozen_log_id' => $freezeLog->id,
  142. ]);
  143. $frozenItem->save();
  144. $frozenItems[] = [
  145. 'user_item_id' => $frozenItem->id,
  146. 'quantity' => $freezeQuantity,
  147. 'freeze_log_id' => $freezeLog->id,
  148. ];
  149. // 记录新冻结堆叠创建(冻结物品创建)
  150. $changedItems[] = [
  151. 'type' => 'frozen_item_create',
  152. 'user_item_id' => $frozenItem->id,
  153. 'old_quantity' => 0, // 新创建的物品,旧数量为0
  154. 'new_quantity' => $freezeQuantity,
  155. 'old_frozen_status' => null, // 新创建的物品,旧冻结状态为null
  156. 'new_frozen_status' => true,
  157. 'freeze_log_id' => $freezeLog->id,
  158. ];
  159. }
  160. $remainingQuantity -= $freezeQuantity;
  161. }
  162. if ($remainingQuantity > 0) {
  163. throw new Exception("冻结操作失败,剩余未冻结数量:{$remainingQuantity}");
  164. }
  165. // 触发物品变更事件(包括非冻结减少和冻结创建)
  166. foreach ($changedItems as $changedItem) {
  167. event(new ItemQuantityChanged(
  168. $userId,
  169. $itemId,
  170. null, // 统一属性物品没有实例ID
  171. $changedItem['old_quantity'],
  172. $changedItem['new_quantity'],
  173. $changedItem['user_item_id'],
  174. $changedItem['old_frozen_status'],
  175. $changedItem['new_frozen_status'],
  176. [
  177. 'freeze_action' => 'freeze',
  178. 'change_type' => $changedItem['type'], // 变更类型:quantity_decrease, frozen_item_create, freeze_status_change
  179. 'freeze_log_id' => $changedItem['freeze_log_id'],
  180. 'reason' => $reason->getName($reason->value),
  181. 'source_id' => $sourceId,
  182. 'source_type' => $sourceType,
  183. 'operator_id' => $operatorId,
  184. ]
  185. ));
  186. }
  187. return [
  188. 'success' => true,
  189. 'user_id' => $userId,
  190. 'item_id' => $itemId,
  191. 'frozen_quantity' => $quantity,
  192. 'frozen_items' => $frozenItems,
  193. ];
  194. }
  195. /**
  196. * 冻结单独属性物品
  197. *
  198. * @param int $userId 用户ID
  199. * @param int $itemId 物品ID
  200. * @param int $instanceId 物品实例ID
  201. * @param FREEZE_REASON_TYPE $reason 冻结原因
  202. * @param int|null $sourceId 来源ID
  203. * @param string|null $sourceType 来源类型
  204. * @param int|null $operatorId 操作员ID
  205. * @return array 冻结结果
  206. * @throws Exception
  207. */
  208. public static function freezeUniqueItem(
  209. int $userId,
  210. int $itemId,
  211. int $instanceId,
  212. FREEZE_REASON_TYPE $reason,
  213. ?int $sourceId = null,
  214. ?string $sourceType = null,
  215. ?int $operatorId = null
  216. ): array {
  217. // 检查事务
  218. Helper::check_tr();
  219. // 查找用户的单独属性物品
  220. // 使用lockForUpdate锁定记录,防止并发修改
  221. $userItem = ItemUser::where('user_id', $userId)
  222. ->where('item_id', $itemId)
  223. ->where('instance_id', $instanceId)
  224. ->where('is_frozen', false)
  225. ->lockForUpdate() // 锁定记录,防止并发修改
  226. ->first();
  227. if (!$userItem) {
  228. throw new Exception("用户 {$userId} 没有可冻结的物品实例 {$instanceId}");
  229. }
  230. // 创建冻结日志
  231. $freezeLog = ItemFreezeLog::createLog(
  232. $userId,
  233. $itemId,
  234. $instanceId,
  235. 1, // 单独属性物品数量始终为1
  236. FREEZE_ACTION_TYPE::FREEZE,
  237. $reason->getName($reason->value),
  238. $sourceId,
  239. $sourceType,
  240. $operatorId
  241. );
  242. // 标记为冻结状态
  243. $userItem->is_frozen = true;
  244. $userItem->frozen_log_id = $freezeLog->id;
  245. $userItem->save();
  246. // 触发物品变更事件(冻结状态变更)
  247. event(new ItemQuantityChanged(
  248. $userId,
  249. $itemId,
  250. $instanceId,
  251. 1, // 旧数量(单独属性物品数量始终为1)
  252. 1, // 新数量(数量未变)
  253. $userItem->id,
  254. false, // 旧冻结状态:未冻结
  255. true, // 新冻结状态:已冻结
  256. [
  257. 'freeze_action' => 'freeze',
  258. 'freeze_log_id' => $freezeLog->id,
  259. 'reason' => $reason->getName($reason->value),
  260. 'source_id' => $sourceId,
  261. 'source_type' => $sourceType,
  262. 'operator_id' => $operatorId,
  263. ]
  264. ));
  265. return [
  266. 'success' => true,
  267. 'user_id' => $userId,
  268. 'item_id' => $itemId,
  269. 'instance_id' => $instanceId,
  270. 'user_item_id' => $userItem->id,
  271. 'freeze_log_id' => $freezeLog->id,
  272. ];
  273. }
  274. /**
  275. * 解冻物品(通过冻结日志ID)
  276. *
  277. * @param int $freezeLogId 冻结日志ID
  278. * @return array 解冻结果
  279. * @throws Exception
  280. */
  281. public static function unfreezeByLogId(int $freezeLogId): array
  282. {
  283. // 检查事务
  284. Helper::check_tr();
  285. // 查找冻结日志
  286. $freezeLog = ItemFreezeLog::find($freezeLogId);
  287. if (!$freezeLog) {
  288. throw new Exception("冻结日志 {$freezeLogId} 不存在");
  289. }
  290. if (!$freezeLog->isFreeze()) {
  291. throw new Exception("日志 {$freezeLogId} 不是冻结操作记录");
  292. }
  293. // 查找对应的冻结物品
  294. $frozenItem = ItemUser::where('frozen_log_id', $freezeLogId)
  295. ->where('is_frozen', true)
  296. ->first();
  297. if (!$frozenItem) {
  298. throw new Exception("未找到冻结日志 {$freezeLogId} 对应的冻结物品");
  299. }
  300. // 获取原始冻结数量和当前剩余数量
  301. $originalFrozenQuantity = $freezeLog->quantity;
  302. $currentQuantity = $frozenItem->quantity;
  303. // 检查是否需要补足差额
  304. $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
  305. if ($currentQuantity <= 0) {
  306. // 冻结堆已被完全消耗,需要从其他冻结堆中完全补足
  307. $shortageQuantity = $originalFrozenQuantity; // 需要补足全部原始数量
  308. }
  309. if ($shortageQuantity > 0) {
  310. // 冻结堆被部分消耗,需要从用户其他冻结物品中解冻来补足
  311. $otherFrozenQuantity = self::getOtherFrozenQuantity(
  312. $frozenItem->user_id,
  313. $frozenItem->item_id,
  314. $frozenItem->instance_id,
  315. $freezeLogId
  316. );
  317. if ($otherFrozenQuantity < $shortageQuantity) {
  318. throw new Exception(
  319. "解冻失败:原始冻结数量 {$originalFrozenQuantity},当前冻结剩余 {$currentQuantity}," .
  320. "需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}"
  321. );
  322. }
  323. // 从用户其他冻结物品中解冻来补足(使用锁定避免并发问题)
  324. $otherFrozenItems = ItemUser::where('user_id', $frozenItem->user_id)
  325. ->where('item_id', $frozenItem->item_id)
  326. ->where('instance_id', $frozenItem->instance_id)
  327. ->where('is_frozen', true)
  328. ->where('frozen_log_id', '!=', $freezeLogId)
  329. ->where('quantity', '>', 0)
  330. ->orderBy('expire_at')
  331. ->lockForUpdate() // 锁定记录,避免并发修改
  332. ->get();
  333. // 重新验证其他冻结数量(锁定后可能已变化)
  334. $actualOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
  335. if ($actualOtherFrozenQuantity < $shortageQuantity) {
  336. throw new Exception(
  337. "解冻失败:需要补足 {$shortageQuantity},但锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}"
  338. );
  339. }
  340. $remainingShortage = $shortageQuantity;
  341. $unfreezeDetails = []; // 记录解冻详情
  342. foreach ($otherFrozenItems as $otherFrozenItem) {
  343. if ($remainingShortage <= 0) break;
  344. $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
  345. $oldQuantity = $otherFrozenItem->quantity;
  346. $newQuantity = $oldQuantity - $unfreezeQuantity;
  347. // 从其他冻结堆中解冻指定数量
  348. $otherFrozenItem->quantity = $newQuantity;
  349. $otherFrozenItem->save();
  350. // 记录解冻详情
  351. $unfreezeDetails[] = [
  352. 'from_frozen_item_id' => $otherFrozenItem->id,
  353. 'from_freeze_log_id' => $otherFrozenItem->frozen_log_id,
  354. 'unfrozen_quantity' => $unfreezeQuantity,
  355. 'old_quantity' => $oldQuantity,
  356. 'new_quantity' => $newQuantity,
  357. ];
  358. // 记录解冻日志(从其他冻结堆解冻)
  359. ItemFreezeLog::createLog(
  360. $frozenItem->user_id,
  361. $frozenItem->item_id,
  362. $frozenItem->instance_id,
  363. $unfreezeQuantity,
  364. FREEZE_ACTION_TYPE::UNFREEZE,
  365. "解冻补足:从冻结堆 {$otherFrozenItem->frozen_log_id} 解冻 {$unfreezeQuantity} 个用于补足解冻日志 {$freezeLogId}",
  366. $freezeLog->source_id,
  367. $freezeLog->source_type,
  368. $freezeLog->operator_id
  369. );
  370. // 触发物品数量变更事件(其他冻结堆减少)
  371. event(new ItemQuantityChanged(
  372. $frozenItem->user_id,
  373. $frozenItem->item_id,
  374. $frozenItem->instance_id,
  375. $oldQuantity,
  376. $newQuantity,
  377. $otherFrozenItem->id,
  378. true, // 旧冻结状态:已冻结
  379. true, // 新冻结状态:仍冻结(如果数量>0)或未冻结(如果数量=0)
  380. [
  381. 'action' => 'unfreeze_compensation_from_other_frozen',
  382. 'target_freeze_log_id' => $freezeLogId,
  383. 'unfrozen_quantity' => $unfreezeQuantity,
  384. ]
  385. ));
  386. $remainingShortage -= $unfreezeQuantity;
  387. }
  388. // 将补足的数量加到目标冻结堆
  389. $oldFrozenQuantity = $frozenItem->quantity;
  390. $frozenItem->quantity = $originalFrozenQuantity;
  391. $frozenItem->save();
  392. // 触发物品数量变更事件(目标冻结堆增加)
  393. event(new ItemQuantityChanged(
  394. $frozenItem->user_id,
  395. $frozenItem->item_id,
  396. $frozenItem->instance_id,
  397. $oldFrozenQuantity,
  398. $originalFrozenQuantity,
  399. $frozenItem->id,
  400. true, // 旧冻结状态:已冻结
  401. true, // 新冻结状态:已冻结
  402. [
  403. 'action' => 'unfreeze_compensation_target_increase',
  404. 'freeze_log_id' => $freezeLogId,
  405. 'compensated_quantity' => $shortageQuantity,
  406. 'unfreeze_details' => $unfreezeDetails,
  407. ]
  408. ));
  409. }
  410. // 创建解冻日志,记录原始冻结数量
  411. $unfreezeLog = ItemFreezeLog::createLog(
  412. $frozenItem->user_id,
  413. $frozenItem->item_id,
  414. $frozenItem->instance_id,
  415. $originalFrozenQuantity, // 解冻原始数量
  416. FREEZE_ACTION_TYPE::UNFREEZE,
  417. "解冻操作,原冻结日志ID: {$freezeLogId},原始冻结数量: {$originalFrozenQuantity}" .
  418. ($shortageQuantity > 0 ? ",补足差额: {$shortageQuantity}" : ""),
  419. $freezeLog->source_id,
  420. $freezeLog->source_type,
  421. $freezeLog->operator_id
  422. );
  423. // 解冻物品(保持独立,不合并堆叠)
  424. $frozenItem->is_frozen = false;
  425. $frozenItem->frozen_log_id = null;
  426. $frozenItem->save();
  427. // 触发物品变更事件(解冻状态变更)
  428. event(new ItemQuantityChanged(
  429. $frozenItem->user_id,
  430. $frozenItem->item_id,
  431. $frozenItem->instance_id,
  432. $originalFrozenQuantity, // 旧数量(恢复到原始数量)
  433. $originalFrozenQuantity, // 新数量(数量未变,只是解冻状态变更)
  434. $frozenItem->id,
  435. true, // 旧冻结状态:已冻结
  436. false, // 新冻结状态:未冻结
  437. [
  438. 'freeze_action' => 'unfreeze',
  439. 'unfreeze_log_id' => $unfreezeLog->id,
  440. 'original_freeze_log_id' => $freezeLogId,
  441. 'original_frozen_quantity' => $originalFrozenQuantity,
  442. 'shortage_compensated' => $shortageQuantity,
  443. 'source_id' => $freezeLog->source_id,
  444. 'source_type' => $freezeLog->source_type,
  445. 'operator_id' => $freezeLog->operator_id,
  446. ]
  447. ));
  448. return [
  449. 'success' => true,
  450. 'user_id' => $frozenItem->user_id,
  451. 'item_id' => $frozenItem->item_id,
  452. 'instance_id' => $frozenItem->instance_id,
  453. 'unfrozen_quantity' => $originalFrozenQuantity, // 返回原始冻结数量
  454. 'shortage_compensated' => $shortageQuantity, // 返回补足的差额
  455. 'user_item_id' => $frozenItem->id,
  456. 'unfreeze_log_id' => $unfreezeLog->id,
  457. ];
  458. }
  459. /**
  460. * 检查用户可用物品数量(排除冻结堆叠)
  461. *
  462. * @param int $userId 用户ID
  463. * @param int $itemId 物品ID
  464. * @param int|null $instanceId 实例ID
  465. * @return int 可用数量
  466. */
  467. public static function getAvailableQuantity(
  468. int $userId,
  469. int $itemId,
  470. ?int $instanceId = null
  471. ): int {
  472. return ItemUser::getAvailableQuantity($userId, $itemId, $instanceId);
  473. }
  474. /**
  475. * 验证用户是否有足够的可用物品
  476. *
  477. * @param int $userId 用户ID
  478. * @param int $itemId 物品ID
  479. * @param int $requiredQuantity 需要的数量
  480. * @param int|null $instanceId 实例ID
  481. * @return bool 是否有足够的可用物品
  482. */
  483. public static function checkAvailableQuantity(
  484. int $userId,
  485. int $itemId,
  486. int $requiredQuantity,
  487. ?int $instanceId = null
  488. ): bool {
  489. $availableQuantity = self::getAvailableQuantity($userId, $itemId, $instanceId);
  490. return $availableQuantity >= $requiredQuantity;
  491. }
  492. /**
  493. * 验证冻结操作的合法性
  494. *
  495. * @param int $userId 用户ID
  496. * @param int $itemId 物品ID
  497. * @param int $quantity 冻结数量
  498. * @param int|null $instanceId 实例ID
  499. * @return bool 是否可以冻结
  500. */
  501. public static function validateFreezeOperation(
  502. int $userId,
  503. int $itemId,
  504. int $quantity,
  505. ?int $instanceId = null
  506. ): bool {
  507. // 检查物品是否存在
  508. $item = Item::find($itemId);
  509. if (!$item) {
  510. return false;
  511. }
  512. // 检查数量是否合法
  513. if ($quantity <= 0) {
  514. return false;
  515. }
  516. // 检查可用数量是否足够
  517. return self::checkAvailableQuantity($userId, $itemId, $quantity, $instanceId);
  518. }
  519. /**
  520. * 批量冻结物品
  521. *
  522. * @param int $userId 用户ID
  523. * @param array $items 物品列表 [['item_id' => 1, 'quantity' => 10], ...]
  524. * @param FREEZE_REASON_TYPE $reason 冻结原因
  525. * @param int|null $sourceId 来源ID
  526. * @param string|null $sourceType 来源类型
  527. * @param int|null $operatorId 操作员ID
  528. * @return array 冻结结果
  529. * @throws Exception
  530. */
  531. public static function batchFreezeItems(
  532. int $userId,
  533. array $items,
  534. FREEZE_REASON_TYPE $reason,
  535. ?int $sourceId = null,
  536. ?string $sourceType = null,
  537. ?int $operatorId = null
  538. ): array {
  539. // 检查事务
  540. Helper::check_tr();
  541. $results = [];
  542. $errors = [];
  543. foreach ($items as $itemData) {
  544. $itemId = $itemData['item_id'];
  545. $quantity = $itemData['quantity'] ?? 1;
  546. $instanceId = $itemData['instance_id'] ?? null;
  547. try {
  548. if ($instanceId) {
  549. // 单独属性物品
  550. $result = self::freezeUniqueItem(
  551. $userId,
  552. $itemId,
  553. $instanceId,
  554. $reason,
  555. $sourceId,
  556. $sourceType,
  557. $operatorId
  558. );
  559. } else {
  560. // 统一属性物品
  561. $result = self::freezeNormalItem(
  562. $userId,
  563. $itemId,
  564. $quantity,
  565. $reason,
  566. $sourceId,
  567. $sourceType,
  568. $operatorId
  569. );
  570. }
  571. $results[] = $result;
  572. } catch (Exception $e) {
  573. $errors[] = [
  574. 'item_id' => $itemId,
  575. 'instance_id' => $instanceId,
  576. 'quantity' => $quantity,
  577. 'error' => $e->getMessage(),
  578. ];
  579. }
  580. }
  581. if (!empty($errors)) {
  582. throw new Exception("批量冻结操作部分失败:" . json_encode($errors, JSON_UNESCAPED_UNICODE));
  583. }
  584. return [
  585. 'success' => true,
  586. 'user_id' => $userId,
  587. 'frozen_items_count' => count($results),
  588. 'results' => $results,
  589. ];
  590. }
  591. /**
  592. * 检查冻结物品是否过期并处理
  593. *
  594. * @param int $userId 用户ID
  595. * @return int 处理的过期冻结物品数量
  596. */
  597. public static function handleExpiredFrozenItems(int $userId): int
  598. {
  599. // 查找过期的冻结物品
  600. $expiredFrozenItems = ItemUser::where('user_id', $userId)
  601. ->where('is_frozen', true)
  602. ->where('expire_at', '<', now())
  603. ->whereNotNull('expire_at')
  604. ->get();
  605. $processedCount = 0;
  606. foreach ($expiredFrozenItems as $frozenItem) {
  607. try {
  608. // 先解冻再处理过期
  609. if ($frozenItem->frozen_log_id) {
  610. self::unfreezeByLogId($frozenItem->frozen_log_id);
  611. }
  612. // 处理过期逻辑(这里可以根据业务需求决定是删除还是其他处理)
  613. // 暂时设置数量为0,不删除记录
  614. $frozenItem->quantity = 0;
  615. $frozenItem->save();
  616. $processedCount++;
  617. } catch (Exception $e) {
  618. // 记录错误日志,但不中断处理
  619. Log::error("处理过期冻结物品失败", [
  620. 'user_id' => $userId,
  621. 'item_user_id' => $frozenItem->id,
  622. 'error' => $e->getMessage(),
  623. ]);
  624. }
  625. }
  626. return $processedCount;
  627. }
  628. /**
  629. * 获取用户的冻结物品列表
  630. *
  631. * @param int $userId 用户ID
  632. * @param array $filters 过滤条件
  633. * @return Collection 冻结物品集合
  634. */
  635. public static function getFrozenItems(int $userId, array $filters = []): Collection
  636. {
  637. $query = ItemUser::where('user_id', $userId)
  638. ->where('is_frozen', true)
  639. ->with(['item', 'instance', 'freezeLog']);
  640. // 应用过滤条件
  641. if (isset($filters['item_id'])) {
  642. $query->where('item_id', $filters['item_id']);
  643. }
  644. if (isset($filters['source_type'])) {
  645. $query->whereHas('freezeLog', function ($q) use ($filters) {
  646. $q->where('source_type', $filters['source_type']);
  647. });
  648. }
  649. if (isset($filters['reason'])) {
  650. $query->whereHas('freezeLog', function ($q) use ($filters) {
  651. $q->where('reason', 'like', '%' . $filters['reason'] . '%');
  652. });
  653. }
  654. return $query->get();
  655. }
  656. /**
  657. * 获取冻结统计信息
  658. *
  659. * @param int $userId 用户ID
  660. * @return array 统计信息
  661. */
  662. public static function getFreezeStatistics(int $userId): array
  663. {
  664. $frozenItems = ItemUser::where('user_id', $userId)
  665. ->where('is_frozen', true)
  666. ->with(['item', 'freezeLog'])
  667. ->get();
  668. $statistics = [
  669. 'total_frozen_items' => $frozenItems->count(),
  670. 'total_frozen_quantity' => $frozenItems->sum('quantity'),
  671. 'frozen_by_reason' => [],
  672. 'frozen_by_source_type' => [],
  673. ];
  674. // 按原因分组统计
  675. $frozenItems->groupBy('freezeLog.reason')->each(function ($items, $reason) use (&$statistics) {
  676. $statistics['frozen_by_reason'][$reason] = [
  677. 'count' => $items->count(),
  678. 'quantity' => $items->sum('quantity'),
  679. ];
  680. });
  681. // 按来源类型分组统计
  682. $frozenItems->groupBy('freezeLog.source_type')->each(function ($items, $sourceType) use (&$statistics) {
  683. $statistics['frozen_by_source_type'][$sourceType] = [
  684. 'count' => $items->count(),
  685. 'quantity' => $items->sum('quantity'),
  686. ];
  687. });
  688. return $statistics;
  689. }
  690. /**
  691. * 安全解冻物品(处理已被消耗的冻结堆)
  692. *
  693. * 与unfreezeByLogId不同,此方法会检查冻结物品是否已被消耗:
  694. * - 如果冻结物品数量为0,则标记为已处理,不抛出异常
  695. * - 如果冻结物品数量大于0,则正常解冻
  696. * - 返回详细的处理结果信息
  697. *
  698. * @param int $freezeLogId 冻结日志ID
  699. * @return array 解冻结果
  700. * @throws Exception
  701. */
  702. public static function safeUnfreezeByLogId(int $freezeLogId): array
  703. {
  704. // 检查事务
  705. Helper::check_tr();
  706. // 查找冻结日志
  707. $freezeLog = ItemFreezeLog::find($freezeLogId);
  708. if (!$freezeLog) {
  709. throw new Exception("冻结日志 {$freezeLogId} 不存在");
  710. }
  711. if (!$freezeLog->isFreeze()) {
  712. throw new Exception("日志 {$freezeLogId} 不是冻结操作记录");
  713. }
  714. // 查找对应的冻结物品
  715. $frozenItem = ItemUser::where('frozen_log_id', $freezeLogId)
  716. ->where('is_frozen', true)
  717. ->lockForUpdate()
  718. ->first();
  719. if (!$frozenItem) {
  720. throw new Exception("未找到冻结日志 {$freezeLogId} 对应的冻结物品");
  721. }
  722. // 获取原始冻结数量和当前剩余数量
  723. $originalFrozenQuantity = $freezeLog->quantity;
  724. $currentQuantity = $frozenItem->quantity;
  725. // 计算需要补足的数量
  726. $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
  727. if ($shortageQuantity > 0) {
  728. // 需要补足分支:从其他冻结堆中解冻来补足
  729. $otherFrozenQuantity = self::getOtherFrozenQuantity(
  730. $frozenItem->user_id,
  731. $frozenItem->item_id,
  732. $frozenItem->instance_id,
  733. $freezeLogId
  734. );
  735. if ($otherFrozenQuantity < $shortageQuantity) {
  736. throw new Exception(
  737. "安全解冻失败:需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}"
  738. );
  739. }
  740. // 从其他冻结物品中解冻来补足
  741. $otherFrozenItems = ItemUser::where('user_id', $frozenItem->user_id)
  742. ->where('item_id', $frozenItem->item_id)
  743. ->where('instance_id', $frozenItem->instance_id)
  744. ->where('is_frozen', true)
  745. ->where('frozen_log_id', '!=', $freezeLogId)
  746. ->where('quantity', '>', 0)
  747. ->orderBy('expire_at')
  748. ->lockForUpdate()
  749. ->get();
  750. // 重新验证其他冻结数量(锁定后可能已变化)
  751. $actualOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
  752. if ($actualOtherFrozenQuantity < $shortageQuantity) {
  753. throw new Exception(
  754. "安全解冻失败:需要补足 {$shortageQuantity},但锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}"
  755. );
  756. }
  757. $remainingShortage = $shortageQuantity;
  758. $unfreezeDetails = [];
  759. foreach ($otherFrozenItems as $otherFrozenItem) {
  760. if ($remainingShortage <= 0) break;
  761. $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
  762. $oldQuantity = $otherFrozenItem->quantity;
  763. $newQuantity = $oldQuantity - $unfreezeQuantity;
  764. // 从其他冻结堆中减少数量
  765. $otherFrozenItem->quantity = $newQuantity;
  766. $otherFrozenItem->save();
  767. // 记录解冻详情
  768. $unfreezeDetails[] = [
  769. 'from_frozen_item_id' => $otherFrozenItem->id,
  770. 'from_freeze_log_id' => $otherFrozenItem->frozen_log_id,
  771. 'unfrozen_quantity' => $unfreezeQuantity,
  772. 'old_quantity' => $oldQuantity,
  773. 'new_quantity' => $newQuantity,
  774. ];
  775. // 记录解冻日志(从其他冻结堆解冻)
  776. ItemFreezeLog::createLog(
  777. $frozenItem->user_id,
  778. $frozenItem->item_id,
  779. $frozenItem->instance_id,
  780. $unfreezeQuantity,
  781. FREEZE_ACTION_TYPE::UNFREEZE,
  782. "安全解冻补足:从冻结堆 {$otherFrozenItem->frozen_log_id} 解冻 {$unfreezeQuantity} 个用于补足解冻日志 {$freezeLogId}",
  783. $freezeLog->source_id,
  784. $freezeLog->source_type,
  785. $freezeLog->operator_id
  786. );
  787. // 触发物品数量变更事件
  788. event(new ItemQuantityChanged(
  789. $frozenItem->user_id,
  790. $frozenItem->item_id,
  791. $frozenItem->instance_id,
  792. $oldQuantity,
  793. $newQuantity,
  794. $otherFrozenItem->id,
  795. true, // 旧冻结状态:已冻结
  796. true, // 新冻结状态:如果数量>0仍冻结,否则解冻
  797. [
  798. 'action' => 'safe_unfreeze_compensation_from_other_frozen',
  799. 'target_freeze_log_id' => $freezeLogId,
  800. 'unfrozen_quantity' => $unfreezeQuantity,
  801. ]
  802. ));
  803. $remainingShortage -= $unfreezeQuantity;
  804. }
  805. // 将补足的数量加到目标冻结堆
  806. $frozenItem->quantity = $originalFrozenQuantity;
  807. // $frozenItem->save();
  808. }
  809. // 创建解冻日志(记录原始冻结数量)
  810. $unfreezeLog = ItemFreezeLog::createLog(
  811. $frozenItem->user_id,
  812. $frozenItem->item_id,
  813. $frozenItem->instance_id,
  814. $originalFrozenQuantity,
  815. FREEZE_ACTION_TYPE::UNFREEZE,
  816. "安全解冻操作,原冻结日志ID: {$freezeLogId}" .
  817. ($shortageQuantity > 0 ? ",补足差额: {$shortageQuantity}" : ""),
  818. $freezeLog->source_id,
  819. $freezeLog->source_type,
  820. $freezeLog->operator_id
  821. );
  822. // 解冻目标冻结堆
  823. $frozenItem->is_frozen = false;
  824. // $frozenItem->frozen_log_id = null;
  825. $frozenItem->save();
  826. // 触发解冻完成事件(目标冻结堆解冻)
  827. event(new ItemQuantityChanged(
  828. $frozenItem->user_id,
  829. $frozenItem->item_id,
  830. $frozenItem->instance_id,
  831. $originalFrozenQuantity, // 解冻的数量
  832. $originalFrozenQuantity, // 数量不变,只是状态变更
  833. $frozenItem->id,
  834. true, // 旧冻结状态:已冻结
  835. false, // 新冻结状态:未冻结
  836. [
  837. 'action' => 'safe_unfreeze_completed',
  838. 'unfreeze_log_id' => $unfreezeLog->id,
  839. 'original_freeze_log_id' => $freezeLogId,
  840. 'shortage_compensated' => $shortageQuantity,
  841. 'compensation_details' => $unfreezeDetails ?? [],
  842. ]
  843. ));
  844. return [
  845. 'success' => true,
  846. 'user_id' => $frozenItem->user_id,
  847. 'item_id' => $frozenItem->item_id,
  848. 'instance_id' => $frozenItem->instance_id,
  849. 'unfrozen_quantity' => $originalFrozenQuantity,
  850. 'shortage_compensated' => $shortageQuantity,
  851. 'user_item_id' => $frozenItem->id,
  852. 'unfreeze_log_id' => $unfreezeLog->id,
  853. 'compensation_details' => $unfreezeDetails ?? [],
  854. ];
  855. }
  856. /**
  857. * 获取已被消耗的冻结物品统计
  858. *
  859. * 查找所有数量为0的冻结物品记录并返回统计信息
  860. * 注意:不删除记录,只提供统计信息
  861. *
  862. * @param int|null $userId 用户ID,为null时统计所有用户
  863. * @return array 统计结果
  864. */
  865. public static function getConsumedFrozenItemsStatistics(?int $userId = null): array
  866. {
  867. $query = ItemUser::where('is_frozen', true)
  868. ->where('quantity', '<=', 0);
  869. if ($userId) {
  870. $query->where('user_id', $userId);
  871. }
  872. $consumedFrozenItems = $query->get();
  873. $statistics = [
  874. 'total_count' => $consumedFrozenItems->count(),
  875. 'by_user' => [],
  876. 'by_item' => [],
  877. 'items' => [],
  878. ];
  879. foreach ($consumedFrozenItems as $frozenItem) {
  880. // 按用户统计
  881. if (!isset($statistics['by_user'][$frozenItem->user_id])) {
  882. $statistics['by_user'][$frozenItem->user_id] = 0;
  883. }
  884. $statistics['by_user'][$frozenItem->user_id]++;
  885. // 按物品统计
  886. if (!isset($statistics['by_item'][$frozenItem->item_id])) {
  887. $statistics['by_item'][$frozenItem->item_id] = 0;
  888. }
  889. $statistics['by_item'][$frozenItem->item_id]++;
  890. // 详细记录
  891. $statistics['items'][] = [
  892. 'user_id' => $frozenItem->user_id,
  893. 'item_id' => $frozenItem->item_id,
  894. 'instance_id' => $frozenItem->instance_id,
  895. 'user_item_id' => $frozenItem->id,
  896. 'frozen_log_id' => $frozenItem->frozen_log_id,
  897. 'quantity' => $frozenItem->quantity,
  898. ];
  899. }
  900. return $statistics;
  901. }
  902. /**
  903. * 获取用户其他冻结物品的数量(排除指定的冻结日志)
  904. *
  905. * @param int $userId 用户ID
  906. * @param int $itemId 物品ID
  907. * @param string|null $instanceId 实例ID
  908. * @param int $excludeFreezeLogId 要排除的冻结日志ID
  909. * @return int 其他冻结物品的总数量
  910. */
  911. private static function getOtherFrozenQuantity(int $userId, int $itemId, ?string $instanceId, int $excludeFreezeLogId): int
  912. {
  913. return ItemUser::where('user_id', $userId)
  914. ->where('item_id', $itemId)
  915. ->where('instance_id', $instanceId)
  916. ->where('is_frozen', true)
  917. ->where('frozen_log_id', '!=', $excludeFreezeLogId)
  918. ->where('quantity', '>', 0)
  919. ->sum('quantity');
  920. }
  921. }