| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270 |
- <?php
- namespace App\Services\Sharing;
- use App\Models\ShareSetting;
- use App\Services\OperationAuditService;
- use Carbon\Carbon;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Validator;
- use Illuminate\Validation\Rule;
- use Illuminate\Validation\ValidationException;
- class ShareAdminService
- {
- private const RETIRED = ['non_seller_threshold', 'tier_one_rate', 'tier_two_rate', 'indirect_bonus_rate'];
- // Configuration is returned at cent precision; reject sub-cent input rather than truncate it on a later edit.
- private const MONEY = ['regex:/^\d{1,14}(?:\.\d{1,2})?$/'];
- private const RATE = ['regex:/^\d{1,3}(?:\.\d{1,4})?$/', 'numeric', 'between:0,100'];
- public function settings(): array
- {
- $setting = ShareSetting::query()->first();
- $current = $this->policy(false);
- $pending = $this->policy(true);
- return [
- 'id' => $setting ? (int)$setting->id : null,
- 'enabled' => (int)($setting->enabled ?? 0),
- 'share_domain' => (string)($setting->share_domain ?? ''),
- 'valid_member_min_deposit' => $this->money($setting->valid_member_min_deposit ?? '20'),
- 'valid_member_min_effective_bet' => isset($setting->valid_member_min_effective_bet) ? $this->money($setting->valid_member_min_effective_bet) : null,
- 'activation_date' => $setting?->activation_date ?: null,
- 'initialized' => DB::table('share_system_state')->where('key', 'initialized_at')->exists(),
- 'effective_date' => $pending['effective_date'] ?? null,
- 'current_policy' => $current,
- 'pending_policy' => $pending,
- ];
- }
- public function saveSettings(array $input): array
- {
- // The old UI switch is retired. Stale clients cannot use it to disable/enable settlement.
- unset($input['benefit_enabled']);
- foreach (self::RETIRED as $key) {
- if (array_key_exists($key, $input)) $this->invalid($key, '旧分享参数已移除,请使用有效会员条件及分类返佣规则');
- }
- foreach (['activation_date', 'effective_date', 'current_policy', 'pending_policy'] as $key) {
- if (array_key_exists($key, $input)) $this->invalid($key, '生效日期及版本由系统生成,不可指定');
- }
- $data = Validator::make($input, [
- 'enabled' => ['required', Rule::in([0, 1])],
- 'share_domain' => ['required', 'string', 'max:500'],
- 'valid_member_min_deposit' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/'],
- 'valid_member_min_effective_bet' => array_merge(['required'], self::MONEY),
- ])->validate();
- $data['share_domain'] = $this->origin($data['share_domain']);
- $data['benefit_enabled'] = 1; // Complete, initialized configuration activates automatically tomorrow.
- return DB::transaction(function () use ($data) {
- $this->lockConfiguration();
- $setting = ShareSetting::query()->lockForUpdate()->first() ?: new ShareSetting();
- $before = $setting->exists ? $setting->toArray() : [];
- if (!DB::table('share_system_state')->where('key', 'initialized_at')->exists()) {
- $this->invalid('initialized', '会员分享关系初始化完成后才可保存佣金配置');
- }
- if (!$setting->activation_date) $setting->activation_date = $this->tomorrow();
- $setting->fill($data + OperationAuditService::actor());
- $setting->save();
- $policy = $this->writablePolicy();
- foreach (['enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) {
- $policy[$field] = $setting->getRawOriginal($field);
- }
- $this->savePolicy($policy);
- OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
- return $this->settings();
- });
- }
- public function rateRules(?string $category = null): array
- {
- if ($category !== null && !array_key_exists($category, ShareDefaults::categories())) $this->invalid('category', '无效的游戏分类');
- $current = $this->policy(false);
- $pending = $this->policy(true);
- $categories = [];
- foreach (ShareDefaults::categories() as $key => $label) {
- if ($category !== null && $category !== $key) continue;
- $categories[] = ['category' => $key, 'name' => $label,
- 'current_rules' => $current['rules'][$key] ?? [],
- 'pending_rules' => $pending['rules'][$key] ?? null];
- }
- return ['current_policy_id' => $current['id'] ?? null, 'current_effective_date' => $current['effective_date'] ?? null,
- 'pending_policy_id' => $pending['id'] ?? null, 'pending_effective_date' => $pending['effective_date'] ?? null,
- 'categories' => $categories];
- }
- public function saveRateRules(string $category, array $rules): array
- {
- $data = Validator::make(['category' => $category, 'rules' => $rules], [
- 'category' => ['required', Rule::in(array_keys(ShareDefaults::categories()))],
- 'rules' => ['required', 'array', 'min:1', 'max:100'],
- 'rules.*.min_valid_members' => ['required', 'integer', 'min:0', 'max:2147483647'],
- 'rules.*.min_performance' => array_merge(['required'], self::MONEY),
- 'rules.*.rate' => array_merge(['required'], self::RATE),
- ])->validate();
- $normalized = [];
- foreach ($data['rules'] as $row) {
- $normalized[] = ['min_valid_members' => (int)$row['min_valid_members'],
- 'min_performance' => bcadd((string)$row['min_performance'], '0', 10), 'rate' => bcadd((string)$row['rate'], '0', 4)];
- }
- usort($normalized, fn ($a, $b) => bccomp($a['min_performance'], $b['min_performance'], 10)
- ?: ($a['min_valid_members'] <=> $b['min_valid_members']));
- $previous = null;
- foreach ($normalized as $row) {
- if ($previous !== null) {
- if (bccomp($row['min_performance'], $previous['min_performance'], 10) === 0
- && $row['min_valid_members'] === $previous['min_valid_members']) {
- $this->invalid('rules', '同一业绩门槛和人数门槛的档位不可重复');
- }
- if ($row['min_valid_members'] < $previous['min_valid_members']) {
- $this->invalid('rules', '随业绩档位提高,有效直属人数门槛不可降低');
- }
- if (bccomp($row['rate'], $previous['rate'], 4) < 0) {
- $this->invalid('rules', '随业绩和人数档位提高,返佣比例不可降低');
- }
- }
- $previous = $row;
- }
- return DB::transaction(function () use ($category, $normalized) {
- $this->lockConfiguration();
- $policy = $this->writablePolicy();
- $before = $policy;
- $policy['rules'][$category] = $normalized;
- $id = $this->savePolicy($policy);
- OperationAuditService::record('share_policy', $id, 'rate_rules', $before, $policy);
- return $this->rateRules($category);
- });
- }
- public function levels(): array
- {
- $total = '0';
- $rows = [];
- foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
- $total = bcadd($total, (string)$row->incremental_commission, 10);
- $rows[] = ['id' => (int)$row->id, 'level' => (int)$row->level, 'name' => $row->name, 'image' => $row->image,
- 'incremental_commission' => $this->money($row->incremental_commission), 'cumulative_commission' => $this->money($total)];
- }
- return ['data' => $rows];
- }
- public function saveLevel(array $input): array
- {
- $data = Validator::make($input, [
- 'id' => ['nullable', 'integer', 'min:1'], 'level' => ['required', 'integer', 'min:1', 'max:2147483647'],
- 'name' => ['required', 'string', 'max:255'], 'image' => ['present', 'nullable', 'string', 'max:1000'],
- 'incremental_commission' => array_merge(['required'], self::MONEY),
- ])->validate();
- $data['image'] = (string)($data['image'] ?? '');
- return DB::transaction(function () use ($data) {
- $this->lockConfiguration();
- $id = (int)($data['id'] ?? 0);
- $old = $id ? DB::table('share_levels')->where('id', $id)->lockForUpdate()->first() : null;
- if ($id && !$old) $this->invalid('id', '分享等级不存在');
- if ($old && (int)$old->level !== (int)$data['level']) $this->invalid('level', '已有等级不可更改等级编号');
- if ((int)$data['level'] === 1 && bccomp((string)$data['incremental_commission'], '0', 10) !== 0) $this->invalid('incremental_commission', '等级1的增量佣金必须为0');
- if (DB::table('share_levels')->where('level', $data['level'])->when($id, fn ($q) => $q->where('id', '!=', $id))->exists()) $this->invalid('level', '分享等级编号已存在');
- unset($data['id']);
- $now = $this->now();
- if ($id) DB::table('share_levels')->where('id', $id)->update($data + ['updated_at' => $now]);
- else $id = DB::table('share_levels')->insertGetId($data + ['created_at' => $now, 'updated_at' => $now]);
- $this->promoteMembers();
- OperationAuditService::record('share_level', $id, $old ? 'update' : 'create', (array)$old, $data);
- return $this->levels();
- });
- }
- public function deleteLevel(int $id): void
- {
- DB::transaction(function () use ($id) {
- $this->lockConfiguration();
- $row = DB::table('share_levels')->where('id', $id)->lockForUpdate()->first();
- if (!$row) $this->invalid('id', '分享等级不存在');
- if ((int)$row->level === 1) $this->invalid('id', '初始等级不可删除');
- if (DB::table('share_members')->where('level', $row->level)->exists()) $this->invalid('id', '该等级已有会员,不可删除');
- DB::table('share_levels')->where('id', $id)->delete();
- $this->promoteMembers();
- OperationAuditService::record('share_level', $id, 'delete', (array)$row, []);
- });
- }
- private function promoteMembers(): void
- {
- $threshold = '0';
- foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
- $threshold = bcadd($threshold, (string)$row->incremental_commission, 10);
- DB::table('share_members')->where('level', '<', $row->level)
- ->whereIn('user_id', DB::table('share_accounts')->select('user_id')->where('earned_amount', '>=', $threshold))
- ->update(['level' => $row->level, 'updated_at' => $this->now()]);
- }
- }
- private function writablePolicy(): array
- {
- $row = DB::table('share_policies')->where('effective_date', $this->tomorrow())->lockForUpdate()->first()
- ?: DB::table('share_policies')->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
- $policy = $row ? (array)$row : ['enabled' => 0, 'benefit_enabled' => 0, 'valid_member_min_deposit' => '20',
- 'valid_member_min_effective_bet' => null, 'indirect_bonus_rate' => null, 'rules' => ShareDefaults::rules()];
- unset($policy['id'], $policy['created_at'], $policy['updated_at']);
- // Keep archived coefficients on past versions only; new versions use the receiver's bracket rate by depth.
- $policy['indirect_bonus_rate'] = null;
- $policy['rules'] = is_string($policy['rules']) ? json_decode($policy['rules'], true, 512, JSON_THROW_ON_ERROR) : $policy['rules'];
- $policy['effective_date'] = $this->tomorrow();
- return $policy;
- }
- private function savePolicy(array $policy): int
- {
- $policy['rules'] = json_encode($policy['rules'], JSON_THROW_ON_ERROR);
- $old = DB::table('share_policies')->where('effective_date', $this->tomorrow())->first();
- $policy['updated_at'] = $this->now();
- if ($old) {
- DB::table('share_policies')->where('id', $old->id)->where('effective_date', '>', $this->today())->update($policy);
- return (int)$old->id;
- }
- return (int)DB::table('share_policies')->insertGetId($policy + ['created_at' => $this->now()]);
- }
- private function policy(bool $pending): ?array
- {
- $query = DB::table('share_policies');
- $row = $pending ? $query->where('effective_date', $this->tomorrow())->first()
- : $query->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
- if (!$row) return null;
- $data = (array)$row;
- foreach (['id', 'enabled'] as $field) $data[$field] = (int)$data[$field];
- foreach (['valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) $data[$field] = $data[$field] === null ? null : $this->money($data[$field]);
- unset($data['indirect_bonus_rate'], $data['benefit_enabled']);
- $data['rules'] = json_decode($data['rules'], true, 512, JSON_THROW_ON_ERROR);
- foreach ($data['rules'] as &$rows) foreach ($rows as &$rule) {
- $rule['min_valid_members'] = (int)$rule['min_valid_members'];
- $rule['min_performance'] = $this->money($rule['min_performance']);
- $rule['rate'] = bcadd((string)$rule['rate'], '0', 4);
- }
- unset($rule, $rows);
- return $data;
- }
- private function lockConfiguration(): void
- {
- if (!DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first()) {
- throw new \RuntimeException('分享系统尚未初始化配置表');
- }
- }
- private function origin(string $value): string
- {
- $value = trim($value);
- if (!preg_match('~^https?://~i', $value)) $value = 'https://' . $value;
- $parts = parse_url($value);
- if (!filter_var($value, FILTER_VALIDATE_URL) || !$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
- || isset($parts['user'], $parts['pass']) || isset($parts['user']) || isset($parts['query']) || isset($parts['fragment']) || !in_array($parts['path'] ?? '', ['', '/'], true)) {
- $this->invalid('share_domain', '请填写PC网站域名源地址,不可包含路径、凭证或查询参数');
- }
- return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
- }
- private function money($value): string { return bcadd((string)$value, '0', 2); }
- private function today(): string { return Carbon::now(ShareDefaults::TIMEZONE)->toDateString(); }
- private function tomorrow(): string { return Carbon::now(ShareDefaults::TIMEZONE)->addDay()->toDateString(); }
- private function now(): string { return Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s'); }
- private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
- }
|