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', 'indirect_bonus_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. 'activation_date' => $setting?->activation_date ?: null,
  29. 'initialized' => DB::table('share_system_state')->where('key', 'initialized_at')->exists(),
  30. 'effective_date' => $pending['effective_date'] ?? null,
  31. 'current_policy' => $current,
  32. 'pending_policy' => $pending,
  33. ];
  34. }
  35. public function saveSettings(array $input): array
  36. {
  37. foreach (self::RETIRED as $key) {
  38. if (array_key_exists($key, $input)) $this->invalid($key, '旧分享参数已移除,请使用有效会员条件及分类返佣规则');
  39. }
  40. foreach (['activation_date', 'effective_date', 'current_policy', 'pending_policy'] as $key) {
  41. if (array_key_exists($key, $input)) $this->invalid($key, '生效日期及版本由系统生成,不可指定');
  42. }
  43. $data = Validator::make($input, [
  44. 'enabled' => ['required', Rule::in([0, 1])],
  45. 'share_domain' => ['required', 'string', 'max:500'],
  46. 'benefit_enabled' => ['required', Rule::in([0, 1])],
  47. 'valid_member_min_deposit' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/'],
  48. 'valid_member_min_effective_bet' => array_merge(['nullable', 'required_if:benefit_enabled,1'], self::MONEY),
  49. ])->validate();
  50. $data['share_domain'] = $this->origin($data['share_domain']);
  51. return DB::transaction(function () use ($data) {
  52. $this->lockConfiguration();
  53. $setting = ShareSetting::query()->lockForUpdate()->first() ?: new ShareSetting();
  54. $before = $setting->exists ? $setting->toArray() : [];
  55. if ((int)$data['benefit_enabled'] === 1) {
  56. if (!DB::table('share_system_state')->where('key', 'initialized_at')->exists()) {
  57. $this->invalid('benefit_enabled', '会员分享关系初始化完成后才可启用佣金');
  58. }
  59. if (!$setting->activation_date) $setting->activation_date = $this->tomorrow();
  60. }
  61. // Blank nullable fields may clear unfinished setup, but can never enable it.
  62. $setting->fill($data + OperationAuditService::actor());
  63. $setting->save();
  64. $policy = $this->writablePolicy();
  65. foreach (['enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) {
  66. $policy[$field] = $setting->getRawOriginal($field);
  67. }
  68. $this->savePolicy($policy);
  69. OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
  70. return $this->settings();
  71. });
  72. }
  73. public function rateRules(?string $category = null): array
  74. {
  75. if ($category !== null && !array_key_exists($category, ShareDefaults::categories())) $this->invalid('category', '无效的游戏分类');
  76. $current = $this->policy(false);
  77. $pending = $this->policy(true);
  78. $categories = [];
  79. foreach (ShareDefaults::categories() as $key => $label) {
  80. if ($category !== null && $category !== $key) continue;
  81. $categories[] = ['category' => $key, 'name' => $label,
  82. 'current_rules' => $current['rules'][$key] ?? [],
  83. 'pending_rules' => $pending['rules'][$key] ?? null];
  84. }
  85. return ['current_policy_id' => $current['id'] ?? null, 'current_effective_date' => $current['effective_date'] ?? null,
  86. 'pending_policy_id' => $pending['id'] ?? null, 'pending_effective_date' => $pending['effective_date'] ?? null,
  87. 'categories' => $categories];
  88. }
  89. public function saveRateRules(string $category, array $rules): array
  90. {
  91. $data = Validator::make(['category' => $category, 'rules' => $rules], [
  92. 'category' => ['required', Rule::in(array_keys(ShareDefaults::categories()))],
  93. 'rules' => ['required', 'array', 'min:1', 'max:100'],
  94. 'rules.*.min_valid_members' => ['required', 'integer', 'min:0', 'max:2147483647'],
  95. 'rules.*.min_performance' => array_merge(['required'], self::MONEY),
  96. 'rules.*.rate' => array_merge(['required'], self::RATE),
  97. ])->validate();
  98. $normalized = [];
  99. foreach ($data['rules'] as $row) {
  100. $normalized[] = ['min_valid_members' => (int)$row['min_valid_members'],
  101. 'min_performance' => bcadd((string)$row['min_performance'], '0', 10), 'rate' => bcadd((string)$row['rate'], '0', 4)];
  102. }
  103. usort($normalized, fn ($a, $b) => bccomp($a['min_performance'], $b['min_performance'], 10)
  104. ?: ($a['min_valid_members'] <=> $b['min_valid_members']));
  105. $previous = null;
  106. foreach ($normalized as $row) {
  107. if ($previous !== null) {
  108. if (bccomp($row['min_performance'], $previous['min_performance'], 10) === 0
  109. && $row['min_valid_members'] === $previous['min_valid_members']) {
  110. $this->invalid('rules', '同一业绩门槛和人数门槛的档位不可重复');
  111. }
  112. if ($row['min_valid_members'] < $previous['min_valid_members']) {
  113. $this->invalid('rules', '随业绩档位提高,有效直属人数门槛不可降低');
  114. }
  115. if (bccomp($row['rate'], $previous['rate'], 4) < 0) {
  116. $this->invalid('rules', '随业绩和人数档位提高,返佣比例不可降低');
  117. }
  118. }
  119. $previous = $row;
  120. }
  121. return DB::transaction(function () use ($category, $normalized) {
  122. $this->lockConfiguration();
  123. $policy = $this->writablePolicy();
  124. $before = $policy;
  125. $policy['rules'][$category] = $normalized;
  126. $id = $this->savePolicy($policy);
  127. OperationAuditService::record('share_policy', $id, 'rate_rules', $before, $policy);
  128. return $this->rateRules($category);
  129. });
  130. }
  131. public function levels(): array
  132. {
  133. $total = '0';
  134. $rows = [];
  135. foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
  136. $total = bcadd($total, (string)$row->incremental_commission, 10);
  137. $rows[] = ['id' => (int)$row->id, 'level' => (int)$row->level, 'name' => $row->name, 'image' => $row->image,
  138. 'incremental_commission' => $this->money($row->incremental_commission), 'cumulative_commission' => $this->money($total)];
  139. }
  140. return ['data' => $rows];
  141. }
  142. public function saveLevel(array $input): array
  143. {
  144. $data = Validator::make($input, [
  145. 'id' => ['nullable', 'integer', 'min:1'], 'level' => ['required', 'integer', 'min:1', 'max:2147483647'],
  146. 'name' => ['required', 'string', 'max:255'], 'image' => ['present', 'nullable', 'string', 'max:1000'],
  147. 'incremental_commission' => array_merge(['required'], self::MONEY),
  148. ])->validate();
  149. $data['image'] = (string)($data['image'] ?? '');
  150. return DB::transaction(function () use ($data) {
  151. $this->lockConfiguration();
  152. $id = (int)($data['id'] ?? 0);
  153. $old = $id ? DB::table('share_levels')->where('id', $id)->lockForUpdate()->first() : null;
  154. if ($id && !$old) $this->invalid('id', '分享等级不存在');
  155. if ($old && (int)$old->level !== (int)$data['level']) $this->invalid('level', '已有等级不可更改等级编号');
  156. if ((int)$data['level'] === 1 && bccomp((string)$data['incremental_commission'], '0', 10) !== 0) $this->invalid('incremental_commission', '等级1的增量佣金必须为0');
  157. if (DB::table('share_levels')->where('level', $data['level'])->when($id, fn ($q) => $q->where('id', '!=', $id))->exists()) $this->invalid('level', '分享等级编号已存在');
  158. unset($data['id']);
  159. $now = $this->now();
  160. if ($id) DB::table('share_levels')->where('id', $id)->update($data + ['updated_at' => $now]);
  161. else $id = DB::table('share_levels')->insertGetId($data + ['created_at' => $now, 'updated_at' => $now]);
  162. $this->promoteMembers();
  163. OperationAuditService::record('share_level', $id, $old ? 'update' : 'create', (array)$old, $data);
  164. return $this->levels();
  165. });
  166. }
  167. public function deleteLevel(int $id): void
  168. {
  169. DB::transaction(function () use ($id) {
  170. $this->lockConfiguration();
  171. $row = DB::table('share_levels')->where('id', $id)->lockForUpdate()->first();
  172. if (!$row) $this->invalid('id', '分享等级不存在');
  173. if ((int)$row->level === 1) $this->invalid('id', '初始等级不可删除');
  174. if (DB::table('share_members')->where('level', $row->level)->exists()) $this->invalid('id', '该等级已有会员,不可删除');
  175. DB::table('share_levels')->where('id', $id)->delete();
  176. $this->promoteMembers();
  177. OperationAuditService::record('share_level', $id, 'delete', (array)$row, []);
  178. });
  179. }
  180. private function promoteMembers(): void
  181. {
  182. $threshold = '0';
  183. foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
  184. $threshold = bcadd($threshold, (string)$row->incremental_commission, 10);
  185. DB::table('share_members')->where('level', '<', $row->level)
  186. ->whereIn('user_id', DB::table('share_accounts')->select('user_id')->where('earned_amount', '>=', $threshold))
  187. ->update(['level' => $row->level, 'updated_at' => $this->now()]);
  188. }
  189. }
  190. private function writablePolicy(): array
  191. {
  192. $row = DB::table('share_policies')->where('effective_date', $this->tomorrow())->lockForUpdate()->first()
  193. ?: DB::table('share_policies')->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
  194. $policy = $row ? (array)$row : ['enabled' => 0, 'benefit_enabled' => 0, 'valid_member_min_deposit' => '20',
  195. 'valid_member_min_effective_bet' => null, 'indirect_bonus_rate' => null, 'rules' => ShareDefaults::rules()];
  196. unset($policy['id'], $policy['created_at'], $policy['updated_at']);
  197. // Keep archived coefficients on past versions only; new versions use the receiver's bracket rate by depth.
  198. $policy['indirect_bonus_rate'] = null;
  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. unset($data['indirect_bonus_rate']);
  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. }