PromotionAdminService.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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 Illuminate\Support\Facades\DB;
  8. use Illuminate\Support\Facades\Schema;
  9. use Illuminate\Support\Facades\Validator;
  10. use Illuminate\Validation\ValidationException;
  11. class PromotionAdminService
  12. {
  13. public function options(): array
  14. {
  15. $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
  16. return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']],
  17. 'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
  18. 'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)],
  19. 'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS,
  20. 'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']],
  21. 'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)];
  22. }
  23. public function listing(array $input): array
  24. {
  25. $input = Validator::make($input, ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100'],
  26. 'type' => ['sometimes', 'integer', 'in:7,8,9'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate();
  27. $q = PromotionActivity::query();
  28. foreach (['type', 'status'] as $key) if (isset($input[$key])) $q->where($key, $input[$key]);
  29. if (!empty($input['title'])) $q->where('title', 'like', '%' . $input['title'] . '%');
  30. 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()];
  31. }
  32. /** Fixed activities (loss invite, wheel) have one row, so the flat edit form can load it by type alone. */
  33. public function detail(?int $id, ?int $type = null): array
  34. {
  35. if ($id === null && !in_array($type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
  36. throw ValidationException::withMessages(['type' => '仅净亏邀请和转盘活动可按type读取']);
  37. }
  38. $row = $id !== null ? PromotionActivity::find($id) : PromotionActivity::where('type', $type)->orderBy('id')->first();
  39. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  40. return $this->format($row);
  41. }
  42. private function format(PromotionActivity $row): array
  43. {
  44. $data = array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
  45. 'start_time' => date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => date('Y-m-d H:i:s', (int)$row->end_time),
  46. 'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
  47. unset($data['sub_title']);
  48. if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']);
  49. return $data;
  50. }
  51. public function save(array $input): array
  52. {
  53. unset($input['sub_title']);
  54. if (isset($input['translations']) && is_array($input['translations'])) {
  55. foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']);
  56. unset($text);
  57. }
  58. if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']);
  59. $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
  60. $textRules = ['translations' => ['required', 'array:zh,en,vi']];
  61. $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题和正文'];
  62. foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
  63. $textRules['translations.' . $lang] = ['required', 'array:title,content'];
  64. $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
  65. foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
  66. $key = 'translations.' . $lang . '.' . $field;
  67. $textRules[$key] = ['required', 'string', 'max:' . $max];
  68. $messages[$key . '.required'] = $label . $name . '不能为空';
  69. }
  70. }
  71. $data = Validator::make($input, $textRules + ['id' => ['sometimes', 'integer', 'min:1'],
  72. 'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'],
  73. 'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'],
  74. 'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'],
  75. 'image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'publish_platforms' => ['sometimes', 'array'], 'publish_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'],
  76. 'register_time_limited' => ['sometimes', 'boolean'], 'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
  77. 'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
  78. 'register_platforms' => ['sometimes', 'array'], 'register_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'deposit_limited' => ['sometimes', 'boolean'],
  79. 'deposit_min' => [...$money, 'required_if:deposit_limited,1'], 'deposit_max' => [...$money, 'required_if:deposit_limited,1', 'gte:deposit_min'],
  80. 'show_ineligible' => ['sometimes', 'boolean'], 'reward_quota' => ['sometimes', 'nullable', 'integer', 'between:0,2147483647'], 'budget' => ['sometimes', ...$money]], $messages)->validate();
  81. if (!isset($data['id']) && in_array((int) $data['type'], [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
  82. throw ValidationException::withMessages(['id' => '净亏和转盘活动不支持新增,请编辑现有活动']);
  83. }
  84. return DB::transaction(function () use ($data) {
  85. $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
  86. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  87. if ($row->exists && (int)$row->type !== (int)$data['type']) throw ValidationException::withMessages(['type' => '创建后不能变更活动类型']);
  88. $before = $row->exists ? $row->toArray() : [];
  89. $params = array_replace($row->params ? json_decode($row->params, true) : [], $data['params'] ?? []);
  90. $values = $data; unset($values['id']);
  91. $texts = $this->completeTexts($data['translations']);
  92. $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
  93. $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
  94. $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']);
  95. if ((int)$values['status'] === 1 && $values['end_time'] < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
  96. if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
  97. $row->fill($values); $row->save(); PromotionService::publish($row);
  98. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $row->id, $before ? 'update' : 'create', $before, $row->toArray());
  99. return $this->format($row);
  100. }, 3);
  101. }
  102. private function completeTexts(array $texts): array
  103. {
  104. foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
  105. foreach (['title' => '标题', 'content' => '正文'] as $field => $name) {
  106. $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
  107. if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') {
  108. throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']);
  109. }
  110. if ($field !== 'content') $texts[$lang][$field] = trim($texts[$lang][$field]);
  111. }
  112. }
  113. return $texts;
  114. }
  115. public function status(int $id, int $status): array
  116. {
  117. return DB::transaction(function () use ($id, $status) {
  118. $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
  119. if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
  120. if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()));
  121. if ($status === 1 && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
  122. $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
  123. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
  124. return $this->format($row);
  125. }, 3);
  126. }
  127. /** Takes ended activities off the shelf; each change is audited like a manual status switch. */
  128. public static function expire(): int
  129. {
  130. $expired = 0;
  131. foreach (PromotionActivity::where('status', 1)->where('end_time', '<', now()->timestamp)->pluck('id') as $id) {
  132. $expired += DB::transaction(function () use ($id) {
  133. $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
  134. if (!$row || (int)$row->status !== 1 || (int)$row->end_time >= now()->timestamp) return 0;
  135. $before = $row->toArray(); $row->status = 0; $row->save();
  136. if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'expire', $before, $row->toArray(), null, ['operator_id' => null, 'operator_name' => '系统']);
  137. return 1;
  138. }, 3);
  139. }
  140. return $expired;
  141. }
  142. public function records(array $input): array
  143. {
  144. $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'],
  145. 'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons'], 'status' => ['sometimes', 'in:unused,used'],
  146. 'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
  147. if ($input['kind'] === 'coupons') {
  148. // Invitee coupons belong to the invitee, so they are found by activity rather than by the inviter's account.
  149. $q = DB::table('promotion_coupons')->where('activity_id', $input['activity_id']);
  150. if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('user_id', $input['user_id'])->orWhere('inviter_user_id', $input['user_id']));
  151. if (isset($input['status'])) $q->where('status', $input['status']);
  152. $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()
  153. ->map(function ($row) { $row->categories = json_decode($row->categories, true); return $row; });
  154. return ['total' => $q->count(), 'data' => $rows];
  155. }
  156. $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
  157. if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']);
  158. if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id');
  159. else {
  160. $table = match ($input['kind']) { 'rounds' => 'promotion_rounds', 'tasks' => 'promotion_task_grants', 'spins' => 'promotion_spins', 'rewards' => 'promotion_rewards' };
  161. $q->join($table . ' as r', 'r.account_id', '=', 'a.id')->select(['r.*', 'a.user_id', 'a.activity_id'])->orderByDesc('r.id');
  162. }
  163. return ['total' => $q->count(), 'data' => $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()];
  164. }
  165. }