1.对局新增查看异常订单
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace app\admin\controller\game;
|
||||
|
||||
use app\common\controller\Backend;
|
||||
use support\think\Db;
|
||||
use support\Response;
|
||||
use Webman\Http\Request as WebmanRequest;
|
||||
|
||||
@@ -66,4 +67,93 @@ class Record extends Backend
|
||||
}
|
||||
return $this->error('游戏对局记录不可删除');
|
||||
}
|
||||
|
||||
public function abnormalList(WebmanRequest $request): Response
|
||||
{
|
||||
$response = $this->initializeBackend($request);
|
||||
if ($response !== null) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$limitRaw = $request->get('limit', 30);
|
||||
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 30;
|
||||
if ($limit < 1) {
|
||||
$limit = 1;
|
||||
}
|
||||
if ($limit > 200) {
|
||||
$limit = 200;
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$meta = $this->parseRecoverVoidReason(is_string($row['void_reason'] ?? null) ? $row['void_reason'] : '');
|
||||
$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'],
|
||||
'recovered_at' => (int) ($row['update_time'] ?? 0),
|
||||
'void_reason' => (string) ($row['void_reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->success('', [
|
||||
'list' => $list,
|
||||
'total' => count($list),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from_status:int,users:int,orders:int,amount:string}
|
||||
*/
|
||||
private function parseRecoverVoidReason(string $reason): array
|
||||
{
|
||||
$meta = [
|
||||
'from_status' => -1,
|
||||
'users' => 0,
|
||||
'orders' => 0,
|
||||
'amount' => '0.00',
|
||||
];
|
||||
if ($reason === '' || str_starts_with($reason, 'system_recover:') === false) {
|
||||
return $meta;
|
||||
}
|
||||
$payload = substr($reason, strlen('system_recover:'));
|
||||
$parts = explode('|', $payload);
|
||||
foreach ($parts as $part) {
|
||||
$item = trim($part);
|
||||
if ($item === '' || !str_contains($item, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$key, $value] = explode('=', $item, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
if ($key === 'from' && is_numeric($value)) {
|
||||
$meta['from_status'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'users' && is_numeric($value)) {
|
||||
$meta['users'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'orders' && is_numeric($value)) {
|
||||
$meta['orders'] = (int) $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'amount' && $value !== '') {
|
||||
$meta['amount'] = $value;
|
||||
}
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use app\common\library\game\StreakWinReward;
|
||||
use app\common\model\UserWalletRecord;
|
||||
use app\common\service\GameHotDataCoordinator;
|
||||
use app\common\service\GameHotDataLock;
|
||||
use support\Log;
|
||||
use support\think\Db;
|
||||
use Throwable;
|
||||
|
||||
@@ -31,6 +32,114 @@ final class GameLiveService
|
||||
|
||||
/** 开奖后派彩展示宽限期(秒),之后再创建下一期 */
|
||||
private const PAYOUT_GRACE_SECONDS = 3;
|
||||
/** 启动自愈:判定“异常卡局”的最小超时冗余秒数 */
|
||||
private const STARTUP_RECOVER_GRACE_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* 服务重启后自动巡检上一局:若长时间卡在进行中状态,则自动作废并退款待开奖注单。
|
||||
*/
|
||||
public static function recoverAbnormalPeriodOnStartup(): void
|
||||
{
|
||||
$row = Db::name('game_record')
|
||||
->whereIn('status', [0, 1, 2, 3])
|
||||
->order('id', 'desc')
|
||||
->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recordId = (int) ($row['id'] ?? 0);
|
||||
if ($recordId <= 0) {
|
||||
return;
|
||||
}
|
||||
$periodStartAt = (int) ($row['period_start_at'] ?? 0);
|
||||
if ($periodStartAt <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$periodSeconds = self::getConfigInt(self::KEY_PERIOD_SECONDS, 30);
|
||||
$timeoutAt = $periodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS;
|
||||
if (time() <= $timeoutAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = (int) ($row['status'] ?? 0);
|
||||
$lock = GameHotDataLock::tryAcquireWithWait(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, 3000);
|
||||
if (!$lock['acquired']) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$fresh = Db::name('game_record')->where('id', $recordId)->find();
|
||||
if (!$fresh) {
|
||||
return;
|
||||
}
|
||||
$freshStatus = (int) ($fresh['status'] ?? 0);
|
||||
if (!in_array($freshStatus, [0, 1, 2, 3], true)) {
|
||||
return;
|
||||
}
|
||||
$freshPeriodStartAt = (int) ($fresh['period_start_at'] ?? 0);
|
||||
if ($freshPeriodStartAt <= 0) {
|
||||
return;
|
||||
}
|
||||
$freshTimeoutAt = $freshPeriodStartAt + $periodSeconds + self::PAYOUT_GRACE_SECONDS + self::STARTUP_RECOVER_GRACE_SECONDS;
|
||||
if (time() <= $freshTimeoutAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$refund = ['user_ids' => [], 'order_count' => 0, 'total_amount' => '0.00'];
|
||||
Db::startTrans();
|
||||
try {
|
||||
$refund = self::refundPendingBetsSummaryForPeriodLocked($recordId, $now);
|
||||
$oldStatus = $freshStatus;
|
||||
$refundedUserCount = count($refund['user_ids']);
|
||||
$refundedOrderCount = (int) ($refund['order_count'] ?? 0);
|
||||
$refundedTotalAmount = is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00';
|
||||
$reason = sprintf(
|
||||
'system_recover:from=%d|users=%d|orders=%d|amount=%s',
|
||||
$oldStatus,
|
||||
$refundedUserCount,
|
||||
$refundedOrderCount,
|
||||
$refundedTotalAmount
|
||||
);
|
||||
Db::name('game_record')->where('id', $recordId)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
'pending_draw_number' => null,
|
||||
'payout_until' => null,
|
||||
'ai_locked_number' => null,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
Db::commit();
|
||||
} catch (Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::warning('game live startup recover failed', [
|
||||
'record_id' => $recordId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
GameHotDataCoordinator::afterGameRecordCommitted($recordId);
|
||||
foreach ($refund['user_ids'] as $uid) {
|
||||
if ($uid > 0) {
|
||||
GameHotDataCoordinator::afterUserCommitted($uid);
|
||||
}
|
||||
}
|
||||
GameRecordService::bootstrapPeriodWhenRuntimeEnabled();
|
||||
self::publishSnapshot(null);
|
||||
Log::info('game live startup recovered abnormal period', [
|
||||
'record_id' => $recordId,
|
||||
'old_status' => $freshStatus,
|
||||
'refunded_user_count' => count($refund['user_ids']),
|
||||
'refunded_order_count' => (int) ($refund['order_count'] ?? 0),
|
||||
'refunded_total_amount' => is_string($refund['total_amount'] ?? null) ? $refund['total_amount'] : '0.00',
|
||||
]);
|
||||
} finally {
|
||||
GameHotDataLock::release(GameHotDataLock::TYPE_GAME_RECORD, (string) $recordId, $lock['token'], $lock['redis_lock']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function buildSnapshot(?int $recordId = null): array
|
||||
{
|
||||
@@ -519,7 +628,8 @@ final class GameLiveService
|
||||
$now = time();
|
||||
Db::startTrans();
|
||||
try {
|
||||
$refundedUserIds = self::refundPendingBetsForPeriodLocked($rid, $now);
|
||||
$refund = self::refundPendingBetsSummaryForPeriodLocked($rid, $now);
|
||||
$refundedUserIds = $refund['user_ids'];
|
||||
Db::name('game_record')->where('id', $rid)->update([
|
||||
'status' => 5,
|
||||
'void_reason' => $reason,
|
||||
@@ -557,8 +667,19 @@ final class GameLiveService
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function refundPendingBetsForPeriodLocked(int $periodId, int $now): array
|
||||
{
|
||||
$summary = self::refundPendingBetsSummaryForPeriodLocked($periodId, $now);
|
||||
return $summary['user_ids'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user_ids:list<int>,order_count:int,total_amount:string}
|
||||
*/
|
||||
private static function refundPendingBetsSummaryForPeriodLocked(int $periodId, int $now): array
|
||||
{
|
||||
$userIdSet = [];
|
||||
$orderCount = 0;
|
||||
$totalAmount = '0.00';
|
||||
$bets = Db::name('bet_order')
|
||||
->where('period_id', $periodId)
|
||||
->where('status', 1)
|
||||
@@ -614,6 +735,8 @@ final class GameLiveService
|
||||
'create_time' => $now,
|
||||
]);
|
||||
$userIdSet[$userId] = true;
|
||||
$orderCount++;
|
||||
$totalAmount = bcadd($totalAmount, $total, 2);
|
||||
}
|
||||
|
||||
$out = [];
|
||||
@@ -621,7 +744,11 @@ final class GameLiveService
|
||||
$out[] = (int) $uid;
|
||||
}
|
||||
|
||||
return $out;
|
||||
return [
|
||||
'user_ids' => $out,
|
||||
'order_count' => $orderCount,
|
||||
'total_amount' => $totalAmount,
|
||||
];
|
||||
}
|
||||
|
||||
public static function publishSnapshot(?int $recordId = null): void
|
||||
|
||||
@@ -12,6 +12,8 @@ class GameLiveTicker
|
||||
{
|
||||
public function onWorkerStart(): void
|
||||
{
|
||||
GameLiveService::recoverAbnormalPeriodOnStartup();
|
||||
|
||||
Timer::add(1, static function (): void {
|
||||
GameLiveService::finalizePayoutGrace();
|
||||
GameLiveService::tickAutoDraw();
|
||||
|
||||
@@ -26,4 +26,13 @@ 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',
|
||||
}
|
||||
|
||||
@@ -26,4 +26,13 @@ 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: '加载异常对局失败',
|
||||
}
|
||||
|
||||
@@ -6,22 +6,58 @@
|
||||
:buttons="['refresh', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('game.record.quick Search Fields') })"
|
||||
></TableHeader>
|
||||
<div class="record-top-actions">
|
||||
<el-button type="warning" @click="openAbnormalDialog">{{ t('game.record.view_abnormal_rounds') }}</el-button>
|
||||
</div>
|
||||
|
||||
<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, useTemplateRef } from 'vue'
|
||||
import { onMounted, provide, reactive, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PopupForm from './popupForm.vue'
|
||||
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'
|
||||
import { timeFormat } from '/@/utils/common'
|
||||
|
||||
defineOptions({
|
||||
name: 'game/record',
|
||||
@@ -30,6 +66,11 @@ 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 '—'
|
||||
@@ -99,6 +140,50 @@ 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?.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(() => {
|
||||
|
||||
Reference in New Issue
Block a user