PromotionAdminService.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. <?php
  2. namespace App\Services;
  3. use App\Models\PromotionActivity;
  4. use App\Services\Sharing\ShareDefaults;
  5. use App\Support\PromotionRules;
  6. use App\Support\PromotionTranslations;
  7. use App\Support\ActivityPeriod;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Schema;
  10. use Illuminate\Support\Facades\Validator;
  11. use Illuminate\Validation\ValidationException;
  12. class PromotionAdminService
  13. {
  14. public function options(): array
  15. {
  16. $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
  17. return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']],
  18. 'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
  19. 'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9), 10 => PromotionRules::defaults(10)],
  20. 'share_levels' => $this->shareLevels(),
  21. 'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS,
  22. 'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']],
  23. 'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)];
  24. }
  25. /** 全部首充活动共用的“禁止投注游戏大类”设置;选项与返佣规则的游戏大类一致。 */
  26. public function firstDepositRestriction(): array
  27. {
  28. return ['categories' => PromotionRules::forbiddenSetting(), 'options' => $this->forbiddenOptions(ShareDefaults::commissionCategories())];
  29. }
  30. public function saveFirstDepositRestriction(array $input): array
  31. {
  32. if (!array_key_exists('categories', $input)) throw ValidationException::withMessages(['categories' => '请传categories,不限制时传空数组']);
  33. $categories = PromotionRules::forbiddenCategories($input['categories']);
  34. if (count($categories) === count(ShareDefaults::commissionCategories())) {
  35. throw ValidationException::withMessages(['categories' => '至少保留一个可投注的游戏大类']);
  36. }
  37. DB::transaction(function () use ($categories) {
  38. $before = PromotionRules::forbiddenSetting();
  39. DB::table('config')->updateOrInsert(['field' => PromotionRules::FORBIDDEN_CONFIG], ['val' => json_encode($categories)]);
  40. if (Schema::hasTable('operation_audits')) {
  41. OperationAuditService::record('promotion_first_deposit_restriction', null, 'update', ['categories' => $before], ['categories' => $categories]);
  42. }
  43. }, 3);
  44. return $this->firstDepositRestriction();
  45. }
  46. /** 首充活动详情链接:PC、H5、APP 各一个,全部首充活动共用。前台首充接口按会员的平台返回对应链接。 */
  47. public function firstDepositLinks(): array
  48. {
  49. return ['links' => PromotionRules::detailLinks()];
  50. }
  51. public function saveFirstDepositLinks(array $input): array
  52. {
  53. $links = [];
  54. foreach (PromotionRules::DETAIL_LINK_PLATFORMS as $platform) {
  55. $key = strtolower($platform);
  56. $value = $input['links'][$platform] ?? $input['links'][$key] ?? $input[$key] ?? '';
  57. if (!is_string($value) && $value !== null) throw ValidationException::withMessages([$key => $platform . ' 链接必须是文本']);
  58. $value = trim((string) $value);
  59. if (mb_strlen($value) > 500) throw ValidationException::withMessages([$key => $platform . ' 链接不能超过 500 个字符']);
  60. $links[$platform] = $value;
  61. }
  62. DB::transaction(function () use ($links) {
  63. $before = PromotionRules::detailLinks();
  64. DB::table('config')->updateOrInsert(['field' => PromotionRules::DETAIL_LINKS_CONFIG], ['val' => json_encode($links, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]);
  65. if (Schema::hasTable('operation_audits')) {
  66. OperationAuditService::record('promotion_first_deposit_links', null, 'update', ['links' => $before], ['links' => $links]);
  67. }
  68. }, 3);
  69. return $this->firstDepositLinks();
  70. }
  71. private function forbiddenOptions(array $categories): array
  72. {
  73. $options = [];
  74. foreach ($categories as $value => $label) $options[] = ['value' => $value, 'label' => $label];
  75. return $options;
  76. }
  77. public function listing(array $input): array
  78. {
  79. $input = Validator::make($input, ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100'],
  80. 'type' => ['sometimes', 'integer', 'in:7,8,9,10'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate();
  81. $q = PromotionActivity::query();
  82. foreach (['type', 'status'] as $key) if (isset($input[$key])) $q->where($key, $input[$key]);
  83. if (!empty($input['title'])) $q->where('title', 'like', '%' . $input['title'] . '%');
  84. return ['total' => $q->count(), 'data' => $q->orderByDesc('sort')->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()->map(fn ($r) => $this->format($r))->all()];
  85. }
  86. /** Fixed activities (loss invite, wheel, upgrade invite) have one row, so the flat edit form can load it by type alone. */
  87. public function detail(?int $id, ?int $type = null): array
  88. {
  89. if ($id === null && !in_array($type, PromotionRules::FIXED, true)) {
  90. throw ValidationException::withMessages(['type' => '仅净亏邀请、转盘活动和邀请升级奖励可按type读取']);
  91. }
  92. $row = $id !== null ? PromotionActivity::find($id) : PromotionActivity::where('type', $type)->orderBy('id')->first();
  93. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  94. return $this->format($row);
  95. }
  96. private function format(PromotionActivity $row): array
  97. {
  98. $data = array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
  99. 'start_time' => $row->start_time === null ? null : date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => $row->end_time === null ? null : date('Y-m-d H:i:s', (int)$row->end_time),
  100. 'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
  101. unset($data['sub_title']);
  102. // 禁止投注的游戏大类改为全部首充活动共用的设置,不在单个活动里返回旧值。
  103. if ((int) $row->type === PromotionRules::FIRST_DEPOSIT && is_array($data['params'])) unset($data['params']['forbidden_categories']);
  104. if (in_array((int) $row->type, PromotionRules::FIXED, true)) unset($data['image']);
  105. if (PromotionTranslations::fields((int) $row->type) === ['content']) {
  106. foreach ($data['translations'] as &$text) unset($text['title']);
  107. unset($text);
  108. }
  109. return $data;
  110. }
  111. public function save(array $input): array
  112. {
  113. unset($input['sub_title']);
  114. $type = (int)(is_scalar($input['type'] ?? null) ? $input['type'] : 0);
  115. $fields = PromotionTranslations::fields($type);
  116. if (isset($input['translations']) && is_array($input['translations'])) {
  117. foreach ($input['translations'] as &$text) if (is_array($text)) { unset($text['sub_title']); if (!in_array('title', $fields, true)) unset($text['title']); }
  118. unset($text);
  119. }
  120. if (in_array((string)($input['type'] ?? ''), array_map('strval', PromotionRules::FIXED), true)) unset($input['image']);
  121. $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
  122. $textRules = ['translations' => ['required', 'array:zh,en,vi']];
  123. $messages = ['start_time.required_with' => '活动开始和结束时间必须同时填写或同时留空', 'end_time.required_with' => '活动开始和结束时间必须同时填写或同时留空', 'translations.required' => '请完整填写中文、英文、越南文的' . ($fields === ['content'] ? '正文' : '标题和正文')];
  124. foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
  125. $textRules['translations.' . $lang] = ['required', 'array:' . implode(',', $fields)];
  126. $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
  127. foreach (array_intersect_key(['title' => ['标题', 140], 'content' => ['正文', 100000]], array_flip($fields)) as $field => [$name, $max]) {
  128. $key = 'translations.' . $lang . '.' . $field;
  129. $textRules[$key] = ['required', 'string', 'max:' . $max];
  130. $messages[$key . '.required'] = $label . $name . '不能为空';
  131. }
  132. }
  133. $data = Validator::make($input, $textRules + ['id' => ['sometimes', 'integer', 'min:1'],
  134. 'type' => ['required', 'integer', 'in:7,8,9,10'], 'status' => ['required', 'integer', 'in:0,1'],
  135. 'start_time' => ['nullable', 'required_with:end_time', 'date'], 'end_time' => ['nullable', 'required_with:start_time', 'date', 'after:start_time'],
  136. 'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'],
  137. 'image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'publish_platforms' => ['sometimes', 'array'], 'publish_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'],
  138. 'register_time_limited' => ['sometimes', 'boolean'], 'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
  139. 'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
  140. 'register_platforms' => ['sometimes', 'array'], 'register_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'deposit_limited' => ['sometimes', 'boolean'],
  141. 'deposit_min' => [...$money, 'required_if:deposit_limited,1'], 'deposit_max' => [...$money, 'required_if:deposit_limited,1', 'gte:deposit_min'],
  142. 'show_ineligible' => ['sometimes', 'boolean'], 'reward_quota' => ['sometimes', 'nullable', 'integer', 'between:0,2147483647'], 'budget' => ['sometimes', ...$money]], $messages)->validate();
  143. if (!isset($data['id']) && in_array((int) $data['type'], PromotionRules::FIXED, true)) {
  144. throw ValidationException::withMessages(['id' => '净亏、转盘和邀请升级活动不支持新增,请编辑现有活动']);
  145. }
  146. return DB::transaction(function () use ($data) {
  147. $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
  148. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  149. if ($row->exists && (int)$row->type !== (int)$data['type']) throw ValidationException::withMessages(['type' => '创建后不能变更活动类型']);
  150. $before = $row->exists ? $row->toArray() : [];
  151. $params = array_replace($row->params ? json_decode($row->params, true) : [], $data['params'] ?? []);
  152. $values = $data; unset($values['id']);
  153. $texts = $this->completeTexts($data['translations'], (int)$data['type']);
  154. $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
  155. // Content-only activities keep the type name in the required title column for admin lists.
  156. if ($values['title'] === '') $values['title'] = PromotionRules::types()[(int)$data['type']];
  157. $rules = PromotionRules::normalize((int)$data['type'], $params);
  158. if ((int)$data['type'] === PromotionRules::UPGRADE_INVITE) $this->checkUpgradeTiers($rules, (int)$values['status'] === 1);
  159. $values['params'] = json_encode($rules);
  160. $values = array_replace($values, ActivityPeriod::forSave($data, $row->exists, true));
  161. $endTime = array_key_exists('end_time', $values) ? $values['end_time'] : $row->end_time;
  162. if ((int)$values['status'] === 1 && $endTime !== null && $endTime < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
  163. if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
  164. $row->fill($values); $row->save(); PromotionService::publish($row);
  165. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $row->id, $before ? 'update' : 'create', $before, $row->toArray());
  166. return $this->format($row);
  167. }, 3);
  168. }
  169. /** Tier levels must be configured share levels; an empty tier list may be saved but not published. */
  170. private function checkUpgradeTiers(array $rules, bool $publishing): void
  171. {
  172. if ($publishing && !$rules['tiers']) throw ValidationException::withMessages(['params' => '请至少配置一档升级奖励']);
  173. $levels = array_column($this->shareLevels(), 'level');
  174. foreach ($rules['tiers'] as $tier) {
  175. if (!in_array($tier['level'], $levels, true)) throw ValidationException::withMessages(['params' => '分享等级' . $tier['level'] . '不存在']);
  176. }
  177. }
  178. private function shareLevels(): array
  179. {
  180. if (!Schema::hasTable('share_levels')) return [];
  181. return DB::table('share_levels')->orderBy('level')->get(['level', 'name'])
  182. ->map(fn ($row) => ['level' => (int)$row->level, 'name' => (string)$row->name])->all();
  183. }
  184. private function completeTexts(array $texts, int $type): array
  185. {
  186. $fields = PromotionTranslations::fields($type);
  187. foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
  188. $texts[$lang] += ['title' => ''];
  189. foreach (array_intersect_key(['title' => '标题', 'content' => '正文'], array_flip($fields)) as $field => $name) {
  190. $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  191. if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') {
  192. throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']);
  193. }
  194. if ($field !== 'content') $texts[$lang][$field] = trim($texts[$lang][$field]);
  195. }
  196. }
  197. return $texts;
  198. }
  199. public function status(int $id, int $status): array
  200. {
  201. return DB::transaction(function () use ($id, $status) {
  202. $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
  203. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  204. if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()), (int)$row->type);
  205. if ($status === 1 && $row->end_time !== null && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
  206. if ($status === 1 && (int)$row->type === PromotionRules::UPGRADE_INVITE) {
  207. $this->checkUpgradeTiers(PromotionRules::normalize(PromotionRules::UPGRADE_INVITE, json_decode($row->params, true) ?: []), true);
  208. }
  209. $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
  210. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
  211. return $this->format($row);
  212. }, 3);
  213. }
  214. /** Takes ended activities off the shelf; each change is audited like a manual status switch. */
  215. public static function expire(): int
  216. {
  217. $expired = 0;
  218. foreach (PromotionActivity::where('status', 1)->where('end_time', '<', now()->timestamp)->pluck('id') as $id) {
  219. $expired += DB::transaction(function () use ($id) {
  220. $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
  221. if (!$row || (int)$row->status !== 1 || $row->end_time === null || (int)$row->end_time >= now()->timestamp) return 0;
  222. $before = $row->toArray(); $row->status = 0; $row->save();
  223. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'expire', $before, $row->toArray(), null, ['operator_id' => null, 'operator_name' => '系统']);
  224. return 1;
  225. }, 3);
  226. }
  227. return $expired;
  228. }
  229. public function records(array $input): array
  230. {
  231. $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'type' => ['sometimes', 'integer', 'in:7,8,9,10'], 'user_id' => ['sometimes', 'integer', 'min:1'],
  232. 'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons,upgrades'], 'status' => ['sometimes', 'in:unused,used'],
  233. 'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
  234. // An explicit type guards against a misconfigured activity_id showing another campaign's records.
  235. $type = DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type');
  236. if ($type === null) throw ValidationException::withMessages(['activity_id' => '独立活动不存在']);
  237. if (isset($input['type']) && (int)$input['type'] !== (int)$type) throw ValidationException::withMessages(['type' => 'activity_id与type不匹配']);
  238. if ($input['kind'] === 'upgrades' && (int)$type !== PromotionRules::UPGRADE_INVITE) throw ValidationException::withMessages(['kind' => 'upgrades仅用于邀请升级奖励']);
  239. if ($input['kind'] === 'coupons') {
  240. // Invitee coupons belong to the invitee, so they are found by activity rather than by the inviter's account.
  241. $q = DB::table('promotion_coupons')->where('activity_id', $input['activity_id']);
  242. if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('user_id', $input['user_id'])->orWhere('inviter_user_id', $input['user_id']));
  243. if (isset($input['status'])) $q->where('status', $input['status']);
  244. $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()
  245. ->map(function ($row) { $row->categories = json_decode($row->categories, true); return $row; });
  246. $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->user_id, $row->inviter_user_id])->all());
  247. foreach ($rows as $row) {
  248. $row->account = $accounts[(int)$row->user_id] ?? null;
  249. $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null;
  250. }
  251. return ['total' => $q->count(), 'data' => $rows];
  252. }
  253. if ($input['kind'] === 'upgrades') return $this->upgradeGrants($input);
  254. $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
  255. if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']);
  256. if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id');
  257. else {
  258. $table = match ($input['kind']) { 'rounds' => 'promotion_rounds', 'tasks' => 'promotion_task_grants', 'spins' => 'promotion_spins', 'rewards' => 'promotion_rewards' };
  259. $q->join($table . ' as r', 'r.account_id', '=', 'a.id')->select(['r.*', 'a.user_id', 'a.activity_id'])->orderByDesc('r.id');
  260. }
  261. $total = (clone $q)->count();
  262. $rows = $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
  263. if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty() && (int)$type === PromotionRules::LOSS) {
  264. $this->lossRecordPeople($rows, $input['kind']);
  265. }
  266. return ['total' => $total, 'data' => $rows];
  267. }
  268. /** Upgrade invite payouts, one row per invitee tier; user_id matches either the inviter or the invitee. */
  269. private function upgradeGrants(array $input): array
  270. {
  271. $q = DB::table('promotion_upgrade_grants')->where('activity_id', $input['activity_id']);
  272. if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('inviter_user_id', $input['user_id'])->orWhere('invitee_user_id', $input['user_id']));
  273. $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
  274. $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->inviter_user_id, $row->invitee_user_id])->all());
  275. $names = array_column($this->shareLevels(), 'name', 'level');
  276. foreach ($rows as $row) {
  277. $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null;
  278. $row->invitee_account = $accounts[(int)$row->invitee_user_id] ?? null;
  279. $row->level_name = $names[(int)$row->level] ?? null;
  280. $row->invitee_level_name = $names[(int)$row->invitee_level] ?? null;
  281. }
  282. return ['total' => $q->count(), 'data' => $rows];
  283. }
  284. /** Loss-invite record display only: historical recipients and actual issued coupon quantities. */
  285. private function lossRecordPeople($rows, string $kind): void
  286. {
  287. // Loss invite repeats; an application row shows its latest claim, and applied_at belongs to the current round.
  288. $all = $kind === 'rewards' ? $rows : DB::table('promotion_rewards')->whereIn('account_id', $rows->pluck('id'))->orderBy('round_no')->get();
  289. $claimed = $all->countBy('account_id');
  290. $rewards = $kind === 'rewards' ? $rows : $all->keyBy('account_id')->values();
  291. $byAccount = $rewards->keyBy('account_id');
  292. $couponRewards = $rewards->filter(fn ($reward) => ($this->recordSnapshot($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon');
  293. $groups = $couponRewards->isEmpty() ? collect() : DB::table('promotion_coupons')->whereIn('reward_id', $couponRewards->pluck('id'))
  294. ->select(['reward_id', 'user_id', 'role', 'min_amount', 'discount'])->selectRaw('COUNT(*) AS quantity')
  295. ->groupBy('reward_id', 'user_id', 'role', 'min_amount', 'discount')->orderBy('min_amount')->orderBy('discount')->get();
  296. $coupons = $issuedInvitees = [];
  297. $people = $rows->pluck('user_id')->all();
  298. foreach ($groups as $group) {
  299. $coupons[(int)$group->reward_id][(int)$group->user_id][$group->role][] = [
  300. 'min_amount' => bcadd((string)$group->min_amount, '0', 2), 'discount' => bcadd((string)$group->discount, '0', 2), 'quantity' => (int)$group->quantity,
  301. ];
  302. if ($group->role === 'invitee') $issuedInvitees[(int)$group->reward_id][] = (int)$group->user_id;
  303. $people[] = (int)$group->user_id;
  304. }
  305. $contexts = [];
  306. foreach ($rows as $row) {
  307. $reward = $kind === 'rewards' ? $row : $byAccount->get($row->id);
  308. $application = $this->recordSnapshot($row->application_snapshot ?? null);
  309. $rules = $reward ? $this->recordSnapshot($reward->rule_snapshot) : ($application['rules'] ?? []);
  310. if (!is_array($rules)) $rules = [];
  311. $mode = ($reward || $rules) ? ($rules['reward_mode'] ?? 'cash') : null;
  312. $ids = $mode === 'coupon' && isset($rules['coupon_invitees']) ? $rules['coupon_invitees'] : ($rules['qualified_invitees'] ?? []);
  313. $ids = $this->recordIds(array_merge(is_array($ids) ? $ids : [], $issuedInvitees[(int)($reward->id ?? 0)] ?? []));
  314. $contexts[] = [$row, $reward, in_array($mode, ['cash', 'coupon'], true) ? $mode : null, $ids];
  315. $people = array_merge($people, $ids);
  316. }
  317. $accounts = $this->recordAccounts($people);
  318. foreach ($contexts as [$row, $reward, $mode, $ids]) {
  319. $rewardId = (int)($reward->id ?? 0); $owner = (int)$row->user_id;
  320. $row->reward_mode = $mode;
  321. if ($kind === 'accounts') {
  322. $row->reward_id = $reward ? (int)$reward->id : null;
  323. $row->reward_status = $reward ? $reward->status : null;
  324. $row->reward_round_no = $reward ? (int)$reward->round_no : null;
  325. $row->claimed_count = (int)($claimed[$row->id] ?? 0);
  326. }
  327. $row->inviter = ['id' => $owner, 'account' => $accounts[$owner] ?? null, 'coupons' => $coupons[$rewardId][$owner]['inviter'] ?? []];
  328. $row->invitees = array_map(fn ($id) => ['id' => $id, 'account' => $accounts[$id] ?? null,
  329. 'coupons' => $coupons[$rewardId][$id]['invitee'] ?? []], $ids);
  330. }
  331. }
  332. private function recordSnapshot($value): array
  333. {
  334. $value = is_string($value) ? json_decode($value, true) : $value;
  335. return is_array($value) ? $value : [];
  336. }
  337. private function recordIds(array $ids): array
  338. {
  339. $valid = [];
  340. foreach ($ids as $id) {
  341. $id = is_int($id) || is_string($id) ? filter_var($id, FILTER_VALIDATE_INT) : false;
  342. if ($id !== false && $id > 0) $valid[$id] = $id;
  343. }
  344. return array_values($valid);
  345. }
  346. private function recordAccounts(array $ids): array
  347. {
  348. $accounts = [];
  349. foreach (array_chunk($this->recordIds($ids), 1000) as $chunk) {
  350. foreach (DB::table('users')->whereIn('id', $chunk)->get(['id', 'account']) as $user) {
  351. $accounts[(int)$user->id] = $user->account === null || $user->account === '' ? null : (string)$user->account;
  352. }
  353. }
  354. return $accounts;
  355. }
  356. }