1.优化实时对局页面样式以及自动创建下一局和作废本局的记录

2.新增派彩达到game_config.jackpot_max_amount必须审核才能发放
3.新增游戏对局记录-查看游玩记录btn
3.备份MySQL数据库
This commit is contained in:
2026-04-28 18:25:44 +08:00
parent 4ea83d2818
commit 687257adaa
22 changed files with 945 additions and 208 deletions

View File

@@ -139,7 +139,7 @@ class Live extends Backend
}
$raw = $request->post('enabled');
$enabled = $raw === true || $raw === '1' || $raw === 1;
GameRecordService::setLiveRuntimeEnabled($enabled);
GameRecordService::setAutoCreateEnabled($enabled);
if ($enabled) {
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
}
@@ -168,6 +168,15 @@ class Live extends Backend
return $this->error(is_string($errMsg) ? $errMsg : __('Void failed'));
}
$okMsg = $res['msg'] ?? '';
return $this->success(is_string($okMsg) ? $okMsg : '', GameLiveService::buildSnapshot(null));
$snapshot = GameLiveService::buildSnapshot(null);
// 作废本局后:必须关闭自动创建下一局开关(允许管理员后续手动重新开启)
$snapshot['runtime_enabled'] = false;
// 作废后一般不存在进行中的局,直接进入维护态(用于前端展示“维护中”倒计时)
$snapshot['maintenance_ui'] = true;
$refund = $res['refund'] ?? null;
if (is_array($refund)) {
$snapshot['void_refund'] = $refund;
}
return $this->success(is_string($okMsg) ? $okMsg : '', $snapshot);
}
}

View File

