1.优化开奖和推送
2.新增控制连续开奖赔率
This commit is contained in:
205
app/common/library/game/StreakWinReward.php
Normal file
205
app/common/library/game/StreakWinReward.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\game;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 连胜奖励(game_config.streak_win_reward):按「连胜档位」1~10 配置赔率系数与是否大奖。
|
||||
*
|
||||
* 派彩:total_amount × odds_factor(与后台「连胜奖励」表一致,不再额外 ×33)。
|
||||
*/
|
||||
final class StreakWinReward
|
||||
{
|
||||
public const CONFIG_KEY = 'streak_win_reward';
|
||||
|
||||
/** @var list<array{streak: int, odds_factor: int, is_jackpot: bool}>|null */
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||
*/
|
||||
public static function defaultRows(): array
|
||||
{
|
||||
$out = [];
|
||||
for ($s = 1; $s <= 10; $s++) {
|
||||
$out[] = [
|
||||
'streak' => $s,
|
||||
'odds_factor' => $s,
|
||||
'is_jackpot' => $s === 10,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
*
|
||||
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||
*/
|
||||
public static function parseFromConfigValue($raw): array
|
||||
{
|
||||
if (!is_string($raw) || trim($raw) === '') {
|
||||
return self::defaultRows();
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return self::defaultRows();
|
||||
}
|
||||
$list = $decoded['rows'] ?? $decoded;
|
||||
if (!is_array($list)) {
|
||||
return self::defaultRows();
|
||||
}
|
||||
$byStreak = [];
|
||||
foreach ($list as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$streak = isset($row['streak']) && is_numeric($row['streak']) ? (int) $row['streak'] : 0;
|
||||
if ($streak < 1 || $streak > 10) {
|
||||
continue;
|
||||
}
|
||||
$factor = isset($row['odds_factor']) && is_numeric($row['odds_factor']) ? (int) $row['odds_factor'] : $streak;
|
||||
if ($factor < 1) {
|
||||
$factor = 1;
|
||||
}
|
||||
$jack = !empty($row['is_jackpot']);
|
||||
$byStreak[$streak] = [
|
||||
'streak' => $streak,
|
||||
'odds_factor' => $factor,
|
||||
'is_jackpot' => $jack,
|
||||
];
|
||||
}
|
||||
$out = [];
|
||||
for ($s = 1; $s <= 10; $s++) {
|
||||
$out[] = $byStreak[$s] ?? [
|
||||
'streak' => $s,
|
||||
'odds_factor' => $s,
|
||||
'is_jackpot' => $s === 10,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从库加载并缓存
|
||||
*
|
||||
* @return list<array{streak: int, odds_factor: int, is_jackpot: bool}>
|
||||
*/
|
||||
public static function loadRows(): array
|
||||
{
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY)->find();
|
||||
self::$cache = self::parseFromConfigValue($row['config_value'] ?? null);
|
||||
|
||||
return self::$cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* streak_at_bet 为下注时快照(0 表示尚未连胜);档位取 min(streak_at_bet+1, 10)。
|
||||
*/
|
||||
public static function levelFromStreakAtBet(int $streakAtBet): int
|
||||
{
|
||||
$level = $streakAtBet + 1;
|
||||
if ($level < 1) {
|
||||
$level = 1;
|
||||
}
|
||||
if ($level > 10) {
|
||||
$level = 10;
|
||||
}
|
||||
|
||||
return $level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{streak: int, odds_factor: int, is_jackpot: bool}
|
||||
*/
|
||||
public static function rowForStreakAtBet(int $streakAtBet): array
|
||||
{
|
||||
$level = self::levelFromStreakAtBet($streakAtBet);
|
||||
foreach (self::loadRows() as $row) {
|
||||
if ((int) ($row['streak'] ?? 0) === $level) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'streak' => $level,
|
||||
'odds_factor' => $level,
|
||||
'is_jackpot' => $level === 10,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isJackpotForStreakAtBet(int $streakAtBet): bool
|
||||
{
|
||||
return self::rowForStreakAtBet($streakAtBet)['is_jackpot'] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回该注单适用的「赔率乘数」字符串(= 配置档位的 odds_factor),供 bcmul(total_amount, ..., 4)。
|
||||
*/
|
||||
public static function totalOddsMultiplierForStreakAtBet(int $streakAtBet): string
|
||||
{
|
||||
$factor = (int) self::rowForStreakAtBet($streakAtBet)['odds_factor'];
|
||||
if ($factor < 1) {
|
||||
$factor = 1;
|
||||
}
|
||||
|
||||
return bcadd((string) $factor, '0', 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{streak: int, odds_factor: int, is_jackpot: bool}> $rows
|
||||
*/
|
||||
public static function encodeForDb(array $rows): string
|
||||
{
|
||||
$normalized = [];
|
||||
for ($s = 1; $s <= 10; $s++) {
|
||||
$found = null;
|
||||
foreach ($rows as $r) {
|
||||
if (!is_array($r)) {
|
||||
continue;
|
||||
}
|
||||
$st = isset($r['streak']) && is_numeric($r['streak']) ? (int) $r['streak'] : 0;
|
||||
if ($st === $s) {
|
||||
$found = $r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($found === null) {
|
||||
$normalized[] = [
|
||||
'streak' => $s,
|
||||
'odds_factor' => $s,
|
||||
'is_jackpot' => $s === 10,
|
||||
];
|
||||
} else {
|
||||
$f = isset($found['odds_factor']) && is_numeric($found['odds_factor']) ? (int) $found['odds_factor'] : $s;
|
||||
if ($f < 1) {
|
||||
$f = 1;
|
||||
}
|
||||
$normalized[] = [
|
||||
'streak' => $s,
|
||||
'odds_factor' => $f,
|
||||
'is_jackpot' => !empty($found['is_jackpot']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$json = json_encode(['rows' => $normalized], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return '{"rows":[]}';
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 开奖后结算注单:写入 win_amount、status=已结算;中奖时入账并记 user_wallet_record(biz_type=payout)。
|
||||
* 连胜赔率来自 game_config.streak_win_reward;结算后更新 user.current_streak(未中奖则连胜归 0)。
|
||||
*/
|
||||
final class GameBetSettleService
|
||||
{
|
||||
private const BASE_ODDS = 33;
|
||||
|
||||
/**
|
||||
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
|
||||
*
|
||||
* @return array{jackpot_hits: list<array{user_id: int, period_no: string, total_win: string, result_number: int}>}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public static function settleBetsForDraw(int $recordId, int $resultNumber): void
|
||||
public static function settleBetsForDraw(int $recordId, int $resultNumber): array
|
||||
{
|
||||
if ($recordId <= 0 || $resultNumber < 1) {
|
||||
return;
|
||||
return ['jackpot_hits' => []];
|
||||
}
|
||||
|
||||
$now = time();
|
||||
@@ -36,12 +38,26 @@ final class GameBetSettleService
|
||||
/** @var array<int, array{period_no: string, total_win: string, balance_after: string, orders: list<array{order_no: string, win_amount: string, hit: bool}>}> */
|
||||
$aggregateByUser = [];
|
||||
|
||||
/** @var array<int, array{streak_at: int, had_win: bool}> */
|
||||
$userOutcome = [];
|
||||
|
||||
/** @var array<int, true> */
|
||||
$jackpotNotify = [];
|
||||
|
||||
foreach ($bets as $bet) {
|
||||
$betId = (int) ($bet['id'] ?? 0);
|
||||
if ($betId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
if ($userId > 0 && !isset($userOutcome[$userId])) {
|
||||
$userOutcome[$userId] = [
|
||||
'streak_at' => (int) ($bet['streak_at_bet'] ?? 0),
|
||||
'had_win' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$win = self::computeWinAmount($bet, $resultNumber);
|
||||
$jackpot = '0.0000';
|
||||
|
||||
@@ -59,10 +75,17 @@ final class GameBetSettleService
|
||||
continue;
|
||||
}
|
||||
|
||||
// 结算刚刚成功(status 1 → 2):把本单下注总额 1:1 累加到用户打码量
|
||||
self::creditUserBetFlow($bet, $now);
|
||||
|
||||
$userId = (int) ($bet['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
if (bccomp($win, '0', 4) > 0) {
|
||||
$userOutcome[$userId]['had_win'] = true;
|
||||
}
|
||||
if (bccomp($win, '0', 4) > 0 && StreakWinReward::isJackpotForStreakAtBet((int) ($bet['streak_at_bet'] ?? 0))) {
|
||||
$jackpotNotify[$userId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($userId <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -93,6 +116,23 @@ final class GameBetSettleService
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($userOutcome as $userId => $info) {
|
||||
$streakAt = (int) ($info['streak_at'] ?? 0);
|
||||
$hadWin = (bool) ($info['had_win'] ?? false);
|
||||
if ($hadWin) {
|
||||
$next = $streakAt + 1;
|
||||
if ($next > 10) {
|
||||
$next = 10;
|
||||
}
|
||||
} else {
|
||||
$next = 0;
|
||||
}
|
||||
Db::name('user')->where('id', $userId)->update([
|
||||
'current_streak' => $next,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($aggregateByUser as $userId => $agg) {
|
||||
$hitOrderCount = 0;
|
||||
foreach ($agg['orders'] as $o) {
|
||||
@@ -119,6 +159,25 @@ final class GameBetSettleService
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$jackpotHits = [];
|
||||
foreach ($jackpotNotify as $uid => $_) {
|
||||
if (!isset($aggregateByUser[$uid])) {
|
||||
continue;
|
||||
}
|
||||
$agg = $aggregateByUser[$uid];
|
||||
if (bccomp($agg['total_win'], '0', 4) <= 0) {
|
||||
continue;
|
||||
}
|
||||
$jackpotHits[] = [
|
||||
'user_id' => (int) $uid,
|
||||
'period_no' => (string) ($agg['period_no'] ?? ''),
|
||||
'total_win' => (string) $agg['total_win'],
|
||||
'result_number' => $resultNumber,
|
||||
];
|
||||
}
|
||||
|
||||
return ['jackpot_hits' => $jackpotHits];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,8 +209,9 @@ final class GameBetSettleService
|
||||
}
|
||||
Db::startTrans();
|
||||
try {
|
||||
self::settleBetsForDraw($rid, $rn);
|
||||
$out = self::settleBetsForDraw($rid, $rn);
|
||||
Db::commit();
|
||||
JackpotPushService::publishHits($out['jackpot_hits'] ?? []);
|
||||
$count++;
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
@@ -163,7 +223,7 @@ final class GameBetSettleService
|
||||
}
|
||||
|
||||
/**
|
||||
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × (连胜+1) × 33(与 GameLiveService 一致)。
|
||||
* 应付派彩:开奖号码 ∈ pick_numbers 即中奖;整笔 total_amount × odds_factor(odds_factor 来自连胜奖励表对应档位)。
|
||||
*/
|
||||
public static function computeWinAmount(array $bet, int $resultNumber): string
|
||||
{
|
||||
@@ -180,7 +240,7 @@ final class GameBetSettleService
|
||||
}
|
||||
$total = (string) ($bet['total_amount'] ?? '0');
|
||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
||||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||
|
||||
return bcmul($total, $odds, 4);
|
||||
}
|
||||
@@ -206,7 +266,6 @@ final class GameBetSettleService
|
||||
if (bccomp($flow, '0', 4) <= 0) {
|
||||
return;
|
||||
}
|
||||
// 原子加法:避免读-改-写导致的并发覆盖;$flow 已由 bcadd 归一化为纯数字字符串,不存在 SQL 注入
|
||||
Db::name('user')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
|
||||
@@ -4,13 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
use Webman\Push\Api;
|
||||
|
||||
final class GameLiveService
|
||||
{
|
||||
private const BASE_ODDS = 33;
|
||||
private const CHANNEL = 'game-live';
|
||||
private const EVENT = 'bet-updated';
|
||||
|
||||
@@ -327,6 +327,7 @@ final class GameLiveService
|
||||
$now = time();
|
||||
$payoutUntil = $now + self::PAYOUT_GRACE_SECONDS;
|
||||
|
||||
$settleOut = ['jackpot_hits' => []];
|
||||
Db::startTrans();
|
||||
try {
|
||||
Db::name('game_record')->where('id', (int) $record['id'])->update([
|
||||
@@ -337,14 +338,19 @@ final class GameLiveService
|
||||
'payout_until' => $payoutUntil,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
||||
$settleOut = GameBetSettleService::settleBetsForDraw((int) $record['id'], $finalNumber);
|
||||
Db::commit();
|
||||
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
return ['ok' => false, 'msg' => $e->getMessage()];
|
||||
}
|
||||
|
||||
try {
|
||||
GameRecordStatService::refreshForRecordId((int) $record['id']);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
JackpotPushService::publishHits($settleOut['jackpot_hits'] ?? []);
|
||||
|
||||
self::publishPublicPeriodOpened((string) $record['period_no'], $finalNumber, $now);
|
||||
self::publishPublicPeriodPayout((string) $record['period_no'], $finalNumber, $payoutUntil);
|
||||
self::publishSnapshot(null);
|
||||
@@ -690,7 +696,7 @@ final class GameLiveService
|
||||
}
|
||||
$total = (string) ($bet['total_amount'] ?? '0');
|
||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
||||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||
$orderPayout = bcmul($total, $odds, 4);
|
||||
$payout = bcadd($payout, $orderPayout, 4);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\game\StreakWinReward;
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
@@ -11,8 +12,6 @@ use support\think\Db;
|
||||
*/
|
||||
final class GameRecordStatService
|
||||
{
|
||||
private const BASE_ODDS = 33;
|
||||
|
||||
/**
|
||||
* 根据注单与开奖号码回写 game_record 统计字段(已结束对局)。
|
||||
*/
|
||||
@@ -82,7 +81,7 @@ final class GameRecordStatService
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 GameLiveService::estimateLossForNumber 中派彩一致:命中号码时 total_amount × (streak+1) × 33。
|
||||
* 与 GameLiveService::estimateLossForNumber 一致:命中号码时 total_amount × odds_factor。
|
||||
*/
|
||||
private static function estimatePayoutForBet(array $bet, int $resultNumber): string
|
||||
{
|
||||
@@ -99,7 +98,7 @@ final class GameRecordStatService
|
||||
}
|
||||
$total = (string) ($bet['total_amount'] ?? '0');
|
||||
$streak = (int) ($bet['streak_at_bet'] ?? 0);
|
||||
$odds = (string) (($streak + 1) * self::BASE_ODDS);
|
||||
$odds = StreakWinReward::totalOddsMultiplierForStreakAtBet($streak);
|
||||
|
||||
return bcmul($total, $odds, 4);
|
||||
}
|
||||
|
||||
72
app/common/service/JackpotPushService.php
Normal file
72
app/common/service/JackpotPushService.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use Throwable;
|
||||
use Webman\Push\Api;
|
||||
|
||||
/**
|
||||
* 大奖派彩:玩家私有频道 + 公共频道(对局频道 + 公告频道,便于大厅与公告测试页均能收到)
|
||||
*/
|
||||
final class JackpotPushService
|
||||
{
|
||||
private const CHANNEL_GAME_PERIOD = 'public-game-period';
|
||||
|
||||
private const CHANNEL_OPERATION_NOTICE = 'public-operation-notice';
|
||||
|
||||
private const EVT_JACKPOT_HIT = 'jackpot.hit';
|
||||
|
||||
/**
|
||||
* @param list<array{user_id: int, period_no: string, total_win: string, result_number: int}> $hits
|
||||
*/
|
||||
public static function publishHits(array $hits): void
|
||||
{
|
||||
foreach ($hits as $h) {
|
||||
$uid = (int) ($h['user_id'] ?? 0);
|
||||
if ($uid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$periodNo = (string) ($h['period_no'] ?? '');
|
||||
$totalWin = (string) ($h['total_win'] ?? '0');
|
||||
$rn = (int) ($h['result_number'] ?? 0);
|
||||
UserPushService::publish($uid, UserPushService::EVT_JACKPOT_HIT, [
|
||||
'period_no' => $periodNo,
|
||||
'total_win_amount' => $totalWin,
|
||||
'result_number' => $rn,
|
||||
'is_jackpot' => true,
|
||||
]);
|
||||
self::publishPublicChannels($periodNo, $uid, $totalWin, $rn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private static function triggerChannel(Api $api, string $channel, array $payload): void
|
||||
{
|
||||
$api->trigger($channel, self::EVT_JACKPOT_HIT, $payload);
|
||||
}
|
||||
|
||||
private static function publishPublicChannels(string $periodNo, int $userId, string $totalWin, int $resultNumber): void
|
||||
{
|
||||
try {
|
||||
$api = new Api(
|
||||
str_replace('0.0.0.0', '127.0.0.1', (string) config('plugin.webman.push.app.api')),
|
||||
(string) config('plugin.webman.push.app.app_key'),
|
||||
(string) config('plugin.webman.push.app.app_secret')
|
||||
);
|
||||
$payload = [
|
||||
'period_no' => $periodNo,
|
||||
'user_id' => $userId,
|
||||
'total_win_amount' => $totalWin,
|
||||
'result_number' => $resultNumber,
|
||||
'message' => '恭喜玩家命中大奖派彩',
|
||||
];
|
||||
self::triggerChannel($api, self::CHANNEL_GAME_PERIOD, $payload);
|
||||
self::triggerChannel($api, self::CHANNEL_OPERATION_NOTICE, $payload);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ final class UserPushService
|
||||
|
||||
public const EVT_WALLET_CHANGED = 'wallet.changed';
|
||||
|
||||
/** 命中配置为「大奖」的连胜档派彩(私有频道) */
|
||||
public const EVT_JACKPOT_HIT = 'jackpot.hit';
|
||||
|
||||
private static function channelName(string $uuid): string
|
||||
{
|
||||
return 'private-user-' . $uuid;
|
||||
|
||||
Reference in New Issue
Block a user