|
|
@@ -18,7 +18,8 @@ class PromotionAdminService
|
|
|
$types = []; foreach (PromotionRules::types() as $value => $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)],
|
|
|
+ 'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9), 10 => PromotionRules::defaults(10)],
|
|
|
+ 'share_levels' => $this->shareLevels(),
|
|
|
'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)];
|
|
|
@@ -27,18 +28,18 @@ class PromotionAdminService
|
|
|
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();
|
|
|
+ 'type' => ['sometimes', 'integer', 'in:7,8,9,10'], '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. */
|
|
|
+ /** Fixed activities (loss invite, wheel, upgrade invite) 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读取']);
|
|
|
+ if ($id === null && !in_array($type, PromotionRules::FIXED, 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' => '独立活动不存在']);
|
|
|
@@ -51,32 +52,38 @@ class PromotionAdminService
|
|
|
'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']);
|
|
|
+ if (in_array((int) $row->type, PromotionRules::FIXED, true)) unset($data['image']);
|
|
|
+ if (PromotionTranslations::fields((int) $row->type) === ['content']) {
|
|
|
+ foreach ($data['translations'] as &$text) unset($text['title']);
|
|
|
+ unset($text);
|
|
|
+ }
|
|
|
return $data;
|
|
|
}
|
|
|
|
|
|
public function save(array $input): array
|
|
|
{
|
|
|
unset($input['sub_title']);
|
|
|
+ $type = (int)(is_scalar($input['type'] ?? null) ? $input['type'] : 0);
|
|
|
+ $fields = PromotionTranslations::fields($type);
|
|
|
if (isset($input['translations']) && is_array($input['translations'])) {
|
|
|
- foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']);
|
|
|
+ foreach ($input['translations'] as &$text) if (is_array($text)) { unset($text['sub_title']); if (!in_array('title', $fields, true)) unset($text['title']); }
|
|
|
unset($text);
|
|
|
}
|
|
|
- if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']);
|
|
|
+ if (in_array((string)($input['type'] ?? ''), array_map('strval', PromotionRules::FIXED), 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' => '请完整填写中文、英文、越南文的标题和正文'];
|
|
|
+ $messages = ['translations.required' => '请完整填写中文、英文、越南文的' . ($fields === ['content'] ? '正文' : '标题和正文')];
|
|
|
foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
|
|
|
- $textRules['translations.' . $lang] = ['required', 'array:title,content'];
|
|
|
+ $textRules['translations.' . $lang] = ['required', 'array:' . implode(',', $fields)];
|
|
|
$messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
|
|
|
- foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
|
|
|
+ foreach (array_intersect_key(['title' => ['标题', 140], 'content' => ['正文', 100000]], array_flip($fields)) 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'],
|
|
|
+ 'type' => ['required', 'integer', 'in:7,8,9,10'], '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'],
|
|
|
@@ -85,8 +92,8 @@ class PromotionAdminService
|
|
|
'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' => '净亏和转盘活动不支持新增,请编辑现有活动']);
|
|
|
+ if (!isset($data['id']) && in_array((int) $data['type'], PromotionRules::FIXED, true)) {
|
|
|
+ throw ValidationException::withMessages(['id' => '净亏、转盘和邀请升级活动不支持新增,请编辑现有活动']);
|
|
|
}
|
|
|
return DB::transaction(function () use ($data) {
|
|
|
$row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
|
|
|
@@ -95,9 +102,13 @@ class PromotionAdminService
|
|
|
$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']);
|
|
|
+ $texts = $this->completeTexts($data['translations'], (int)$data['type']);
|
|
|
$values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
|
|
|
- $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
|
|
|
+ // Content-only activities keep the type name in the required title column for admin lists.
|
|
|
+ if ($values['title'] === '') $values['title'] = PromotionRules::types()[(int)$data['type']];
|
|
|
+ $rules = PromotionRules::normalize((int)$data['type'], $params);
|
|
|
+ if ((int)$data['type'] === PromotionRules::UPGRADE_INVITE) $this->checkUpgradeTiers($rules, (int)$values['status'] === 1);
|
|
|
+ $values['params'] = json_encode($rules);
|
|
|
$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' => []];
|
|
|
@@ -107,10 +118,29 @@ class PromotionAdminService
|
|
|
}, 3);
|
|
|
}
|
|
|
|
|
|
- private function completeTexts(array $texts): array
|
|
|
+ /** Tier levels must be configured share levels; an empty tier list may be saved but not published. */
|
|
|
+ private function checkUpgradeTiers(array $rules, bool $publishing): void
|
|
|
+ {
|
|
|
+ if ($publishing && !$rules['tiers']) throw ValidationException::withMessages(['params' => '请至少配置一档升级奖励']);
|
|
|
+ $levels = array_column($this->shareLevels(), 'level');
|
|
|
+ foreach ($rules['tiers'] as $tier) {
|
|
|
+ if (!in_array($tier['level'], $levels, true)) throw ValidationException::withMessages(['params' => '分享等级' . $tier['level'] . '不存在']);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function shareLevels(): array
|
|
|
{
|
|
|
+ if (!Schema::hasTable('share_levels')) return [];
|
|
|
+ return DB::table('share_levels')->orderBy('level')->get(['level', 'name'])
|
|
|
+ ->map(fn ($row) => ['level' => (int)$row->level, 'name' => (string)$row->name])->all();
|
|
|
+ }
|
|
|
+
|
|
|
+ private function completeTexts(array $texts, int $type): array
|
|
|
+ {
|
|
|
+ $fields = PromotionTranslations::fields($type);
|
|
|
foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
|
|
|
- foreach (['title' => '标题', 'content' => '正文'] as $field => $name) {
|
|
|
+ $texts[$lang] += ['title' => ''];
|
|
|
+ foreach (array_intersect_key(['title' => '标题', 'content' => '正文'], array_flip($fields)) 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 . '不能为空']);
|
|
|
@@ -126,8 +156,11 @@ class PromotionAdminService
|
|
|
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) $this->completeTexts(PromotionTranslations::all($row->toArray()), (int)$row->type);
|
|
|
if ($status === 1 && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
|
|
|
+ if ($status === 1 && (int)$row->type === PromotionRules::UPGRADE_INVITE) {
|
|
|
+ $this->checkUpgradeTiers(PromotionRules::normalize(PromotionRules::UPGRADE_INVITE, json_decode($row->params, true) ?: []), true);
|
|
|
+ }
|
|
|
$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);
|
|
|
@@ -152,9 +185,14 @@ class PromotionAdminService
|
|
|
|
|
|
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'],
|
|
|
+ $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'type' => ['sometimes', 'integer', 'in:7,8,9,10'], 'user_id' => ['sometimes', 'integer', 'min:1'],
|
|
|
+ 'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons,upgrades'], 'status' => ['sometimes', 'in:unused,used'],
|
|
|
'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
|
|
|
+ // An explicit type guards against a misconfigured activity_id showing another campaign's records.
|
|
|
+ $type = DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type');
|
|
|
+ if ($type === null) throw ValidationException::withMessages(['activity_id' => '独立活动不存在']);
|
|
|
+ if (isset($input['type']) && (int)$input['type'] !== (int)$type) throw ValidationException::withMessages(['type' => 'activity_id与type不匹配']);
|
|
|
+ if ($input['kind'] === 'upgrades' && (int)$type !== PromotionRules::UPGRADE_INVITE) throw ValidationException::withMessages(['kind' => 'upgrades仅用于邀请升级奖励']);
|
|
|
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']);
|
|
|
@@ -169,6 +207,7 @@ class PromotionAdminService
|
|
|
}
|
|
|
return ['total' => $q->count(), 'data' => $rows];
|
|
|
}
|
|
|
+ if ($input['kind'] === 'upgrades') return $this->upgradeGrants($input);
|
|
|
$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');
|
|
|
@@ -178,13 +217,29 @@ class PromotionAdminService
|
|
|
}
|
|
|
$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) {
|
|
|
+ if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty() && (int)$type === PromotionRules::LOSS) {
|
|
|
$this->lossRecordPeople($rows, $input['kind']);
|
|
|
}
|
|
|
return ['total' => $total, 'data' => $rows];
|
|
|
}
|
|
|
|
|
|
+ /** Upgrade invite payouts, one row per invitee tier; user_id matches either the inviter or the invitee. */
|
|
|
+ private function upgradeGrants(array $input): array
|
|
|
+ {
|
|
|
+ $q = DB::table('promotion_upgrade_grants')->where('activity_id', $input['activity_id']);
|
|
|
+ if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('inviter_user_id', $input['user_id'])->orWhere('invitee_user_id', $input['user_id']));
|
|
|
+ $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
|
|
|
+ $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->inviter_user_id, $row->invitee_user_id])->all());
|
|
|
+ $names = array_column($this->shareLevels(), 'name', 'level');
|
|
|
+ foreach ($rows as $row) {
|
|
|
+ $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null;
|
|
|
+ $row->invitee_account = $accounts[(int)$row->invitee_user_id] ?? null;
|
|
|
+ $row->level_name = $names[(int)$row->level] ?? null;
|
|
|
+ $row->invitee_level_name = $names[(int)$row->invitee_level] ?? null;
|
|
|
+ }
|
|
|
+ return ['total' => $q->count(), 'data' => $rows];
|
|
|
+ }
|
|
|
+
|
|
|
/** Loss-invite record display only: historical recipients and actual issued coupon quantities. */
|
|
|
private function lossRecordPeople($rows, string $kind): void
|
|
|
{
|