@@ -3,6 +3,8 @@
namespace app\admin\controller\game;
use app\common\controller\Backend;
use app\common\service\GameBetSettleService;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
@@ -78,26 +80,191 @@ class PlayRecord extends Backend
$where[] = ['user.admin_id', 'in', $this->scopedAdminIds()];
}
$res = $this->model
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->visible([
'user' => ['username', 'phone'],
'channel' => ['name'],
'gameRecord' => ['period_no', 'status'],
// 避免 ThinkORM withJoin 对 game_record 的字段缓存导致 select 出已删除列(如 preset_number
// 这里改为手写 join + 明确 field 列表,保证数据库字段变更后不受 schema 缓存影响。
$query = Db::name($table)->alias($mainShort !== '' ? $mainShort : 'play_record')
->leftJoin('user user', 'user.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.user_id')
->leftJoin('channel channel', 'channel.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.channel_id')
->leftJoin('game_record game_record', 'game_record.id = ' . ($mainShort !== '' ? $mainShort : 'play_record') . '.period_id')
->where($where);
$res = $query
->field([
($mainShort !== '' ? $mainShort : 'play_record') . '.*',
'user.username as user__username',
'user.phone as user__phone',
'channel.name as channel__name',
'game_record.period_no as gameRecord__period_no',
'game_record.status as gameRecord__status',
])
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = $res->items();
$total = $res->total();
// 将 join 扁平字段还原为原页面所需结构user/channel/gameRecord
foreach ($list as $idx => $row) {
if (!is_array($row)) {
continue;
}
$row['user'] = [
'username' => isset($row['user__username']) ? (string) $row['user__username'] : '',
'phone' => isset($row['user__phone']) ? (string) $row['user__phone'] : '',
];
$row['channel'] = [
'name' => isset($row['channel__name']) ? (string) $row['channel__name'] : '',
];
$row['gameRecord'] = [
'period_no' => isset($row['gameRecord__period_no']) ? (string) $row['gameRecord__period_no'] : '',
'status' => isset($row['gameRecord__status']) && is_numeric((string) $row['gameRecord__status'])
? (int) $row['gameRecord__status']
: null,
];
unset(
$row['user__username'],
$row['user__phone'],
$row['channel__name'],
$row['gameRecord__period_no'],
$row['gameRecord__status'],
);
$list[$idx] = $row;
}
$threshold = $this->jackpotMaxAmount();
foreach ($list as $idx => $row) {
if (!is_array($row)) {
continue;
}
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
$win = bcadd(strval($row['win_amount'] ?? '0'), '0', 2);
$needReview = bccomp($threshold, '0', 2) > 0 && bccomp($win, $threshold, 2) >= 0;
$canApprove = $needReview && $status === GameBetSettleService::PLAY_STATUS_PENDING_REVIEW;
$row['jackpot_need_review'] = $needReview ? 1 : 0;
$row['can_jackpot_approve'] = $canApprove ? 1 : 0;
$list[$idx] = $row;
}
return $this->success('', [
'list' => $res->items(),
'total' => $res->total(),
'list' => $list,
'total' => $total,
'remark' => get_route_remark(),
]);
}
/**
* 大奖审核通过并派彩(仅 win_amount >= game_config.jackpot_max_amount 且 status=待审核 才可操作)
*/
public function approveJackpot(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$idRaw = $request->post('id');
if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) {
return $this->error(__('Parameter error'));
}
$id = (int) $idRaw;
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
// 权限范围校验:复用列表逻辑(非超管只能操作其下辖用户)
if ($this->auth && !$this->auth->isSuperAdmin()) {
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
if ($uid <= 0) {
return $this->error(__('Record not found'));
}
$ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id');
$aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId;
if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) {
return $this->error(__('You have no permission'));
}
}
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
Db::startTrans();
try {
$result = GameBetSettleService::approveJackpotPlayRecord($id, $adminId, $remark);
if (($result['ok'] ?? false) !== true) {
Db::rollback();
$msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error');
return $this->error($msg);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Approved'));
}
/**
* 大奖审核拒绝remark 必填)
*/
public function rejectJackpot(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$idRaw = $request->post('id');
if ($idRaw === null || $idRaw === '' || !is_numeric(strval($idRaw))) {
return $this->error(__('Parameter error'));
}
$id = (int) $idRaw;
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
if ($remark === '') {
return $this->error(__('Please provide reject reason'));
}
if ($this->auth && !$this->auth->isSuperAdmin()) {
$uidRaw = Db::name('game_play_record')->where('id', $id)->value('user_id');
$uid = ($uidRaw === null || $uidRaw === '' || !is_numeric(strval($uidRaw))) ? 0 : (int) $uidRaw;
if ($uid <= 0) {
return $this->error(__('Record not found'));
}
$ownerAdminId = Db::name('user')->where('id', $uid)->value('admin_id');
$aid = ($ownerAdminId === null || $ownerAdminId === '' || !is_numeric(strval($ownerAdminId))) ? 0 : (int) $ownerAdminId;
if ($aid <= 0 || !in_array($aid, $this->scopedAdminIds(), true)) {
return $this->error(__('You have no permission'));
}
}
$adminId = $this->auth ? (int) ($this->auth->id ?? 0) : 0;
Db::startTrans();
try {
$result = GameBetSettleService::rejectJackpotPlayRecord($id, $adminId, $remark);
if (($result['ok'] ?? false) !== true) {
Db::rollback();
$msg = is_string($result['msg'] ?? null) ? $result['msg'] : __('Parameter error');
return $this->error($msg);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Rejected'));
}
/**
* @return int[]
*/
@@ -116,5 +283,26 @@ class PlayRecord extends Backend
$adminIds = array_values(array_unique(array_filter($adminIds, static fn($id) => $id > 0)));
return $adminIds === [] ? [0] : $adminIds;
}
private function jackpotMaxAmount(): string
{
$row = Db::name('game_config')->where('config_key', GameBetSettleService::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
if (!is_array($row)) {
return '0.00';
}
$raw = $row['config_value'] ?? null;
if ($raw === null || $raw === '') {
return '0.00';
}
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
if ($v === '' || !is_numeric($v)) {
return '0.00';
}
$normalized = bcadd($v, '0', 2);
if (bccomp($normalized, '0', 2) <= 0) {
return '0.00';
}
return $normalized;
}
}

View File

@@ -32,6 +32,44 @@ class Record extends Backend
return null;
}
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
$res = $this->model
->field($this->indexField)
->withJoin($this->withJoinTable, $this->withJoinType)
->with($this->withJoinTable)
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$list = $res->items();
foreach ($list as $idx => $row) {
if (!is_array($row)) {
continue;
}
$status = isset($row['status']) && is_numeric((string) $row['status']) ? (int) $row['status'] : 0;
$reason = isset($row['void_reason']) && is_string($row['void_reason']) ? $row['void_reason'] : '';
// 将“系统自愈作废”的对局在列表中标记为【异常】(展示态,不改库中 status=5 的事实)
if ($status === 5 && $reason !== '' && str_starts_with($reason, 'system_recover:')) {
$row['status'] = 6;
$list[$idx] = $row;
}
}
return $this->success('', [
'list' => $list,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
public function add(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
@@ -86,24 +124,95 @@ class Record extends Backend
$rows = Db::name('game_record')
->where('status', 5)
// 仅展示服务重启自愈导致的异常作废,排除管理员手动作废
->whereLike('void_reason', 'system_recover:%')
->field(['id', 'period_no', 'void_reason', 'update_time'])
->order('id', 'desc')
->limit($limit)
->select()
->toArray();
$periodIds = [];
foreach ($rows as $row) {
$pid = (int) ($row['id'] ?? 0);
if ($pid > 0) {
$periodIds[] = $pid;
}
}
$refundAggByPeriod = [];
$refundIdsByPeriod = [];
if ($periodIds !== []) {
$aggRows = Db::name('bet_order')
->whereIn('period_id', $periodIds)
->where('status', 3)
->fieldRaw('period_id as pid, COUNT(*) as cnt, COUNT(DISTINCT user_id) as users, COALESCE(SUM(total_amount), 0) as amt')
->group('period_id')
->select()
->toArray();
foreach ($aggRows as $a) {
$pid = (int) ($a['pid'] ?? 0);
if ($pid <= 0) {
continue;
}
$refundAggByPeriod[$pid] = [
'orders' => (int) ($a['cnt'] ?? 0),
'users' => (int) ($a['users'] ?? 0),
'amount' => (string) ($a['amt'] ?? '0.00'),
];
}
$idRows = Db::name('bet_order')
->whereIn('period_id', $periodIds)
->where('status', 3)
->field(['period_id', 'id'])
->order('id', 'desc')
->limit(2000)
->select()
->toArray();
foreach ($idRows as $r) {
$pid = (int) ($r['period_id'] ?? 0);
$bid = (int) ($r['id'] ?? 0);
if ($pid <= 0 || $bid <= 0) {
continue;
}
if (!isset($refundIdsByPeriod[$pid])) {
$refundIdsByPeriod[$pid] = [];
}
if (count($refundIdsByPeriod[$pid]) < 50) {
$refundIdsByPeriod[$pid][] = $bid;
}
}
}
$list = [];
foreach ($rows as $row) {
$meta = $this->parseRecoverVoidReason(is_string($row['void_reason'] ?? null) ? $row['void_reason'] : '');
$reason = is_string($row['void_reason'] ?? null) ? $row['void_reason'] : '';
$isAutoRecover = $this->isSystemRecoverReason($reason);
$pid = (int) ($row['id'] ?? 0);
$agg = $refundAggByPeriod[$pid] ?? null;
$users = (int) ($meta['users'] ?? 0);
$orders = (int) ($meta['orders'] ?? 0);
$amount = is_string($meta['amount'] ?? null) ? $meta['amount'] : '0.00';
if (is_array($agg)) {
if ($orders <= 0 && ($agg['orders'] ?? 0) > 0) {
$orders = (int) ($agg['orders'] ?? 0);
}
if ($users <= 0 && ($agg['users'] ?? 0) > 0) {
$users = (int) ($agg['users'] ?? 0);
}
if (bccomp($amount, '0', 2) <= 0 && is_string($agg['amount'] ?? null)) {
$amount = (string) $agg['amount'];
}
}
$list[] = [
'id' => (int) ($row['id'] ?? 0),
'period_no' => (string) ($row['period_no'] ?? ''),
'abnormal_from_status' => $meta['from_status'],
'refunded_user_count' => $meta['users'],
'refunded_order_count' => $meta['orders'],
'refunded_total_amount' => $meta['amount'],
'refunded_user_count' => $users,
'refunded_order_count' => $orders,
'refunded_total_amount' => $amount,
'refunded_order_ids' => $refundIdsByPeriod[$pid] ?? [],
'recovered_at' => (int) ($row['update_time'] ?? 0),
'void_reason' => $reason,
'is_auto_recover' => $isAutoRecover ? 1 : 0,
@@ -116,6 +225,77 @@ class Record extends Backend
]);
}
/**
* 某期对局的游玩(压注)记录列表(用于对局记录页弹窗查看)。
*/
public function playRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$periodIdRaw = $request->get('period_id');
if (!is_numeric((string) $periodIdRaw)) {
return $this->error(__('Parameter error'));
}
$periodId = (int) $periodIdRaw;
if ($periodId <= 0) {
return $this->error(__('Parameter error'));
}
$pageRaw = $request->get('page', 1);
$page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1;
if ($page < 1) {
$page = 1;
}
$limitRaw = $request->get('limit', 30);
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30;
if ($limit < 1) {
$limit = 1;
}
if ($limit > 200) {
$limit = 200;
}
$query = Db::name('game_play_record')
->alias('pr')
->leftJoin('user u', 'u.id = pr.user_id')
->leftJoin('channel c', 'c.id = pr.channel_id')
->where('pr.period_id', $periodId);
$total = (int) $query->count('pr.id');
$list = $query
->field([
'pr.id',
'pr.period_id',
'pr.user_id',
'pr.channel_id',
'pr.pick_numbers',
'pr.total_amount',
'pr.streak_at_bet',
'pr.is_auto',
'pr.win_amount',
'pr.jackpot_extra_amount',
'pr.status',
'pr.idempotency_key',
'pr.create_time',
'pr.update_time',
'u.username as user_username',
'c.name as channel_name',
])
->order('pr.id', 'desc')
->page($page, $limit)
->select()
->toArray();
return $this->success('', [
'list' => $list,
'total' => $total,
]);
}
/**
* @return array{from_status:int,users:int,orders:int,amount:string}
*/

View File

@@ -49,7 +49,7 @@ class Game extends MobileBase
$user = $this->auth->getUser();
return $this->mobileSuccess([
'server_time' => $now,
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
'period' => [
'period_no' => (string) ($periodRow['period_no'] ?? ''),
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
@@ -107,7 +107,7 @@ class Game extends MobileBase
$now = time();
$startAt = $this->intValue($periodRow['period_start_at'] ?? 0);
return $this->mobileSuccess([
'runtime_enabled' => GameRecordService::isLiveRuntimeEnabled(),
'runtime_enabled' => GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE),
'period_id' => $periodRow['id'],
'period_no' => $periodRow['period_no'],
'status' => $this->mapPeriodStatus($periodRow['status'] ?? null),
@@ -169,7 +169,7 @@ class Game extends MobileBase
$numberCount = (string) count($numbers);
$totalAmount = bcmul($singleAmount, $numberCount, 2);
if (!GameRecordService::isLiveRuntimeEnabled()) {
if (!GameRecordService::getConfigBool(GameRecordService::KEY_AUTO_CREATE)) {
return $this->mobileError(3001, 'Game is paused');
}
$period = GameRecord::where('period_no', $periodNo)->find();

View File

@@ -16,7 +16,6 @@ class GamePeriod extends Model
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
];

View File

@@ -17,7 +17,6 @@ class GameRecord extends Model
'period_start_at' => 'integer',
'status' => 'integer',
'draw_mode' => 'integer',
'preset_number' => 'integer',
'result_number' => 'integer',
'ai_locked_number' => 'integer',
'pending_draw_number' => 'integer',

View File

@@ -14,6 +14,14 @@ use Throwable;
*/
final class GameBetSettleService
{
public const PLAY_STATUS_PENDING_DRAW = 1;
public const PLAY_STATUS_SETTLED = 2;
public const PLAY_STATUS_REFUNDED = 3;
public const PLAY_STATUS_RETURNED = 4;
public const PLAY_STATUS_PENDING_REVIEW = 5;
public const CONFIG_KEY_JACKPOT_MAX_AMOUNT = 'jackpot_max_amount';
/**
* 对指定期次按开奖号码结算所有「待开奖」注单;同一注单幂等(仅 status=1 会更新)。
*
@@ -28,9 +36,10 @@ final class GameBetSettleService
}
$now = time();
$jackpotMaxAmount = self::jackpotMaxAmount();
$bets = Db::name('bet_order')
->where('period_id', $recordId)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->order('id', 'asc')
->select()
->toArray();
@@ -60,14 +69,16 @@ final class GameBetSettleService
$win = self::computeWinAmount($bet, $resultNumber);
$jackpot = '0.00';
$needReview = self::shouldRequireJackpotReview($win, $jackpotMaxAmount);
$nextStatus = $needReview ? self::PLAY_STATUS_PENDING_REVIEW : self::PLAY_STATUS_SETTLED;
$affected = Db::name('bet_order')
->where('id', $betId)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->update([
'win_amount' => $win,
'jackpot_extra_amount' => $jackpot,
'status' => 2,
'status' => $nextStatus,
'update_time' => $now,
]);
@@ -91,8 +102,8 @@ final class GameBetSettleService
}
$balanceAfter = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
if (bccomp($win, '0', 2) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now);
if (!$needReview && bccomp($win, '0', 2) > 0) {
$paid = self::creditUserPayout($bet, $betId, $win, $now, null, '压注派彩');
if ($paid !== null) {
$balanceAfter = $paid;
}
@@ -154,6 +165,109 @@ final class GameBetSettleService
return ['jackpot_hits' => $jackpotHits];
}
/**
* 大奖审核通过后派彩(幂等):仅当 play_record.status=待审核 且 win_amount>=阈值时执行。
*
* @return array{ok: bool, msg: string, balance_after?: string}
*/
public static function approveJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark = ''): array
{
if ($playRecordId <= 0) {
return ['ok' => false, 'msg' => __('Parameter error')];
}
// 兼容bet_order 可能是 VIEW且 * 列表会固化;审核字段始终以 game_play_record 为准
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
if (!is_array($row)) {
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
}
if (!is_array($row)) {
return ['ok' => false, 'msg' => __('Record not found')];
}
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
return ['ok' => false, 'msg' => __('This record does not require review')];
}
$winAmount = bcadd((string) ($row['win_amount'] ?? '0'), '0', 2);
$threshold = self::jackpotMaxAmount();
if (!self::shouldRequireJackpotReview($winAmount, $threshold)) {
return ['ok' => false, 'msg' => __('This record does not meet jackpot review threshold')];
}
$userId = isset($row['user_id']) && is_numeric($row['user_id']) ? (int) $row['user_id'] : 0;
if ($userId <= 0) {
return ['ok' => false, 'msg' => __('Order is missing user info')];
}
$now = time();
$balanceAfter = null;
if (bccomp($winAmount, '0', 2) > 0) {
$balanceAfter = self::creditUserPayout($row, $playRecordId, $winAmount, $now, $operatorAdminId > 0 ? $operatorAdminId : null, '大奖审核通过派彩');
}
$reviewRemark = trim($remark);
if ($reviewRemark === '') {
$reviewRemark = 'approved';
}
$update = [
'status' => self::PLAY_STATUS_SETTLED,
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
'review_time' => $now,
'review_remark' => substr($reviewRemark, 0, 255),
'update_time' => $now,
];
// 优先写主表
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
// 兼容写 view 场景(若存在且可写)
try {
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
} catch (\Throwable) {
}
$out = ['ok' => true, 'msg' => __('Approved')];
if (is_string($balanceAfter)) {
$out['balance_after'] = $balanceAfter;
}
return $out;
}
/**
* 大奖审核拒绝:仅当 status=待审核 才可操作;拒绝后不派彩,标记为已退回(status=4)。
*
* @return array{ok: bool, msg: string}
*/
public static function rejectJackpotPlayRecord(int $playRecordId, int $operatorAdminId, string $remark): array
{
if ($playRecordId <= 0) {
return ['ok' => false, 'msg' => __('Parameter error')];
}
$reason = trim($remark);
if ($reason === '') {
return ['ok' => false, 'msg' => __('Please provide reject reason')];
}
$row = Db::name('game_play_record')->where('id', $playRecordId)->find();
if (!is_array($row)) {
$row = Db::name('bet_order')->where('id', $playRecordId)->find();
}
if (!is_array($row)) {
return ['ok' => false, 'msg' => __('Record not found')];
}
$status = isset($row['status']) && is_numeric($row['status']) ? (int) $row['status'] : 0;
if ($status !== self::PLAY_STATUS_PENDING_REVIEW) {
return ['ok' => false, 'msg' => __('This record does not require review')];
}
$now = time();
$update = [
'status' => self::PLAY_STATUS_RETURNED,
'review_admin_id' => $operatorAdminId > 0 ? $operatorAdminId : null,
'review_time' => $now,
'review_remark' => substr($reason, 0, 255),
'update_time' => $now,
];
Db::name('game_play_record')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
try {
Db::name('bet_order')->where('id', $playRecordId)->where('status', self::PLAY_STATUS_PENDING_REVIEW)->update($update);
} catch (\Throwable) {
}
return ['ok' => true, 'msg' => __('Rejected')];
}
/**
* 补偿:库中已结束局次但注单仍为待开奖的,可重复调用(幂等)。
*/
@@ -176,7 +290,7 @@ final class GameBetSettleService
}
$pending = Db::name('bet_order')
->where('period_id', $rid)
->where('status', 1)
->where('status', self::PLAY_STATUS_PENDING_DRAW)
->count();
if ($pending === 0) {
continue;
@@ -251,7 +365,7 @@ final class GameBetSettleService
/**
* @return string|null 派彩后余额;已幂等入账过时返回当前余额;失败或未执行派彩返回 null
*/
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now): ?string
private static function creditUserPayout(array $bet, int $betId, string $winAmount, int $now, ?int $operatorAdminId, string $remark): ?string
{
$userId = (int) ($bet['user_id'] ?? 0);
if ($userId <= 0) {
@@ -284,8 +398,8 @@ final class GameBetSettleService
'ref_type' => 'bet_order',
'ref_id' => $betId,
'idempotency_key' => $idem,
'operator_admin_id' => null,
'remark' => '压注派彩',
'operator_admin_id' => $operatorAdminId,
'remark' => $remark !== '' ? $remark : '压注派彩',
'create_time' => $now,
]);
@@ -304,4 +418,35 @@ final class GameBetSettleService
return $after;
}
private static function jackpotMaxAmount(): string
{
// 结算属于高频长驻进程逻辑:为避免 GameHotDataRedis::$gcLocal 进程内静态缓存导致阈值更新不生效,
// 这里直接读库拿最新值(本方法在 settleBetsForDraw 中仅调用一次)。
$row = Db::name('game_config')->where('config_key', self::CONFIG_KEY_JACKPOT_MAX_AMOUNT)->find();
if (!is_array($row)) {
return '0.00';
}
$raw = $row['config_value'] ?? null;
if ($raw === null || $raw === '') {
return '0.00';
}
$v = is_string($raw) ? trim($raw) : (is_numeric($raw) ? strval($raw) : '');
if ($v === '' || !is_numeric($v)) {
return '0.00';
}
$normalized = bcadd($v, '0', 2);
if (bccomp($normalized, '0', 2) <= 0) {
return '0.00';
}
return $normalized;
}
private static function shouldRequireJackpotReview(string $winAmount, string $threshold): bool
{
if (bccomp($threshold, '0', 2) <= 0) {
return false;
}
return bccomp($winAmount, $threshold, 2) >= 0;
}
}

View File

@@ -47,30 +47,25 @@ final class GameHotDataRedis
if ($configKey === '') {
return null;
}
if (array_key_exists($configKey, self::$gcLocal)) {
$cachedLocal = self::$gcLocal[$configKey];
return is_array($cachedLocal) ? $cachedLocal : null;
}
// game_config 为全局配置,多进程/多 worker 间必须强一致;
// 因此不使用进程内本地缓存gcLocal避免某个进程读到旧值导致前端回弹/行为冲突。
if (self::enabled()) {
$cached = self::redisGet(self::KEY_GC . $configKey);
if ($cached !== null && $cached !== '') {
$decoded = json_decode($cached, true);
if (is_array($decoded)) {
self::$gcLocal[$configKey] = $decoded;
return $decoded;
}
}
}
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
self::$gcLocal[$configKey] = null;
return null;
}
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
self::$gcLocal[$configKey] = $row;
return $row;
}
@@ -88,17 +83,24 @@ final class GameHotDataRedis
*/
public static function gameConfigReplaceFromDb(string $configKey): void
{
if ($configKey === '' || !self::enabled()) {
if ($configKey === '') {
return;
}
// 无论是否启用 Redis 热点缓存,都要刷新进程内缓存,避免同一 worker 读到旧值
$row = Db::name('game_config')->where('config_key', $configKey)->find();
if (!$row) {
self::gameConfigForget($configKey);
self::$gcLocal[$configKey] = null;
if (self::enabled()) {
self::redisDel(self::KEY_GC . $configKey);
}
return;
}
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
self::$gcLocal[$configKey] = $row;
if (self::enabled()) {
$ttl = self::intConfig('ttl_game_config', 86400);
self::redisSetEx(self::KEY_GC . $configKey, $ttl, json_encode($row, JSON_UNESCAPED_UNICODE));
}
}
/**

View File

@@ -202,7 +202,9 @@ final class GameLiveService
GameHotDataCoordinator::afterUserCommitted($uid);
}
}
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
// 异常对局作废后:自动暂停游戏,不自动创建新一期;需管理员手动开启「游戏运行」才会重新开局
GameRecordService::setAutoCreateEnabled(false);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
self::publishSnapshot(null);
Log::info('game live startup marked abnormal and refunded', [
'record_id' => $recordId,
@@ -733,6 +735,7 @@ final class GameLiveService
$refundedUserIds = [];
try {
$now = time();
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00', 'order_ids' => []];
Db::startTrans();
try {
$refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now);
@@ -750,9 +753,9 @@ final class GameLiveService
Db::rollback();
return ['ok' => false, 'msg' => __('Void failed') . ': ' . $e->getMessage()];
}
GameRecordService::setLiveRuntimeEnabled(false);
GameRecordService::setAutoCreateEnabled(false);
GameHotDataCoordinator::afterGameRecordCommitted($rid);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_LIVE_RUNTIME);
GameHotDataCoordinator::afterGameConfigKeyCommitted(GameRecordService::KEY_AUTO_CREATE);
foreach ($refundedUserIds as $uid) {
if ($uid > 0) {
GameHotDataCoordinator::afterUserCommitted($uid);
@@ -764,6 +767,7 @@ final class GameLiveService
'ok' => true,
'msg' => __('Period voided'),
'record' => self::reloadRecord($rid),
'refund' => $refund,
];
} finally {
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $rid, $lock['token'], $lock['redis_lock']);
@@ -780,13 +784,14 @@ final class GameLiveService
}
/**
* @return array{user_ids:list<int>,order_count:int,total_amount:string}
* @return array{user_ids:list<int>,order_count:int,total_amount:string,order_ids:list<int>}
*/
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
{
$userIdSet = [];
$orderCount = 0;
$totalAmount = '0.00';
$orderIds = [];
$bets = Db::name('bet_order')
->where('period_id', $periodId)
->where('status', 1)
@@ -806,6 +811,8 @@ final class GameLiveService
'status' => 3,
'update_time' => $now,
]);
$orderCount++;
$orderIds[] = $betId;
continue;
}
$before = (string) (Db::name('user')->where('id', $userId)->value('coin') ?? '0');
@@ -832,7 +839,7 @@ final class GameLiveService
UserWalletRecord::create([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'bet_void',
'biz_type' => 'void_refund',
'direction' => 1,
'amount' => $total,
'balance_before' => $before,
@@ -844,6 +851,7 @@ final class GameLiveService
$userIdSet[$userId] = true;
$orderCount++;
$totalAmount = bcadd($totalAmount, $total, 2);
$orderIds[] = $betId;
}
$out = [];
@@ -855,6 +863,7 @@ final class GameLiveService
'user_ids' => $out,
'order_count' => $orderCount,
'total_amount' => $totalAmount,
'order_ids' => $orderIds,
];
}

