ShareAdminService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. namespace App\Services\Sharing;
  3. use App\Models\ShareSetting;
  4. use App\Services\OperationAuditService;
  5. use Carbon\Carbon;
  6. use Illuminate\Support\Facades\DB;
  7. use Illuminate\Support\Facades\Validator;
  8. use Illuminate\Validation\Rule;
  9. use Illuminate\Validation\ValidationException;
  10. class ShareAdminService
  11. {
  12. private const RETIRED = ['non_seller_threshold', 'tier_one_rate', 'tier_two_rate'];
  13. // Configuration is returned at cent precision; reject sub-cent input rather than truncate it on a later edit.
  14. private const MONEY = ['regex:/^\d{1,14}(?:\.\d{1,2})?$/'];
  15. private const RATE = ['regex:/^\d{1,3}(?:\.\d{1,4})?$/', 'numeric', 'between:0,100'];
  16. public function settings(): array
  17. {
  18. $setting = ShareSetting::query()->first();
  19. $current = $this->policy(false);
  20. $pending = $this->policy(true);
  21. return [
  22. 'id' => $setting ? (int)$setting->id : null,
  23. 'enabled' => (int)($setting->enabled ?? 0),
  24. 'share_domain' => (string)($setting->share_domain ?? ''),
  25. 'benefit_enabled' => (int)($setting->benefit_enabled ?? 0),
  26. 'valid_member_min_deposit' => $this->money($setting->valid_member_min_deposit ?? '20'),
  27. 'valid_member_min_effective_bet' => isset($setting->valid_member_min_effective_bet) ? $this->money($setting->valid_member_min_effective_bet) : null,
  28. 'indirect_bonus_rate' => isset($setting->indirect_bonus_rate) ? bcadd((string)$setting->indirect_bonus_rate, '0', 4) : null,
  29. 'activation_date' => $setting?->activation_date ?: null,
  30. 'initialized' => DB::table('share_system_state')->where('key', 'initialized_at')->exists(),
  31. 'effective_date' => $pending['effective_date'] ?? null,
  32. 'current_policy' => $current,
  33. 'pending_policy' => $pending,
  34. ];
  35. }
  36. public function saveSettings(array $input): array
  37. {
  38. foreach (self::RETIRED as $key) {
  39. if (array_key_exists($key, $input)) $this->invalid($key, '旧分享参数已移除,请使用有效会员条件及分类返佣规则');
  40. }
  41. foreach (['activation_date', 'effective_date', 'current_policy', 'pending_policy'] as $key) {
  42. if (array_key_exists($key, $input)) $this->invalid($key, '生效日期及版本由系统生成,不可指定');
  43. }
  44. $data = Validator::make($input, [
  45. 'enabled' => ['required', Rule::in([0, 1])],
  46. 'share_domain' => ['required', 'string', 'max:500'],
  47. 'benefit_enabled' => ['required', Rule::in([0, 1])],
  48. 'valid_member_min_deposit' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/'],
  49. 'valid_member_min_effective_bet' => array_merge(['nullable', 'required_if:benefit_enabled,1'], self::MONEY),
  50. 'indirect_bonus_rate' => array_merge(['nullable', 'required_if:benefit_enabled,1'], self::RATE),
  51. ])->validate();
  52. $data['share_domain'] = $this->origin($data['share_domain']);
  53. return DB::transaction(function () use ($data) {
  54. $this->lockConfiguration();
  55. $setting = ShareSetting::query()->lockForUpdate()->first() ?: new ShareSetting();
  56. $before = $setting->exists ? $setting->toArray() : [];
  57. if ((int)$data['benefit_enabled'] === 1) {
  58. if (!DB::table('share_system_state')->where('key', 'initialized_at')->exists()) {
  59. $this->invalid('benefit_enabled', '会员分享关系初始化完成后才可启用佣金');
  60. }
  61. if (!$setting->activation_date) $setting->activation_date = $this->tomorrow();
  62. }
  63. // Blank nullable fields may clear unfinished setup, but can never enable it.
  64. $setting->fill($data + OperationAuditService::actor());
  65. $setting->save();
  66. $policy = $this->writablePolicy();
  67. foreach (['enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'indirect_bonus_rate'] as $field) {
  68. $policy[$field] = $setting->getRawOriginal($field);
  69. }
  70. $this->savePolicy($policy);
  71. OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
  72. return $this->settings();
  73. });
  74. }
  75. public function rateRules(?string $category = null): array
  76. {
  77. if ($category !== null && !array_key_exists($category, ShareDefaults::categories())) $this->invalid('category', '无效的游戏分类');
  78. $current = $this->policy(false);
  79. $pending = $this->policy(true);
  80. $categories = [];
  81. foreach (ShareDefaults::categories() as $key => $label) {
  82. if ($category !== null && $category !== $key) continue;
  83. $categories[] = ['category' => $key, 'name' => $label,
  84. 'current_rules' => $current['rules'][$key] ?? [],
  85. 'pending_rules' => $pending['rules'][$key] ?? null];
  86. }
  87. return ['current_policy_id' => $current['id'] ?? null, 'current_effective_date' => $current['effective_date'] ?? null,
  88. 'pending_policy_id' => $pending['id'] ?? null, 'pending_effective_date' => $pending['effective_date'] ?? null,
  89. 'categories' => $categories];
  90. }
  91. public function saveRateRules(string $category, array $rules): array
  92. {
  93. $data = Validator::make(['category' => $category, 'rules' => $rules], [
  94. 'category' => ['required', Rule::in(array_keys(ShareDefaults::categories()))],
  95. 'rules' => ['required', 'array', 'min:1', 'max:100'],
  96. 'rules.*.min_valid_members' => ['required', 'integer', 'min:0', 'max:2147483647'],
  97. 'rules.*.min_performance' => array_merge(['required'], self::MONEY),
  98. 'rules.*.rate' => array_merge(['required'], self::RATE),
  99. ])->validate();
  100. $normalized = [];
  101. foreach ($data['rules'] as $row) {
  102. $normalized[] = ['min_valid_members' => (int)$row['min_valid_members'],
  103. 'min_performance' => bcadd((string)$row['min_performance'], '0', 10), 'rate' => bcadd((string)$row['rate'], '0', 4)];
  104. }
  105. usort($normalized, fn ($a, $b) => bccomp($a['min_performance'], $b['min_performance'], 10)
  106. ?: ($a['min_valid_members'] <=> $b['min_valid_members']));
  107. $previous = null;
  108. foreach ($normalized as $row) {
  109. if ($previous !== null) {
  110. if (bccomp($row['min_performance'], $previous['min_performance'], 10) === 0
  111. && $row['min_valid_members'] === $previous['min_valid_members']) {
  112. $this->invalid('rules', '同一业绩门槛和人数门槛的档位不可重复');
  113. }
  114. if ($row['min_valid_members'] < $previous['min_valid_members']) {
  115. $this->invalid('rules', '随业绩档位提高,有效直属人数门槛不可降低');
  116. }
  117. if (bccomp($row['rate'], $previous['rate'], 4) < 0) {
  118. $this->invalid('rules', '随业绩和人数档位提高,返佣比例不可降低');
  119. }
  120. }
  121. $previous = $row;
  122. }
  123. return DB::transaction(function () use ($category, $normalized) {
  124. $this->lockConfiguration();
  125. $policy = $this->writablePolicy();
  126. $before = $policy;
  127. $policy['rules'][$category] = $normalized;
  128. $id = $this->savePolicy($policy);
  129. OperationAuditService::record('share_policy', $id, 'rate_rules', $before, $policy);
  130. return $this->rateRules($category);
  131. });
  132. }
  133. public function levels(): array
  134. {
  135. $total = '0';
  136. $rows = [];
  137. foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
  138. $total = bcadd($total, (string)$row->incremental_commission, 10);
  139. $rows[] = ['id' => (int)$row->id, 'level' => (int)$row->level, 'name' => $row->name, 'image' => $row->image,
  140. 'incremental_commission' => $this->money($row->incremental_commission), 'cumulative_commission' => $this->money($total)];
  141. }
  142. return ['data' => $rows];
  143. }
  144. public function saveLevel(array $input): array
  145. {
  146. $data = Validator::make($input, [
  147. 'id' => ['nullable', 'integer', 'min:1'], 'level' => ['required', 'integer', 'min:1', 'max:2147483647'],
  148. 'name' => ['required', 'string', 'max:255'], 'image' => ['present', 'nullable', 'string', 'max:1000'],
  149. 'incremental_commission' => array_merge(['required'], self::MONEY),
  150. ])->validate();
  151. $data['image'] = (string)($data['image'] ?? '');
  152. return DB::transaction(function () use ($data) {
  153. $this->lockConfiguration();
  154. $id = (int)($data['id'] ?? 0);
  155. $old = $id ? DB::table('share_levels')->where('id', $id)->lockForUpdate()->first() : null;
  156. if ($id && !$old) $this->invalid('id', '分享等级不存在');
  157. if ($old && (int)$old->level !== (int)$data['level']) $this->invalid('level', '已有等级不可更改等级编号');
  158. if ((int)$data['level'] === 1 && bccomp((string)$data['incremental_commission'], '0', 10) !== 0) $this->invalid('incremental_commission', '等级1的增量佣金必须为0');
  159. if (DB::table('share_levels')->where('level', $data['level'])->when($id, fn ($q) => $q->where('id', '!=', $id))->exists()) $this->invalid('level', '分享等级编号已存在');
  160. unset($data['id']);
  161. $now = $this->now();
  162. if ($id) DB::table('share_levels')->where('id', $id)->update($data + ['updated_at' => $now]);
  163. else $id = DB::table('share_levels')->insertGetId($data + ['created_at' => $now, 'updated_at' => $now]);
  164. $this->promoteMembers();
  165. OperationAuditService::record('share_level', $id, $old ? 'update' : 'create', (array)$old, $data);
  166. return $this->levels();
  167. });
  168. }
  169. public function deleteLevel(int $id): void
  170. {
  171. DB::transaction(function () use ($id) {
  172. $this->lockConfiguration();
  173. $row = DB::table('share_levels')->where('id', $id)->lockForUpdate()->first();
  174. if (!$row) $this->invalid('id', '分享等级不存在');
  175. if ((int)$row->level === 1) $this->invalid('id', '初始等级不可删除');
  176. if (DB::table('share_members')->where('level', $row->level)->exists()) $this->invalid('id', '该等级已有会员,不可删除');
  177. DB::table('share_levels')->where('id', $id)->delete();
  178. $this->promoteMembers();
  179. OperationAuditService::record('share_level', $id, 'delete', (array)$row, []);
  180. });
  181. }
  182. private function promoteMembers(): void
  183. {
  184. $threshold = '0';
  185. foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
  186. $threshold = bcadd($threshold, (string)$row->incremental_commission, 10);
  187. DB::table('share_members')->where('level', '<', $row->level)
  188. ->whereIn('user_id', DB::table('share_accounts')->select('user_id')->where('earned_amount', '>=', $threshold))
  189. ->update(['level' => $row->level, 'updated_at' => $this->now()]);
  190. }
  191. }
  192. private function writablePolicy(): array
  193. {
  194. $row = DB::table('share_policies')->where('effective_date', $this->tomorrow())->lockForUpdate()->first()
  195. ?: DB::table('share_policies')->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
  196. $policy = $row ? (array)$row : ['enabled' => 0, 'benefit_enabled' => 0, 'valid_member_min_deposit' => '20',
  197. 'valid_member_min_effective_bet' => null, 'indirect_bonus_rate' => null, 'rules' => ShareDefaults::rules()];
  198. unset($policy['id'], $policy['created_at'], $policy['updated_at']);
  199. $policy['rules'] = is_string($policy['rules']) ? json_decode($policy['rules'], true, 512, JSON_THROW_ON_ERROR) : $policy['rules'];
  200. $policy['effective_date'] = $this->tomorrow();
  201. return $policy;
  202. }
  203. private function savePolicy(array $policy): int
  204. {
  205. $policy['rules'] = json_encode($policy['rules'], JSON_THROW_ON_ERROR);
  206. $old = DB::table('share_policies')->where('effective_date', $this->tomorrow())->first();
  207. $policy['updated_at'] = $this->now();
  208. if ($old) {
  209. DB::table('share_policies')->where('id', $old->id)->where('effective_date', '>', $this->today())->update($policy);
  210. return (int)$old->id;
  211. }
  212. return (int)DB::table('share_policies')->insertGetId($policy + ['created_at' => $this->now()]);
  213. }
  214. private function policy(bool $pending): ?array
  215. {
  216. $query = DB::table('share_policies');
  217. $row = $pending ? $query->where('effective_date', $this->tomorrow())->first()
  218. : $query->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
  219. if (!$row) return null;
  220. $data = (array)$row;
  221. foreach (['id', 'enabled', 'benefit_enabled'] as $field) $data[$field] = (int)$data[$field];
  222. foreach (['valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) $data[$field] = $data[$field] === null ? null : $this->money($data[$field]);
  223. $data['indirect_bonus_rate'] = $data['indirect_bonus_rate'] === null ? null : bcadd((string)$data['indirect_bonus_rate'], '0', 4);
  224. $data['rules'] = json_decode($data['rules'], true, 512, JSON_THROW_ON_ERROR);
  225. foreach ($data['rules'] as &$rows) foreach ($rows as &$rule) {
  226. $rule['min_valid_members'] = (int)$rule['min_valid_members'];
  227. $rule['min_performance'] = $this->money($rule['min_performance']);
  228. $rule['rate'] = bcadd((string)$rule['rate'], '0', 4);
  229. }
  230. unset($rule, $rows);
  231. return $data;
  232. }
  233. private function lockConfiguration(): void
  234. {
  235. if (!DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first()) {
  236. throw new \RuntimeException('分享系统尚未初始化配置表');
  237. }
  238. }
  239. private function origin(string $value): string
  240. {
  241. $value = trim($value);
  242. if (!preg_match('~^https?://~i', $value)) $value = 'https://' . $value;
  243. $parts = parse_url($value);
  244. if (!filter_var($value, FILTER_VALIDATE_URL) || !$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
  245. || isset($parts['user'], $parts['pass']) || isset($parts['user']) || isset($parts['query']) || isset($parts['fragment']) || !in_array($parts['path'] ?? '', ['', '/'], true)) {
  246. $this->invalid('share_domain', '请填写PC网站域名源地址,不可包含路径、凭证或查询参数');
  247. }
  248. return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
  249. }
  250. private function money($value): string { return bcadd((string)$value, '0', 2); }
  251. private function today(): string { return Carbon::now(ShareDefaults::TIMEZONE)->toDateString(); }
  252. private function tomorrow(): string { return Carbon::now(ShareDefaults::TIMEZONE)->addDay()->toDateString(); }
  253. private function now(): string { return Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s'); }
  254. private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
  255. }