按数据库连接缓存表结构探测结果 */ 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 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); } }