[色子游戏]奖励配置权重测试记录

This commit is contained in:
2026-03-11 18:12:19 +08:00
parent 2af7fedcce
commit 064ce06393
18 changed files with 1720 additions and 19 deletions

View File

@@ -11,23 +11,42 @@ export default {
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/index',
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/index',
params
})
},
/**
* 获取 DiceLotteryPoolConfig 列表数据,仅含 id、name用于 lottery_config_id 下拉
* @returns DiceLotteryPoolConfig['id','name'] 列表
* @returns DiceLotteryPoolConfig['id','name','t1_weight'..'t5_weight'] 列表
*/
async getOptions(): Promise<Array<{ id: number; name: string }>> {
async getOptions(): Promise<
Array<{
id: number
name: string
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}>
> {
const res = await request.get<any>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions'
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions'
})
const rows = (res?.data ?? []) as Array<{ id: number; name: string }>
return Array.isArray(rows)
? rows.map((r) => ({ id: Number(r.id), name: String(r.name ?? r.id ?? '') }))
: []
// 兼容request.get 通常返回后端 success(data) 的 data数组部分环境可能返回整包 { data: [] }
const rows = Array.isArray(res) ? res : (Array.isArray((res as any)?.data) ? (res as any).data : [])
if (!Array.isArray(rows)) return []
return rows.map((r: any) => ({
id: Number(r.id),
name: String(r.name ?? r.id ?? ''),
t1_weight: Number(r.t1_weight ?? 0),
t2_weight: Number(r.t2_weight ?? 0),
t3_weight: Number(r.t3_weight ?? 0),
t4_weight: Number(r.t4_weight ?? 0),
t5_weight: Number(r.t5_weight ?? 0)
}))
},
/**
@@ -37,7 +56,7 @@ export default {
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/read?id=' + id
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/read?id=' + id
})
},
@@ -48,7 +67,7 @@ export default {
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/save',
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/save',
data: params
})
},
@@ -60,7 +79,7 @@ export default {
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/update',
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/update',
data: params
})
},
@@ -72,7 +91,7 @@ export default {
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/destroy',
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/destroy',
data: params
})
},
@@ -92,7 +111,7 @@ export default {
t5_weight: number
profit_amount: number
}>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool'
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool'
})
},
@@ -108,7 +127,7 @@ export default {
t5_weight?: number
}) {
return request.post<any>({
url: '/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool',
url: '/core/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool',
data: params
})
}

View File

@@ -80,5 +80,22 @@ export default {
url: '/core/dice/reward_config/DiceRewardConfig/batchUpdateWeights',
data: { items }
})
},
/**
* 权重配比测试:按当前配置模拟 N 次抽奖,返回各 grid_number 落点次数
* @param test_count 100 | 500 | 1000
* @param save_record 是否保存到 dice_reward_config_record
* @param lottery_config_id 奖池配置IDDiceLotteryPoolConfig用于设定 T1-T5 档位概率;不传则使用 type=0 或均等
*/
runWeightTest(params: {
test_count: number
save_record?: boolean
lottery_config_id?: number | null
}) {
return request.post<{ data: { counts: Record<string, number>; record_id: number | null } }>({
url: '/core/dice/reward_config/DiceRewardConfig/runWeightTest',
data: params
})
}
}

View File

@@ -0,0 +1,77 @@
import request from '@/utils/http'
/**
* 奖励配置权重测试记录 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/destroy',
data: params
})
},
/**
* 导入:将测试记录的权重写入 DiceRewardConfig 与 DiceLotteryPoolConfig并刷新缓存
* @param record_id 测试记录 ID
* @param lottery_config_id 可选,导入档位权重到的奖池配置 ID不传则用记录内的 lottery_config_id
*/
importFromRecord(params: { record_id: number; lottery_config_id?: number | null }) {
return request.post<any>({
url: '/dice/reward_config_record/DiceRewardConfigRecord/importFromRecord',
data: params
})
}
}

View File

