ItemFreeze.php 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  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. ->first();
  718. if (!$frozenItem) {
  719. throw new Exception("未找到冻结日志 {$freezeLogId} 对应的冻结物品");
  720. }
  721. // 获取原始冻结数量和当前剩余数量
  722. $originalFrozenQuantity = $freezeLog->quantity;
  723. $currentQuantity = $frozenItem->quantity;
  724. // 计算需要补足的数量
  725. $shortageQuantity = $originalFrozenQuantity - $currentQuantity;
  726. if ($shortageQuantity > 0) {
  727. // 需要补足分支:从其他冻结堆中解冻来补足
  728. $otherFrozenQuantity = self::getOtherFrozenQuantity(
  729. $frozenItem->user_id,
  730. $frozenItem->item_id,
  731. $frozenItem->instance_id,
  732. $freezeLogId
  733. );
  734. if ($otherFrozenQuantity < $shortageQuantity) {
  735. throw new Exception(
  736. "安全解冻失败:需要补足 {$shortageQuantity},但用户其他冻结数量只有 {$otherFrozenQuantity}"
  737. );
  738. }
  739. // 从其他冻结物品中解冻来补足
  740. $otherFrozenItems = ItemUser::where('user_id', $frozenItem->user_id)
  741. ->where('item_id', $frozenItem->item_id)
  742. ->where('instance_id', $frozenItem->instance_id)
  743. ->where('is_frozen', true)
  744. ->where('frozen_log_id', '!=', $freezeLogId)
  745. ->where('quantity', '>', 0)
  746. ->orderBy('expire_at')
  747. ->lockForUpdate()
  748. ->get();
  749. // 重新验证其他冻结数量(锁定后可能已变化)
  750. $actualOtherFrozenQuantity = $otherFrozenItems->sum('quantity');
  751. if ($actualOtherFrozenQuantity < $shortageQuantity) {
  752. throw new Exception(
  753. "安全解冻失败:需要补足 {$shortageQuantity},但锁定后用户其他冻结数量只有 {$actualOtherFrozenQuantity}"
  754. );
  755. }
  756. $remainingShortage = $shortageQuantity;
  757. $unfreezeDetails = [];
  758. foreach ($otherFrozenItems as $otherFrozenItem) {
  759. if ($remainingShortage <= 0) break;
  760. $unfreezeQuantity = min($otherFrozenItem->quantity, $remainingShortage);
  761. $oldQuantity = $otherFrozenItem->quantity;
  762. $newQuantity = $oldQuantity - $unfreezeQuantity;
  763. // 从其他冻结堆中减少数量
  764. $otherFrozenItem->quantity = $newQuantity;
  765. $otherFrozenItem->save();
  766. // 记录解冻详情
  767. $unfreezeDetails[] = [
  768. 'from_frozen_item_id' => $otherFrozenItem->id,
  769. 'from_freeze_log_id' => $otherFrozenItem->frozen_log_id,
  770. 'unfrozen_quantity' => $unfreezeQuantity,
  771. 'old_quantity' => $oldQuantity,
  772. 'new_quantity' => $newQuantity,
  773. ];
  774. // 记录解冻日志(从其他冻结堆解冻)
  775. ItemFreezeLog::createLog(
  776. $frozenItem->user_id,
  777. $frozenItem->item_id,
  778. $frozenItem->instance_id,
  779. $unfreezeQuantity,
  780. FREEZE_ACTION_TYPE::UNFREEZE,
  781. "安全解冻补足:从冻结堆 {$otherFrozenItem->frozen_log_id} 解冻 {$unfreezeQuantity} 个用于补足解冻日志 {$freezeLogId}",
  782. $freezeLog->source_id,
  783. $freezeLog->source_type,
  784. $freezeLog->operator_id
  785. );
  786. // 触发物品数量变更事件
  787. event(new ItemQuantityChanged(
  788. $frozenItem->user_id,
  789. $frozenItem->item_id,
  790. $frozenItem->instance_id,
  791. $oldQuantity,
  792. $newQuantity,
  793. $otherFrozenItem->id,
  794. true, // 旧冻结状态:已冻结
  795. $newQuantity > 0, // 新冻结状态:如果数量>0仍冻结,否则解冻
  796. [
  797. 'action' => 'safe_unfreeze_compensation_from_other_frozen',
  798. 'target_freeze_log_id' => $freezeLogId,
  799. 'unfrozen_quantity' => $unfreezeQuantity,
  800. ]
  801. ));
  802. $remainingShortage -= $unfreezeQuantity;
  803. }
  804. // 将补足的数量加到目标冻结堆
  805. $frozenItem->quantity = $originalFrozenQuantity;
  806. $frozenItem->save();
  807. }
  808. // 创建解冻日志
  809. $unfreezeLog = ItemFreezeLog::createLog(
  810. $frozenItem->user_id,
  811. $frozenItem->item_id,
  812. $frozenItem->instance_id,
  813. $originalFrozenQuantity,
  814. FREEZE_ACTION_TYPE::UNFREEZE,
  815. "安全解冻操作,原冻结日志ID: {$freezeLogId}" .
  816. ($shortageQuantity > 0 ? ",补足差额: {$shortageQuantity}" : ""),
  817. $freezeLog->source_id,
  818. $freezeLog->source_type,
  819. $freezeLog->operator_id
  820. );
  821. // 解冻物品
  822. $frozenItem->is_frozen = false;
  823. $frozenItem->frozen_log_id = null;
  824. $frozenItem->save();
  825. // 触发解冻完成事件
  826. event(new ItemQuantityChanged(
  827. $frozenItem->user_id,
  828. $frozenItem->item_id,
  829. $frozenItem->instance_id,
  830. $originalFrozenQuantity,
  831. $originalFrozenQuantity,
  832. $frozenItem->id,
  833. true, // 旧冻结状态:已冻结
  834. false, // 新冻结状态:未冻结
  835. [
  836. 'action' => 'safe_unfreeze_completed',
  837. 'unfreeze_log_id' => $unfreezeLog->id,
  838. 'original_freeze_log_id' => $freezeLogId,
  839. 'shortage_compensated' => $shortageQuantity,
  840. 'compensation_details' => $unfreezeDetails ?? [],
  841. ]
  842. ));
  843. return [
  844. 'success' => true,
  845. 'user_id' => $frozenItem->user_id,
  846. 'item_id' => $frozenItem->item_id,
  847. 'instance_id' => $frozenItem->instance_id,
  848. 'unfrozen_quantity' => $originalFrozenQuantity,
  849. 'shortage_compensated' => $shortageQuantity,
  850. 'user_item_id' => $frozenItem->id,
  851. 'unfreeze_log_id' => $unfreezeLog->id,
  852. 'compensation_details' => $unfreezeDetails ?? [],
  853. ];
  854. }
  855. /**
  856. * 获取已被消耗的冻结物品统计
  857. *
  858. * 查找所有数量为0的冻结物品记录并返回统计信息
  859. * 注意:不删除记录,只提供统计信息
  860. *
  861. * @param int|null $userId 用户ID,为null时统计所有用户
  862. * @return array 统计结果
  863. */
  864. public static function getConsumedFrozenItemsStatistics(?int $userId = null): array
  865. {
  866. $query = ItemUser::where('is_frozen', true)
  867. ->where('quantity', '<=', 0);
  868. if ($userId) {
  869. $query->where('user_id', $userId);
  870. }
  871. $consumedFrozenItems = $query->get();
  872. $statistics = [
  873. 'total_count' => $consumedFrozenItems->count(),
  874. 'by_user' => [],
  875. 'by_item' => [],
  876. 'items' => [],
  877. ];
  878. foreach ($consumedFrozenItems as $frozenItem) {
  879. // 按用户统计
  880. if (!isset($statistics['by_user'][$frozenItem->user_id])) {
  881. $statistics['by_user'][$frozenItem->user_id] = 0;
  882. }
  883. $statistics['by_user'][$frozenItem->user_id]++;
  884. // 按物品统计
  885. if (!isset($statistics['by_item'][$frozenItem->item_id])) {
  886. $statistics['by_item'][$frozenItem->item_id] = 0;
  887. }
  888. $statistics['by_item'][$frozenItem->item_id]++;
  889. // 详细记录
  890. $statistics['items'][] = [
  891. 'user_id' => $frozenItem->user_id,
  892. 'item_id' => $frozenItem->item_id,
  893. 'instance_id' => $frozenItem->instance_id,
  894. 'user_item_id' => $frozenItem->id,
  895. 'frozen_log_id' => $frozenItem->frozen_log_id,
  896. 'quantity' => $frozenItem->quantity,
  897. ];
  898. }
  899. return $statistics;
  900. }
  901. /**
  902. * 获取用户其他冻结物品的数量(排除指定的冻结日志)
  903. *
  904. * @param int $userId 用户ID
  905. * @param int $itemId 物品ID
  906. * @param string|null $instanceId 实例ID
  907. * @param int $excludeFreezeLogId 要排除的冻结日志ID
  908. * @return int 其他冻结物品的总数量
  909. */
  910. private static function getOtherFrozenQuantity(int $userId, int $itemId, ?string $instanceId, int $excludeFreezeLogId): int
  911. {
  912. return ItemUser::where('user_id', $userId)
  913. ->where('item_id', $itemId)
  914. ->where('instance_id', $instanceId)
  915. ->where('is_frozen', true)
  916. ->where('frozen_log_id', '!=', $excludeFreezeLogId)
  917. ->where('quantity', '>', 0)
  918. ->sum('quantity');
  919. }
  920. }