View File

@@ -13,9 +13,6 @@ final class GameRecordService
public const KEY_MANUAL_CREATE = 'period_manual_create_enabled';
/** 后台「游戏实时对局」运行开关0=暂停自动开奖与派彩后自动创建下一期1=运行 */
public const KEY_LIVE_RUNTIME = 'game_live_runtime_enabled';
private const ACTIVE_STATUSES = [0, 1, 2, 3];
public static function getConfigBool(string $key): bool
@@ -53,9 +50,6 @@ final class GameRecordService
public static function tickAutoCreate(): void
{
if (!self::isLiveRuntimeEnabled()) {
return;
}
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
@@ -86,7 +80,8 @@ final class GameRecordService
public static function createNextRecordAfterDraw(): ?string
{
if (!self::isLiveRuntimeEnabled()) {
// 派彩结束后是否自动开新局:由 period_auto_create_enabled 控制
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return null;
}
if (self::hasActiveRecord()) {
@@ -96,29 +91,16 @@ final class GameRecordService
}
/**
* 未配置键时视为开启(兼容旧库未跑迁移)。
* 实时对局页「自动创建下一局」开关(兼容旧命名 runtime)。
*/
public static function isLiveRuntimeEnabled(): bool
{
$row = GameHotDataRedis::gameConfigRow(self::KEY_LIVE_RUNTIME);
if ($row === null) {
return true;
}
$v = $row['config_value'] ?? '';
return $v === '1' || $v === 1;
return self::getConfigBool(self::KEY_AUTO_CREATE);
}
public static function setLiveRuntimeEnabled(bool $enabled): void
{
$now = time();
$v = $enabled ? '1' : '0';
self::upsertConfig(
self::KEY_LIVE_RUNTIME,
$v,
'int',
'后台「游戏实时对局」运行开关0=维护禁止下注、结束后不自动开新期当局仍自动开奖并结算1=运行',
$now
);
self::setAutoCreateEnabled($enabled);
}
/**
@@ -126,6 +108,9 @@ final class GameRecordService
*/
public static function bootstrapPeriodWhenRuntimeEnabled(): void
{
if (!self::getConfigBool(self::KEY_AUTO_CREATE)) {
return;
}
if (self::hasActiveRecord()) {
return;
}
@@ -135,6 +120,13 @@ final class GameRecordService
}
}
public static function setAutoCreateEnabled(bool $enabled): void
{
$now = time();
$v = $enabled ? '1' : '0';
self::upsertConfig(self::KEY_AUTO_CREATE, $v, 'int', '是否允许自动创建下一局(全局仅一局)', $now);
}
private static function createNextRecordRow(): string
{
$periodNo = self::generatePeriodNo();

View File

@@ -15,12 +15,11 @@ class GamePeriod extends Validate
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'],
];
}

