format('Y-m-d H:i:s'); } public static function percent($value): string { $n = bcadd((string)$value, '0', 2); return str_ends_with($n, '.00') ? substr($n, 0, -3) : $n; } public static function capture(string $channel, string $member): ?array { if (!self::available()) return null; $rule = DB::table('recharge_bonuses')->where('channel_type', $channel)->first(); $user = User::where('member_id', $member)->first(); return ['channel_type' => $channel, 'enabled' => (bool)($rule->enabled ?? false) && $user && (int)$user->from === 1, 'bonus_percent' => bcadd((string)($rule->bonus_percent ?? '0'), '0', 2), 'captured_at' => self::now(), 'version' => $rule->updated_at ?? null]; } public static function decorate(array $channels, string $language, bool $eligible = true): array { $rules = self::available() ? DB::table('recharge_bonuses')->whereIn('channel_type', array_column($channels, 'value'))->get()->keyBy('channel_type') : collect(); foreach ($channels as &$channel) { $rule = $rules[$channel['value']] ?? null; $enabled = $eligible && (bool)($rule->enabled ?? false); $texts = $rule ? json_decode($rule->translations, true) : []; $channel['bonus_enabled'] = $enabled; $channel['bonus_percent'] = $enabled ? self::percent($rule->bonus_percent) : '0'; $channel['badge_text'] = $enabled ? (string)($texts[$language]['text'] ?? '') : ''; } unset($channel); return $channels; } public static function chainKey(string $net, string $txid): string { return hash('sha256', 'chain:' . strtoupper(trim($net)) . ':' . strtolower(trim($txid))); } public static function registerChain(array $row): int { if (!self::available()) return DB::table('recharges')->insertOrIgnore($row); if (empty($row['txid']) || empty($row['net'])) return 0; return DB::transaction(function () use ($row) { $key = self::chainKey($row['net'], $row['txid']); $now = self::now(); DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'created_at' => $now, 'updated_at' => $now]); $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first(); if ($receipt->recharge_id !== null) return 0; $old = self::chainRows($row['net'], $row['txid'])->orderBy('id')->first(); if ($old) { DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $old->id]); return 0; } $snapshot = self::capture('usdt', (string)$row['member_id']); $row['recharge_bonus_snapshot'] = $snapshot === null ? null : json_encode($snapshot, JSON_THROW_ON_ERROR); $row['created_at'] = $row['updated_at'] = $now; $id = DB::table('recharges')->insertGetId($row); DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $id]); return 1; }, 3); } private static function chainRows(string $net, string $txid) { return DB::table('recharges')->whereRaw('UPPER(TRIM(net)) = ?', [strtoupper(trim($net))])->whereRaw('LOWER(TRIM(txid)) = ?', [strtolower(trim($txid))]); } public static function chainCreditAllowed(object $order): bool { if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return true; $key = self::chainKey((string)$order->net, (string)$order->txid); $now = self::now(); DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'recharge_id' => $order->id, 'created_at' => $now, 'updated_at' => $now]); $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first(); return $receipt->credited_log_id === null && !self::chainRows((string)$order->net, (string)$order->txid)->where('status', 1)->where('id', '<>', $order->id)->exists(); } public static function markChainCredited(object $order, BalanceLog $log): void { if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return; DB::table('recharge_bonus_chain_receipts')->where('dedup_key', self::chainKey((string)$order->net, (string)$order->txid))->update(['credited_log_id' => $log->id]); } /** Caller holds the source order and wallet locks in the principal-credit transaction. */ public static function grant(string $source, object $order, BalanceLog $principal): void { $snapshot = $order->recharge_bonus_snapshot ?? null; if (is_string($snapshot)) $snapshot = json_decode($snapshot, true); if (!$snapshot || empty($snapshot['enabled']) || bccomp((string)$principal->amount, '0', 10) <= 0) return; if (!in_array($source, ['payment_order', 'recharge'], true) || (int)$principal->related_id !== (int)$order->id || !in_array($principal->change_type, ['充值', '三方充值'], true) || (string)$order->member_id !== (string)$principal->member_id) { throw new RuntimeException('充值赠送来源不匹配'); } if (DB::connection()->transactionLevel() < 1) throw new RuntimeException('充值赠送必须与本金同事务处理'); $user = User::where('member_id', $principal->member_id)->first(); if (!$user || (int)$user->from !== 1 || (string)$order->member_id !== (string)$principal->member_id) return; $amount = bcdiv(bcmul((string)$principal->amount, (string)$snapshot['bonus_percent'], 12), '100', 2); $key = $source === 'recharge' && (int)($order->type ?? 0) === 1 && !empty($order->txid) ? self::chainKey((string)$order->net, (string)$order->txid) : hash('sha256', $source . ':' . $order->id); $now = self::now(); $wallet = Wallet::where('member_id', $principal->member_id)->lockForUpdate()->first(); if (!$wallet) throw new RuntimeException('充值赠送钱包不存在'); $created = DB::table('recharge_bonus_rewards')->insertOrIgnore(['source_type' => $source, 'source_id' => $order->id, 'dedup_key' => $key, 'member_id' => $principal->member_id, 'channel_type' => $snapshot['channel_type'], 'principal_log_id' => $principal->id, 'principal_amount' => $principal->amount, 'bonus_percent' => $snapshot['bonus_percent'], 'amount' => $amount, 'rule_snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => $now, 'updated_at' => $now]); if (!$created || bccomp($amount, '0', 2) <= 0) return; $before = (string)$wallet->available_balance; $after = bcadd($before, $amount, 10); $wallet->available_balance = $after; $wallet->save(); $reward = DB::table('recharge_bonus_rewards')->where('source_type', $source)->where('source_id', $order->id)->first(); $log = BalanceLogService::addLog($principal->member_id, $amount, $before, $after, '优惠活动', $reward->id, '充值渠道赠送'); DB::table('recharge_bonus_rewards')->where('id', $reward->id)->update(['credited_log_id' => $log->id]); } }