$label) $types[] = compact('value', 'label'); return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']], 'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]], 'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)], 'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS, 'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']], 'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)]; } public function listing(array $input): array { $input = Validator::make($input, ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100'], 'type' => ['sometimes', 'integer', 'in:7,8,9'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate(); $q = PromotionActivity::query(); foreach (['type', 'status'] as $key) if (isset($input[$key])) $q->where($key, $input[$key]); if (!empty($input['title'])) $q->where('title', 'like', '%' . $input['title'] . '%'); 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()]; } /** Fixed activities (loss invite, wheel) have one row, so the flat edit form can load it by type alone. */ public function detail(?int $id, ?int $type = null): array { if ($id === null && !in_array($type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) { throw ValidationException::withMessages(['type' => '仅净亏邀请和转盘活动可按type读取']); } $row = $id !== null ? PromotionActivity::find($id) : PromotionActivity::where('type', $type)->orderBy('id')->first(); if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']); return $this->format($row); } private function format(PromotionActivity $row): array { $data = array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true), '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), 'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]); unset($data['sub_title']); if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']); return $data; } public function save(array $input): array { unset($input['sub_title']); if (isset($input['translations']) && is_array($input['translations'])) { foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']); unset($text); } if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']); $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/']; $textRules = ['translations' => ['required', 'array:zh,en,vi']]; $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题和正文']; foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) { $textRules['translations.' . $lang] = ['required', 'array:title,content']; $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本'; foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) { $key = 'translations.' . $lang . '.' . $field; $textRules[$key] = ['required', 'string', 'max:' . $max]; $messages[$key . '.required'] = $label . $name . '不能为空'; } } $data = Validator::make($input, $textRules + ['id' => ['sometimes', 'integer', 'min:1'], 'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'], 'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'], 'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'], 'image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'publish_platforms' => ['sometimes', 'array'], 'publish_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'register_time_limited' => ['sometimes', 'boolean'], 'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'], 'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'], 'register_platforms' => ['sometimes', 'array'], 'register_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'deposit_limited' => ['sometimes', 'boolean'], 'deposit_min' => [...$money, 'required_if:deposit_limited,1'], 'deposit_max' => [...$money, 'required_if:deposit_limited,1', 'gte:deposit_min'], 'show_ineligible' => ['sometimes', 'boolean'], 'reward_quota' => ['sometimes', 'nullable', 'integer', 'between:0,2147483647'], 'budget' => ['sometimes', ...$money]], $messages)->validate(); if (!isset($data['id']) && in_array((int) $data['type'], [PromotionRules::LOSS, PromotionRules::WHEEL], true)) { throw ValidationException::withMessages(['id' => '净亏和转盘活动不支持新增,请编辑现有活动']); } return DB::transaction(function () use ($data) { $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity(); if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']); if ($row->exists && (int)$row->type !== (int)$data['type']) throw ValidationException::withMessages(['type' => '创建后不能变更活动类型']); $before = $row->exists ? $row->toArray() : []; $params = array_replace($row->params ? json_decode($row->params, true) : [], $data['params'] ?? []); $values = $data; unset($values['id']); $texts = $this->completeTexts($data['translations']); $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']); $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params)); $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']); if ((int)$values['status'] === 1 && $values['end_time'] < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']); if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []]; $row->fill($values); $row->save(); PromotionService::publish($row); if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $row->id, $before ? 'update' : 'create', $before, $row->toArray()); return $this->format($row); }, 3); } private function completeTexts(array $texts): array { foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) { foreach (['title' => '标题', 'content' => '正文'] as $field => $name) { $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8'); if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') { throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']); } if ($field !== 'content') $texts[$lang][$field] = trim($texts[$lang][$field]); } } return $texts; } public function status(int $id, int $status): array { return DB::transaction(function () use ($id, $status) { $row = PromotionActivity::where('id', $id)->lockForUpdate()->first(); if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']); if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray())); if ($status === 1 && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']); $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row); if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray()); return $this->format($row); }, 3); } /** Takes ended activities off the shelf; each change is audited like a manual status switch. */ public static function expire(): int { $expired = 0; foreach (PromotionActivity::where('status', 1)->where('end_time', '<', now()->timestamp)->pluck('id') as $id) { $expired += DB::transaction(function () use ($id) { $row = PromotionActivity::where('id', $id)->lockForUpdate()->first(); if (!$row || (int)$row->status !== 1 || (int)$row->end_time >= now()->timestamp) return 0; $before = $row->toArray(); $row->status = 0; $row->save(); if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'expire', $before, $row->toArray(), null, ['operator_id' => null, 'operator_name' => '系统']); return 1; }, 3); } return $expired; } public function records(array $input): array { $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'], 'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons'], 'status' => ['sometimes', 'in:unused,used'], 'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate(); if ($input['kind'] === 'coupons') { // Invitee coupons belong to the invitee, so they are found by activity rather than by the inviter's account. $q = DB::table('promotion_coupons')->where('activity_id', $input['activity_id']); if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('user_id', $input['user_id'])->orWhere('inviter_user_id', $input['user_id'])); if (isset($input['status'])) $q->where('status', $input['status']); $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get() ->map(function ($row) { $row->categories = json_decode($row->categories, true); return $row; }); $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->user_id, $row->inviter_user_id])->all()); foreach ($rows as $row) { $row->account = $accounts[(int)$row->user_id] ?? null; $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null; } return ['total' => $q->count(), 'data' => $rows]; } $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']); if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']); if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id'); else { $table = match ($input['kind']) { 'rounds' => 'promotion_rounds', 'tasks' => 'promotion_task_grants', 'spins' => 'promotion_spins', 'rewards' => 'promotion_rewards' }; $q->join($table . ' as r', 'r.account_id', '=', 'a.id')->select(['r.*', 'a.user_id', 'a.activity_id'])->orderByDesc('r.id'); } $total = (clone $q)->count(); $rows = $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get(); if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty() && (int)DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type') === PromotionRules::LOSS) { $this->lossRecordPeople($rows, $input['kind']); } return ['total' => $total, 'data' => $rows]; } /** Loss-invite record display only: historical recipients and actual issued coupon quantities. */ private function lossRecordPeople($rows, string $kind): void { $rewards = $kind === 'rewards' ? $rows : DB::table('promotion_rewards')->whereIn('account_id', $rows->pluck('id')) ->where('round_no', 1)->get(); $byAccount = $rewards->keyBy('account_id'); $couponRewards = $rewards->filter(fn ($reward) => ($this->recordSnapshot($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon'); $groups = $couponRewards->isEmpty() ? collect() : DB::table('promotion_coupons')->whereIn('reward_id', $couponRewards->pluck('id')) ->select(['reward_id', 'user_id', 'role', 'min_amount', 'discount'])->selectRaw('COUNT(*) AS quantity') ->groupBy('reward_id', 'user_id', 'role', 'min_amount', 'discount')->orderBy('min_amount')->orderBy('discount')->get(); $coupons = $issuedInvitees = []; $people = $rows->pluck('user_id')->all(); foreach ($groups as $group) { $coupons[(int)$group->reward_id][(int)$group->user_id][$group->role][] = [ 'min_amount' => bcadd((string)$group->min_amount, '0', 2), 'discount' => bcadd((string)$group->discount, '0', 2), 'quantity' => (int)$group->quantity, ]; if ($group->role === 'invitee') $issuedInvitees[(int)$group->reward_id][] = (int)$group->user_id; $people[] = (int)$group->user_id; } $contexts = []; foreach ($rows as $row) { $reward = $kind === 'rewards' ? $row : $byAccount->get($row->id); $application = $this->recordSnapshot($row->application_snapshot ?? null); $rules = $reward ? $this->recordSnapshot($reward->rule_snapshot) : ($application['rules'] ?? []); if (!is_array($rules)) $rules = []; $mode = ($reward || $rules) ? ($rules['reward_mode'] ?? 'cash') : null; $ids = $mode === 'coupon' && isset($rules['coupon_invitees']) ? $rules['coupon_invitees'] : ($rules['qualified_invitees'] ?? []); $ids = $this->recordIds(array_merge(is_array($ids) ? $ids : [], $issuedInvitees[(int)($reward->id ?? 0)] ?? [])); $contexts[] = [$row, $reward, in_array($mode, ['cash', 'coupon'], true) ? $mode : null, $ids]; $people = array_merge($people, $ids); } $accounts = $this->recordAccounts($people); foreach ($contexts as [$row, $reward, $mode, $ids]) { $rewardId = (int)($reward->id ?? 0); $owner = (int)$row->user_id; $row->reward_mode = $mode; if ($kind === 'accounts') { $row->reward_id = $reward ? (int)$reward->id : null; $row->reward_status = $reward ? $reward->status : null; } $row->inviter = ['id' => $owner, 'account' => $accounts[$owner] ?? null, 'coupons' => $coupons[$rewardId][$owner]['inviter'] ?? []]; $row->invitees = array_map(fn ($id) => ['id' => $id, 'account' => $accounts[$id] ?? null, 'coupons' => $coupons[$rewardId][$id]['invitee'] ?? []], $ids); } } private function recordSnapshot($value): array { $value = is_string($value) ? json_decode($value, true) : $value; return is_array($value) ? $value : []; } private function recordIds(array $ids): array { $valid = []; foreach ($ids as $id) { $id = is_int($id) || is_string($id) ? filter_var($id, FILTER_VALIDATE_INT) : false; if ($id !== false && $id > 0) $valid[$id] = $id; } return array_values($valid); } private function recordAccounts(array $ids): array { $accounts = []; foreach (array_chunk($this->recordIds($ids), 1000) as $chunk) { foreach (DB::table('users')->whereIn('id', $chunk)->get(['id', 'account']) as $user) { $accounts[(int)$user->id] = $user->account === null || $user->account === '' ? null : (string)$user->account; } } return $accounts; } }