@@ -16,6 +16,14 @@
>
T1-T5 BIGWIN 权重配比
</ElButton>
<ElButton
v-permission="'dice:reward_config:index:index'"
type="success"
@click="weightTestVisible = true"
v-ripple
>
测试中奖
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
@@ -60,6 +68,8 @@
/>
<!-- T1-T5BIGWIN 权重配比弹窗 -->
<WeightRatioDialog v-model="weightRatioVisible" @success="refreshData" />
<!-- 权重配比测试弹窗 -->
<WeightTestDialog v-model="weightTestVisible" />
</div>
</template>
@@ -70,8 +80,10 @@
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import WeightRatioDialog from './modules/weight-ratio-dialog.vue'
import WeightTestDialog from './modules/weight-test-dialog.vue'
const weightRatioVisible = ref(false)
const weightTestVisible = ref(false)
// 搜索表单
const searchForm = ref<Record<string, unknown>>({

View File

@@ -0,0 +1,227 @@
<template>
<el-dialog
v-model="visible"
title="权重配比测试"
width="720px"
align-center
:close-on-click-modal="false"
destroy-on-close
@close="handleClose"
>
<div v-if="!result" class="test-form">
<el-form :model="form" label-width="120px">
<el-form-item label="奖池配置">
<el-select
v-model="form.lottery_config_id"
placeholder="选择 T1-T5 档位概率来源,不选则使用默认"
clearable
style="width: 320px"
>
<el-option
v-for="opt in lotteryConfigOptions"
:key="opt.id"
:label="opt.name"
:value="opt.id"
/>
</el-select>
<div class="form-tip">
{{
selectedTierSummary || '选定奖池的 t1_weightt5_weight 将作为测试时 T1-T5 的抽取概率'
}}
</div>
</el-form-item>
<el-form-item label="测试次数">
<el-radio-group v-model="form.test_count">
<el-radio :value="100">100 </el-radio>
<el-radio :value="500">500 </el-radio>
<el-radio :value="1000">1000 </el-radio>
<el-radio :value="5000">5000 </el-radio>
<el-radio :value="10000">10000 </el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="保存记录">
<el-switch v-model="form.save_record" />
<span class="form-tip">保存到测试记录表便于后续对比</span>
</el-form-item>
</el-form>
</div>
<div v-else class="test-result">
<div class="result-summary">
共模拟 <strong>{{ form.test_count }}</strong> 次抽奖落点分布如下
</div>
<div class="chart-wrap">
<ArtBarChart
x-axis-name="色子点数 (grid_number)"
:x-axis-data="chartLabels"
:data="chartData"
height="320px"
/>
</div>
<div v-if="result.record_id" class="record-tip"
>已保存至测试记录记录 ID{{ result.record_id }}</div
>
</div>
<template #footer>
<template v-if="!result">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleRun">开始测试</el-button>
</template>
<template v-else>
<el-button type="primary" @click="handleReset">再测一次</el-button>
<el-button @click="handleClose">关闭</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/reward_config/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import { ElMessage } from 'element-plus'
const GRID_NUMBERS = [
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
30
]
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false
})
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const form = ref({
lottery_config_id: null as number | null,
test_count: 100 as 100 | 500 | 1000 | 5000 | 10000,
save_record: true
})
const lotteryConfigOptions = ref<
Array<{
id: number
name: string
t1_weight: number
t2_weight: number
t3_weight: number
t4_weight: number
t5_weight: number
}>
>([])
const submitting = ref(false)
const result = ref<{ counts: Record<string, number>; record_id: number | null } | null>(null)
const selectedLotteryConfig = computed(
() => lotteryConfigOptions.value.find((opt) => opt.id === form.value.lottery_config_id) || null
)
const selectedTierSummary = computed(() => {
const opt = selectedLotteryConfig.value
if (!opt) return ''
const t1 = opt.t1_weight
const t2 = opt.t2_weight
const t3 = opt.t3_weight
const t4 = opt.t4_weight
const t5 = opt.t5_weight
const total = t1 + t2 + t3 + t4 + t5
if (!total) {
return `当前奖池 T1-T5 权重T1=${t1} T2=${t2} T3=${t3} T4=${t4} T5=${t5}(总和为 0测试时会按均等档位概率`
}
const p = (v: number) => ((v / total) * 100).toFixed(1)
return `当前奖池 T1-T5 权重T1=${t1} (${p(t1)}%)T2=${t2} (${p(t2)}%)T3=${t3} (${p(t3)}%)T4=${t4} (${p(t4)}%)T5=${t5} (${p(t5)}%)`
})
const chartLabels = computed(() => GRID_NUMBERS.map((n) => String(n)))
const chartData = computed(() => {
if (!result.value?.counts) return GRID_NUMBERS.map(() => 0)
const counts = result.value.counts
return GRID_NUMBERS.map((n) => {
const v = counts[String(n)] ?? counts[n]
return typeof v === 'number' && !Number.isNaN(v) ? v : 0
})
})
async function loadLotteryOptions() {
try {
const list = await lotteryConfigApi.getOptions()
lotteryConfigOptions.value = Array.isArray(list) ? list : []
} catch {
lotteryConfigOptions.value = []
}
}
async function handleRun() {
submitting.value = true
result.value = null
try {
const res = await api.runWeightTest({
test_count: form.value.test_count,
save_record: form.value.save_record,
lottery_config_id: form.value.lottery_config_id ?? undefined
})
const data = (res as any)?.data ?? res
result.value = {
counts: data.counts ?? {},
record_id: data.record_id ?? null
}
} catch (e: any) {
ElMessage.error(e?.message ?? '测试请求失败')
} finally {
submitting.value = false
}
}
function handleReset() {
result.value = null
}
function handleClose() {
visible.value = false
result.value = null
}
watch(visible, (open) => {
if (open) {
loadLotteryOptions()
}
})
</script>
<style lang="scss" scoped>
.test-form {
padding: 8px 0;
}
.form-tip {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.test-result {
padding: 8px 0;
}
.result-summary {
margin-bottom: 16px;
font-size: 14px;
}
.chart-wrap {
margin-bottom: 12px;
}
.record-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'dice:reward_config_record:index:save'"
@click="showDialog('add')"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:reward_config_record:index:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 权重快照显示条数 -->
<template #snapshot_summary="{ row }">
<span>{{ getSnapshotSummary(row) }}</span>
</template>
<!-- 落点统计显示总次数 -->
<template #result_summary="{ row }">
<span>{{ getResultSummary(row) }}</span>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:reward_config_record:index:read'"
type="success"
toolTip="查看详情"
@click="openDetail(row)"
/>
<SaButton
v-permission="'dice:reward_config_record:index:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
<!-- 详情抽屉 -->
<DetailDrawer v-model="detailVisible" :record="detailRecord" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/reward_config_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import DetailDrawer from './modules/detail-drawer.vue'
// 搜索表单
const searchForm = ref({})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 详情抽屉
const detailVisible = ref(false)
const detailRecord = ref<Record<string, any> | null>(null)
function openDetail(row: Record<string, any>) {
detailRecord.value = { ...row }
detailVisible.value = true
}
// 权重快照摘要:条数(兼容后端返回数组或字符串)
function getSnapshotSummary(row: Record<string, any>): string {
const snap = row.weight_config_snapshot
if (Array.isArray(snap)) return `${snap.length}`
if (snap && typeof snap === 'object') return `${Object.keys(snap).length}`
if (typeof snap === 'string') {
try {
const arr = JSON.parse(snap)
return Array.isArray(arr) ? `${arr.length}` : '—'
} catch {
return '—'
}
}
return '—'
}
// 落点统计摘要:总次数
function getResultSummary(row: Record<string, any>): string {
const counts = row.result_counts
if (counts && typeof counts === 'object' && !Array.isArray(counts)) {
const total = Object.values(counts).reduce((s: number, v: any) => s + (Number(v) || 0), 0)
return `${total}`
}
if (typeof counts === 'string') {
try {
const obj = JSON.parse(counts)
if (obj && typeof obj === 'object') {
const total = Object.values(obj).reduce((s: number, v: any) => s + (Number(v) || 0), 0)
return `${total}`
}
} catch {
// ignore
}
}
return '—'
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: { limit: 100 },
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: 'ID', width: 80, align: 'center' },
{ prop: 'test_count', label: '测试次数', width: 100, align: 'center' },
{
prop: 'admin_name',
label: '管理员',
width: 120,
align: 'center',
showOverflowTooltip: true
},
{
prop: 'snapshot_summary',
label: '权重快照',
width: 110,
align: 'center',
useSlot: true
},
{
prop: 'result_summary',
label: '落点统计',
width: 150,
align: 'center',
useSlot: true
},
{ prop: 'create_time', label: '创建时间', width: 170, align: 'center' },
{
prop: 'operation',
label: '操作',
width: 100,
align: 'center',
fixed: 'right',
useSlot: true
}
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,323 @@
<template>
<el-drawer
v-model="visible"
title="测试记录详情"
:size="drawerSize"
direction="rtl"
destroy-on-close
@close="handleClose"
>
<template v-if="record">
<div class="detail-section">
<div class="section-title">基本信息</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="记录ID">
{{ record.id }}
</el-descriptions-item>
<el-descriptions-item label="测试次数">{{ record.test_count }} </el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ record.create_time || '—' }}
</el-descriptions-item>
<el-descriptions-item label="执行管理员">
{{ record.admin_name ?? record.admin_id ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="奖池配置ID" :span="2">
{{ record.lottery_config_id ?? '—' }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="detail-section">
<div class="section-title">T1-T5 档位权重配比测试时使用的奖池配比</div>
<el-table
v-if="tierWeightsTableData.length"
:data="tierWeightsTableData"
border
size="small"
class="tier-weights-table"
max-height="200"
>
<el-table-column prop="tier" label="档位" width="80" align="center" />
<el-table-column prop="weight" label="权重" width="100" align="center" />
<el-table-column prop="percent" label="占比" width="100" align="center" />
</el-table>
<div v-else class="empty-tip">
暂无档位权重数据旧记录可能未保存 tier_weights_snapshot
</div>
</div>
<div class="detail-section">
<div class="section-title">权重配比快照测试时使用的 T1-T5/BIGWIN 配置</div>
<el-table
:data="snapshotTableData"
border
size="small"
max-height="280"
class="snapshot-table"
>
<el-table-column prop="tier" label="档位" width="80" align="center" />
<el-table-column prop="grid_number" label="色子点数" width="100" align="center" />
<el-table-column prop="weight" label="权重" width="90" align="center" />
<el-table-column prop="id" label="配置ID" width="80" align="center" />
</el-table>
<div v-if="!snapshotTableData.length" class="empty-tip">暂无快照数据</div>
</div>
<div class="detail-section">
<div class="section-title">落点统计 grid_number 出现次数</div>
<div class="chart-wrap">
<ArtBarChart
x-axis-name="色子点数 (grid_number)"
:x-axis-data="chartLabels"
:data="chartData"
height="280px"
/>
</div>
<div v-if="resultTotal === 0" class="empty-tip">暂无落点数据</div>
<div v-else class="result-summary">总落点次数{{ resultTotal }}</div>
</div>
<div class="detail-section footer-actions">
<el-button type="primary" :loading="importing" @click="openImport">
导入到当前配置
</el-button>
</div>
</template>
<!-- 导入弹窗 -->
<el-dialog
v-model="importVisible"
title="导入权重到正式配置"
width="460px"
align-center
:close-on-click-modal="false"
>
<p class="import-desc">
将本测试记录的权重配比快照写入 DiceRewardConfigT1-T5
档位权重写入所选奖池配置并刷新缓存
</p>
<el-form label-width="120px">
<el-form-item label="导入档位到奖池">
<el-select
v-model="importLotteryConfigId"
placeholder="选择要写入 T1-T5 权重的奖池配置"
clearable
style="width: 100%"
>
<el-option
v-for="opt in lotteryConfigOptions"
:key="opt.id"
:label="opt.name"
:value="opt.id"
/>
</el-select>
<div class="form-tip">不选则使用本记录保存时的奖池配置 ID</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="importVisible = false">取消</el-button>
<el-button type="primary" :loading="importing" @click="confirmImport">确认导入</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup lang="ts">
import ArtBarChart from '@/components/core/charts/art-bar-chart/index.vue'
import recordApi from '../../../api/reward_config_record/index'
import lotteryConfigApi from '../../../api/lottery_pool_config/index'
import { ElMessage } from 'element-plus'
const GRID_NUMBERS = [
5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
30
]
interface RecordRow {
id?: number
test_count?: number
create_time?: string
admin_id?: number | null
admin_name?: string
lottery_config_id?: number | null
tier_weights_snapshot?: Record<string, number>
weight_config_snapshot?: Array<{
id?: number
grid_number?: number
tier?: string
weight?: number
}>
result_counts?: Record<string, number>
}
interface Props {
modelValue: boolean
record: RecordRow | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'import-done'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
record: null
})
const emit = defineEmits<Emits>()
const drawerSize = ref('720px')
const importVisible = ref(false)
const importing = ref(false)
const importLotteryConfigId = ref<number | null>(null)
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const tierWeightsTableData = computed(() => {
const t = props.record?.tier_weights_snapshot
if (!t || typeof t !== 'object') return []
const tiers = ['T1', 'T2', 'T3', 'T4', 'T5']
const rows = tiers.map((tier) => {
const w = t[tier] ?? t[tier.toLowerCase()] ?? 0
return { tier, weight: w }
})
const total = rows.reduce((sum, r) => sum + r.weight, 0)
return rows.map((r) => ({
tier: r.tier,
weight: r.weight,
percent: total > 0 ? `${((r.weight / total) * 100).toFixed(1)}%` : '—'
}))
})
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const snapshotTableData = computed(() => {
const snapshot = props.record?.weight_config_snapshot
if (!Array.isArray(snapshot)) return []
return snapshot.map((item) => ({
id: item.id ?? '—',
grid_number: item.grid_number ?? '—',
tier: item.tier ?? '—',
weight: item.weight ?? '—'
}))
})
const chartLabels = computed(() => GRID_NUMBERS.map((n) => String(n)))
const chartData = computed(() => {
const counts = props.record?.result_counts
if (!counts || typeof counts !== 'object') return GRID_NUMBERS.map(() => 0)
return GRID_NUMBERS.map((n) => {
const v = counts[String(n)] ?? counts[n]
return typeof v === 'number' && !Number.isNaN(v) ? v : 0
})
})
const resultTotal = computed(() => {
const data = chartData.value
return data.reduce((sum, n) => sum + n, 0)
})
function handleClose() {
visible.value = false
importVisible.value = false
}
if (typeof window !== 'undefined') {
const updateDrawerSize = () => {
drawerSize.value = window.innerWidth <= 768 ? '100%' : '720px'
}
updateDrawerSize()
window.addEventListener('resize', updateDrawerSize)
}
async function loadLotteryOptions() {
try {
const list = await lotteryConfigApi.getOptions()
lotteryConfigOptions.value = Array.isArray(list) ? list : []
} catch {
lotteryConfigOptions.value = []
}
}
function openImport() {
importLotteryConfigId.value = props.record?.lottery_config_id ?? null
importVisible.value = true
loadLotteryOptions()
}
async function confirmImport() {
const id = props.record?.id
if (!id) return
importing.value = true
try {
await recordApi.importFromRecord({
record_id: id,
lottery_config_id: importLotteryConfigId.value ?? undefined
})
ElMessage.success('导入成功,已刷新奖励配置与奖池配置')
importVisible.value = false
emit('import-done')
} catch (e: any) {
ElMessage.error(e?.message ?? '导入失败')
} finally {
importing.value = false
}
}
</script>
<style lang="scss" scoped>
:deep(.el-drawer__body) {
padding: 16px;
overflow: auto;
max-height: calc(100vh - 60px);
}
.detail-section {
margin-bottom: 24px;
}
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: var(--el-text-color-primary);
}
.snapshot-table {
margin-bottom: 8px;
}
.chart-wrap {
margin-bottom: 8px;
}
.empty-tip {
padding: 12px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.result-summary {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.tier-weights-table {
margin-bottom: 8px;
}
.footer-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.import-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 16px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增奖励配置权重测试记录' : '编辑奖励配置权重测试记录'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="测试次数100/500/1000" prop="test_count">
<el-input v-model="formData.test_count" placeholder="请输入测试次数100/500/1000" />
</el-form-item>
<el-form-item label="测试时权重配比快照:按档位保存 id,grid_number,tier,weight" prop="weight_config_snapshot">
<el-input v-model="formData.weight_config_snapshot" placeholder="请输入测试时权重配比快照:按档位保存 id,grid_number,tier,weight" />
</el-form-item>
<el-form-item label="落点统计grid_number=&gt;出现次数" prop="result_counts">
<el-input v-model="formData.result_counts" placeholder="请输入落点统计grid_number=&gt;出现次数" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/reward_config_record/index'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
test_count: [{ required: true, message: '测试次数100/500/1000必需填写', trigger: 'blur' }],
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
test_count: 100,
weight_config_snapshot: '',
result_counts: '',
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -30,16 +30,26 @@ class DiceLotteryPoolConfigController extends BaseController
}
/**
* 获取 DiceLotteryPoolConfig 列表数据,仅含 id、name用于 lottery_config_id 下拉(值为 id显示为 name
* 获取 DiceLotteryPoolConfig 列表数据,用于 lottery_config_id 下拉(值为 id显示为 name,并附带 T1-T5 档位权重
* @param Request $request
* @return Response 返回 [ ['id' => int, 'name' => string], ... ]
* @return Response 返回 [ ['id' => int, 'name' => string, 't1_weight' => int, ... 't5_weight' => int], ... ]
*/
#[Permission('色子奖池配置列表', 'dice:lottery_pool_config:index:index')]
public function getOptions(Request $request): Response
{
$list = DiceLotteryPoolConfig::field('id,name')->order('id', 'asc')->select();
$list = DiceLotteryPoolConfig::field('id,name,t1_weight,t2_weight,t3_weight,t4_weight,t5_weight')
->order('id', 'asc')
->select();
$data = $list->map(function ($item) {
return ['id' => (int) $item['id'], 'name' => (string) ($item['name'] ?? '')];
return [
'id' => (int) $item['id'],
'name' => (string) ($item['name'] ?? ''),
't1_weight' => (int) ($item['t1_weight'] ?? 0),
't2_weight' => (int) ($item['t2_weight'] ?? 0),
't3_weight' => (int) ($item['t3_weight'] ?? 0),
't4_weight' => (int) ($item['t4_weight'] ?? 0),
't5_weight' => (int) ($item['t5_weight'] ?? 0),
];
})->toArray();
return $this->success($data);
}

View File

@@ -155,4 +155,29 @@ class DiceRewardConfigController extends BaseController
return $this->fail($e->getMessage());
}
}
/**
* 权重配比测试:仅模拟落点统计,不创建游玩记录。按当前配置在内存中模拟 N 次抽奖,返回各 grid_number 落点次数,可选保存到 dice_reward_config_record。
* @param Request $request test_count: 100|500|1000, save_record: bool, lottery_config_id: int|null 奖池配置ID用于设定 T1-T5 概率
* @return Response
*/
#[Permission('奖励配置列表', 'dice:reward_config:index:index')]
public function runWeightTest(Request $request): Response
{
$testCount = (int) $request->post('test_count', 100);
$saveRecord = (bool) $request->post('save_record', true);
$adminId = isset($this->adminInfo['id']) ? (int) $this->adminInfo['id'] : null;
$lotteryConfigId = $request->post('lottery_config_id', null);
if ($lotteryConfigId !== null && $lotteryConfigId !== '') {
$lotteryConfigId = (int) $lotteryConfigId;
} else {
$lotteryConfigId = null;
}
try {
$result = $this->logic->runWeightTest($testCount, $saveRecord, $adminId, $lotteryConfigId);
return $this->success($result);
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
}

View File

@@ -0,0 +1,168 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\reward_config_record;
use app\dice\logic\reward_config_record\DiceRewardConfigRecordLogic;
use app\dice\validate\reward_config_record\DiceRewardConfigRecordValidate;
use plugin\saiadmin\basic\BaseController;
use plugin\saiadmin\app\model\system\SystemUser;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 奖励配置权重测试记录控制器
*/
class DiceRewardConfigRecordController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DiceRewardConfigRecordLogic();
$this->validate = new DiceRewardConfigRecordValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录列表', 'dice:reward_config_record:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录读取', 'dice:reward_config_record:index:read')]
public function read(Request $request): Response
{
$id = $request->input('id', '');
$model = $this->logic->read($id);
if ($model) {
$data = is_array($model) ? $model : $model->toArray();
$data['admin_name'] = $this->getAdminName((int) ($data['admin_id'] ?? 0));
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 根据管理员 ID 获取姓名realname 优先,否则 username
*/
private function getAdminName(int $adminId): string
{
if ($adminId <= 0) {
return '—';
}
$user = SystemUser::where('id', $adminId)->field('id,realname,username')->find();
if (!$user) {
return '';
}
$user = is_array($user) ? $user : $user->toArray();
$name = trim((string) ($user['realname'] ?? ''));
if ($name !== '') {
return $name;
}
$name = trim((string) ($user['username'] ?? ''));
return $name !== '' ? $name : (string) $adminId;
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录添加', 'dice:reward_config_record:index:save')]
public function save(Request $request): Response
{
$data = $request->post();
$this->validate('save', $data);
$result = $this->logic->add($data);
if ($result) {
return $this->success('添加成功');
} else {
return $this->fail('添加失败');
}
}
/**
* 更新数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录修改', 'dice:reward_config_record:index:update')]
public function update(Request $request): Response
{
$data = $request->post();
$this->validate('update', $data);
$result = $this->logic->edit($data['id'], $data);
if ($result) {
return $this->success('修改成功');
} else {
return $this->fail('修改失败');
}
}
/**
* 删除数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置权重测试记录删除', 'dice:reward_config_record:index:destroy')]
public function destroy(Request $request): Response
{
$ids = $request->post('ids', '');
if (empty($ids)) {
return $this->fail('请选择要删除的数据');
}
$result = $this->logic->destroy($ids);
if ($result) {
return $this->success('删除成功');
} else {
return $this->fail('删除失败');
}
}
/**
* 导入:将测试记录的权重写入 DiceRewardConfig 与 DiceLotteryPoolConfig并重新实例化缓存
* @param Request $request record_id: 测试记录ID, lottery_config_id: 可选导入档位权重到的奖池配置ID不传则用记录内的 lottery_config_id
* @return Response
*/
#[Permission('奖励配置权重测试记录列表', 'dice:reward_config_record:index:index')]
public function importFromRecord(Request $request): Response
{
$recordId = (int) $request->post('record_id', 0);
$lotteryConfigId = $request->post('lottery_config_id', null);
if ($recordId <= 0) {
return $this->fail('请指定测试记录');
}
if ($lotteryConfigId !== null && $lotteryConfigId !== '') {
$lotteryConfigId = (int) $lotteryConfigId;
} else {
$lotteryConfigId = null;
}
try {
$this->logic->importFromRecord($recordId, $lotteryConfigId);
return $this->success('导入成功,已刷新奖励配置与奖池配置');
} catch (\plugin\saiadmin\exception\ApiException $e) {
return $this->fail($e->getMessage());
}
}
}

View File

@@ -6,10 +6,12 @@
// +----------------------------------------------------------------------
namespace app\dice\logic\reward_config;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward_config\DiceRewardConfig;
use app\dice\model\reward_config\DiceRewardConfigRecord;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\reward_config\DiceRewardConfig;
use support\Log;
/**
@@ -135,4 +137,160 @@ class DiceRewardConfigLogic extends BaseLogic
}
DiceRewardConfig::refreshCache();
}
/** 测试时档位权重均为 0 的异常标识 */
private const EXCEPTION_WEIGHT_ALL_ZERO = 'REWARD_WEIGHT_ALL_ZERO';
/**
* 按权重抽取一条配置(与 PlayStartLogic 抽奖逻辑一致,仅 weight>0 参与)
*/
private static function drawRewardByWeight(array $rewards): array
{
if (empty($rewards)) {
throw new \InvalidArgumentException('rewards 不能为空');
}
$candidateWeights = [];
foreach ($rewards as $i => $row) {
$w = isset($row['weight']) ? (float) $row['weight'] : 0.0;
if ($w > 0) {
$candidateWeights[$i] = $w;
}
}
$total = (float) array_sum($candidateWeights);
if ($total > 0) {
$r = (random_int(0, PHP_INT_MAX - 1) / (float) PHP_INT_MAX) * $total;
$acc = 0.0;
foreach ($candidateWeights as $i => $w) {
$acc += $w;
if ($r < $acc) {
return $rewards[$i];
}
}
return $rewards[array_key_last($candidateWeights)];
}
throw new \RuntimeException(self::EXCEPTION_WEIGHT_ALL_ZERO);
}
/**
* 按档位权重数组抽取 T1-T5
*/
private static function drawTierByWeightArray(array $tiers, array $weights): string
{
$total = array_sum($weights);
if ($total <= 0) {
return $tiers[random_int(0, count($tiers) - 1)];
}
$r = random_int(1, (int) $total);
$acc = 0;
foreach ($weights as $i => $w) {
$acc += (int) $w;
if ($r <= $acc) {
return $tiers[$i];
}
}
return $tiers[count($tiers) - 1];
}
/**
* 运行权重配比测试:仅按当前配置在内存中模拟 N 次抽奖,统计各 grid_number 落点数量。
* 不创建任何游玩记录DicePlayRecord、不扣券、不写钱包仅用于验证权重配比效果。
*
* @param int $testCount 测试次数 100/500/1000/5000/10000
* @param bool $saveRecord 是否保存到 dice_reward_config_record测试记录表非游玩记录
* @param int|null $adminId 执行人管理员ID
* @param int|null $lotteryConfigId 奖池配置IDDiceLotteryPoolConfig用于设定 T1-T5 档位概率;不传则使用 type=0 的配置或均等
* @return array{counts: array<int,int>, record_id: int|null} counts 为 grid_number=>出现次数
*/
public function runWeightTest(int $testCount, bool $saveRecord = true, ?int $adminId = null, ?int $lotteryConfigId = null): array
{
$allowedCounts = [100, 500, 1000, 5000, 10000];
if (!in_array($testCount, $allowedCounts, true)) {
throw new ApiException('测试次数仅支持 100、500、1000、5000、10000');
}
$grouped = $this->getListGroupedByTier();
$tiers = ['T1', 'T2', 'T3', 'T4', 'T5'];
$tierWeights = [1, 1, 1, 1, 1];
$config = null;
if ($lotteryConfigId !== null && $lotteryConfigId > 0) {
$config = DiceLotteryPoolConfig::find($lotteryConfigId);
}
if (!$config) {
$config = DiceLotteryPoolConfig::where('type', 0)->find();
}
if ($config) {
$tierWeights = [
(int) ($config->t1_weight ?? 0),
(int) ($config->t2_weight ?? 0),
(int) ($config->t3_weight ?? 0),
(int) ($config->t4_weight ?? 0),
(int) ($config->t5_weight ?? 0),
];
if (array_sum($tierWeights) <= 0) {
$tierWeights = [1, 1, 1, 1, 1];
}
}
$counts = [];
$maxRetry = 20;
for ($i = 0; $i < $testCount; $i++) {
$tier = self::drawTierByWeightArray($tiers, $tierWeights);
$rewards = $grouped[$tier] ?? [];
if (empty($rewards)) {
continue;
}
$attempt = 0;
while ($attempt < $maxRetry) {
try {
$chosen = self::drawRewardByWeight($rewards);
$gridNumber = isset($chosen['grid_number']) ? (int) $chosen['grid_number'] : 0;
if ($gridNumber >= 5 && $gridNumber <= 30) {
$counts[$gridNumber] = ($counts[$gridNumber] ?? 0) + 1;
}
break;
} catch (\RuntimeException $e) {
if ($e->getMessage() === self::EXCEPTION_WEIGHT_ALL_ZERO) {
$attempt++;
continue;
}
throw $e;
}
}
}
$snapshot = [];
foreach ($grouped as $tierKey => $rows) {
foreach ($rows as $row) {
$snapshot[] = [
'id' => (int) ($row['id'] ?? 0),
'grid_number' => (int) ($row['grid_number'] ?? 0),
'tier' => (string) ($row['tier'] ?? ''),
'weight' => (int) ($row['weight'] ?? 0),
];
}
}
$tierWeightsSnapshot = [
'T1' => $tierWeights[0] ?? 0,
'T2' => $tierWeights[1] ?? 0,
'T3' => $tierWeights[2] ?? 0,
'T4' => $tierWeights[3] ?? 0,
'T5' => $tierWeights[4] ?? 0,
];
$recordId = null;
if ($saveRecord) {
$record = new DiceRewardConfigRecord();
$record->test_count = $testCount;
$record->weight_config_snapshot = $snapshot;
$record->tier_weights_snapshot = $tierWeightsSnapshot;
$record->lottery_config_id = $config ? (int) $config->id : null;
$record->result_counts = $counts;
$record->admin_id = $adminId;
$record->create_time = date('Y-m-d H:i:s');
$record->save();
$recordId = (int) $record->id;
}
return ['counts' => $counts, 'record_id' => $recordId];
}
}

View File

@@ -0,0 +1,125 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace app\dice\logic\reward_config_record;
use app\dice\model\lottery_pool_config\DiceLotteryPoolConfig;
use app\dice\model\reward_config\DiceRewardConfig;
use app\dice\model\reward_config_record\DiceRewardConfigRecord;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\app\model\system\SystemUser;
/**
* 奖励配置权重测试记录逻辑层
*/
class DiceRewardConfigRecordLogic extends BaseLogic
{
public function __construct()
{
$this->model = new DiceRewardConfigRecord();
}
/**
* 分页列表,并为每条记录附加 admin_name管理员姓名realname 或 username
*/
public function getList($query): mixed
{
$result = parent::getList($query);
if (!is_array($result)) {
return $result;
}
$rows = $result['data'] ?? $result['records'] ?? null;
if (!is_array($rows) || empty($rows)) {
return $result;
}
$adminIds = array_unique(array_filter(array_column($rows, 'admin_id')));
$nameMap = $this->getAdminNameMap($adminIds);
$key = isset($result['data']) ? 'data' : 'records';
foreach ($result[$key] as &$row) {
$aid = isset($row['admin_id']) ? (int) $row['admin_id'] : 0;
$row['admin_name'] = $nameMap[$aid] ?? ($aid > 0 ? '' : '—');
}
unset($row);
return $result;
}
/**
* 根据管理员 ID 列表获取 id => 姓名realname 优先,否则 username
* @param array $adminIds
* @return array<int, string>
*/
private function getAdminNameMap(array $adminIds): array
{
if (empty($adminIds)) {
return [];
}
$list = SystemUser::whereIn('id', $adminIds)->field('id,realname,username')->select()->toArray();
$map = [];
foreach ($list as $user) {
$user = is_array($user) ? $user : (array) $user;
$id = (int) ($user['id'] ?? 0);
$name = trim((string) ($user['realname'] ?? ''));
if ($name === '') {
$name = trim((string) ($user['username'] ?? ''));
}
$map[$id] = $name !== '' ? $name : (string) $id;
}
return $map;
}
/**
* 将测试记录的权重导入到正式配置weight_config_snapshot → DiceRewardConfigtier_weights_snapshot → DiceLotteryPoolConfig并刷新缓存
* @param int $recordId 测试记录 ID
* @param int|null $lotteryConfigId 要导入档位权重的奖池配置 ID不传则使用记录中的 lottery_config_id若有
* @throws ApiException
*/
public function importFromRecord(int $recordId, ?int $lotteryConfigId = null): void
{
$record = $this->model->find($recordId);
if (!$record) {
throw new ApiException('测试记录不存在');
}
$record = is_array($record) ? $record : $record->toArray();
$snapshot = $record['weight_config_snapshot'] ?? null;
if (is_string($snapshot)) {
$snapshot = json_decode($snapshot, true);
}
if (is_array($snapshot) && !empty($snapshot)) {
foreach ($snapshot as $item) {
$id = isset($item['id']) ? (int) $item['id'] : 0;
$weight = isset($item['weight']) ? (int) $item['weight'] : 1;
if ($id > 0) {
DiceRewardConfig::where('id', $id)->update(['weight' => $weight]);
}
}
}
$tierSnapshot = $record['tier_weights_snapshot'] ?? null;
if (is_string($tierSnapshot)) {
$tierSnapshot = json_decode($tierSnapshot, true);
}
$targetLotteryId = $lotteryConfigId !== null && $lotteryConfigId > 0
? $lotteryConfigId
: (isset($record['lottery_config_id']) && (int) $record['lottery_config_id'] > 0 ? (int) $record['lottery_config_id'] : null);
if (is_array($tierSnapshot) && !empty($tierSnapshot) && $targetLotteryId > 0) {
$pool = DiceLotteryPoolConfig::find($targetLotteryId);
if (!$pool) {
throw new ApiException('奖池配置不存在');
}
$update = [
't1_weight' => (int) ($tierSnapshot['T1'] ?? $tierSnapshot['t1'] ?? 0),
't2_weight' => (int) ($tierSnapshot['T2'] ?? $tierSnapshot['t2'] ?? 0),
't3_weight' => (int) ($tierSnapshot['T3'] ?? $tierSnapshot['t3'] ?? 0),
't4_weight' => (int) ($tierSnapshot['T4'] ?? $tierSnapshot['t4'] ?? 0),
't5_weight' => (int) ($tierSnapshot['T5'] ?? $tierSnapshot['t5'] ?? 0),
];
DiceLotteryPoolConfig::where('id', $targetLotteryId)->update($update);
}
DiceRewardConfig::refreshCache();
DiceRewardConfig::clearRequestInstance();
}
}

View File

@@ -0,0 +1,32 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
namespace app\dice\model\reward_config;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 权重配比测试记录模型
*
* dice_reward_config_record 保存测试时的权重快照与落点统计
*
* @property int $id
* @property int $test_count 测试次数 100/500/1000/5000/10000
* @property array $weight_config_snapshot 测试时权重配比快照
* @property array $tier_weights_snapshot 测试时 T1-T5 档位权重快照(来自奖池配置)
* @property int|null $lottery_config_id 测试时使用的奖池配置 ID
* @property array $result_counts 落点统计 grid_number=>出现次数
* @property int|null $admin_id 执行测试的管理员ID
* @property string|null $create_time 创建时间
*/
class DiceRewardConfigRecord extends BaseModel
{
protected $pk = 'id';
protected $table = 'dice_reward_config_record';
protected $json = ['weight_config_snapshot', 'tier_weights_snapshot', 'result_counts'];
protected $jsonAssoc = true;
}

View File

@@ -0,0 +1,34 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\reward_config_record;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 奖励配置权重测试记录模型
*
* dice_reward_config_record 奖励配置权重测试记录
*
* @property int $id 主键
* @property int $test_count 测试次数 100/500/1000/5000/10000
* @property array $weight_config_snapshot 测试时权重配比快照:按档位 id,grid_number,tier,weight
* @property array $tier_weights_snapshot 测试时 T1-T5 档位权重快照(来自奖池配置)
* @property int|null $lottery_config_id 测试时使用的奖池配置 ID
* @property array $result_counts 落点统计 grid_number=>出现次数
* @property int|null $admin_id 执行测试的管理员ID
* @property string|null $create_time 创建时间
*/
class DiceRewardConfigRecord extends BaseModel
{
protected $pk = 'id';
protected $table = 'dice_reward_config_record';
protected $json = ['weight_config_snapshot', 'tier_weights_snapshot', 'result_counts'];
protected $jsonAssoc = true;
}

View File

@@ -0,0 +1,42 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\reward_config_record;
use plugin\saiadmin\basic\BaseValidate;
/**
* 奖励配置权重测试记录验证器
*/
class DiceRewardConfigRecordValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'test_count' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'test_count' => '测试次数100/500/1000必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'test_count',
],
'update' => [
'test_count',
],
];
}

