| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- <?php
- namespace App\Services;
- use App\Constants\HttpStatus;
- use App\Models\Config;
- use App\Models\User;
- use App\Models\WagerRecord;
- use App\Models\Wallet;
- use Exception;
- use Illuminate\Support\Facades\DB;
- /**
- * 统一提现打码量。
- *
- * 一个打码周期从上一次提现申请开始:
- * - wager_required:本周期所有打码要求之和(充值×平台倍数、活动配置打码量、后台填写打码量等累加)。
- * - wager_completed:本周期的有效投注(与活动解冻同一口径 PromotionFacts / EffectiveBet),由 wagering:sync 缓存。
- * - 剩余打码量 = max(required - completed, 0),大于0不可提现;提现申请成功后重置周期。
- */
- class WageringService
- {
- public const CONFIG_FIELD = 'withdraw_wager_multiple';
- /** @var array<int,bool> 按数据库连接缓存表结构探测结果 */
- private static array $enabled = [];
- /** 表结构未升级(或隔离测试未建表)时整个打码量逻辑静默跳过。 */
- public static function enabled(): bool
- {
- $key = spl_object_id(DB::connection()->getPdo());
- if (!array_key_exists($key, self::$enabled)) {
- $schema = DB::connection()->getSchemaBuilder();
- self::$enabled[$key] = $schema->hasTable('wager_records') && $schema->hasColumn('wallets', 'wager_required');
- }
- return self::$enabled[$key];
- }
- /** 测试与迁移后重置结构探测缓存。 */
- public static function flushSchemaCache(): void
- {
- self::$enabled = [];
- self::$multiple = null;
- self::$userIds = [];
- WithdrawFundsService::flushSchemaCache();
- }
- /** @var array{0:int,1:int,2:string}|null [连接, 读取时间, 倍数],短时缓存,后台修改后几秒内生效 */
- private static ?array $multiple = null;
- /** 平台提现打码倍数,未配置时为1倍。 */
- public static function multiple(): string
- {
- if (!self::enabled()) return '1';
- $key = spl_object_id(DB::connection()->getPdo());
- if (self::$multiple !== null && self::$multiple[0] === $key && time() - self::$multiple[1] < 10) return self::$multiple[2];
- $val = Config::where('field', self::CONFIG_FIELD)->value('val');
- $result = self::normalizeMultiple($val);
- self::$multiple = [$key, time(), $result];
- return $result;
- }
- private static function normalizeMultiple($val): string
- {
- if ($val === null || $val === '' || !is_numeric($val) || bccomp((string)$val, '0', 10) < 0) return '1';
- return bcadd((string)$val, '0', 10);
- }
- /** 充值/人工充值金额对应的平台打码量。 */
- public static function platformRequirement($amount): string
- {
- $amount = ltrim(bcadd((string)$amount, '0', 10), '-');
- return bcmul($amount, self::multiple(), 10);
- }
- /**
- * 调整所需打码量(有符号,结果不低于0),调用方必须已在事务中锁定该钱包行。
- * 只写 wager_required 一列,不会覆盖调用方钱包对象上尚未保存的余额改动。
- */
- public static function addRequirement(Wallet $wallet, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
- {
- if (!self::enabled()) return null;
- $amount = bcadd((string)$amount, '0', 10);
- if (bccomp($amount, '0', 10) === 0) return null;
- // 以库内当前值为准(调用方已持有行锁),不依赖调用方可能过期的钱包对象
- $before = bcadd((string)(Wallet::where('id', $wallet->id)->value('wager_required') ?? '0'), '0', 10);
- $after = bcadd($before, $amount, 10);
- if (bccomp($after, '0', 10) < 0) $after = bcadd('0', '0', 10);
- Wallet::where('id', $wallet->id)->update(['wager_required' => $after]);
- self::setClean($wallet, 'wager_required', $after);
- return WagerRecord::create([
- 'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id,
- 'cycle_id' => (int)($wallet->getAttribute('wager_cycle_id') ?? 0), 'source' => $source,
- 'source_id' => $sourceId === null ? null : (string)$sourceId, 'amount' => $amount,
- 'before_required' => $before, 'after_required' => $after,
- 'completed' => (string)($wallet->getAttribute('wager_completed') ?? '0'), 'admin_id' => $adminId,
- 'remark' => mb_substr($remark, 0, 255),
- ]);
- }
- /** 自行锁钱包后调整打码量;供没有持有钱包锁的流程(活动发放、充值钩子)调用。 */
- public static function addRequirementFor(string $memberId, $amount, string $source, $sourceId = null, string $remark = '', ?int $adminId = null): ?WagerRecord
- {
- if (!self::enabled() || bccomp(bcadd((string)$amount, '0', 10), '0', 10) === 0) return null;
- return DB::transaction(function () use ($memberId, $amount, $source, $sourceId, $remark, $adminId) {
- $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
- if (!$wallet) return null;
- return self::addRequirement($wallet, $amount, $source, $sourceId, $remark, $adminId);
- }, 3);
- }
- /** 重新计算本周期已完成的有效投注并写回缓存(周期已被重置时不覆盖)。 */
- public static function refresh(Wallet $wallet, ?PromotionFacts $facts = null): string
- {
- if (!self::enabled()) return '0';
- $userId = self::userId($wallet);
- $from = (string)($wallet->getAttribute('wager_cycle_from') ?: ($wallet->getRawOriginal('created_at') ?: '1970-01-01 00:00:00'));
- $cursors = json_decode((string)($wallet->getAttribute('wager_cycle_cursors') ?: '[]'), true) ?: [];
- $completed = '0';
- if ($userId) {
- $facts ??= new PromotionFacts();
- $completed = $facts->total($facts->betting([$userId], $from, null, $cursors)['effective']);
- }
- $completed = bcadd($completed, '0', 10);
- $now = PromotionFacts::now();
- Wallet::where('id', $wallet->id)->where('wager_cycle_id', (int)($wallet->getAttribute('wager_cycle_id') ?? 0))
- ->update(['wager_completed' => $completed, 'wager_synced_at' => $now]);
- self::setClean($wallet, 'wager_completed', $completed);
- self::setClean($wallet, 'wager_synced_at', $now);
- return $completed;
- }
- public static function remaining($required, $completed): string
- {
- $left = bcsub(bcadd((string)($required ?? '0'), '0', 10), bcadd((string)($completed ?? '0'), '0', 10), 10);
- return bccomp($left, '0', 10) > 0 ? $left : bcadd('0', '0', 10);
- }
- /** 接口展示用:所需打码量、当前打码量、剩余打码量(两位小数)。 */
- public static function summary($wallet): array
- {
- $required = (string)(data_get($wallet, 'wager_required') ?? '0');
- $completed = (string)(data_get($wallet, 'wager_completed') ?? '0');
- return [
- 'wager_required' => bcadd($required, '0', 2),
- 'wager_completed' => bcadd($completed, '0', 2),
- 'wager_remaining' => bcadd(self::remaining($required, $completed), '0', 2),
- ];
- }
- /** 提现前校验:任意一项打码量未完成(合计剩余>0)都不能提现。调用方需已锁定钱包。 */
- public static function assertWithdrawable(Wallet $wallet, bool $recompute = true): void
- {
- if (!self::enabled()) return;
- $required = (string)($wallet->getAttribute('wager_required') ?? '0');
- if (bccomp($required, '0', 10) <= 0) return;
- $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
- if ($recompute && bccomp(self::remaining($required, $completed), '0', 10) > 0) $completed = self::refresh($wallet);
- $remaining = self::remaining($required, $completed);
- if (bccomp($remaining, '0', 10) > 0) {
- throw new Exception('打码量未完成,剩余打码量:' . bcadd($remaining, '0', 2), HttpStatus::CUSTOM_ERROR);
- }
- }
- /** 提现入口的提示用(不加锁):打码量未完成时返回剩余打码量(两位小数),否则返回 null。 */
- public static function remainingFor(string $memberId): ?string
- {
- if (!self::enabled()) return null;
- $wallet = Wallet::where('member_id', $memberId)->first();
- if (!$wallet || bccomp((string)($wallet->wager_required ?? '0'), '0', 10) <= 0) return null;
- $remaining = self::remaining($wallet->wager_required, self::refresh($wallet));
- return bccomp($remaining, '0', 10) > 0 ? bcadd($remaining, '0', 2) : null;
- }
- /** 提现申请成功后开启新周期:所需与当前打码量清零。调用方需已锁定钱包。 */
- public static function resetCycle(Wallet $wallet, string $source = 'reset', $sourceId = null, string $remark = '提现重置打码量'): void
- {
- if (!self::enabled()) return;
- $before = bcadd((string)($wallet->getAttribute('wager_required') ?? '0'), '0', 10);
- $completed = (string)($wallet->getAttribute('wager_completed') ?? '0');
- $cycle = (int)($wallet->getAttribute('wager_cycle_id') ?? 0);
- $now = PromotionFacts::now();
- $values = ['wager_required' => '0', 'wager_completed' => '0', 'wager_cycle_id' => $cycle + 1,
- 'wager_cycle_from' => $now, 'wager_cycle_cursors' => json_encode((new PromotionFacts())->cursors()), 'wager_synced_at' => $now];
- Wallet::where('id', $wallet->id)->update($values);
- foreach ($values as $key => $value) self::setClean($wallet, $key, $value);
- WagerRecord::create([
- 'user_id' => self::userId($wallet), 'member_id' => (string)$wallet->member_id, 'cycle_id' => $cycle,
- 'source' => $source, 'source_id' => $sourceId === null ? null : (string)$sourceId,
- 'amount' => bcsub('0', $before, 10), 'before_required' => $before, 'after_required' => '0',
- 'completed' => $completed, 'remark' => $remark,
- ]);
- }
- /** @var array<string,int|null> member_id => users.id(不会变化,进程内缓存) */
- private static array $userIds = [];
- private static function userId(Wallet $wallet): ?int
- {
- $memberId = (string)$wallet->member_id;
- if (!array_key_exists($memberId, self::$userIds)) {
- if (count(self::$userIds) > 5000) self::$userIds = [];
- $id = User::where('member_id', $memberId)->value('id');
- self::$userIds[$memberId] = $id ? (int)$id : ($wallet->user_id ? (int)$wallet->user_id : null);
- }
- return self::$userIds[$memberId];
- }
- /** 已直接写库的列同步到模型原始值,避免调用方后续 save() 用旧值覆盖。 */
- private static function setClean(Wallet $wallet, string $key, $value): void
- {
- $wallet->setAttribute($key, $value);
- $wallet->syncOriginalAttribute($key);
- }
- }
|