ItemFreeze.php 46 KB

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