[接口]新增抽奖接口/api/game/playStart

This commit is contained in:
2026-03-04 17:06:30 +08:00
parent 21c638a231
commit 0a3af2d422
6 changed files with 428 additions and 4 deletions

View File

@@ -7,9 +7,13 @@ use support\Request;
use support\Response;
use app\api\logic\UserLogic;
use app\api\logic\GameLogic;
use app\api\logic\PlayStartLogic;
use app\api\util\ReturnCode;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\basic\OpenController;
use plugin\saiadmin\exception\ApiException;
/**
* 游戏相关接口(购买抽奖券等)
@@ -65,4 +69,76 @@ class GameController extends OpenController
$list = DiceRewardConfig::order('id', 'asc')->select()->toArray();
return $this->success($list);
}
/**
* 开始游戏(抽奖一局)
* POST /api/game/playStart
* header: auth-token, user-token
* body: rediction 必传0=无 1=中奖
* 余额不足时返回 code=200、message=玩家当前余额不足无法开启对局;超时返回 code=200、message=服务超时,并记录 status=0
*/
public function playStart(Request $request): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
return $this->fail('请携带 user-token', ReturnCode::MISSING_TOKEN);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
return $this->fail('user-token 无效或已过期', ReturnCode::TOKEN_TIMEOUT);
}
$rediction = $request->post('rediction');
if ($rediction === '' || $rediction === null) {
return $this->fail('请传递 rediction 参数', ReturnCode::EMPTY_PARAMS);
}
$direction = (int) $rediction;
if (!in_array($direction, [0, 1], true)) {
return $this->fail('rediction 必须为 0 或 1', ReturnCode::EMPTY_PARAMS);
}
$player = DicePlayer::find($userId);
if (!$player) {
return $this->fail('用户不存在', ReturnCode::EMPTY_PARAMS);
}
$minEv = (float) DiceRewardConfig::min('real_ev');
$minCoin = abs($minEv + 100);
$coin = (float) $player->coin;
if ($coin < $minCoin) {
return $this->success([], '当前玩家余额小于DiceRewardConfigMin.real_ev+100无法继续游戏');
}
try {
$logic = new PlayStartLogic();
$data = $logic->run($userId, $direction);
return $this->success($data);
} catch (ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::EMPTY_PARAMS);
} catch (\Throwable $e) {
$timeoutRecord = null;
try {
$timeoutRecord = DicePlayRecord::create([
'player_id' => $userId,
'lottery_config_id' => 0,
'lottery_type' => 0,
'win_coin' => 0,
'direction' => $direction,
'reward_config_id' => 0,
'start_index' => 0,
'target_index' => 0,
'roll_array' => '[]',
'status' => PlayStartLogic::RECORD_STATUS_TIMEOUT,
]);
} catch (\Throwable $_) {
}
$payload = $timeoutRecord ? ['record' => $timeoutRecord->toArray()] : [];
return $this->success($payload, '服务超时');
}
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace app\api\logic;
use app\api\cache\UserCache;
use app\api\service\LotteryService;
use app\dice\model\lottery_config\DiceLotteryConfig;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\exception\ApiException;
use support\think\Cache;
use support\think\Db;
/**
* 开始游戏 / 抽奖一局
*/
class PlayStartLogic
{
/** 抽奖类型:付费 */
public const LOTTERY_TYPE_PAID = 0;
/** 抽奖类型:免费 */
public const LOTTERY_TYPE_FREE = 1;
/** 钱包流水类型:抽奖 */
public const WALLET_TYPE_DRAW = 5;
/** 对局状态:成功 */
public const RECORD_STATUS_SUCCESS = 1;
/** 对局状态:超时/失败 */
public const RECORD_STATUS_TIMEOUT = 0;
/** 开启对局最低余额 = |DiceRewardConfig 最小 real_ev + 100| */
private const MIN_COIN_EXTRA = 100;
/**
* 执行一局游戏
* @param int $playerId 玩家ID
* @param int $direction 方向 0=无/顺时针 1=中奖/逆时针(前端 rediction
* @return array 成功返回 DicePlayRecord 数据;余额不足时抛 ApiExceptionmessage 为约定文案
*/
public function run(int $playerId, int $direction): array
{
$player = DicePlayer::find($playerId);
if (!$player) {
throw new ApiException('用户不存在');
}
$minEv = (float) DiceRewardConfig::min('real_ev');
$minCoin = abs($minEv + self::MIN_COIN_EXTRA);
$coin = (float) $player->coin;
if ($coin < $minCoin) {
throw new ApiException('当前玩家余额小于DiceRewardConfigMin.real_ev+100无法继续游戏');
}
$paid = (int) ($player->paid_draw_count ?? 0);
$free = (int) ($player->free_draw_count ?? 0);
if ($paid + $free <= 0) {
throw new ApiException('抽奖券不足');
}
$lotteryService = LotteryService::getOrCreate($playerId);
$ticketType = LotteryService::drawTicketType($paid, $free);
$config = $ticketType === self::LOTTERY_TYPE_PAID
? ($lotteryService->getConfigType0Id() ? DiceLotteryConfig::find($lotteryService->getConfigType0Id()) : null)
: ($lotteryService->getConfigType1Id() ? DiceLotteryConfig::find($lotteryService->getConfigType1Id()) : null);
if (!$config) {
throw new ApiException('奖池配置不存在');
}
$tier = LotteryService::drawTierByWeights($config);
$rewards = DiceRewardConfig::where('tier', $tier)->select();
if ($rewards->isEmpty()) {
throw new ApiException('该档位暂无奖励配置');
}
$rewardList = $rewards->all();
$reward = $rewardList[array_rand($rewardList)];
$realEv = (float) $reward->real_ev;
$winCoin = 100 + $realEv; // 赢取平台币 = 100 + DiceRewardConfig.real_ev
$gridNumber = (int) $reward->grid_number;
$startIndex = (int) Cache::get(LotteryService::getStartIndexKey($playerId), 0);
$targetIndex = (int) $reward->id;
$rollArray = $this->generateRollArray($gridNumber);
$record = null;
$configId = (int) $config->id;
$rewardId = (int) $reward->id;
$configName = (string) ($config->name ?? '');
try {
Db::transaction(function () use (
$playerId,
$configId,
$rewardId,
$configName,
$ticketType,
$winCoin,
$realEv,
$direction,
$startIndex,
$targetIndex,
$rollArray,
&$record
) {
$record = DicePlayRecord::create([
'player_id' => $playerId,
'lottery_config_id' => $configId,
'lottery_type' => $ticketType,
'win_coin' => $winCoin,
'direction' => $direction,
'reward_config_id' => $rewardId,
'start_index' => $startIndex,
'target_index' => $targetIndex,
'roll_array' => is_array($rollArray) ? json_encode($rollArray) : $rollArray,
'lottery_name' => $configName,
'status' => self::RECORD_STATUS_SUCCESS,
]);
$p = DicePlayer::find($playerId);
if (!$p) {
throw new \RuntimeException('玩家不存在');
}
$coinBefore = (float) $p->coin;
$coinAfter = $coinBefore + $winCoin;
$p->coin = $coinAfter;
$p->total_draw_count = max(0, (int) $p->total_draw_count - 1);
if ($ticketType === self::LOTTERY_TYPE_PAID) {
$p->paid_draw_count = max(0, (int) $p->paid_draw_count - 1);
} else {
$p->free_draw_count = max(0, (int) $p->free_draw_count - 1);
}
$p->save();
// 累加彩金池盈利额度(累加值为 -real_ev。若 dice_lottery_config 表有 ev 字段则执行
try {
DiceLotteryConfig::where('id', $configId)->update([
'ev' => Db::raw('IFNULL(ev,0) - ' . (float) $realEv),
]);
} catch (\Throwable $_) {
}
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'coin' => $winCoin,
'type' => self::WALLET_TYPE_DRAW,
'wallet_before' => $coinBefore,
'wallet_after' => $coinAfter,
'remark' => '抽奖|play_record_id=' . $record->id,
]);
Cache::set(LotteryService::getStartIndexKey($playerId), $targetIndex, 86400 * 30);
});
} catch (\Throwable $e) {
if ($record === null) {
try {
$record = DicePlayRecord::create([
'player_id' => $playerId,
'lottery_config_id' => $configId ?? 0,
'lottery_type' => $ticketType,
'win_coin' => 0,
'direction' => $direction,
'reward_config_id' => 0,
'start_index' => $startIndex,
'target_index' => 0,
'roll_array' => '[]',
'status' => self::RECORD_STATUS_TIMEOUT,
]);
} catch (\Throwable $_) {
// 表可能无 status 字段时忽略
}
}
throw $e;
}
$updated = DicePlayer::find($playerId);
if ($updated) {
UserCache::setUser($playerId, $updated->hidden(['password'])->toArray());
}
if (!$record instanceof DicePlayRecord) {
throw new \RuntimeException('对局记录创建失败');
}
$arr = $record->toArray();
if (isset($arr['roll_array']) && is_string($arr['roll_array'])) {
$arr['roll_array'] = json_decode($arr['roll_array'], true) ?? [];
}
return $arr;
}
/** 生成 5 个 1-6 的点数,和为 grid_number5~30严格不超范围 */
private function generateRollArray(int $gridNumber): array
{
$minSum = 5;
$maxSum = 30;
$n = max($minSum, min($maxSum, $gridNumber));
$dice = [1, 1, 1, 1, 1];
$remain = $n - 5;
while ($remain > 0) {
$i = array_rand($dice);
if ($dice[$i] < 6) {
$add = min($remain, 6 - $dice[$i]);
$dice[$i] += $add;
$remain -= $add;
}
}
shuffle($dice);
return $dice;
}
}

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace app\api\service;
use app\dice\model\lottery_config\DiceLotteryConfig;
use app\dice\model\player\DicePlayer;
use support\think\Cache;
/**
* 彩金池实例,按玩家权重与奖池配置创建,存 Redis 便于增删改查
*/
class LotteryService
{
private const REDIS_KEY_PREFIX = 'api:game:lottery_pool:';
private const REDIS_KEY_START_INDEX = 'api:game:start_index:';
private const EXPIRE = 86400 * 7; // 7天
private int $playerId;
private ?int $configType0Id = null;
private ?int $configType1Id = null;
/** @var array{t1_wight?:int,t2_wight?:int,t3_wight?:int,t4_wight?:int,t5_wight?:int} */
private array $playerWeights = [];
public function __construct(int $playerId)
{
$this->playerId = $playerId;
}
public static function getRedisKey(int $playerId): string
{
return self::REDIS_KEY_PREFIX . $playerId;
}
public static function getStartIndexKey(int $playerId): string
{
return self::REDIS_KEY_START_INDEX . $playerId;
}
/** 从 Redis 加载或根据玩家与 DiceLotteryConfig 创建并保存 */
public static function getOrCreate(int $playerId): self
{
$key = self::getRedisKey($playerId);
$cached = Cache::get($key);
if ($cached && is_string($cached)) {
$data = json_decode($cached, true);
if (is_array($data)) {
$s = new self($playerId);
$s->configType0Id = (int) ($data['config_type_0_id'] ?? 0);
$s->configType1Id = (int) ($data['config_type_1_id'] ?? 0);
$s->playerWeights = $data['player_weights'] ?? [];
return $s;
}
}
$player = DicePlayer::find($playerId);
if (!$player) {
throw new \RuntimeException('玩家不存在');
}
$config0 = DiceLotteryConfig::where('type', 0)->find();
$config1 = DiceLotteryConfig::where('type', 1)->find();
$s = new self($playerId);
$s->configType0Id = $config0 ? (int) $config0->id : null;
$s->configType1Id = $config1 ? (int) $config1->id : null;
$s->playerWeights = [
't1_wight' => (int) ($player->t1_wight ?? 0),
't2_wight' => (int) ($player->t2_wight ?? 0),
't3_wight' => (int) ($player->t3_wight ?? 0),
't4_wight' => (int) ($player->t4_wight ?? 0),
't5_wight' => (int) ($player->t5_wight ?? 0),
];
$s->save();
return $s;
}
public function save(): void
{
$key = self::getRedisKey($this->playerId);
$data = [
'config_type_0_id' => $this->configType0Id,
'config_type_1_id' => $this->configType1Id,
'player_weights' => $this->playerWeights,
];
Cache::set($key, json_encode($data), self::EXPIRE);
}
/** 根据奖池配置的 t1_wight..t5_wight 权重随机抽取档位 T1-T5 */
public static function drawTierByWeights(DiceLotteryConfig $config): string
{
$tiers = ['T1', 'T2', 'T3', 'T4', 'T5'];
$weights = [
(int) ($config->t1_wight ?? 0),
(int) ($config->t2_wight ?? 0),
(int) ($config->t3_wight ?? 0),
(int) ($config->t4_wight ?? 0),
(int) ($config->t5_wight ?? 0),
];
$total = array_sum($weights);
if ($total <= 0) {
return $tiers[array_rand($tiers)];
}
$r = mt_rand(1, $total);
$acc = 0;
foreach ($weights as $i => $w) {
$acc += $w;
if ($r <= $acc) {
return $tiers[$i];
}
}
return $tiers[4];
}
/** 按 paid_draw_count 与 free_draw_count 权重随机抽取 0=付费 1=免费 */
public static function drawTicketType(int $paid, int $free): int
{
if ($paid <= 0 && $free <= 0) {
throw new \RuntimeException('抽奖券不足');
}
if ($paid <= 0) {
return 1;
}
if ($free <= 0) {
return 0;
}
$total = $paid + $free;
$r = mt_rand(1, $total);
return $r <= $paid ? 0 : 1;
}
public function getConfigType0Id(): ?int
{
return $this->configType0Id;
}
public function getConfigType1Id(): ?int
{
return $this->configType1Id;
}
}

View File

@@ -30,6 +30,7 @@ use think\model\relation\BelongsTo;
* @property $target_index 结束索引
* @property $roll_array 摇取点数,格式:[1,2,3,4,5]5个点数
* @property $lottery_name 奖池名
* @property $status 状态:0=超时/失败 1=成功
* @property $create_time 创建时间
* @property $update_time 修改时间
*/