WageringService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\HttpStatus;
  4. use App\Models\Config;
  5. use App\Models\User;
  6. use App\Models\WagerRecord;
  7. use App\Models\Wallet;
  8. use Exception;
  9. use Illuminate\Support\Facades\DB;
  10. /**
  11. * 统一提现打码量。
  12. *
  13. * 一个打码周期从上一次提现申请开始:
  14. * - wager_required:本周期所有打码要求之和(充值×平台倍数、活动配置打码量、后台填写打码量等累加)。
  15. * - wager_completed:本周期的有效投注(与活动解冻同一口径 PromotionFacts / EffectiveBet),由 wagering:sync 缓存。
  16. * - 剩余打码量 = max(required - completed, 0),大于0不可提现;提现申请成功后重置周期。
  17. */
  18. class WageringService
  19. {
  20. public const CONFIG_FIELD = 'withdraw_wager_multiple';
  21. /** @var array<int,bool> 按数据库连接缓存表结构探测结果 */
  22. private static array $enabled = [];
  23. /** 表结构未升级(或隔离测试未建表)时整个打码量逻辑静默跳过。 */
  24. public static function enabled(): bool
  25. {
  26. $key = spl_object_id(DB::connection()->getPdo());
  27. if (!array_key_exists($key, self::$enabled)) {
  28. $schema = DB::connection()->getSchemaBuilder();
  29. self::$enabled[$key] = $schema->hasTable('wager_records') && $schema->hasColumn('wallets', 'wager_required');
  30. }
  31. return self::$enabled[$key];
  32. }
  33. /** 测试与迁移后重置结构探测缓存。 */
  34. public static function flushSchemaCache(): void
  35. {
  36. self::$enabled = [];
  37. self::$multiple = null;
  38. self::$userIds = [];
  39. WithdrawFundsService::flushSchemaCache();
  40. }
  41. /** @var array{0:int,1:int,2:string}|null [连接, 读取时间, 倍数],短时缓存,后台修改后几秒内生效 */
  42. private static ?array $multiple = null;
  43. /** 平台提现打码倍数,未配置时为1倍。 */
  44. public static function multiple(): string
  45. {
  46. if (!self::enabled()) return '1';
  47. $key = spl_object_id(DB::connection()->getPdo());
  48. if (self::$multiple !== null && self::$multiple[0] === $key && time() - self::$multiple[1] < 10) return self::$multiple[2];
  49. $val = Config::where('field', self::CONFIG_FIELD)->value('val');
  50. $result = self::normalizeMultiple($val);
  51. self::$multiple = [$key, time(), $result];
  52. return $result;
  53. }
  54. private static function normalizeMultiple($val): string
  55. {
  56. if ($val === null || $val === '' || !is_numeric($val) || bccomp((string)$val, '0', 10) < 0) return '1';
  57. return bcadd((string)$val, '0', 10);
  58. }
  59. /** 充值/人工充值金额对应的平台打码量。 */
  60. public static function platformRequirement($amount): string
  61. {
  62. $amount = ltrim(bcadd((string)$amount, '0', 10), '-');
  63. return bcmul($amount, self::multiple(), 10);
  64. }
  65. /**
  66. * 调整所需打码量(有符号,结果不低于0),调用方必须已在事务中锁定该钱包行。
  67. * 只写 wager_required 一列,不会覆盖调用方钱包对象上尚未保存的余额改动。
  68. */
  69. public static function addRequirement(Wallet $wallet, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
  70. {
  71. if (!self::enabled()) return null;
  72. $amount = bcadd((string)$amount, '0', 10);
  73. if (bccomp($amount, '0', 10) === 0) return null;
  74. // 以库内当前值为准(调用方已持有行锁),不依赖调用方可能过期的钱包对象
  75. $before = bcadd((string)(Wallet::where('id', $wallet->id)->value('wager_required') ?? '0'), '0', 10);
  76. $after = bcadd($before, $amount, 10);
  77. if (bccomp($after, '0', 10) < 0) $after = bcadd('0', '0', 10);
  78. Wallet::where('id', $wallet->id)->update(['wager_required' => $after]);
  79. self::setClean($wallet, 'wager_required', $after);
  80. return WagerRecord::create([
  81. 'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id,
  82. 'cycle_id' => (int)($wallet->getAttribute('wager_cycle_id') ?? 0), 'source' => $source,
  83. 'source_id' => $sourceId === null ? null : (string)$sourceId, 'amount' => $amount,
  84. 'before_required' => $before, 'after_required' => $after,
  85. 'completed' => (string)($wallet->getAttribute('wager_completed') ?? '0'), 'admin_id' => $adminId,
  86. 'remark' => mb_substr($remark, 0, 255),
  87. ]);
  88. }
  89. /** 自行锁钱包后调整打码量;供没有持有钱包锁的流程(活动发放、充值钩子)调用。 */
  90. public static function addRequirementFor(string $memberId, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
  91. {
  92. if (!self::enabled() || bccomp(bcadd((string)$amount, '0', 10), '0', 10) === 0) return null;
  93. return DB::transaction(function () use ($memberId, $amount, $source, $sourceId, $remark, $adminId) {
  94. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  95. if (!$wallet) return null;
  96. return self::addRequirement($wallet, $amount, $source, $sourceId, $remark, $adminId);
  97. }, 3);
  98. }
  99. /** 重新计算本周期已完成的有效投注并写回缓存(周期已被重置时不覆盖)。 */
  100. public static function refresh(Wallet $wallet, ?PromotionFacts $facts = null): string
  101. {
  102. if (!self::enabled()) return '0';
  103. $userId = self::userId($wallet);
  104. $from = (string)($wallet->getAttribute('wager_cycle_from') ?: ($wallet->getRawOriginal('created_at') ?: '1970-01-01 00:00:00'));
  105. $cursors = json_decode((string)($wallet->getAttribute('wager_cycle_cursors') ?: '[]'), true) ?: [];
  106. $completed = '0';
  107. if ($userId) {
  108. $facts ??= new PromotionFacts();
  109. $completed = $facts->total($facts->betting([$userId], $from, null, $cursors)['effective']);
  110. }
  111. $completed = bcadd($completed, '0', 10);
  112. $now = PromotionFacts::now();
  113. Wallet::where('id', $wallet->id)->where('wager_cycle_id', (int)($wallet->getAttribute('wager_cycle_id') ?? 0))
  114. ->update(['wager_completed' => $completed, 'wager_synced_at' => $now]);
  115. self::setClean($wallet, 'wager_completed', $completed);
  116. self::setClean($wallet, 'wager_synced_at', $now);
  117. return $completed;
  118. }
  119. public static function remaining($required, $completed): string
  120. {
  121. $left = bcsub(bcadd((string)($required ?? '0'), '0', 10), bcadd((string)($completed ?? '0'), '0', 10), 10);
  122. return bccomp($left, '0', 10) > 0 ? $left : bcadd('0', '0', 10);
  123. }
  124. /** 接口展示用:所需打码量、当前打码量、剩余打码量(两位小数)。 */
  125. public static function summary($wallet): array
  126. {
  127. $required = (string)(data_get($wallet, 'wager_required') ?? '0');
  128. $completed = (string)(data_get($wallet, 'wager_completed') ?? '0');
  129. return [
  130. 'wager_required' => bcadd($required, '0', 2),
  131. 'wager_completed' => bcadd($completed, '0', 2),
  132. 'wager_remaining' => bcadd(self::remaining($required, $completed), '0', 2),
  133. ];
  134. }
  135. /** 提现前校验:任意一项打码量未完成(合计剩余>0)都不能提现。调用方需已锁定钱包。 */
  136. public static function assertWithdrawable(Wallet $wallet, bool $recompute = true): void
  137. {
  138. if (!self::enabled()) return;
  139. $required = (string)($wallet->getAttribute('wager_required') ?? '0');
  140. if (bccomp($required, '0', 10) <= 0) return;
  141. $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
  142. if ($recompute && bccomp(self::remaining($required, $completed), '0', 10) > 0) $completed = self::refresh($wallet);
  143. $remaining = self::remaining($required, $completed);
  144. if (bccomp($remaining, '0', 10) > 0) {
  145. throw new Exception('打码量未完成,剩余打码量:' . bcadd($remaining, '0', 2), HttpStatus::CUSTOM_ERROR);
  146. }
  147. }
  148. /** 提现入口的提示用(不加锁):打码量未完成时返回剩余打码量(两位小数),否则返回 null。 */
  149. public static function remainingFor(string $memberId): ?string
  150. {
  151. if (!self::enabled()) return null;
  152. $wallet = Wallet::where('member_id', $memberId)->first();
  153. if (!$wallet || bccomp((string)($wallet->wager_required ?? '0'), '0', 10) <= 0) return null;
  154. $remaining = self::remaining($wallet->wager_required, self::refresh($wallet));
  155. return bccomp($remaining, '0', 10) > 0 ? bcadd($remaining, '0', 2) : null;
  156. }
  157. /** 提现申请成功后开启新周期:所需与当前打码量清零。调用方需已锁定钱包。 */
  158. public static function resetCycle(Wallet $wallet, string $source = 'reset', $sourceId = null, string $remark = '提现重置打码量'): void
  159. {
  160. if (!self::enabled()) return;
  161. $before = bcadd((string)($wallet->getAttribute('wager_required') ?? '0'), '0', 10);
  162. $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
  163. $cycle = (int)($wallet->getAttribute('wager_cycle_id') ?? 0);
  164. $now = PromotionFacts::now();
  165. $values = ['wager_required' => '0', 'wager_completed' => '0', 'wager_cycle_id' => $cycle + 1,
  166. 'wager_cycle_from' => $now, 'wager_cycle_cursors' => json_encode((new PromotionFacts())->cursors()), 'wager_synced_at' => $now];
  167. Wallet::where('id', $wallet->id)->update($values);
  168. foreach ($values as $key => $value) self::setClean($wallet, $key, $value);
  169. WagerRecord::create([
  170. 'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id, 'cycle_id' => $cycle,
  171. 'source' => $source, 'source_id' => $sourceId === null ? null : (string)$sourceId,
  172. 'amount' => bcsub('0', $before, 10), 'before_required' => $before, 'after_required' => '0',
  173. 'completed' => $completed, 'remark' => $remark,
  174. ]);
  175. }
  176. /** @var array<string,int|null> member_id => users.id(不会变化,进程内缓存) */
  177. private static array $userIds = [];
  178. private static function userId(Wallet $wallet): ?int
  179. {
  180. $memberId = (string)$wallet->member_id;
  181. if (!array_key_exists($memberId, self::$userIds)) {
  182. if (count(self::$userIds) > 5000) self::$userIds = [];
  183. $id = User::where('member_id', $memberId)->value('id');
  184. self::$userIds[$memberId] = $id ? (int)$id : ($wallet->user_id ? (int)$wallet->user_id : null);
  185. }
  186. return self::$userIds[$memberId];
  187. }
  188. /** 已直接写库的列同步到模型原始值,避免调用方后续 save() 用旧值覆盖。 */
  189. private static function setClean(Wallet $wallet, string $key, $value): void
  190. {
  191. $wallet->setAttribute($key, $value);
  192. $wallet->syncOriginalAttribute($key);
  193. }
  194. }