ShareAdminService.php 15 KB

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