PromotionRules.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. <?php
  2. namespace App\Support;
  3. use App\Services\Sharing\ShareDefaults;
  4. use Illuminate\Support\Facades\DB;
  5. use Illuminate\Support\Facades\Validator;
  6. use Illuminate\Validation\ValidationException;
  7. final class PromotionRules
  8. {
  9. public const LOSS = 7;
  10. public const FIRST_DEPOSIT = 8;
  11. public const WHEEL = 9;
  12. public const UPGRADE_INVITE = 10;
  13. /** Fixed campaigns have exactly one row: edited in place, never created. */
  14. public const FIXED = [self::LOSS, self::WHEEL, self::UPGRADE_INVITE];
  15. public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
  16. public const DAILY = ['online', 'login'];
  17. public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
  18. public const ORDINARY_POOL_RULE = '未完成本轮全部任务时不会达到666,累计越接近666单次奖额越小。任务完成后,未命中666时按剩余差额和达成概率控制单次奖额。可用奖项等概率,固定奖项按面额发放。';
  19. public const REWARD_MODES = ['cash', 'coupon'];
  20. public const COUPON_CATEGORIES = ['self:sport', 'self:lottery'];
  21. private const COUPON_KEYS = ['reward_mode', 'coupon_valid_from', 'coupon_valid_until', 'coupon_categories', 'inviter_coupons', 'invitee_coupons'];
  22. public static function supports(int $type): bool { return in_array($type, [7, 8, 9, 10], true); }
  23. public static function types(): array { return [7 => '净亏邀请奖励', 8 => '首充奖励', 9 => '转盘活动', 10 => '邀请升级奖励']; }
  24. public static function defaults(int $type): array
  25. {
  26. // Upgrade invite pays straight to the available balance, so it has no turnover ratio.
  27. if ($type === self::UPGRADE_INVITE) return ['tiers' => []];
  28. $common = ['turnover_ratio' => '0.00'];
  29. return $common + match ($type) {
  30. 7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00', 'reward_mode' => 'cash',
  31. 'coupon_valid_from' => null, 'coupon_valid_until' => null, 'coupon_categories' => [], 'inviter_coupons' => [], 'invitee_coupons' => []],
  32. 8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00'],
  33. 9 => ['completion_probability' => '20.00', 'online_seconds' => 3600,
  34. 'deposit_amount' => '3000.00', 'team_deposit_amount' => '500.00',
  35. 'bet_amount' => '5000.00', 'team_bet_amount' => '3000.00'],
  36. default => [],
  37. };
  38. }
  39. public static function normalize(int $type, array $input): array
  40. {
  41. $defaults = self::defaults($type);
  42. // 禁止投注的游戏大类改为全部首充活动共用的设置;旧配置里遗留的该键直接忽略。
  43. if ($type === self::FIRST_DEPOSIT) unset($input['forbidden_categories']);
  44. Validator::make(['params' => $input], ['params' => ['array:' . implode(',', array_keys($defaults))]])->validate();
  45. $data = array_replace($defaults, $input);
  46. if ($type === self::UPGRADE_INVITE) return ['tiers' => self::tiers($data['tiers'])];
  47. $coupon = array_intersect_key($data, array_flip(self::COUPON_KEYS));
  48. $data = array_diff_key($data, $coupon);
  49. $rules = [];
  50. foreach ($data as $key => $value) {
  51. $rules[$key] = match ($key) {
  52. 'invite_count' => ['required', 'integer', 'min:1', 'max:1000'],
  53. 'online_seconds' => ['required', 'integer', 'min:60', 'max:86400'],
  54. 'completion_probability' => ['required', 'numeric', 'between:0,100', 'regex:/^\d{1,3}(\.\d{1,2})?$/'],
  55. 'turnover_ratio' => ['required', 'numeric', 'between:0,10000', 'regex:/^\d{1,5}(\.\d{1,2})?$/'],
  56. default => ['required', 'numeric', 'gt:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
  57. };
  58. }
  59. Validator::make($data, $rules)->validate();
  60. foreach ($data as $key => $value) $data[$key] = in_array($key, ['invite_count', 'online_seconds'], true) ? (int)$value : bcadd((string)$value, '0', 2);
  61. return $coupon ? $data + self::coupons($coupon, $data['reward_amount']) : $data;
  62. }
  63. /** 全部首充活动共用的禁止投注游戏大类,存于 config 表(melbet 读取同一项)。 */
  64. public const FORBIDDEN_CONFIG = 'first_deposit_forbidden_categories';
  65. /** 首充活动详情链接(PC / H5 / APP),JSON 对象,全部首充活动共用。 */
  66. public const DETAIL_LINKS_CONFIG = 'first_deposit_detail_links';
  67. public const DETAIL_LINK_PLATFORMS = ['PC', 'H5', 'APP'];
  68. /** @return array{PC:string,H5:string,APP:string} */
  69. public static function detailLinks(): array
  70. {
  71. $saved = json_decode((string) DB::table('config')->where('field', self::DETAIL_LINKS_CONFIG)->value('val'), true);
  72. $links = [];
  73. foreach (self::DETAIL_LINK_PLATFORMS as $platform) {
  74. $links[$platform] = is_array($saved) && is_string($saved[$platform] ?? null) ? $saved[$platform] : '';
  75. }
  76. return $links;
  77. }
  78. /** 当前设置;未配置或内容损坏时视为不限制。 */
  79. public static function forbiddenSetting(): array
  80. {
  81. $value = json_decode((string) DB::table('config')->where('field', self::FORBIDDEN_CONFIG)->value('val'), true);
  82. try { return self::forbiddenCategories(is_array($value) ? $value : []); }
  83. catch (ValidationException) { return []; }
  84. }
  85. /** 首充活动规则附上当前的全局禁止大类,供展示、参加确认和发奖快照使用。 */
  86. public static function withForbidden(array $rules): array
  87. {
  88. return $rules + ['forbidden_categories' => self::forbiddenSetting()];
  89. }
  90. /** 禁止投注的游戏大类:与返佣规则同一套大类,去重后按大类顺序保存。 */
  91. public static function forbiddenCategories($value): array
  92. {
  93. $options = array_keys(ShareDefaults::commissionCategories());
  94. if ($value === null) return [];
  95. if (!is_array($value) || array_values($value) !== $value || array_filter($value, fn ($item) => !is_string($item) || !in_array($item, $options, true))
  96. || count(array_unique($value)) !== count($value)) {
  97. throw ValidationException::withMessages(['categories' => 'categories参数错误:只能选择返佣规则中的游戏大类且不能重复']);
  98. }
  99. return array_values(array_intersect($options, $value));
  100. }
  101. public static function hasPlayableCategory(array $rules, ?string $onlyCategory = null): bool
  102. {
  103. if (bccomp(bcmul($rules['reward_amount'], $rules['turnover_ratio'], 2), '0', 2) === 0) return true;
  104. $categories = $onlyCategory === null ? array_keys(ShareDefaults::commissionCategories()) : [$onlyCategory];
  105. return (bool) array_diff($categories, $rules['forbidden_categories']);
  106. }
  107. /** Coupon settings stay well-formed in cash mode so switching modes keeps them; completeness is enforced only in coupon mode. */
  108. private static function coupons(array $input, string $rewardAmount): array
  109. {
  110. $invalid = fn (string $message) => throw ValidationException::withMessages(['params' => $message]);
  111. $mode = $input['reward_mode'];
  112. if (!is_string($mode) || !in_array($mode, self::REWARD_MODES, true)) $invalid('reward_mode参数错误');
  113. $data = ['reward_mode' => $mode];
  114. foreach (['coupon_valid_from', 'coupon_valid_until'] as $key) {
  115. $value = $input[$key];
  116. if ($value === null || $value === '') { $data[$key] = null; continue; }
  117. $time = is_string($value) ? \DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value) : false;
  118. if (!$time || $time->format('Y-m-d H:i:s') !== $value) $invalid($key . '参数错误');
  119. $data[$key] = $value;
  120. }
  121. $categories = $input['coupon_categories'];
  122. if (!is_array($categories) || array_diff($categories, self::COUPON_CATEGORIES) || count(array_unique($categories)) !== count($categories)) {
  123. $invalid('coupon_categories参数错误');
  124. }
  125. $data['coupon_categories'] = array_values(array_intersect(self::COUPON_CATEGORIES, $categories));
  126. foreach (['inviter_coupons', 'invitee_coupons'] as $key) {
  127. $tiers = $input[$key];
  128. if (!is_array($tiers) || array_values($tiers) !== $tiers || count($tiers) > 10) $invalid($key . '参数错误');
  129. $data[$key] = [];
  130. foreach ($tiers as $tier) {
  131. if (!is_array($tier) || array_diff(array_keys($tier), ['min_amount', 'discount', 'quantity']) || count($tier) !== 3) $invalid($key . '参数错误');
  132. foreach (['min_amount', 'discount'] as $field) {
  133. if (!is_numeric($tier[$field]) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$tier[$field])) $invalid($key . '参数错误');
  134. }
  135. $quantity = is_scalar($tier['quantity']) ? filter_var($tier['quantity'], FILTER_VALIDATE_INT) : false;
  136. $min = bcadd((string)$tier['min_amount'], '0', 2); $discount = bcadd((string)$tier['discount'], '0', 2);
  137. if (bccomp($discount, '0', 2) <= 0 || bccomp($min, $discount, 2) < 0 || $quantity === false || $quantity < 1 || $quantity > 100) {
  138. $invalid($key . '参数错误:抵扣金额须大于0且不高于使用门槛,份数为1至100');
  139. }
  140. $data[$key][] = ['min_amount' => $min, 'discount' => $discount, 'quantity' => $quantity];
  141. }
  142. }
  143. if ($data['coupon_valid_from'] !== null && $data['coupon_valid_until'] !== null && $data['coupon_valid_until'] <= $data['coupon_valid_from']) {
  144. $invalid('优惠券结束时间必须晚于开始时间');
  145. }
  146. if ($mode === 'coupon') {
  147. if (!$data['inviter_coupons']) $invalid('请至少配置一档邀请人优惠券');
  148. if (bccomp(self::faceValue($data['inviter_coupons']), $rewardAmount, 2) > 0) $invalid('优惠券抵扣总额不能超过奖励金额');
  149. if ($data['coupon_valid_from'] === null || $data['coupon_valid_until'] === null) $invalid('请填写优惠券可用时间');
  150. if (!$data['coupon_categories']) $invalid('请选择优惠券可用游戏类型');
  151. }
  152. return $data;
  153. }
  154. /** Upgrade tiers: one bonus per share level reached, distinct levels from 2 up, sorted by level. */
  155. public static function tiers($tiers): array
  156. {
  157. $invalid = fn (string $message) => throw ValidationException::withMessages(['params' => $message]);
  158. if (!is_array($tiers) || array_values($tiers) !== $tiers || count($tiers) > 50) $invalid('tiers参数错误:最多50档');
  159. $data = [];
  160. foreach ($tiers as $tier) {
  161. if (!is_array($tier) || array_diff(array_keys($tier), ['level', 'amount']) || count($tier) !== 2) $invalid('tiers参数错误:每档须包含level和amount');
  162. $level = is_scalar($tier['level']) ? filter_var($tier['level'], FILTER_VALIDATE_INT) : false;
  163. if ($level === false || $level < 2) $invalid('tiers参数错误:等级须为2及以上的整数');
  164. if (isset($data[$level])) $invalid('tiers参数错误:等级' . $level . '重复');
  165. if (!is_numeric($tier['amount']) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$tier['amount']) || bccomp((string)$tier['amount'], '0', 2) <= 0) {
  166. $invalid('tiers参数错误:奖励金额须大于0且最多两位小数');
  167. }
  168. $data[$level] = ['level' => $level, 'amount' => bcadd((string)$tier['amount'], '0', 2)];
  169. }
  170. ksort($data);
  171. return array_values($data);
  172. }
  173. /** Total discount of one coupon set: sum of discount × quantity. */
  174. public static function faceValue(array $tiers): string
  175. {
  176. $total = '0.00';
  177. foreach ($tiers as $tier) $total = bcadd($total, bcmul((string)$tier['discount'], (string)$tier['quantity'], 2), 2);
  178. return $total;
  179. }
  180. public static function draw(int $coins, bool $complete, string $probability, ?callable $random = null): array
  181. {
  182. $random ??= fn (int $min, int $max) => random_int($min, $max);
  183. $remaining = 66600 - $coins;
  184. if ($remaining <= 0) throw new \LogicException('请先领取本轮奖金');
  185. $probabilityBps = (int)bcmul($probability, '100', 0);
  186. $hit = $complete && $random(1, 10000) <= $probabilityBps;
  187. if ($hit) return ['prize_index' => 0, 'amount_cents' => $remaining, 'reached' => true];
  188. if ($complete) {
  189. // Use twice the expected draws, rounded up and capped at ten. Budget one unit per draw,
  190. // then spread the remaining cents across that many misses once inside the buffer.
  191. $pacedSpins = (int)min(10, ceil(20000 / max(1, $probabilityBps)));
  192. $maxAmount = min($remaining - 1, max($remaining - $pacedSpins * 100, intdiv($remaining - 1, $pacedSpins), 1));
  193. } else {
  194. // No hard stop at 600: always leave 1.00 for the qualified stage. Below 600 award at most a quarter
  195. // of the room, above it at most a twentieth, so amounts shrink toward 666 without collapsing to zero.
  196. $headroom = max(0, 66500 - $coins);
  197. $divisor = $coins < 60000 ? 4 : 20;
  198. $maxAmount = intdiv($headroom + $divisor - 1, $divisor);
  199. }
  200. if ($maxAmount === 0) return ['prize_index' => 0, 'amount_cents' => 0, 'reached' => false];
  201. // Choose eligible sectors before drawing. A fixed face value is never truncated.
  202. $eligible = [0];
  203. foreach (self::PRIZES as $i => $amount) if ($i && (int)$amount * 100 <= $maxAmount) $eligible[] = $i;
  204. $index = $eligible[$random(0, count($eligible) - 1)];
  205. $amount = $index === 0 ? $random(1, $maxAmount) : (int)self::PRIZES[$index] * 100;
  206. return ['prize_index' => $index, 'amount_cents' => $amount, 'reached' => false];
  207. }
  208. }