View File

@@ -15,12 +15,11 @@ class GameRecord extends Validate
'period_start_at' => 'integer',
'status' => 'require|in:0,1,2,3,4,5',
'draw_mode' => 'in:0,1',
'preset_number' => 'between:1,36',
'result_number' => 'between:1,36',
];
protected $scene = [
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'preset_number', 'result_number'],
'add' => ['period_no', 'period_start_at', 'status', 'draw_mode', 'result_number'],
'edit' => ['period_start_at', 'status', 'draw_mode', 'result_number'],
];
}

View File

@@ -35,12 +35,12 @@ export default {
pick_numbers: 'Pick numbers',
total_amount: 'Total bet amount',
streak_at_bet: 'Streak at bet',
runtime_switch: 'Game runtime',
runtime_switch: 'Auto-create next round',
countdown_maintenance: 'Maintenance',
runtime_draining_banner:
'Game stopped: the current round will run through draw, settlement and payout. Full maintenance UI appears after payout completes.',
runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn runtime on to resume; a new round is created when idle.',
runtime_off_tip: 'When turning runtime on with no active round, a new period is created immediately.',
runtime_maintenance_banner: 'Maintenance: player betting is disabled. Turn on auto-create to resume; a new round is created when idle.',
runtime_off_tip: 'When enabling auto-create with no active round, a new period is created immediately.',
void_btn: 'Void round',
void_dialog_title: 'Void current round',
void_reason_label: 'Reason',

View File

@@ -17,6 +17,18 @@ export default {
'status 1': 'Pending draw',
'status 2': 'Settled',
'status 3': 'Refunded',
'status 4': 'Returned',
'status 5': 'Pending review',
review_title: 'Win review',
review_open: 'Review',
review_approve: 'Approve',
review_reject: 'Reject',
review_remark: 'Reject remark',
review_remark_placeholder: 'Please enter reject reason',
review_remark_required: 'Remark is required when rejecting',
review_approve_confirm: 'Confirm approve and pay out?',
review_approve_success: 'Approved and paid out',
review_reject_success: 'Rejected',
idempotency_key: 'Idempotency key',
create_time: 'Created',
update_time: 'Updated',

View File

@@ -10,10 +10,10 @@ export default {
'status 3': 'Paying',
'status 4': 'Ended',
'status 5': 'Void',
'status 6': 'Abnormal',
draw_mode: 'Draw mode',
'draw_mode 0': 'Auto AI',
'draw_mode 1': 'Manual preset',
preset_number: 'Preset number',
result_number: 'Result number',
platform_profit_amount: 'Round P/L (platform)',
winner_user_count: 'Winners',
@@ -26,13 +26,23 @@ export default {
manual_create_label: 'Allow manual create next round',
manual_create_tip: 'When enabled, button below can create next round manually',
btn_create_next: 'Create next round (manual)',
view_abnormal_rounds: 'View abnormal rounds',
abnormal_dialog_title: 'Abnormal round recovery logs',
abnormal_dialog_tip: 'Shows rounds auto-recovered after service restart (auto-void + refund).',
abnormal_from_status: 'Status before recovery',
refunded_user_count: 'Refunded users',
refunded_order_count: 'Refunded orders',
refunded_total_amount: 'Total refunded amount',
recovered_at: 'Recovered at',
load_abnormal_failed: 'Failed to load abnormal rounds',
view_play_records: 'View play records',
play_record_dialog_title: 'Play (bet) records',
play_record_col_id: 'ID',
play_record_col_user: 'Username',
play_record_col_channel: 'Channel',
play_record_col_pick_numbers: 'Picks',
play_record_col_total_amount: 'Play amount',
play_record_col_is_auto: 'Auto',
play_record_col_win_amount: 'Payout',
play_record_col_jackpot_extra_amount: 'Jackpot extra',
play_record_col_status: 'Status',
play_record_col_create_time: 'Created',
play_record_is_auto_0: 'Manual',
play_record_is_auto_1: 'Auto bet',
play_record_status_1: 'Pending draw',
play_record_status_2: 'Settled',
play_record_status_3: 'Refunded',
play_record_status_4: 'Returned',
load_play_record_failed: 'Failed to load play records',
}

View File

@@ -35,11 +35,11 @@ export default {
pick_numbers: '下注号码',
total_amount: '下注总额',
streak_at_bet: '下注时连胜',
runtime_switch: '游戏运行',
runtime_switch: '自动创建下一局',
countdown_maintenance: '维护中',
runtime_draining_banner: '已关闭游戏:当前局将正常进行至开奖、结算并完成派彩;全部结束后进入维护模式(倒计时与操作区将切换为维护中)。',
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「游戏运行」恢复;若无进行中的局将自动创建新一期。',
runtime_off_tip: '开启「游戏运行」后,若无进行中的局将立即创建新一期。',
runtime_maintenance_banner: '维护中:玩家端已禁止下注。请开启「自动创建下一局」恢复;若无进行中的局将自动创建新一期。',
runtime_off_tip: '开启「自动创建下一局」后,若无进行中的局将立即创建新一期。',
void_btn: '作废本局',
void_dialog_title: '作废本局',
void_reason_label: '作废原因',

View File

@@ -17,6 +17,18 @@ export default {
'status 1': '待开奖',
'status 2': '已结算',
'status 3': '已退款',
'status 4': '已退回',
'status 5': '待审核',
review_title: '中奖审核',
review_open: '审核',
review_approve: '通过',
review_reject: '拒绝',
review_remark: '拒绝备注',
review_remark_placeholder: '请输入拒绝原因',
review_remark_required: '拒绝时必须填写备注',
review_approve_confirm: '确认审核通过并派彩提现吗?',
review_approve_success: '审核通过,已派彩',
review_reject_success: '已拒绝派彩',
idempotency_key: '幂等键',
create_time: '创建时间',
update_time: '更新时间',

View File

@@ -10,10 +10,10 @@ export default {
'status 3': '派彩中',
'status 4': '已结束',
'status 5': '已作废',
'status 6': '异常',
draw_mode: '开奖方式',
'draw_mode 0': '自动AI',
'draw_mode 1': '手动预设',
preset_number: '预设号码',
result_number: '开奖号码',
platform_profit_amount: '对局盈亏(平台)',
winner_user_count: '中奖人数',
@@ -26,13 +26,23 @@ export default {
manual_create_label: '允许手动创建下一局',
manual_create_tip: '开启后可在本页使用「手动创建下一局」按钮',
btn_create_next: '手动创建下一局',
view_abnormal_rounds: '查看异常对局',
abnormal_dialog_title: '异常对局恢复记录',
abnormal_dialog_tip: '展示服务重启后自动恢复的异常对局(自动作废并退款)。',
abnormal_from_status: '异常前状态',
refunded_user_count: '退款用户数',
refunded_order_count: '退款注单数',
refunded_total_amount: '退款总金额',
recovered_at: '恢复时间',
load_abnormal_failed: '加载异常对局失败',
view_play_records: '查看游玩记录',
play_record_dialog_title: '游玩(压注)记录',
play_record_col_id: 'ID',
play_record_col_user: '用户名',
play_record_col_channel: '渠道',
play_record_col_pick_numbers: '选号',
play_record_col_total_amount: '游玩金额',
play_record_col_is_auto: '托管',
play_record_col_win_amount: '派彩',
play_record_col_jackpot_extra_amount: 'Jackpot',
play_record_col_status: '状态',
play_record_col_create_time: '创建时间',
play_record_is_auto_0: '手动',
play_record_is_auto_1: '托管',
play_record_status_1: '待开奖',
play_record_status_2: '已结算',
play_record_status_3: '已退款',
play_record_status_4: '已退回',
load_play_record_failed: '加载游玩记录失败',
}

View File

@@ -256,6 +256,7 @@ const calcLoading = ref(false)
const drawLoading = ref(false)
const pendingSwitchNumber = ref<number | null>(null)
const runtimeSwitchLoading = ref(false)
const pendingRuntimeTarget = ref<boolean | null>(null)
const voidDialogVisible = ref(false)
const voidReason = ref('')
const voidSubmitting = ref(false)
@@ -534,6 +535,23 @@ const canVoidPeriod = computed(() => {
/** 派彩结束后的完整维护态:操作区除顶部开关外全部锁定 */
const asideOperationLocked = computed(() => snapshot.maintenance_ui === true)
function toBool(v: unknown): boolean | null {
if (typeof v === 'boolean') {
return v
}
if (typeof v === 'number' && Number.isFinite(v)) {
if (v === 1) return true
if (v === 0) return false
return null
}
if (typeof v === 'string') {
const s = v.trim().toLowerCase()
if (s === '1' || s === 'true') return true
if (s === '0' || s === 'false') return false
}
return null
}
function mergeLiveSnapshot(data: anyObj): void {
if (data.record !== undefined) {
snapshot.record = data.record
@@ -553,11 +571,20 @@ function mergeLiveSnapshot(data: anyObj): void {
snapshot.can_calculate = !!data.can_calculate
snapshot.can_draw = !!data.can_draw
snapshot.can_schedule_draw = !!(data.can_schedule_draw || data.can_draw)
if (typeof data.runtime_enabled === 'boolean') {
snapshot.runtime_enabled = data.runtime_enabled
const runtimeEnabled = toBool(data.runtime_enabled)
if (pendingRuntimeTarget.value !== null) {
// 开关请求进行中:避免 ws 的旧快照覆盖用户刚切换的状态,导致 UI 回弹
snapshot.runtime_enabled = pendingRuntimeTarget.value
} else if (runtimeEnabled !== null) {
snapshot.runtime_enabled = runtimeEnabled
}
if (typeof data.maintenance_ui === 'boolean') {
snapshot.maintenance_ui = data.maintenance_ui
if (pendingRuntimeTarget.value !== null) {
// 开启时不应显示维护中;关闭后的维护中应由服务端在对局完全结束后下发
snapshot.maintenance_ui = pendingRuntimeTarget.value ? false : snapshot.maintenance_ui
} else {
snapshot.maintenance_ui = data.maintenance_ui
}
}
syncServerClock(data.server_time)
}
@@ -615,11 +642,18 @@ async function loadSnapshot() {
}
async function onRuntimeSwitch(val: boolean | string | number): void {
const on = val === true || val === 'true' || val === 1
const on = toBool(val) === true
// 防止某些场景下 model-value 变化触发重复 change 事件,造成 runtime 接口循环调用
if (on === !!snapshot.runtime_enabled) {
const current = toBool(snapshot.runtime_enabled) === true
if (on === current) {
return
}
// el-switch 为受控组件model-value 来自 snapshot接口返回前先乐观更新避免点击后立刻回弹
snapshot.runtime_enabled = on
if (on) {
snapshot.maintenance_ui = false
}
pendingRuntimeTarget.value = on
runtimeSwitchLoading.value = true
try {
const res = await createAxios({
@@ -637,6 +671,7 @@ async function onRuntimeSwitch(val: boolean | string | number): void {
await loadSnapshot()
} finally {
runtimeSwitchLoading.value = false
pendingRuntimeTarget.value = null
}
}
@@ -667,6 +702,14 @@ async function submitVoidPeriod(): Promise<void> {
})
if (res.code === 1 && res.data) {
mergeLiveSnapshot(res.data as anyObj)
const refund = (res.data as anyObj).void_refund
if (refund && typeof refund === 'object') {
const orderCount = Number(refund.order_count ?? 0)
const totalAmount = String(refund.total_amount ?? '0.00')
if (orderCount > 0) {
ElMessage.success(`已退款 ${orderCount} 笔注单,合计 ${totalAmount}`)
}
}
}
voidDialogVisible.value = false
} finally {

View File

@@ -8,16 +8,49 @@
></TableHeader>
<Table ref="tableRef"></Table>
<el-dialog class="ba-operate-dialog" :title="t('game.playRecord.review_title')" :model-value="reviewDialog.visible" width="620px" :close-on-click-modal="false" @close="closeReviewDialog">
<div v-loading="reviewDialog.loading">
<el-descriptions :column="2" border class="mb-12">
<el-descriptions-item :label="t('game.playRecord.id')">{{ reviewDialog.row?.id ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.gameRecord_period_no')">{{ reviewDialog.row?.gameRecord?.period_no ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.user_username')">{{ reviewDialog.row?.user?.username ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.channel_name')">{{ reviewDialog.row?.channel?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.pick_numbers')">{{ formatPickNumbers({}, {}, reviewDialog.row?.pick_numbers) }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.total_amount')">{{ formatAmount({}, {}, reviewDialog.row?.total_amount) }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.win_amount')">{{ formatAmount({}, {}, reviewDialog.row?.win_amount) }}</el-descriptions-item>
<el-descriptions-item :label="t('game.playRecord.status')">{{ reviewDialog.row?.status ? t(`game.playRecord.status ${reviewDialog.row.status}`) : '-' }}</el-descriptions-item>
</el-descriptions>
<el-form label-width="90px">
<el-form-item :label="t('game.playRecord.review_remark')">
<el-input
v-model="reviewDialog.remark"
type="textarea"
:rows="3"
:placeholder="t('game.playRecord.review_remark_placeholder')"
/>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="closeReviewDialog">{{ t('Cancel') }}</el-button>
<el-button type="danger" :loading="reviewDialog.loading" @click="submitReject">{{ t('game.playRecord.review_reject') }}</el-button>
<el-button type="primary" :loading="reviewDialog.loading" @click="submitApprove">{{ t('game.playRecord.review_approve') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
defineOptions({
name: 'game/playRecord',
@@ -25,6 +58,14 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons([])
const reviewDialog = reactive({
visible: false,
loading: false,
id: 0,
remark: '',
row: null as any,
})
function formatPickNumbers(_row: anyObj, _column: any, cellValue: unknown) {
if (cellValue === null || cellValue === undefined) {
@@ -119,7 +160,6 @@ const baTable = new baTableClass(
replaceValue: { '0': t('game.playRecord.is_auto 0'), '1': t('game.playRecord.is_auto 1') },
},
{ label: t('game.playRecord.win_amount'), prop: 'win_amount', align: 'center', minWidth: 110, operator: 'RANGE', formatter: formatAmount },
{ label: t('game.playRecord.jackpot_extra_amount'), prop: 'jackpot_extra_amount', align: 'center', minWidth: 120, operator: 'RANGE', formatter: formatAmount },
{
label: t('game.playRecord.status'),
prop: 'status',
@@ -128,11 +168,13 @@ const baTable = new baTableClass(
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { '1': 'warning', '2': 'success', '3': 'danger' },
custom: { '1': 'warning', '2': 'success', '3': 'danger', '4': 'info', '5': 'warning' },
replaceValue: {
'1': t('game.playRecord.status 1'),
'2': t('game.playRecord.status 2'),
'3': t('game.playRecord.status 3'),
'4': t('game.playRecord.status 4'),
'5': t('game.playRecord.status 5'),
},
},
{
@@ -166,12 +208,110 @@ const baTable = new baTableClass(
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 90, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
},
{}
)
const openReviewDialog = (row: any) => {
const idRaw = row?.id
const id = typeof idRaw === 'number' ? idRaw : Number(idRaw)
if (!Number.isFinite(id) || id <= 0) {
ElMessage.error(t('Parameter error'))
return
}
reviewDialog.visible = true
reviewDialog.id = id
reviewDialog.remark = ''
reviewDialog.row = row ?? null
}
const closeReviewDialog = (force = false) => {
if (reviewDialog.loading && !force) return
reviewDialog.visible = false
reviewDialog.id = 0
reviewDialog.remark = ''
reviewDialog.row = null
}
const submitApprove = async () => {
if (!reviewDialog.id) return
reviewDialog.loading = true
try {
await createAxios(
{
url: '/admin/game.PlayRecord/approveJackpot',
method: 'post',
data: { id: reviewDialog.id, remark: reviewDialog.remark },
},
{ showSuccessMessage: false }
)
ElMessage.success(t('game.playRecord.review_approve_success'))
if (reviewDialog.row) {
reviewDialog.row.status = 2
reviewDialog.row.can_jackpot_approve = 0
}
closeReviewDialog(true)
baTable.onTableHeaderAction('refresh', { event: 'review-approve' })
} catch (error: any) {
const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('Operation failed')
ElMessage.error(message)
} finally {
reviewDialog.loading = false
}
}
const submitReject = async () => {
if (!reviewDialog.id) return
const remark = String(reviewDialog.remark ?? '').trim()
if (!remark) {
ElMessage.error(t('game.playRecord.review_remark_required'))
return
}
reviewDialog.loading = true
try {
await createAxios(
{
url: '/admin/game.PlayRecord/rejectJackpot',
method: 'post',
data: { id: reviewDialog.id, remark },
},
{ showSuccessMessage: false }
)
ElMessage.success(t('game.playRecord.review_reject_success'))
if (reviewDialog.row) {
reviewDialog.row.status = 4
reviewDialog.row.can_jackpot_approve = 0
}
closeReviewDialog(true)
baTable.onTableHeaderAction('refresh', { event: 'review-reject' })
} catch (error: any) {
const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('Operation failed')
ElMessage.error(message)
} finally {
reviewDialog.loading = false
}
}
optButtons.unshift({
render: 'tipButton',
name: 'approve-jackpot',
title: 'game.playRecord.review_open',
text: '',
type: 'warning',
icon: 'fa fa-check',
disabledTip: true,
display: (row: any) => {
const v = row?.can_jackpot_approve
return String(v ?? '') === '1'
},
click: (row: any) => {
openReviewDialog(row)
},
})
provide('baTable', baTable)
onMounted(() => {

View File

@@ -1,4 +1,4 @@
<template>
<template>
<div class="default-main ba-table-box">
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
@@ -6,50 +6,16 @@
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })"
>
<el-button v-blur class="table-header-operate btns-ml-12" type="warning" @click="openAbnormalDialog">
<Icon name="fa fa-exclamation-triangle" />
<span class="table-header-operate-text">{{ t('game.record.view_abnormal_rounds') }}</span>
</el-button>
</TableHeader>
<Table ref="tableRef"></Table>
<PopupForm />
<el-dialog
class="ba-operate-dialog"
:title="t('game.record.abnormal_dialog_title')"
:model-value="abnormalDialog.visible"
width="900px"
:close-on-click-modal="false"
@close="closeAbnormalDialog"
>
<div v-loading="abnormalDialog.loading">
<el-alert type="info" :closable="false" show-icon class="mb-12">
{{ t('game.record.abnormal_dialog_tip') }}
</el-alert>
<el-table :data="abnormalDialog.list" border size="small" max-height="420">
<el-table-column prop="period_no" :label="t('game.record.period_no')" min-width="180" />
<el-table-column :label="t('game.record.abnormal_from_status')" min-width="120">
<template #default="scope">{{ formatStatusLabel(scope.row.abnormal_from_status) }}</template>
</el-table-column>
<el-table-column prop="refunded_user_count" :label="t('game.record.refunded_user_count')" min-width="120" />
<el-table-column prop="refunded_order_count" :label="t('game.record.refunded_order_count')" min-width="120" />
<el-table-column prop="refunded_total_amount" :label="t('game.record.refunded_total_amount')" min-width="140" />
<el-table-column prop="recovered_at" :label="t('game.record.recovered_at')" min-width="170">
<template #default="scope">{{ formatRecoveredTime(scope.row.recovered_at) }}</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<el-button @click="closeAbnormalDialog">{{ t('Cancel') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
import { onMounted, provide, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import PopupForm from './popupForm.vue'
@@ -58,9 +24,7 @@ import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
import { timeFormat } from '/@/utils/common'
import { routePush } from '/@/utils/router'
defineOptions({
name: 'game/record',
})
@@ -68,19 +32,37 @@ defineOptions({
const { t } = useI18n()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit'])
const abnormalDialog = reactive({
visible: false,
loading: false,
list: [] as any[],
})
const formatCoin = (_row: any, _column: any, cellValue: number | string | null | undefined) => {
if (cellValue === null || cellValue === undefined || cellValue === '') return ''
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
const n = Number(cellValue)
if (Number.isNaN(n)) return ''
if (Number.isNaN(n)) return '-'
return n.toFixed(2)
}
optButtons.unshift({
render: 'tipButton',
name: 'view-play-record',
title: 'game.record.view_play_records',
text: '',
type: 'success',
icon: 'fa fa-list',
class: 'table-row-view-play-record',
disabledTip: false,
click: (row: any) => {
const pidRaw = row?.id
const pid = typeof pidRaw === 'number' ? pidRaw : Number(pidRaw)
if (!Number.isFinite(pid) || pid <= 0) {
ElMessage.error(t('Parameter error'))
return
}
void routePush({
path: '/admin/game/playRecord',
query: { period_id: String(pid) },
})
},
})
const baTable = new baTableClass(
new baTableApi('/admin/game.Record/'),
{
@@ -88,8 +70,25 @@ const baTable = new baTableClass(
column: [
{ type: 'selection', align: 'center', operator: false },
{ label: t('game.record.id'), prop: 'id', align: 'center', width: 100, operator: 'RANGE', sortable: 'custom' },
{ label: t('game.record.period_no'), prop: 'period_no', align: 'center', minWidth: 180, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE' },
{ label: t('game.record.period_start_at'), prop: 'period_start_at', align: 'center', width: 170, render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('game.record.period_no'),
prop: 'period_no',
align: 'center',
minWidth: 180,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
},
{
label: t('game.record.period_start_at'),
prop: 'period_start_at',
align: 'center',
width: 170,
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.record.status'),
prop: 'status',
@@ -98,8 +97,16 @@ const baTable = new baTableClass(
operator: 'eq',
render: 'tag',
effect: 'dark',
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger' },
replaceValue: { '0': t('game.record.status 0'), '1': t('game.record.status 1'), '2': t('game.record.status 2'), '3': t('game.record.status 3'), '4': t('game.record.status 4'), '5': t('game.record.status 5') },
custom: { '0': 'success', '1': 'warning', '2': 'info', '3': 'primary', '4': 'warning', '5': 'danger', '6': 'danger' },
replaceValue: {
'0': t('game.record.status 0'),
'1': t('game.record.status 1'),
'2': t('game.record.status 2'),
'3': t('game.record.status 3'),
'4': t('game.record.status 4'),
'5': t('game.record.status 5'),
'6': t('game.record.status 6'),
},
},
{
label: t('game.record.draw_mode'),
@@ -111,7 +118,6 @@ const baTable = new baTableClass(
custom: { '0': 'info', '1': 'warning' },
replaceValue: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') },
},
{ label: t('game.record.preset_number'), prop: 'preset_number', align: 'center', width: 100, operator: 'RANGE' },
{ label: t('game.record.result_number'), prop: 'result_number', align: 'center', width: 100, operator: 'RANGE' },
{
label: t('game.record.platform_profit_amount'),
@@ -130,9 +136,37 @@ const baTable = new baTableClass(
operator: 'RANGE',
sortable: false,
},
{ label: t('game.record.void_reason'), prop: 'void_reason', align: 'center', minWidth: 140, operatorPlaceholder: t('Fuzzy query'), operator: 'LIKE', showOverflowTooltip: true },
{ label: t('game.record.create_time'), prop: 'create_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{ label: t('game.record.update_time'), prop: 'update_time', align: 'center', render: 'datetime', operator: 'RANGE', comSearchRender: 'datetime', sortable: 'custom', width: 170, timeFormat: 'yyyy-mm-dd hh:MM:ss' },
{
label: t('game.record.void_reason'),
prop: 'void_reason',
align: 'center',
minWidth: 140,
operatorPlaceholder: t('Fuzzy query'),
operator: 'LIKE',
showOverflowTooltip: true,
},
{
label: t('game.record.create_time'),
prop: 'create_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{
label: t('game.record.update_time'),
prop: 'update_time',
align: 'center',
render: 'datetime',
operator: 'RANGE',
comSearchRender: 'datetime',
sortable: 'custom',
width: 170,
timeFormat: 'yyyy-mm-dd hh:MM:ss',
},
{ label: t('Operate'), align: 'center', width: 100, render: 'buttons', buttons: optButtons, operator: false, fixed: 'right' },
],
dblClickNotEditColumn: [undefined],
@@ -142,50 +176,6 @@ const baTable = new baTableClass(
}
)
const formatStatusLabel = (status: number) => {
const key = String(status)
if (!['0', '1', '2', '3', '4', '5'].includes(key)) {
return '-'
}
return t(`game.record.status ${key}`)
}
const openAbnormalDialog = async () => {
abnormalDialog.visible = true
abnormalDialog.loading = true
try {
const response = await createAxios(
{
url: '/admin/game.Record/abnormalList',
method: 'get',
params: { limit: 100 },
},
{
showSuccessMessage: false,
}
)
const data = response?.data ?? {}
abnormalDialog.list = Array.isArray(data.list) ? data.list : []
} catch (error: any) {
const message = typeof error?.message === 'string' && error.message !== '' ? error.message : t('game.record.load_abnormal_failed')
ElMessage.error(message)
abnormalDialog.list = []
} finally {
abnormalDialog.loading = false
}
}
const closeAbnormalDialog = () => {
abnormalDialog.visible = false
}
const formatRecoveredTime = (timestamp: number) => {
if (!timestamp) {
return '-'
}
return timeFormat(timestamp)
}
provide('baTable', baTable)
onMounted(() => {

View File

@@ -24,7 +24,6 @@
prop="draw_mode"
:input-attr="{ content: { '0': t('game.record.draw_mode 0'), '1': t('game.record.draw_mode 1') } }"
/>
<FormItem :label="t('game.record.preset_number')" type="number" v-model="baTable.form.items!.preset_number" prop="preset_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.result_number')" type="number" v-model="baTable.form.items!.result_number" prop="result_number" :input-attr="{ step: 1, min: 1, max: 36 }" />
<FormItem :label="t('game.record.platform_profit_amount')" type="string" v-model="baTable.form.items!.platform_profit_amount" prop="platform_profit_amount" />
<FormItem :label="t('game.record.winner_user_count')" type="number" v-model="baTable.form.items!.winner_user_count" prop="winner_user_count" :input-attr="{ step: 1, min: 0 }" />