View File

@@ -105,10 +105,13 @@ Route::group('/core', function () {
fastRoute('dice/reward_config/DiceRewardConfig', \app\dice\controller\reward_config\DiceRewardConfigController::class);
Route::get('/dice/reward_config/DiceRewardConfig/weightRatioList', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'weightRatioList']);
Route::post('/dice/reward_config/DiceRewardConfig/batchUpdateWeights', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'batchUpdateWeights']);
Route::post('/dice/reward_config/DiceRewardConfig/runWeightTest', [\app\dice\controller\reward_config\DiceRewardConfigController::class, 'runWeightTest']);
fastRoute('dice/lottery_pool_config/DiceLotteryPoolConfig', \app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class);
Route::get('/dice/lottery_pool_config/DiceLotteryPoolConfig/getOptions', [\app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class, 'getOptions']);
Route::get('/dice/lottery_pool_config/DiceLotteryPoolConfig/getCurrentPool', [\app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class, 'getCurrentPool']);
Route::post('/dice/lottery_pool_config/DiceLotteryPoolConfig/updateCurrentPool', [\app\dice\controller\lottery_pool_config\DiceLotteryPoolConfigController::class, 'updateCurrentPool']);
fastRoute('dice/reward_config_record/DiceRewardConfigRecord', \app\dice\controller\reward_config_record\DiceRewardConfigRecordController::class);
Route::post('/dice/reward_config_record/DiceRewardConfigRecord/importFromRecord', [\app\dice\controller\reward_config_record\DiceRewardConfigRecordController::class, 'importFromRecord']);
// 数据表维护
Route::get("/database/index", [\plugin\saiadmin\app\controller\system\DataBaseController::class, 'index']);