Item.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  1. <?php
  2. namespace App\Module\GameItems\Logics;
  3. use App\Module\GameItems\Enums\ITEM_TYPE;
  4. use App\Module\GameItems\Enums\TRANSACTION_TYPE;
  5. use App\Module\GameItems\Events\ItemAcquired;
  6. use App\Module\GameItems\Events\ItemConsumed;
  7. use App\Module\GameItems\Events\ItemQuantityChanged;
  8. use App\Module\GameItems\Models\Item as ItemModel;
  9. use App\Module\GameItems\Models\ItemInstance;
  10. use App\Module\GameItems\Models\ItemTransactionLog;
  11. use App\Module\GameItems\Models\ItemUser;
  12. use Exception;
  13. use Illuminate\Support\Facades\Event;
  14. use UCore\Db\Helper;
  15. use UCore\Dto\Res;
  16. /**
  17. * 物品逻辑类
  18. */
  19. class Item
  20. {
  21. /**
  22. * 判断物品是否为宝箱
  23. *
  24. * @param ItemModel $item 物品模型
  25. * @return bool
  26. */
  27. public static function isChest(ItemModel $item): bool
  28. {
  29. return $item->type == ITEM_TYPE::CHEST; // 使用枚举代替魔法数字
  30. }
  31. /**
  32. * 检查物品是否已过期(全局过期)
  33. *
  34. * @param ItemModel $item 物品模型
  35. * @return bool
  36. */
  37. public static function isExpired(ItemModel $item): bool
  38. {
  39. if (empty($item->global_expire_at)) {
  40. return false;
  41. }
  42. // 确保 global_expire_at 是 Carbon 实例
  43. $expireAt = $item->global_expire_at;
  44. if (is_string($expireAt)) {
  45. $expireAt = \Carbon\Carbon::parse($expireAt);
  46. }
  47. return $expireAt->isPast();
  48. }
  49. /**
  50. * 添加统一属性物品
  51. *
  52. * @param int $userId 用户ID
  53. * @param int $itemId 物品ID
  54. * @param int $quantity 数量
  55. * @param array $options 选项
  56. * @return array 添加结果
  57. * @throws Exception
  58. */
  59. public static function addNormalItem(int $userId, int $itemId, int $quantity, array $options = []): array
  60. {
  61. // 获取物品信息
  62. $item = ItemModel::findOrFail($itemId);
  63. // 计算过期时间
  64. $expireAt = null;
  65. if (!empty($options['expire_at'])) {
  66. $expireAt = $options['expire_at'];
  67. } elseif ($item->default_expire_seconds > 0) {
  68. $expireAt = now()->addSeconds($item->default_expire_seconds);
  69. }
  70. // 检查事务是否已开启
  71. Helper::check_tr();
  72. // 获取来源信息
  73. $sourceType = $options['source_type'] ?? null;
  74. $sourceId = $options['source_id'] ?? null;
  75. // 检查用户是否已有该物品且过期时间相同,并且未满堆叠(排除冻结的物品)
  76. $userItem = ItemUser::where('user_id', $userId)
  77. ->where('item_id', $itemId)
  78. ->where(function ($query) use ($expireAt) {
  79. if ($expireAt === null) {
  80. $query->whereNull('expire_at');
  81. } else {
  82. $query->where('expire_at', $expireAt);
  83. }
  84. })
  85. ->whereNull('instance_id')
  86. ->where('is_frozen', false) // 排除冻结的物品
  87. ->where(function ($query) use ($item) {
  88. // 如果有最大堆叠限制,只查找未满的堆叠
  89. if ($item->max_stack > 0) {
  90. $query->where('quantity', '<', $item->max_stack);
  91. }
  92. })
  93. ->first();
  94. $addedQuantity = $quantity;
  95. $currentQuantity = 0;
  96. if ($userItem) {
  97. // 已有物品,增加数量
  98. $currentQuantity = $userItem->quantity;
  99. $newQuantity = $currentQuantity + $quantity;
  100. // 检查最大堆叠限制
  101. if ($item->max_stack > 0 && $newQuantity > $item->max_stack) {
  102. // 超过最大堆叠,先填满当前堆叠
  103. $canAddToCurrent = $item->max_stack - $currentQuantity;
  104. $userItem->quantity = $item->max_stack;
  105. $userItem->save();
  106. // 触发物品数量变更事件(更新现有堆叠)
  107. Event::dispatch(new ItemQuantityChanged(
  108. $userId,
  109. $itemId,
  110. null,
  111. $currentQuantity,
  112. $item->max_stack,
  113. $userItem->id,
  114. $userItem->is_frozen, // 旧冻结状态(数量变更时冻结状态不变)
  115. $userItem->is_frozen, // 新冻结状态(数量变更时冻结状态不变)
  116. $options
  117. ));
  118. // 剩余数量递归添加到新堆叠
  119. $remainingQuantity = $quantity - $canAddToCurrent;
  120. if ($remainingQuantity > 0) {
  121. self::addNormalItem($userId, $itemId, $remainingQuantity, $options);
  122. }
  123. $addedQuantity = $quantity;
  124. $currentQuantity = $item->max_stack;
  125. } else {
  126. // 未超过最大堆叠,直接更新数量
  127. $oldQuantity = $userItem->quantity;
  128. $userItem->quantity = $newQuantity;
  129. $userItem->save();
  130. $currentQuantity = $newQuantity;
  131. // 触发物品数量变更事件
  132. Event::dispatch(new ItemQuantityChanged(
  133. $userId,
  134. $itemId,
  135. null,
  136. $oldQuantity,
  137. $newQuantity,
  138. $userItem->id,
  139. $userItem->is_frozen, // 旧冻结状态(数量变更时冻结状态不变)
  140. $userItem->is_frozen, // 新冻结状态(数量变更时冻结状态不变)
  141. $options
  142. ));
  143. }
  144. } else {
  145. // 没有该物品,创建新记录
  146. $createQuantity = min($quantity, $item->max_stack > 0 ? $item->max_stack : $quantity);
  147. $userItem = new ItemUser([
  148. 'user_id' => $userId,
  149. 'item_id' => $itemId,
  150. 'quantity' => $createQuantity,
  151. 'expire_at' => $expireAt,
  152. ]);
  153. $userItem->save();
  154. // 触发物品数量变更事件(新增物品)
  155. Event::dispatch(new ItemQuantityChanged(
  156. $userId,
  157. $itemId,
  158. null,
  159. 0, // 旧数量为0
  160. $createQuantity,
  161. $userItem->id,
  162. false, // 旧冻结状态(新增物品时为 false )
  163. false, // 新冻结状态(新增物品默认未冻结)
  164. $options
  165. ));
  166. // 如果数量超过最大堆叠,递归添加剩余数量
  167. if ($item->max_stack > 0 && $quantity > $item->max_stack) {
  168. $remainingQuantity = $quantity - $item->max_stack;
  169. self::addNormalItem($userId, $itemId, $remainingQuantity, $options);
  170. $addedQuantity = $quantity; // 总添加数量
  171. } else {
  172. $addedQuantity = $createQuantity;
  173. }
  174. $currentQuantity = $createQuantity;
  175. }
  176. // 记录交易日志
  177. self::logTransaction(
  178. $userId,
  179. $itemId,
  180. null,
  181. $addedQuantity,
  182. TRANSACTION_TYPE::ACQUIRE,
  183. $sourceType,
  184. $sourceId,
  185. $options['details'] ?? null,
  186. $expireAt,
  187. $options['ip_address'] ?? null,
  188. $options['device_info'] ?? null
  189. );
  190. // 触发物品获取事件
  191. Event::dispatch(new ItemAcquired($userId, $itemId, null, $addedQuantity, $options));
  192. return [
  193. 'success' => true,
  194. 'item_id' => $itemId,
  195. 'quantity' => $addedQuantity,
  196. 'current_quantity' => $currentQuantity,
  197. 'user_item_id' => $userItem->id,
  198. ];
  199. }
  200. /**
  201. * 添加单独属性物品
  202. *
  203. * @param int $userId 用户ID
  204. * @param int $itemId 物品ID
  205. * @param array $options 选项
  206. * @return array 添加结果
  207. * @throws Exception
  208. */
  209. public static function addUniqueItem(int $userId, int $itemId, array $options = []): array
  210. {
  211. // 获取物品信息
  212. $item = ItemModel::findOrFail($itemId);
  213. // 确保物品是单独属性物品
  214. if (!$item->is_unique) {
  215. throw new Exception("物品 {$itemId} 不是单独属性物品");
  216. }
  217. // 计算过期时间
  218. $expireAt = null;
  219. if (!empty($options['expire_at'])) {
  220. $expireAt = $options['expire_at'];
  221. } elseif ($item->default_expire_seconds > 0) {
  222. $expireAt = now()->addSeconds($item->default_expire_seconds);
  223. }
  224. // 检查事务是否已开启
  225. Helper::check_tr();
  226. // 获取来源信息
  227. $sourceType = $options['source_type'] ?? null;
  228. $sourceId = $options['source_id'] ?? null;
  229. if (!$sourceType || !$sourceId) {
  230. throw new Exception("物品 {$itemId} ,缺少来源类型.");
  231. }
  232. // 创建物品实例
  233. $instance = new ItemInstance([
  234. 'item_id' => $itemId,
  235. 'name' => $options['name'] ?? $item->name,
  236. 'display_attributes' => $options['display_attributes'] ?? $item->display_attributes,
  237. 'numeric_attributes' => $options['numeric_attributes'] ?? $item->numeric_attributes,
  238. 'tradable' => $options['tradable'] ?? $item->tradable,
  239. 'is_bound' => $options['is_bound'] ?? false,
  240. 'bound_to' => $options['bound_to'] ?? null,
  241. 'bind_exp_time' => $options['bind_exp_time'] ?? null,
  242. 'expire_at' => $expireAt,
  243. ]);
  244. $instance->save();
  245. // 关联到用户
  246. $userItem = new ItemUser([
  247. 'user_id' => $userId,
  248. 'item_id' => $itemId,
  249. 'instance_id' => $instance->id,
  250. 'quantity' => 1, // 单独属性物品数量始终为1
  251. 'expire_at' => $expireAt,
  252. ]);
  253. $userItem->save();
  254. // 记录交易日志
  255. self::logTransaction(
  256. $userId,
  257. $itemId,
  258. $instance->id,
  259. 1,
  260. TRANSACTION_TYPE::ACQUIRE,
  261. $sourceType,
  262. $sourceId,
  263. $options['details'] ?? null,
  264. $expireAt,
  265. $options['ip_address'] ?? null,
  266. $options['device_info'] ?? null
  267. );
  268. // 触发物品获取事件
  269. Event::dispatch(new ItemAcquired($userId, $itemId, $instance->id, 1, $options));
  270. // 触发物品数量变更事件(新增物品)
  271. Event::dispatch(new ItemQuantityChanged(
  272. $userId,
  273. $itemId,
  274. $instance->id,
  275. 0, // 旧数量为0
  276. 1, // 新数量为1
  277. $userItem->id,
  278. false, // 旧冻结状态(新增物品时为false)
  279. false, // 新冻结状态(新增物品默认未冻结)
  280. $options
  281. ));
  282. return [
  283. 'success' => true,
  284. 'item_id' => $itemId,
  285. 'instance_id' => $instance->id,
  286. 'user_item_id' => $userItem->id,
  287. ];
  288. }
  289. /**
  290. * 消耗统一属性物品
  291. *
  292. * @param int $userId 用户ID
  293. * @param int $itemId 物品ID
  294. * @param int $quantity 数量
  295. * @param array $options 选项
  296. * @return array 消耗结果
  297. * @throws Exception
  298. */
  299. public static function consumeNormalItem(int $userId, int $itemId, int $quantity, array $options = []): array
  300. {
  301. Helper::check_tr();
  302. // 检查是否包含冻结物品
  303. $includeFrozen = $options['include_frozen'] ?? false;
  304. // 构建查询条件
  305. $query = ItemUser::where('user_id', $userId)
  306. ->where('item_id', $itemId)
  307. ->whereNull('instance_id')
  308. ->where('quantity', '>', 0); // 确保数量大于0
  309. // 根据include_frozen参数决定是否包含冻结物品
  310. if (!$includeFrozen) {
  311. $query->where('is_frozen', false); // 只获取未冻结的物品
  312. }
  313. // 获取用户物品(优先消耗冻结物品,然后按过期时间排序)
  314. // 使用lockForUpdate锁定记录,防止并发修改
  315. if ($includeFrozen) {
  316. // 当包含冻结物品时,优先消耗冻结物品,再消耗未冻结物品
  317. $userItems = $query->orderBy('is_frozen', 'desc') // 冻结物品优先(true > false)
  318. ->orderBy('quantity', 'desc')// 数量倒序
  319. ->orderBy('expire_at') // 然后按过期时间排序
  320. ->lockForUpdate() // 锁定记录,防止并发修改
  321. ->get();
  322. } else {
  323. // 只消耗未冻结物品时,按过期时间排序
  324. $userItems = $query->orderBy('expire_at')
  325. ->lockForUpdate() // 锁定记录,防止并发修改
  326. ->get();
  327. }
  328. // 重新检查物品数量是否足够(锁定后可能已变化)
  329. $totalQuantity = $userItems->sum('quantity');
  330. if ($totalQuantity < $quantity) {
  331. throw new Exception("用户 {$userId} 的物品 {$itemId} 数量不足,需要 {$quantity},锁定后实际 {$totalQuantity}");
  332. }
  333. // 获取来源信息
  334. $sourceType = $options['source_type'] ?? null;
  335. $sourceId = $options['source_id'] ?? null;
  336. // 开始消耗物品
  337. $remainingQuantity = $quantity;
  338. foreach ($userItems as $userItem) {
  339. if ($remainingQuantity <= 0) {
  340. break;
  341. }
  342. if ($userItem->quantity <= $remainingQuantity) {
  343. // 当前堆叠数量不足,全部消耗
  344. $consumedQuantity = $userItem->quantity;
  345. $remainingQuantity -= $consumedQuantity;
  346. $oldQuantity = $userItem->quantity;
  347. // 记录交易日志
  348. self::logTransaction(
  349. $userId,
  350. $itemId,
  351. null,
  352. -$consumedQuantity,
  353. TRANSACTION_TYPE::CONSUME,
  354. $sourceType,
  355. $sourceId,
  356. $options['details'] ?? null,
  357. null,
  358. $options['ip_address'] ?? null,
  359. $options['device_info'] ?? null
  360. );
  361. // 将数量设置为0,不删除记录
  362. $userItem->quantity = 0;
  363. $userItem->save();
  364. // 触发物品数量变更事件
  365. Event::dispatch(new ItemQuantityChanged(
  366. $userId,
  367. $itemId,
  368. null,
  369. $oldQuantity,
  370. 0,
  371. $userItem->id,
  372. $userItem->is_frozen, // 旧冻结状态(消耗时冻结状态不变)
  373. $userItem->is_frozen, // 新冻结状态(消耗时冻结状态不变)
  374. $options
  375. ));
  376. } else {
  377. // 当前堆叠数量足够,部分消耗
  378. $consumedQuantity = $remainingQuantity;
  379. $oldQuantity = $userItem->quantity;
  380. $newQuantity = $oldQuantity - $consumedQuantity;
  381. $userItem->quantity = $newQuantity;
  382. $userItem->save();
  383. $remainingQuantity = 0;
  384. // 记录交易日志
  385. self::logTransaction(
  386. $userId,
  387. $itemId,
  388. null,
  389. -$consumedQuantity,
  390. TRANSACTION_TYPE::CONSUME,
  391. $sourceType,
  392. $sourceId,
  393. $options['details'] ?? null,
  394. null,
  395. $options['ip_address'] ?? null,
  396. $options['device_info'] ?? null
  397. );
  398. // 触发物品数量变更事件
  399. Event::dispatch(new ItemQuantityChanged(
  400. $userId,
  401. $itemId,
  402. null,
  403. $oldQuantity,
  404. $newQuantity,
  405. $userItem->id,
  406. $userItem->is_frozen, // 旧冻结状态(消耗时冻结状态不变)
  407. $userItem->is_frozen, // 新冻结状态(消耗时冻结状态不变)
  408. $options
  409. ));
  410. }
  411. // 触发物品消耗事件
  412. Event::dispatch(new ItemConsumed($userId, $itemId, null, $consumedQuantity, $options));
  413. }
  414. return [
  415. 'success' => true,
  416. 'item_id' => $itemId,
  417. 'quantity' => $quantity,
  418. 'remaining_quantity' => $totalQuantity - $quantity,
  419. ];
  420. }
  421. /**
  422. * 消耗单独属性物品
  423. *
  424. * @param int $userId 用户ID
  425. * @param int $itemId 物品ID
  426. * @param int $instanceId 物品实例ID
  427. * @param array $options 选项
  428. * @return array 消耗结果
  429. * @throws Exception
  430. */
  431. public static function consumeUniqueItem(int $userId, int $itemId, int $instanceId, array $options = []): Res
  432. {
  433. Helper::check_tr();
  434. // 检查是否包含冻结物品
  435. $includeFrozen = $options['include_frozen'] ?? false;
  436. // 构建查询条件
  437. $query = ItemUser::where('user_id', $userId)
  438. ->where('item_id', $itemId)
  439. ->where('instance_id', $instanceId);
  440. // 根据include_frozen参数决定是否包含冻结物品
  441. if (!$includeFrozen) {
  442. $query->where('is_frozen', false); // 只获取未冻结的物品
  443. }
  444. // 使用lockForUpdate锁定记录,防止并发修改
  445. $userItem = $query->lockForUpdate()->first();
  446. if (!$userItem) {
  447. $frozenText = $includeFrozen ? '' : '(未冻结)';
  448. throw new Exception("用户 {$userId} 没有物品实例 {$instanceId}{$frozenText}");
  449. }
  450. // 获取来源信息
  451. $sourceType = $options['source_type'] ?? null;
  452. $sourceId = $options['source_id'] ?? null;
  453. // 记录交易日志
  454. self::logTransaction(
  455. $userId,
  456. $itemId,
  457. $instanceId,
  458. -1,
  459. TRANSACTION_TYPE::CONSUME,
  460. $sourceType,
  461. $sourceId,
  462. $options['details'] ?? null,
  463. null,
  464. $options['ip_address'] ?? null,
  465. $options['device_info'] ?? null
  466. );
  467. // 删除用户物品记录
  468. $userItem->delete();
  469. // 是否删除物品实例
  470. if (!empty($options['delete_instance'])) {
  471. ItemInstance::where('id', $instanceId)->delete();
  472. }
  473. // 触发物品消耗事件
  474. Event::dispatch(new ItemConsumed($userId, $itemId, $instanceId, 1, $options));
  475. return Res::success('', [
  476. 'item_id' => $itemId,
  477. 'instance_id' => $instanceId,
  478. ]);
  479. }
  480. /**
  481. * 记录物品交易日志
  482. *
  483. * @param int $userId 用户ID
  484. * @param int $itemId 物品ID
  485. * @param int|null $instanceId 物品实例ID
  486. * @param int $quantity 数量
  487. * @param int $transactionType 交易类型
  488. * @param mixed $sourceType 来源类型(支持字符串或枚举类型)
  489. * @param int|null $sourceId 来源ID
  490. * @param array|null $details 详细信息
  491. * @param string|null $expireAt 过期时间
  492. * @param string|null $ipAddress IP地址
  493. * @param string|null $deviceInfo 设备信息
  494. * @return ItemTransactionLog
  495. */
  496. public static function logTransaction(
  497. int $userId,
  498. int $itemId,
  499. ?int $instanceId,
  500. int $quantity,
  501. int $transactionType,
  502. $sourceType = null,
  503. ?int $sourceId = null,
  504. ?array $details = null,
  505. ?string $expireAt = null,
  506. ?string $ipAddress = null,
  507. ?string $deviceInfo = null
  508. ): ItemTransactionLog
  509. {
  510. // 处理枚举类型的sourceType
  511. $sourceTypeValue = null;
  512. if ($sourceType !== null) {
  513. if (is_object($sourceType) && method_exists($sourceType, 'value')) {
  514. // 如果是枚举类型,获取其值
  515. $sourceTypeValue = $sourceType->value;
  516. } elseif (is_string($sourceType)) {
  517. // 如果是字符串,直接使用
  518. $sourceTypeValue = $sourceType;
  519. } else {
  520. // 其他类型转换为字符串
  521. $sourceTypeValue = (string)$sourceType;
  522. }
  523. }
  524. return ItemTransactionLog::create([
  525. 'user_id' => $userId,
  526. 'item_id' => $itemId,
  527. 'instance_id' => $instanceId,
  528. 'quantity' => $quantity,
  529. 'transaction_type' => $transactionType,
  530. 'source_type' => $sourceTypeValue,
  531. 'source_id' => $sourceId,
  532. 'details' => $details,
  533. 'expire_at' => $expireAt,
  534. 'ip_address' => $ipAddress,
  535. 'device_info' => $deviceInfo,
  536. ]);
  537. }
  538. }