ShareAdminService.php 15 KB

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