Compare commits

41 Commits

Author SHA1 Message Date
e5f83846b3 将项目中所有total_draw_count字段重构为total_ticket_count字段
将项目中所有paid_draw_count字段重构为paid_ticket_count字段
将项目中所有free_draw_count字段重构为free_ticket_count字段
2026-03-05 14:15:32 +08:00
5ab16243bd 重新设计状态码规范 2026-03-05 13:44:56 +08:00
8d8cee696f 重新登录或注册后清除掉原有用户的user-token保证只有一个用户能够登录 2026-03-05 12:25:59 +08:00
74612f136e 相同的设备标识dice只保证一个auth-token生效,清除掉多余的同一个dice多余的auth-token 2026-03-05 12:22:41 +08:00
13d8adbfe0 添加authToken和userToken 2026-03-05 12:17:20 +08:00
a10afa5add 优化获取平台币接口coin返回null的问题 2026-03-04 20:17:40 +08:00
2cfbe0c80c 修复打包失败问题 2026-03-04 20:12:23 +08:00
85babe3fd4 [接口]新增玩家-钱包记录,玩家-抽奖记录 2026-03-04 20:01:25 +08:00
2dcc9f479a [接口]优化新增购买抽奖券时记录DicePlayerTicketRecord 2026-03-04 17:36:52 +08:00
bff8ea04e6 [色子游戏]玩家获取抽奖券记录-重构DicePlayerCoinRecord为DicePlayerTicketRecord 2026-03-04 17:25:05 +08:00
0a3af2d422 [接口]新增抽奖接口/api/game/playStart 2026-03-04 17:06:30 +08:00
21c638a231 [色子游戏]奖池配置-优化样式 2026-03-04 17:06:24 +08:00
a6858adf14 统一规范状态码 2026-03-04 16:07:07 +08:00
5d0e2a82ff [接口]新增获取彩金池,购买游戏次数(购买抽奖券)接口 2026-03-04 15:35:30 +08:00
dead78a5f3 [色子游戏]玩家钱包流水记录-优化样式 2026-03-04 15:24:22 +08:00
0492e08cc7 [色子游戏]玩家-新增玩家钱包操作 2026-03-04 15:04:11 +08:00
00d964ad80 [色子游戏]玩家钱包流水记录-优化样式 2026-03-04 14:34:51 +08:00
33e3603932 [色子游戏]玩家抽奖记录-优化抽奖roll_array记录五个色子 2026-03-04 13:43:56 +08:00
6d2b74a899 [接口]新增获取用户信息接口user/info, 获取钱包余额接口 2026-03-04 11:49:53 +08:00
77a898df22 [接口]鉴权authToken用户登录login-注册register-退出logout, 并将用户信息保存到redis中 2026-03-04 11:39:11 +08:00
ad56d6d4ce [色子游戏]玩家-新增phone字段 2026-03-04 10:55:31 +08:00
01f5d6c832 [色子游戏]玩家-新增phone字段 2026-03-04 10:55:23 +08:00
894a562eb4 [色子游戏]玩家抽奖记录-新增字段保存中奖详情记录信息 2026-03-04 10:21:05 +08:00
5b39efc7a3 关闭验证码功能 2026-03-03 18:57:16 +08:00
267b088242 关闭验证码功能 2026-03-03 18:55:03 +08:00
eaf3f2f48f 修改前端访问后台地址为dice-api.yuliao666.top 2026-03-03 18:25:10 +08:00
ce0af98157 修改端口号8787为6688 2026-03-03 17:28:49 +08:00
7e3cee4150 修复打包前端报错 2026-03-03 17:05:36 +08:00
878dbbf578 [色子游戏]玩家抽奖记录-优化样式 2026-03-03 16:21:41 +08:00
3606d4635e [色子游戏]玩家抽奖记录 2026-03-03 16:01:47 +08:00
18c1a0a693 [色子游戏]奖池配置-优化样式 2026-03-03 15:49:12 +08:00
c02d19b1fd [色子游戏]玩家购买抽奖记录-优化样式 2026-03-03 15:28:13 +08:00
1b62a4f3e0 [色子游戏]玩家钱包流水记录-优化样式 2026-03-03 15:15:16 +08:00
ae3c0f0f78 [色子游戏]玩家购买抽奖记录 2026-03-03 15:07:01 +08:00
2cf409345e [色子游戏]奖池配置-优化样式 2026-03-03 14:36:13 +08:00
a54f4623c5 [色子游戏]玩家-优化样式 2026-03-03 14:36:04 +08:00
fc5f8bb1ca [色子游戏]玩家钱包流水记录-优化关联方式 2026-03-03 14:11:52 +08:00
d214ccc2ba [色子游戏]玩家钱包流水记录 2026-03-03 14:10:07 +08:00
439f01ee6c [色子游戏]玩家 2026-03-03 13:51:29 +08:00
ea9f17d43b [色子游戏]奖池配置 2026-03-03 13:47:25 +08:00
1e8ea9c886 [色子游戏]奖池配置 2026-03-03 11:49:27 +08:00
80 changed files with 8095 additions and 46 deletions

View File

@@ -20,3 +20,6 @@ VITE_OPEN_ROUTE_INFO = false
# 锁屏加密密钥 # 锁屏加密密钥
VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro
# 登录页是否显示验证码,设为 false 可关闭(需与后端 LOGIN_CAPTCHA_ENABLE 一致)
VITE_LOGIN_CAPTCHA_ENABLED = false

View File

@@ -7,7 +7,7 @@ VITE_BASE_URL = /
VITE_API_URL = /api VITE_API_URL = /api
# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题) # 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题)
VITE_API_PROXY_URL = http://127.0.0.1:8787 VITE_API_PROXY_URL = http://127.0.0.1:6688
# Delete console # Delete console
VITE_DROP_CONSOLE = false VITE_DROP_CONSOLE = false

View File

@@ -1,10 +1,14 @@
# 【生产】环境变量 # 【生产】环境变量
# 前端47.86.91.1:8866 后端47.86.91.1:6688
# 应用部署基础路径(如部署在子目录 /admin则设置为 /admin/ # 应用部署基础路径:相对路径,静态资源与页面同源(从 8866 加载
VITE_BASE_URL = / VITE_BASE_URL = ./
# API 地址前缀 # API 地址(后端路由为 /core、/tool、/dice 等,无 /prod 前缀,不要加 /prod
VITE_API_URL = /prod VITE_API_URL = https://dice-api.yuliao666.top
# 登录页是否显示验证码,设为 false 可关闭(需与后端 LOGIN_CAPTCHA_ENABLE 一致)
VITE_LOGIN_CAPTCHA_ENABLED = false
# Delete console # Delete console
VITE_DROP_CONSOLE = true VITE_DROP_CONSOLE = true

View File

@@ -14,12 +14,6 @@
<ElCol :xs="24" :sm="24" :md="6" :lg="6" :xl="6" class="action-column"> <ElCol :xs="24" :sm="24" :md="6" :lg="6" :xl="6" class="action-column">
<div class="action-buttons-wrapper" :style="actionButtonsStyle"> <div class="action-buttons-wrapper" :style="actionButtonsStyle">
<div class="form-buttons"> <div class="form-buttons">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
{{ t('table.searchBar.reset') }}
</ElButton>
<ElButton <ElButton
v-if="showSearch" v-if="showSearch"
type="primary" type="primary"
@@ -33,6 +27,12 @@
</template> </template>
{{ t('table.searchBar.search') }} {{ t('table.searchBar.search') }}
</ElButton> </ElButton>
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
{{ t('table.searchBar.reset') }}
</ElButton>
</div> </div>
<div v-if="showExpand" class="filter-toggle" @click="toggleExpand"> <div v-if="showExpand" class="filter-toggle" @click="toggleExpand">
<span>{{ expandToggleText }}</span> <span>{{ expandToggleText }}</span>

View File

@@ -82,12 +82,12 @@ declare namespace Api {
image: string image: string
} }
/** 登录参数 */ /** 登录参数(关闭验证码时可不传 code、uuid */
interface LoginParams { interface LoginParams {
username: string username: string
password: string password: string
code: string code?: string
uuid: string uuid?: string
} }
/** 登录响应 */ /** 登录响应 */

View File

@@ -164,6 +164,8 @@ export interface EnvConfig {
VITE_USE_GZIP?: string VITE_USE_GZIP?: string
// 是否开启 CDN // 是否开启 CDN
VITE_USE_CDN?: string VITE_USE_CDN?: string
// 登录页是否启用验证码,设为 false 或 0 关闭
VITE_LOGIN_CAPTCHA_ENABLED?: string
} }
// 应用配置 // 应用配置

View File

@@ -35,7 +35,7 @@
show-password show-password
/> />
</ElFormItem> </ElFormItem>
<ElFormItem prop="code"> <ElFormItem v-if="captchaEnabled" prop="code">
<ElInput <ElInput
class="custom-height" class="custom-height"
:placeholder="$t('login.placeholder.code')" :placeholder="$t('login.placeholder.code')"
@@ -115,6 +115,12 @@
const systemName = AppConfig.systemInfo.name const systemName = AppConfig.systemInfo.name
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
/** 是否启用登录验证码(与后端 LOGIN_CAPTCHA_ENABLE 保持一致) */
const captchaEnabled = computed(() => {
const v = import.meta.env.VITE_LOGIN_CAPTCHA_ENABLED
return v !== 'false' && v !== '0'
})
const formData = reactive({ const formData = reactive({
username: '', username: '',
password: '', password: '',
@@ -123,16 +129,21 @@
rememberPassword: true rememberPassword: true
}) })
const rules = computed<FormRules>(() => ({ const rules = computed<FormRules>(() => {
username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }], const r: FormRules = {
password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }], username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }],
code: [{ required: true, message: t('login.placeholder.code'), trigger: 'blur' }] password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }]
})) }
if (captchaEnabled.value) {
r.code = [{ required: true, message: t('login.placeholder.code'), trigger: 'blur' }]
}
return r
})
const loading = ref(false) const loading = ref(false)
onMounted(() => { onMounted(() => {
refreshCaptcha() if (captchaEnabled.value) refreshCaptcha()
}) })
// 登录 // 登录
@@ -146,12 +157,11 @@
loading.value = true loading.value = true
// 登录请求 // 登录请求(关闭验证码时不传 code/uuid
const { access_token, refresh_token } = await fetchLogin({ const { access_token, refresh_token } = await fetchLogin({
username: formData.username, username: formData.username,
password: formData.password, password: formData.password,
code: formData.code, ...(captchaEnabled.value ? { code: formData.code, uuid: formData.uuid } : {})
uuid: formData.uuid
}) })
// 验证token // 验证token
@@ -178,7 +188,7 @@
console.error('[Login] Unexpected error:', error) console.error('[Login] Unexpected error:', error)
} }
} finally { } finally {
refreshCaptcha() if (captchaEnabled.value) refreshCaptcha()
loading.value = false loading.value = false
} }
} }

View File

@@ -0,0 +1,65 @@
import request from '@/utils/http'
/**
* 色子奖池配置 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/lottery_config/DiceLotteryConfig/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/lottery_config/DiceLotteryConfig/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/lottery_config/DiceLotteryConfig/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/lottery_config/DiceLotteryConfig/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/lottery_config/DiceLotteryConfig/destroy',
data: params
})
}
}

View File

@@ -0,0 +1,86 @@
import request from '@/utils/http'
/**
* 玩家抽奖记录 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/play_record/DicePlayRecord/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/play_record/DicePlayRecord/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/play_record/DicePlayRecord/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/play_record/DicePlayRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/play_record/DicePlayRecord/destroy',
data: params
})
},
/** 获取玩家选项id、username */
getPlayerOptions() {
return request.get<{ id: number; username: string }[]>({
url: '/dice/play_record/DicePlayRecord/getPlayerOptions'
})
},
/** 获取彩金池配置选项id、name */
getLotteryConfigOptions() {
return request.get<{ id: number; name: string }[]>({
url: '/dice/play_record/DicePlayRecord/getLotteryConfigOptions'
})
},
/** 获取奖励配置选项id、ui_text、tier */
getRewardConfigOptions() {
return request.get<{ id: number; ui_text: string; tier: string }[]>({
url: '/dice/play_record/DicePlayRecord/getRewardConfigOptions'
})
}
}

View File

@@ -0,0 +1,75 @@
import request from '@/utils/http'
/**
* 大富翁-玩家 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/player/DicePlayer/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/player/DicePlayer/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/player/DicePlayer/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/player/DicePlayer/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/player/DicePlayer/destroy',
data: params
})
},
/**
* 仅更新状态(列表内开关用)
*/
updateStatus(params: { id: number | string; status: number }) {
return request.put<any>({
url: '/dice/player/DicePlayer/updateStatus',
data: params
})
}
}

View File

@@ -0,0 +1,74 @@
import request from '@/utils/http'
/**
* 抽奖券获取记录 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/destroy',
data: params
})
},
/**
* 获取玩家选项id、username用于下拉
*/
getPlayerOptions() {
return request.get<Api.Common.ApiData>({
url: '/dice/player_ticket_record/DicePlayerTicketRecord/getPlayerOptions'
})
}
}

View File

@@ -0,0 +1,100 @@
import request from '@/utils/http'
/**
* 玩家钱包流水 API接口
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/destroy',
data: params
})
},
/**
* 获取玩家选项id、username用于下拉
*/
getPlayerOptions() {
return request.get<{ id: number; username: string }[]>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/getPlayerOptions'
})
},
/**
* 获取指定玩家当前平台币(钱包操作前)
*/
getPlayerWalletBefore(playerId: number | string) {
return request.get<{ wallet_before: number }>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/getPlayerWalletBefore',
params: { player_id: playerId }
})
},
/**
* 管理员钱包操作(加点/扣点):更新玩家平台币并创建流水记录
* type 3=加点 4=扣点
*/
adminOperate(params: {
player_id: number
type: 3 | 4
coin: number
remark?: string
}) {
return request.post<any>({
url: '/dice/player_wallet_record/DicePlayerWalletRecord/adminOperate',
data: params
})
}
}

View File

@@ -0,0 +1,65 @@
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/DiceRewardConfig/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/dice/reward_config/DiceRewardConfig/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/dice/reward_config/DiceRewardConfig/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/dice/reward_config/DiceRewardConfig/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/dice/reward_config/DiceRewardConfig/destroy',
data: params
})
}
}

View File

@@ -0,0 +1,149 @@
<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:lottery_config:index:save'"
@click="showDialog('add')"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:lottery_config: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 #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:lottery_config:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:lottery_config: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"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/lottery_config/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
type: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 奖池类型展示0=正常 1=强制杀猪 2=T1高倍率
const typeFormatter = (row: Record<string, unknown>) =>
row.type === 0 ? '正常' : row.type === 1 ? '强制杀猪' : row.type === 2 ? 'T1高倍率' : '-'
// 权重列带 %
const weightFormatter = (prop: string) => (row: Record<string, unknown>) => {
const v = row[prop]
return v != null && v !== '' ? `${v}%` : '-'
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'name', label: '名称' },
{ prop: 'type', label: '奖池类型', width: 100, formatter: typeFormatter },
{ prop: 'safety_line', label: '安全线' },
{ prop: 't1_wight', label: 'T1池权重', width: 100, formatter: weightFormatter('t1_wight') },
{ prop: 't2_wight', label: 'T2池权重', width: 100, formatter: weightFormatter('t2_wight') },
{ prop: 't3_wight', label: 'T3池权重', width: 100, formatter: weightFormatter('t3_wight') },
{ prop: 't4_wight', label: 'T4池权重', width: 100, formatter: weightFormatter('t4_wight') },
{ prop: 't5_wight', label: 'T5池权重', width: 100, formatter: weightFormatter('t5_wight') },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,235 @@
<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="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="奖池类型" prop="type">
<el-select
v-model="formData.type"
placeholder="请选择奖池类型"
clearable
style="width: 100%"
>
<el-option label="正常" :value="0" />
<el-option label="强制杀猪" :value="1" />
<el-option label="T1高倍率" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="安全线" prop="safety_line">
<el-input-number
v-model="formData.safety_line"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="T1池权重(%)" prop="t1_wight">
<el-slider v-model="formData.t1_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T2池权重(%)" prop="t2_wight">
<el-slider v-model="formData.t2_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T3池权重(%)" prop="t3_wight">
<el-slider v-model="formData.t3_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T4池权重(%)" prop="t4_wight">
<el-slider v-model="formData.t4_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T5池权重(%)" prop="t5_wight">
<el-slider v-model="formData.t5_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item>
<div class="text-gray-500 text-sm">
五个池权重总和<span :class="Math.abs(weightsSum - 100) > 0.01 ? 'text-red-500' : ''">{{
weightsSum
}}</span
>% / 100%100%
</div>
</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/lottery_config/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 WEIGHT_KEYS = ['t1_wight', 't2_wight', 't3_wight', 't4_wight', 't5_wight'] as const
/** 五个池权重总和(用于展示与校验) */
const weightsSum = computed(() => {
return WEIGHT_KEYS.reduce((sum, key) => sum + Number(formData[key] ?? 0), 0)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '名称必需填写', trigger: 'blur' }],
type: [{ required: true, message: '请选择奖池类型', trigger: 'change' }],
t1_wight: [{ required: true, message: 'T1池权重必需填写', trigger: 'blur' }],
t2_wight: [{ required: true, message: 'T2池权重必需填写', trigger: 'blur' }],
t3_wight: [{ required: true, message: 'T3池权重必需填写', trigger: 'blur' }],
t4_wight: [{ required: true, message: 'T4池权重必需填写', trigger: 'blur' }],
t5_wight: [{ required: true, message: 'T5池权重必需填写', trigger: 'blur' }]
})
/**
* 初始数据(权重为数字便于输入与校验)
*/
const initialFormData = {
id: null as number | null,
name: '',
remark: '',
type: null as number | null,
safety_line: 0 as number,
t1_wight: 0 as number,
t2_wight: 0 as number,
t3_wight: 0 as number,
t4_wight: 0 as number,
t5_wight: 0 as number
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据(数值字段转为 number 便于滑块/输入框回显与校验)
*/
const initForm = () => {
if (!props.data) return
const numKeys = [
'id',
'type',
'safety_line',
't1_wight',
't2_wight',
't3_wight',
't4_wight',
't5_wight'
]
for (const key of Object.keys(formData)) {
if (!(key in props.data)) continue
const val = props.data[key]
if (numKeys.includes(key)) {
;(formData as any)[key] =
key === 'id' ? (val != null ? Number(val) || null : null) : Number(val) || 0
} else {
;(formData as any)[key] = val ?? ''
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (Math.abs(weightsSum.value - 100) > 0.01) {
ElMessage.warning('五个池权重总和必须为100%')
return
}
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,82 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="奖池类型" prop="type">
<el-select
v-model="formData.type"
:options="typeOptions"
placeholder="请选择奖池类型"
clearable
/>
</el-form-item>
</el-col>
</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 typeOptions = [
{ name: '0', value: '正常' },
{ name: '1', value: '强制杀猪' },
{ name: '2', value: 'T1高倍率' }
]
// 表单数据双向绑定
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

@@ -0,0 +1,214 @@
<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:play_record:index:save'"
@click="showDialog('add')"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:play_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"
>
<!-- 彩金池配置 tag -->
<template #lottery_config_id="{ row }">
<ElTag size="small">{{ lotteryConfigNameFormatter(row) }}</ElTag>
</template>
<!-- 抽奖类型 tag -->
<template #lottery_type="{ row }">
<ElTag size="small" :type="row.lottery_type === 0 ? 'warning' : 'success'">
{{ row.lottery_type === 0 ? '付费' : row.lottery_type === 1 ? '赠送' : '-' }}
</ElTag>
</template>
<!-- 中奖 tag -->
<template #is_win="{ row }">
<ElTag size="small" :type="row.is_win === 1 ? 'success' : 'info'">
{{ row.is_win === 0 ? '无' : row.is_win === 1 ? '中奖' : '-' }}
</ElTag>
</template>
<!-- 方向 tag -->
<template #direction="{ row }">
<ElTag size="small" :type="row.direction === 0 ? 'primary' : 'warning'">
{{ row.direction === 0 ? '顺时针' : row.direction === 1 ? '逆时针' : '-' }}
</ElTag>
</template>
<!-- 摇取点数 tag -->
<template #roll_array="{ row }">
<ElTag size="small">
{{ formatRollArray(row.roll_array) }}
</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:play_record:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:play_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"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/play_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref<Record<string, unknown>>({
username: undefined,
lottery_config_name: undefined,
lottery_type: undefined,
is_win: undefined,
win_coin_min: undefined,
win_coin_max: undefined,
reward_ui_text: undefined,
reward_tier: undefined,
direction: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
const usernameFormatter = (row: Record<string, any>) =>
row?.dicePlayer?.username ?? row?.player_id ?? '-'
const lotteryConfigNameFormatter = (row: Record<string, any>) =>
row?.diceLotteryConfig?.name ?? row?.lottery_config_id ?? '-'
const rewardTierFormatter = (row: Record<string, any>) =>
row?.diceRewardConfig?.tier ?? row?.reward_config_id ?? '-'
/** 摇取点数格式化为 1,3,4,5,6,6 */
function formatRollArray(val: unknown): string {
if (val == null || val === '') return '-'
if (Array.isArray(val)) return val.join(',')
if (typeof val === 'string') {
try {
const arr = JSON.parse(val)
return Array.isArray(arr) ? arr.join(',') : val
} catch {
return val
}
}
return String(val)
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: 'ID', width: 80 },
{
prop: 'player_id',
label: '玩家',
formatter: (row: Record<string, any>) => usernameFormatter(row)
},
{
prop: 'lottery_config_id',
label: '彩金池配置',
width: 120,
useSlot: true
},
{ prop: 'lottery_type', label: '抽奖类型', width: 100, useSlot: true },
{ prop: 'is_win', label: '中奖', width: 80, useSlot: true },
{ prop: 'win_coin', label: '赢取平台币' },
{ prop: 'direction', label: '方向', width: 90, useSlot: true },
{ prop: 'start_index', label: '起始索引', width: 90 },
{ prop: 'target_index', label: '终点索引', width: 90 },
{ prop: 'roll_array', label: '摇取点数', width: 140, useSlot: true },
{
prop: 'reward_config_id',
label: '奖励配置',
formatter: (row: Record<string, any>) => rewardTierFormatter(row)
},
{ prop: 'create_time', label: '创建时间', width: 170 },
{ prop: 'update_time', label: '修改时间', width: 170 },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,383 @@
<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="玩家" prop="player_id">
<el-select
v-model="formData.player_id"
placeholder="请选择玩家(显示用户名)"
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option
v-for="item in playerOptions"
:key="item.id"
:label="item.username"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="彩金池配置" prop="lottery_config_id">
<el-select
v-model="formData.lottery_config_id"
placeholder="请选择彩金池配置"
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option
v-for="item in lotteryConfigOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="抽奖类型" prop="lottery_type">
<el-select
v-model="formData.lottery_type"
placeholder="请选择"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option label="付费" :value="0" />
<el-option label="赠送" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="中奖" prop="is_win">
<el-select
v-model="formData.is_win"
placeholder="请选择"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option label="无" :value="0" />
<el-option label="中奖" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="赢取平台币" prop="win_coin">
<el-input-number
v-model="formData.win_coin"
placeholder="请输入赢取平台币"
:precision="2"
style="width: 100%"
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item label="方向" prop="direction">
<el-select
v-model="formData.direction"
placeholder="请选择方向"
clearable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option label="顺时针" :value="0" />
<el-option label="逆时针" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="起始索引" prop="start_index">
<el-input-number
v-model="formData.start_index"
placeholder="起始索引"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item label="终点索引" prop="target_index">
<el-input-number
v-model="formData.target_index"
placeholder="终点索引"
:min="0"
style="width: 100%"
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item label="摇取点数" prop="rollArrayItems">
<div class="roll-array-wrap">
<el-input-number
v-for="(_, i) in 5"
:key="i"
v-model="formData.rollArrayItems[i]"
:min="1"
:max="6"
:precision="0"
controls-position="right"
placeholder=""
class="roll-array-input"
:disabled="dialogType === 'edit'"
/>
</div>
<div class="roll-array-hint">固定 5 个数每个 16</div>
</el-form-item>
<el-form-item label="奖励配置" prop="reward_config_id">
<el-select
v-model="formData.reward_config_id"
placeholder="请选择奖励配置(显示前端文本)"
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option
v-for="item in rewardConfigOptions"
:key="item.id"
:label="
item.ui_text
? `${item.ui_text}${item.tier ? ' (' + item.tier + ')' : ''}`
: String(item.id)
"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">{{ dialogType === 'edit' ? '关闭' : '取消' }}</el-button>
<el-button v-if="dialogType === 'add'" type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '../../../api/play_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>({
player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }],
lottery_config_id: [{ required: true, message: '请选择彩金池配置', trigger: 'change' }],
lottery_type: [{ required: true, message: '请选择抽奖类型', trigger: 'change' }],
is_win: [{ required: true, message: '请选择中奖', trigger: 'change' }],
win_coin: [{ required: true, message: '赢取平台币必填', trigger: 'blur' }],
rollArrayItems: [
{
validator: (_rule: any, value: (number | null)[], callback: (e?: Error) => void) => {
if (!value || value.length !== 5) {
callback(new Error('摇取点数必须为 5 个数'))
return
}
const ok = value.every((n) => n != null && n >= 1 && n <= 6)
if (!ok) {
callback(new Error('摇取点数必须填写 5 个数,每个 16'))
return
}
callback()
},
trigger: 'change'
}
],
reward_config_id: [{ required: true, message: '请选择奖励配置', trigger: 'change' }]
})
const playerOptions = ref<Array<{ id: number; username: string }>>([])
const lotteryConfigOptions = ref<Array<{ id: number; name: string }>>([])
const rewardConfigOptions = ref<Array<{ id: number; ui_text: string; tier: string }>>([])
const initialFormData = {
id: null as number | null,
player_id: null as number | null,
lottery_config_id: null as number | null,
lottery_type: null as number | null,
is_win: null as number | null,
win_coin: null as number | null,
direction: null as number | null,
start_index: null as number | null,
target_index: null as number | null,
roll_array: null as string | number[] | null,
reward_config_id: null as number | null
}
/** 摇取点数固定 5 位 [n0..n4],每项 16 */
const rollArrayItemsDefault = (): (number | null)[] => [null, null, null, null, null]
const formData = reactive({
...initialFormData,
rollArrayItems: rollArrayItemsDefault() as (number | null)[]
})
watch(
() => props.modelValue,
async (open) => {
if (open) {
initPage()
try {
const [players, lotteryConfigs, rewardConfigs] = await Promise.all([
api.getPlayerOptions(),
api.getLotteryConfigOptions(),
api.getRewardConfigOptions()
])
playerOptions.value = Array.isArray(players) ? players : ((players as any)?.data ?? [])
lotteryConfigOptions.value = Array.isArray(lotteryConfigs)
? lotteryConfigs
: ((lotteryConfigs as any)?.data ?? [])
rewardConfigOptions.value = Array.isArray(rewardConfigs)
? rewardConfigs
: ((rewardConfigs as any)?.data ?? [])
} catch {
playerOptions.value = []
lotteryConfigOptions.value = []
rewardConfigOptions.value = []
}
}
}
)
const initPage = async () => {
Object.assign(formData, { ...initialFormData, rollArrayItems: rollArrayItemsDefault() })
if (props.data) {
await nextTick()
initForm()
}
}
const initForm = () => {
if (!props.data) return
const keys = [
'id',
'player_id',
'lottery_config_id',
'lottery_type',
'is_win',
'win_coin',
'direction',
'start_index',
'target_index',
'roll_array',
'reward_config_id'
]
keys.forEach((key) => {
const val = props.data![key]
if (val != null && val !== undefined) {
if (key === 'roll_array') {
formData.roll_array = val
formData.rollArrayItems = parseRollArrayToItems(val)
} else {
;(formData as Record<string, unknown>)[key] = val
}
}
})
}
/** 将接口的 roll_array 转为固定 5 项数组,不足补 null */
function parseRollArrayToItems(val: unknown): (number | null)[] {
let arr: number[] = []
if (Array.isArray(val)) {
arr = val.map((n) => (typeof n === 'number' && !Number.isNaN(n) ? n : 0)).slice(0, 5)
} else if (typeof val === 'string') {
try {
const parsed = JSON.parse(val)
arr = Array.isArray(parsed) ? parsed.slice(0, 5).map((n: any) => Number(n) || 0) : []
} catch {
arr = val
.split(',')
.map((n) => parseInt(n, 10))
.filter((n) => !Number.isNaN(n))
.slice(0, 5)
}
}
const items: (number | null)[] = [...arr]
while (items.length < 5) items.push(null)
return items.slice(0, 5)
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const payload = { ...formData } as Record<string, unknown>
// 将 5 个输入值拼成 [1,2,3,4,5] 格式,确保每项为 16 的整数
const items = formData.rollArrayItems
payload.roll_array = items.map((n) => {
const v = n != null ? Number(n) : 1
return Math.min(6, Math.max(1, Number.isNaN(v) ? 1 : Math.floor(v)))
})
delete payload.rollArrayItems
if (props.dialogType === 'add') {
delete payload.id
await api.save(payload)
ElMessage.success('新增成功')
} else {
await api.update(payload)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error: any) {
let msg = '表单验证失败,请检查必填项与格式'
if (error?.message) {
msg = error.message
} else if (typeof error === 'string') {
msg = error
} else if (error && typeof error === 'object') {
const first = Object.values(error).find((v: any) => v?.[0]?.message)
if (first && Array.isArray(first)) {
msg = (first[0] as any).message || msg
}
}
ElMessage.warning(msg)
}
}
</script>
<style lang="scss" scoped>
.roll-array-wrap {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.roll-array-input {
width: 72px;
}
.roll-array-hint {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="120px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="玩家" prop="username">
<el-input v-model="formData.username" placeholder="用户名模糊" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="彩金池配置" prop="lottery_config_name">
<el-input v-model="formData.lottery_config_name" placeholder="名称模糊" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="抽奖类型" prop="lottery_type">
<el-select v-model="formData.lottery_type" placeholder="全部" clearable style="width: 100%">
<el-option label="付费" :value="0" />
<el-option label="赠送" :value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="中奖" prop="is_win">
<el-select v-model="formData.is_win" placeholder="全部" clearable style="width: 100%">
<el-option label="无" :value="0" />
<el-option label="中奖" :value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="方向" prop="direction">
<el-select v-model="formData.direction" placeholder="全部" clearable style="width: 100%">
<el-option label="顺时针" :value="0" />
<el-option label="逆时针" :value="1" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="赢取平台币" prop="win_coin_min">
<div class="range-wrap">
<el-input-number
v-model="formData.win_coin_min"
placeholder="最小"
:precision="2"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.win_coin_max"
placeholder="最大"
:precision="2"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="奖励配置" prop="reward_ui_text">
<el-input v-model="formData.reward_ui_text" placeholder="前端显示文本模糊" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="中奖名(档位)" prop="reward_tier">
<el-select v-model="formData.reward_tier" placeholder="全部" clearable style="width: 100%">
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
<el-option label="T3" value="T3" />
<el-option label="T4" value="T4" />
<el-option label="T5" value="T5" />
</el-select>
</el-form-item>
</el-col>
</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) => ({
span,
xs: 24,
sm: span >= 12 ? span : 12,
md: span >= 8 ? span : 8,
lg: span,
xl: span
})
</script>
<style lang="scss" scoped>
.range-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.range-input {
flex: 1;
min-width: 0;
}
.range-sep {
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,220 @@
<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:player:index:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:player: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 #status="{ row }">
<ElSwitch
v-permission="'dice:player:index:update'"
:model-value="row.status === 1"
:loading="row._statusLoading"
@change="(v: string | number | boolean) => handleStatusChange(row, v ? 1 : 0)"
/>
</template>
<!-- 平台币tag 可点击打开钱包操作弹窗 -->
<template #coin="{ row }">
<ElTag
type="info"
size="small"
class="cursor-pointer hover:opacity-80"
@click="openWalletOperate(row)"
>
{{ row.coin ?? 0 }}
</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:player:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:player: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"
/>
<!-- 钱包操作弹窗加点/扣点 -->
<WalletOperateDialog
v-model="walletDialogVisible"
:player="walletOperatePlayer"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/player/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import WalletOperateDialog from './modules/WalletOperateDialog.vue'
// 搜索表单
const searchForm = ref({
username: undefined,
name: undefined,
phone: undefined,
status: undefined,
coin: undefined,
is_up: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 权重列带 % 的 formatterColumnOption.formatter 仅接收 row
const weightFormatter = (prop: string) => (row: any) => {
const cellValue = row[prop]
return cellValue != null && cellValue !== '' ? `${cellValue}%` : '-'
}
// 倍率列展示0=正常 1=强制杀猪 2=T1高倍率
const isUpFormatter = (row: any) => {
const cellValue = row.is_up
return cellValue === 0
? '正常'
: cellValue === 1
? '强制杀猪'
: cellValue === 2
? 'T1高倍率'
: '-'
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'username', label: '用户名' },
{ prop: 'phone', label: '手机号' },
{ prop: 'name', label: '昵称' },
{ prop: 'status', label: '状态', width: 88, useSlot: true },
{ prop: 'coin', label: '平台币', width: 100, useSlot: true },
{ prop: 'is_up', label: '倍率', width: 80, formatter: isUpFormatter },
{ prop: 't1_wight', label: 'T1池权重', width: 100, formatter: weightFormatter('t1_wight') },
{ prop: 't2_wight', label: 'T2池权重', width: 100, formatter: weightFormatter('t2_wight') },
{ prop: 't3_wight', label: 'T3池权重', width: 100, formatter: weightFormatter('t3_wight') },
{ prop: 't4_wight', label: 'T4池权重', width: 100, formatter: weightFormatter('t4_wight') },
{ prop: 't5_wight', label: 'T5池权重', width: 100, formatter: weightFormatter('t5_wight') },
{ prop: 'total_ticket_count', label: '总抽奖次数' },
{ prop: 'paid_ticket_count', label: '购买抽奖次数' },
{ prop: 'free_ticket_count', label: '赠送抽奖次数' },
{ prop: 'created_at', label: '创建时间' },
{ prop: 'updated_at', label: '更新时间' },
{ prop: 'operation', label: '操作', width: 120, fixed: 'right', useSlot: true }
]
}
})
// 状态开关切换(列表内直接修改)
const handleStatusChange = async (row: Record<string, any>, status: number) => {
row._statusLoading = true
try {
await api.updateStatus({ id: row.id, status })
row.status = status
} catch {
refreshData()
} finally {
row._statusLoading = false
}
}
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
// 钱包操作弹窗(从平台币 tag 点击打开)
const walletDialogVisible = ref(false)
type WalletPlayer = { id: number; username?: string; coin?: number }
const walletOperatePlayer = ref<WalletPlayer | null>(null)
function openWalletOperate(row: Record<string, any>) {
walletOperatePlayer.value = {
id: Number(row.id),
username: row.username,
coin: row.coin != null ? Number(row.coin) : undefined
}
walletDialogVisible.value = true
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<el-dialog
v-model="visible"
title="玩家钱包操作"
width="480px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="玩家">
<el-input :model-value="player?.username" disabled placeholder="-" />
</el-form-item>
<el-form-item label="钱包余额">
<el-input-number
:model-value="walletBalance"
disabled
:precision="2"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="操作类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择" clearable style="width: 100%">
<el-option label="加点" :value="3" />
<el-option label="扣点" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="平台币变动" prop="coin">
<el-input-number
v-model="formData.coin"
:min="0.01"
:precision="2"
placeholder="正数,扣点时不能超过余额"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="选填,不填则按类型自动填写"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import walletRecordApi from '../../../api/player_wallet_record/index'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface PlayerRow {
id: number
username?: string
coin?: number
}
interface Props {
modelValue: boolean
player: PlayerRow | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
player: null
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
const walletBalance = computed(() => {
const c = props.player?.coin
return c != null ? Number(c) : 0
})
const rules = reactive<FormRules>({
type: [{ required: true, message: '请选择操作类型', trigger: 'change' }],
coin: [
{ required: true, message: '请输入平台币变动', trigger: 'blur' },
{
validator: (_rule, value, callback) => {
const n = Number(value)
if (Number.isNaN(n) || n <= 0) {
callback(new Error('平台币变动必须大于 0'))
return
}
if (formData.type === 4 && n > walletBalance.value) {
callback(new Error('扣点不能超过当前余额'))
return
}
callback()
},
trigger: 'blur'
}
]
})
const initialFormData = {
type: null as 3 | 4 | null,
coin: null as number | null,
remark: ''
}
const formData = reactive({ ...initialFormData })
watch(
() => props.modelValue,
(open) => {
if (open) {
Object.assign(formData, initialFormData)
formData.type = 3
}
}
)
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value || !props.player?.id) return
try {
await formRef.value.validate()
const coin = Number(formData.coin) || 0
if (coin <= 0) {
ElMessage.warning('平台币变动必须大于 0')
return
}
if (formData.type === 4 && coin > walletBalance.value) {
ElMessage.warning('扣点不能超过当前余额')
return
}
submitting.value = true
await walletRecordApi.adminOperate({
player_id: props.player.id,
type: formData.type!,
coin,
remark: formData.remark?.trim() || undefined
})
ElMessage.success('操作成功')
emit('success')
handleClose()
} catch (e) {
if ((e as any)?.message) ElMessage.warning((e as any).message)
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,221 @@
<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="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" />
</el-form-item>
<el-form-item label="昵称" prop="name">
<el-input v-model="formData.name" placeholder="请输入昵称" />
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable maxlength="20" show-word-limit />
</el-form-item>
<el-form-item label="密码" prop="password" :rules="passwordRules">
<el-input
v-model="formData.password"
type="password"
placeholder="编辑留空则不修改"
show-password
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<sa-switch v-model="formData.status" />
</el-form-item>
<el-form-item label="平台币" prop="coin">
<el-input-number
v-model="formData.coin"
:min="0"
:precision="2"
:disabled="dialogType === 'add'"
placeholder="创建时默认0不可改"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="倍率" prop="is_up">
<el-select v-model="formData.is_up" placeholder="请选择倍率" clearable style="width: 100%">
<el-option label="正常" :value="0" />
<el-option label="强制杀猪" :value="1" />
<el-option label="T1高倍率" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="T1池权重(%)" prop="t1_wight">
<el-slider v-model="formData.t1_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T2池权重(%)" prop="t2_wight">
<el-slider v-model="formData.t2_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T3池权重(%)" prop="t3_wight">
<el-slider v-model="formData.t3_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T4池权重(%)" prop="t4_wight">
<el-slider v-model="formData.t4_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item label="T5池权重(%)" prop="t5_wight">
<el-slider v-model="formData.t5_wight" :min="0" :max="100" :step="0.01" show-input />
</el-form-item>
<el-form-item>
<div class="text-gray-500 text-sm">
五个池权重总和<span :class="Math.abs(weightsSum - 100) > 0.01 ? 'text-red-500' : ''">{{
weightsSum
}}</span
>% / 100%100%
</div>
</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/player/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 WEIGHT_KEYS = ['t1_wight', 't2_wight', 't3_wight', 't4_wight', 't5_wight'] as const
const weightsSum = computed(() => {
return WEIGHT_KEYS.reduce((sum, key) => sum + Number(formData[key] ?? 0), 0)
})
/** 新增时密码必填,编辑时选填 */
const passwordRules = computed(() =>
props.dialogType === 'add' ? [{ required: true, message: '密码必需填写', trigger: 'blur' }] : []
)
const rules = reactive<FormRules>({
username: [{ required: true, message: '用户名必需填写', trigger: 'blur' }],
name: [{ required: true, message: '昵称必需填写', trigger: 'blur' }],
phone: [{ required: true, message: '手机号必需填写', trigger: 'blur' }],
status: [{ required: true, message: '状态必需填写', trigger: 'blur' }],
coin: [{ required: true, message: '平台币必需填写', trigger: 'blur' }]
})
const initialFormData = {
id: null as number | null,
username: '',
name: '',
phone: '',
password: '',
status: 1 as number,
coin: 0 as number,
is_up: null as number | null,
t1_wight: 0 as number,
t2_wight: 0 as number,
t3_wight: 0 as number,
t4_wight: 0 as number,
t5_wight: 0 as number
}
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 numKeys = [
'id',
'status',
'coin',
'is_up',
't1_wight',
't2_wight',
't3_wight',
't4_wight',
't5_wight'
]
const initForm = () => {
if (!props.data) return
for (const key of Object.keys(formData)) {
if (!(key in props.data)) continue
if (key === 'password') {
;(formData as any).password = ''
continue
}
const val = props.data[key]
if (numKeys.includes(key)) {
;(formData as any)[key] =
key === 'id' ? (val != null ? Number(val) || null : null) : Number(val) || 0
} else {
;(formData as any)[key] = val ?? ''
}
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (Math.abs(weightsSum.value - 100) > 0.01) {
ElMessage.warning('五个池权重总和必须为100%')
return
}
const payload = { ...formData }
if (props.dialogType === 'edit' && !payload.password) {
delete (payload as any).password
}
if (props.dialogType === 'add') {
await api.save(payload)
ElMessage.success('新增成功')
} else {
await api.update(payload)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="昵称" prop="name">
<el-input v-model="formData.name" placeholder="请输入昵称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="手机号模糊查询" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<el-select v-model="formData.status" placeholder="全部" clearable style="width: 100%">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="平台币" prop="coin">
<el-input-number
v-model="formData.coin"
:min="0"
:precision="2"
placeholder="精确搜索"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="倍率" prop="is_up">
<el-select v-model="formData.is_up" placeholder="全部" clearable style="width: 100%">
<el-option label="正常" :value="0" />
<el-option label="强制杀猪" :value="1" />
<el-option label="T1高倍率" :value="2" />
</el-select>
</el-form-item>
</el-col>
</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) => ({
span,
xs: 24,
sm: span >= 12 ? span : 12,
md: span >= 8 ? span : 8,
lg: span,
xl: span
})
</script>

View File

@@ -0,0 +1,157 @@
<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:player_ticket_record:index:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:player_ticket_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 #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:player_ticket_record:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:player_ticket_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"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/player_ticket_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref<Record<string, unknown>>({
username: undefined,
use_coins_min: undefined,
use_coins_max: undefined,
total_ticket_count_min: undefined,
total_ticket_count_max: undefined,
paid_ticket_count_min: undefined,
paid_ticket_count_max: undefined,
free_ticket_count_min: undefined,
free_ticket_count_max: undefined,
create_time_min: undefined,
create_time_max: undefined,
create_time: undefined as [string, string] | undefined
})
// 搜索处理(将创建时间范围 create_time 转为 create_time_min / create_time_max
const handleSearch = (params: Record<string, any>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
Object.assign(searchParams, p)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => {
const usernameFormatter = (row: Record<string, any>) =>
row?.dicePlayer?.username ?? row?.player_id ?? '-'
return [
{ type: 'selection' },
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'player_id', label: '玩家用户名', formatter: (row: Record<string, any>) => usernameFormatter(row) },
{ prop: 'use_coins', label: '消耗硬币' },
{ prop: 'total_ticket_count', label: '总抽奖次数' },
{ prop: 'paid_ticket_count', label: '购买抽奖次数' },
{ prop: 'free_ticket_count', label: '赠送抽奖次数' },
{ prop: 'remark', label: '备注', width: 100, showOverflowTooltip: true },
{ prop: 'create_time', label: '创建时间', width: 170 },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,235 @@
<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="玩家" prop="player_id">
<el-select
v-model="formData.player_id"
placeholder="请选择玩家(显示用户名)"
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
>
<el-option
v-for="item in playerOptions"
:key="item.id"
:label="item.username"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="消耗硬币" prop="use_coins">
<el-input-number v-model="formData.use_coins" placeholder="请输入消耗硬币" :min="0" />
</el-form-item>
<el-form-item label="购买抽奖次数" prop="paid_ticket_count">
<el-input-number
v-model="formData.paid_ticket_count"
placeholder="请输入购买抽奖次数"
:min="0"
@change="onTicketCountChange"
/>
</el-form-item>
<el-form-item label="赠送抽奖次数" prop="free_ticket_count">
<el-input-number
v-model="formData.free_ticket_count"
placeholder="请输入赠送抽奖次数"
:min="0"
@change="onTicketCountChange"
/>
</el-form-item>
<el-form-item label="总抽奖次数" prop="total_ticket_count">
<el-input-number
:model-value="totalTicketCountComputed"
placeholder="自动求和"
:min="0"
disabled
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注(必填)"
maxlength="500"
show-word-limit
/>
</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/player_ticket_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>({
player_id: [{ required: true, message: '请选择玩家', trigger: 'change' }],
use_coins: [{ required: true, message: '消耗硬币必需填写', trigger: 'blur' }],
paid_ticket_count: [{ required: true, message: '购买抽奖次数必需填写', trigger: 'blur' }],
free_ticket_count: [{ required: true, message: '赠送抽奖次数必需填写', trigger: 'blur' }],
remark: [{ required: true, message: '备注必需填写', trigger: 'blur' }]
})
/** 玩家下拉选项id、username */
const playerOptions = ref<Array<{ id: number; username: string }>>([])
/** total_ticket_count = paid_ticket_count + free_ticket_count只读展示 */
const totalTicketCountComputed = computed(() => {
const paid = Number(formData.paid_ticket_count) || 0
const free = Number(formData.free_ticket_count) || 0
return paid + free
})
function onTicketCountChange() {
formData.total_ticket_count = totalTicketCountComputed.value
}
/**
* 初始数据
*/
const initialFormData = {
id: null,
player_id: null,
use_coins: null as number | null,
total_ticket_count: null as number | null,
paid_ticket_count: null as number | null,
free_ticket_count: null as number | null,
remark: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单并拉取玩家选项(与 player_wallet_record 一致)
*/
watch(
() => props.modelValue,
async (open) => {
if (open) {
initPage()
try {
const list = await api.getPlayerOptions()
const arr = Array.isArray(list) ? list : (list as any)?.data
playerOptions.value = Array.isArray(arr)
? (arr as Array<{ id: number; username: string }>)
: []
} catch {
playerOptions.value = []
}
}
}
)
/**
* 初始化页面数据(仅重置表单、回填编辑数据,不在此处请求玩家列表)
*/
const initPage = async () => {
Object.assign(formData, { ...initialFormData })
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (!props.data) return
const keys = [
'id',
'player_id',
'use_coins',
'total_ticket_count',
'paid_ticket_count',
'free_ticket_count',
'remark'
]
keys.forEach((key) => {
const val = props.data![key]
if (val != null && val !== undefined) {
;(formData as Record<string, unknown>)[key] = val
}
})
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单total_ticket_count 由 paid_ticket_count + free_ticket_count 自动求和,提交前写入)
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
formData.total_ticket_count = totalTicketCountComputed.value
await formRef.value.validate()
if (props.dialogType === 'add') {
const rest = { ...formData } as Record<string, unknown>
delete rest.id
await api.save(rest)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,184 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="玩家(用户名)" prop="username">
<el-input v-model="formData.username" placeholder="按用户名搜索" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="消耗硬币" prop="use_coins_min">
<div class="range-wrap">
<el-input-number
v-model="formData.use_coins_min"
placeholder="最小"
:min="0"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.use_coins_max"
placeholder="最大"
:min="0"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="总抽奖次数" prop="total_ticket_count_min">
<div class="range-wrap">
<el-input-number
v-model="formData.total_ticket_count_min"
placeholder="最小"
:min="0"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.total_ticket_count_max"
placeholder="最大"
:min="0"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="购买抽奖次数" prop="paid_ticket_count_min">
<div class="range-wrap">
<el-input-number
v-model="formData.paid_ticket_count_min"
placeholder="最小"
:min="0"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.paid_ticket_count_max"
placeholder="最大"
:min="0"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="赠送抽奖次数" prop="free_ticket_count_min">
<div class="range-wrap">
<el-input-number
v-model="formData.free_ticket_count_min"
placeholder="最小"
:min="0"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.free_ticket_count_max"
placeholder="最大"
:min="0"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item label="创建时间" prop="create_time_min">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
clearable
/>
</el-form-item>
</el-col>
</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>
<style lang="scss" scoped>
.range-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.range-input {
flex: 1;
min-width: 0;
}
.range-sep {
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,234 @@
<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:player_wallet_record:index:save'"
@click="showDialog('add')"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:player_wallet_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"
>
<!-- 类型不同类型不同底色 tag放大一倍 -->
<template #type="{ row }">
<ElTag class="wallet-type-tag" size="large" :type="typeTagType(row.type)">
{{ typeFormatter(row) }}
</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:player_wallet_record:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:player_wallet_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"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/player_wallet_record/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref({
type: undefined,
username: undefined,
coin_min: undefined,
coin_max: undefined,
create_time: undefined as [string, string] | undefined
})
// 搜索处理:将 create_time 区间转为 create_time_min / create_time_max
const handleSearch = (params: Record<string, any>) => {
const p = { ...params }
if (Array.isArray(p.create_time) && p.create_time.length === 2) {
p.create_time_min = p.create_time[0]
p.create_time_max = p.create_time[1]
}
delete p.create_time
Object.assign(searchParams, p)
getData()
}
// 类型展示0=充值 1=提现 2=购买抽奖次数 3=管理员加点 4=管理员扣点 5=抽奖
const typeFormatter = (row: Record<string, unknown>) => {
const t = row.type
if (t === 0) return '充值'
if (t === 1) return '提现'
if (t === 2) return '购买抽奖次数'
if (t === 3) return '管理员加点'
if (t === 4) return '管理员扣点'
if (t === 5) return '抽奖'
return '-'
}
// 类型对应 tag 底色0 充值 1 提现 2 购买 3 加点 4 扣点
const typeTagType = (t: unknown): 'success' | 'warning' | 'danger' | 'info' | 'primary' => {
if (t === 0) return 'success'
if (t === 1) return 'warning'
if (t === 2) return 'primary'
if (t === 3) return 'success'
if (t === 4) return 'danger'
if (t === 5) return 'info'
return 'info'
}
// 操作人:关联管理员用户名
const operatorFormatter = (row: Record<string, any>) => {
const op = row.operator ?? row.operator_id
if (op && typeof op === 'object' && op.username) return op.username
return row.user_id ?? '-'
}
// 用户列展示为 dicePlayer.username兼容 dice_player
const usernameFormatter = (row: Record<string, any>) => {
const player = row.dicePlayer ?? row.dice_player
return player?.username ?? row.player_id ?? '-'
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection', align: 'center' },
{ prop: 'id', label: 'ID', width: 80, align: 'center' },
{
prop: 'player_id',
label: '用户',
width: 120,
align: 'center',
formatter: usernameFormatter
},
{ prop: 'coin', label: '平台币变化', width: 110, align: 'center' },
{
prop: 'type',
label: '类型',
width: 140,
align: 'center',
useSlot: true,
formatter: typeFormatter
},
{
prop: 'user_id',
label: '操作人',
width: 100,
align: 'center',
formatter: operatorFormatter
},
{ prop: 'wallet_before', label: '钱包操作前', width: 110, align: 'center' },
{ prop: 'wallet_after', label: '钱包操作后', width: 110, align: 'center' },
{
prop: 'remark',
label: '备注',
width: 100,
align: 'center',
showOverflowTooltip: true
},
{ prop: 'total_ticket_count', label: '总抽奖次数', align: 'center' },
{ prop: 'paid_ticket_count', label: '购买抽奖次数', align: 'center' },
{ prop: 'free_ticket_count', label: '赠送抽奖次数', align: 'center' },
{ 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>
<style lang="scss" scoped>
/* 类型 tag 放大一倍large + scale */
:deep(.wallet-record-type-tag) {
transform: scale(0.8);
transform-origin: center;
}
</style>

View File

@@ -0,0 +1,231 @@
<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="用户" prop="player_id">
<el-select
v-model="formData.player_id"
placeholder="请选择用户(显示用户名)"
clearable
filterable
style="width: 100%"
:disabled="dialogType === 'edit'"
@change="onPlayerChange"
>
<el-option
v-for="item in playerOptions"
:key="item.id"
:label="item.username"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择类型" clearable style="width: 100%">
<el-option label="充值" :value="0" />
<el-option label="提现" :value="1" />
<el-option label="购买抽奖次数" :value="2" />
<el-option label="管理员加点" :value="3" />
<el-option label="管理员扣点" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="平台币变化" prop="coin">
<el-input-number
v-model="formData.coin"
placeholder="正数增加、负数减少"
:precision="2"
style="width: 100%"
@change="onCoinChange"
/>
</el-form-item>
<el-form-item label="钱包操作前" prop="wallet_before">
<el-input-number
v-model="formData.wallet_before"
placeholder="选择用户后自动带出当前平台币"
:precision="2"
disabled
style="width: 100%"
/>
</el-form-item>
<el-form-item label="钱包操作后" prop="wallet_after">
<el-input-number
v-model="formData.wallet_after"
placeholder="根据平台币变化自动计算"
:precision="2"
disabled
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="选填"
maxlength="500"
show-word-limit
/>
</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/player_wallet_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>()
/** 玩家下拉选项id、username */
const playerOptions = ref<{ id: number; username: string }[]>([])
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const rules = reactive<FormRules>({
player_id: [{ required: true, message: '请选择用户', trigger: 'change' }],
coin: [{ required: true, message: '平台币变化必填', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
})
const initialFormData = {
id: null as number | null,
player_id: null as number | null,
coin: 0 as number,
type: null as number | null,
wallet_before: 0 as number,
wallet_after: 0 as number,
remark: '' as string
}
const formData = reactive({ ...initialFormData })
/** 选择用户后拉取当前平台币作为钱包操作前 */
async function onPlayerChange(playerId: number | null) {
if (playerId == null) {
formData.wallet_before = 0
calcWalletAfter()
return
}
try {
const res = await api.getPlayerWalletBefore(playerId)
const before = res?.wallet_before ?? 0
formData.wallet_before = Number(before)
calcWalletAfter()
} catch {
formData.wallet_before = 0
calcWalletAfter()
}
}
/** 平台币变化时重算钱包操作后 */
function onCoinChange() {
calcWalletAfter()
}
/** 钱包操作后 = 钱包操作前 + 平台币变化 */
function calcWalletAfter() {
const before = Number(formData.wallet_before) || 0
const coin = Number(formData.coin) || 0
formData.wallet_after = Math.round((before + coin) * 100) / 100
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
initPage()
try {
const list = await api.getPlayerOptions()
playerOptions.value = Array.isArray(list) ? list : []
} catch {
playerOptions.value = []
}
}
}
)
const initPage = async () => {
Object.assign(formData, initialFormData)
if (props.data) {
await nextTick()
initForm()
}
}
const numKeys = ['id', 'player_id', 'coin', 'type', 'wallet_before', 'wallet_after']
const initForm = () => {
if (!props.data) return
for (const key of Object.keys(formData)) {
if (!(key in props.data)) continue
const val = props.data[key]
if (numKeys.includes(key)) {
if (key === 'id' || key === 'player_id' || key === 'type') {
;(formData as any)[key] = val != null && val !== '' ? Number(val) : null
} else {
;(formData as any)[key] = val != null && val !== '' ? Number(val) : 0
}
} else {
;(formData as any)[key] = val ?? ''
}
}
}
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
calcWalletAfter()
const payload = { ...formData }
if (props.dialogType === 'add') {
await api.save(payload)
ElMessage.success('新增成功')
} else {
await api.update(payload)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,131 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(5)">
<el-form-item label="类型" prop="type">
<el-select v-model="formData.type" placeholder="全部" clearable style="width: 100%">
<el-option label="充值" :value="0" />
<el-option label="提现" :value="1" />
<el-option label="购买抽奖次数" :value="2" />
<el-option label="管理员加点" :value="3" />
<el-option label="管理员扣点" :value="4" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(5)">
<el-form-item label="用户(用户名)" prop="username">
<el-input v-model="formData.username" placeholder="按用户名搜索" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item label="平台币" prop="coin_min">
<div class="coin-range-wrap">
<el-input-number
v-model="formData.coin_min"
placeholder="最小"
:precision="2"
controls-position="right"
class="coin-range-input"
/>
<span class="coin-range-sep"></span>
<el-input-number
v-model="formData.coin_max"
placeholder="最大"
:precision="2"
controls-position="right"
class="coin-range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(8)">
<el-form-item label="创建时间" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
</el-col>
</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>
<style lang="scss" scoped>
.coin-range-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0; /* 允许在栅格内收缩,避免挤压右侧按钮 */
}
.coin-range-input {
flex: 1;
min-width: 0; /* 数字框可收缩 */
}
.coin-range-sep {
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,142 @@
<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:index:save'"
@click="showDialog('add')"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'dice:reward_config: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 #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'dice:reward_config:index:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'dice:reward_config: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"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '../../api/reward_config/index'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref<Record<string, unknown>>({
grid_number_min: undefined,
grid_number_max: undefined,
ui_text: undefined,
real_ev_min: undefined,
real_ev_max: undefined,
tier: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置(默认 100 条/页)
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 },
{ prop: 'grid_number', label: '色子点数' },
{ prop: 'ui_text', label: '前端显示文本' },
{ prop: 'real_ev', label: '真实资金结算' },
{ prop: 'tier', label: '所属档位', sortable: true },
// { prop: 'create_time', label: '创建时间', sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,179 @@
<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="色子点数" prop="grid_number">
<el-input-number v-model="formData.grid_number" placeholder="请输入色子点数" />
</el-form-item>
<el-form-item label="前端显示文本" prop="ui_text">
<el-input v-model="formData.ui_text" placeholder="请输入前端显示文本" />
</el-form-item>
<el-form-item label="真实资金结算" prop="real_ev">
<el-input-number v-model="formData.real_ev" placeholder="请输入真实资金结算" />
</el-form-item>
<el-form-item label="所属档位" prop="tier">
<el-select
v-model="formData.tier"
placeholder="请选择所属档位"
clearable
style="width: 100%"
>
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
<el-option label="T3" value="T3" />
<el-option label="T4" value="T4" />
<el-option label="T5" value="T5" />
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
maxlength="500"
show-word-limit
/>
</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/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>({
grid_number: [{ required: true, message: '色子点数必需填写', trigger: 'blur' }],
ui_text: [{ required: true, message: '前端显示文本必需填写', trigger: 'blur' }],
real_ev: [{ required: true, message: '真实资金结算必需填写', trigger: 'blur' }],
tier: [{ required: true, message: '所属档位必需填写', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
grid_number: null,
ui_text: '',
real_ev: '',
tier: '',
remark: ''
}
/**
* 表单数据
*/
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,137 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="120px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="色子点数" prop="grid_number_min">
<div class="range-wrap">
<el-input-number
v-model="formData.grid_number_min"
placeholder="最小"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.grid_number_max"
placeholder="最大"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="前端显示文本" prop="ui_text">
<el-input v-model="formData.ui_text" placeholder="模糊查询" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="真实资金结算" prop="real_ev_min">
<div class="range-wrap">
<el-input-number
v-model="formData.real_ev_min"
placeholder="最小"
:precision="2"
controls-position="right"
class="range-input"
/>
<span class="range-sep"></span>
<el-input-number
v-model="formData.real_ev_max"
placeholder="最大"
:precision="2"
controls-position="right"
class="range-input"
/>
</div>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="所属档位" prop="tier">
<el-select v-model="formData.tier" placeholder="全部" clearable style="width: 100%">
<el-option label="T1" value="T1" />
<el-option label="T2" value="T2" />
<el-option label="T3" value="T3" />
<el-option label="T4" value="T4" />
<el-option label="T5" value="T5" />
</el-select>
</el-form-item>
</el-col>
</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,
md: span >= 8 ? span : 8,
lg: span,
xl: span
}
}
</script>
<style lang="scss" scoped>
.range-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.range-input {
flex: 1;
min-width: 0;
}
.range-sep {
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
</style>

View File

@@ -7,8 +7,8 @@ DB_USER = root
DB_PASSWORD = 123456 DB_PASSWORD = 123456
DB_PREFIX = DB_PREFIX =
# 缓存方式,支持file|redis # 缓存方式,支持file|redisAPI 用户登录缓存需使用 redis
CACHE_MODE = file CACHE_MODE = redis
# Redis配置 # Redis配置
REDIS_HOST = 127.0.0.1 REDIS_HOST = 127.0.0.1
@@ -16,8 +16,19 @@ REDIS_PORT = 6379
REDIS_PASSWORD = '' REDIS_PASSWORD = ''
REDIS_DB = 0 REDIS_DB = 0
# API 鉴权与用户(可选,不填则用默认值)
# authToken 签名密钥(必填,与客户端约定,用于 signature 校验)
API_AUTH_TOKEN_SECRET = xF75oK91TQj13s0UmNIr1NBWMWGfflNO
# authToken 时间戳允许误差秒数,防重放,默认 300
API_AUTH_TOKEN_TIME_TOLERANCE = 300
API_AUTH_TOKEN_EXP = 86400
# API_USER_TOKEN_EXP = 604800
API_USER_CACHE_EXPIRE = 86400
API_USER_ENCRYPT_KEY = Wj818SK8dhKBKNOY3PUTmZfhQDMCXEZi
# 验证码配置,支持cache|session # 验证码配置,支持cache|session
CAPTCHA_MODE = cache CAPTCHA_MODE = cache
LOGIN_CAPTCHA_ENABLE = false
#前端目录 #前端目录
FRONTEND_DIR = saiadmin-vue FRONTEND_DIR = saiadmin-vue

54
server/app/api/cache/AuthTokenCache.php vendored Normal file
View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace app\api\cache;
use support\think\Cache;
/**
* 按设备标识存储当前有效的 auth-token同一设备只保留最新一个旧 token 自动失效
*/
class AuthTokenCache
{
private static function prefix(): string
{
return config('api.auth_token_device_prefix', 'api:auth_token:');
}
/**
* 设置该设备当前有效的 auth-token会覆盖同设备之前的 token使旧 token 失效)
* @param string $device 设备标识,如 dice
* @param string $token 完整 auth-token 字符串
* @param int $ttl 过期时间(秒),应与 auth_token_exp 一致
*/
public static function setDeviceToken(string $device, string $token, int $ttl): bool
{
if ($device === '' || $ttl <= 0) {
return false;
}
$key = self::prefix() . $device;
return Cache::set($key, $token, $ttl);
}
/**
* 获取该设备当前有效的 auth-token不存在或已过期返回 null
*/
public static function getDeviceToken(string $device): ?string
{
if ($device === '') {
return null;
}
$key = self::prefix() . $device;
$value = Cache::get($key);
return $value !== null && $value !== '' ? (string) $value : null;
}
/**
* 校验请求中的 token 是否为该设备当前唯一有效 token
*/
public static function isCurrentToken(string $device, string $token): bool
{
$current = self::getDeviceToken($device);
return $current !== null && $current === $token;
}
}

181
server/app/api/cache/UserCache.php vendored Normal file
View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace app\api\cache;
use support\think\Cache;
/**
* API 用户信息 Redis 缓存
* key = base64(user_id)value = 加密后的用户信息 JSON
*/
class UserCache
{
private static function prefix(): string
{
return config('api.user_cache_prefix', 'api:user:');
}
private static function expire(): int
{
return (int) config('api.user_cache_expire', 604800);
}
private static function encryptKey(): string
{
$key = config('api.user_encrypt_key', 'dafuweng_api_user_cache_key_32');
return str_pad($key, 32, '0', STR_PAD_RIGHT);
}
/** 加密 */
public static function encrypt(string $data): string
{
$key = self::encryptKey();
$iv = substr(md5($key), 0, 16);
return base64_encode(openssl_encrypt($data, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv));
}
/** 解密 */
public static function decrypt(string $data): string
{
$key = self::encryptKey();
$iv = substr(md5($key), 0, 16);
$dec = openssl_decrypt(base64_decode($data, true), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
return $dec !== false ? $dec : '';
}
/**
* 写入用户信息到 Redis
* @param int $userId
* @param array $userInfo 从数据库读取的用户信息(可含敏感字段,会加密存储)
*/
public static function setUser(int $userId, array $userInfo): bool
{
$key = self::prefix() . base64_encode((string) $userId);
$value = self::encrypt(json_encode($userInfo));
return Cache::set($key, $value, self::expire());
}
/**
* 从 Redis 读取用户信息
* @return array 解密后的用户信息,不存在或失败返回空数组
*/
public static function getUser(int $userId): array
{
$key = self::prefix() . base64_encode((string) $userId);
$value = Cache::get($key);
if ($value === null || $value === '') {
return [];
}
$dec = self::decrypt($value);
if ($dec === '') {
return [];
}
$data = json_decode($dec, true);
return is_array($data) ? $data : [];
}
/**
* 仅从缓存读取用户平台币 coin不查库低延迟
* @return int|float|null 余额,缓存未命中返回 null缓存中 coin 可能为字符串,统一转为数值)
*/
public static function getUserCoin(int $userId): int|float|null
{
$user = self::getUser($userId);
if (empty($user) || !array_key_exists('coin', $user)) {
return null;
}
$coin = $user['coin'];
if (is_int($coin) || is_float($coin)) {
return $coin;
}
if (is_string($coin) && is_numeric($coin)) {
return str_contains($coin, '.') ? (float) $coin : (int) $coin;
}
return null;
}
/** 删除用户缓存 */
public static function deleteUser(int $userId): bool
{
$key = self::prefix() . base64_encode((string) $userId);
return Cache::delete($key);
}
/** user-token 黑名单前缀(退出登录后使 token 失效) */
private static function blacklistPrefix(): string
{
return config('api.user_cache_prefix', 'api:user:') . 'token_blacklist:';
}
/**
* 将 user-token 加入黑名单(退出登录)
* @param string $token 完整 token 字符串
* @param int $ttl 黑名单过期时间(秒),建议为 token 剩余有效期
*/
public static function addTokenToBlacklist(string $token, int $ttl = 86400): bool
{
if ($ttl <= 0) {
return true;
}
$key = self::blacklistPrefix() . md5($token);
return Cache::set($key, '1', $ttl);
}
/**
* 检查 user-token 是否在黑名单中(已退出)
*/
public static function isTokenBlacklisted(string $token): bool
{
$key = self::blacklistPrefix() . md5($token);
$val = Cache::get($key);
return $val !== null && $val !== '';
}
/** 当前有效 user-token 按用户存储的 key 前缀(重新登录/注册后覆盖,保证单用户单 token */
private static function currentTokenPrefix(): string
{
return config('api.user_token_current_prefix', 'api:user:current_token:');
}
private static function userTokenExpire(): int
{
return (int) config('api.user_token_exp', 604800);
}
/**
* 设置该用户当前唯一有效的 user-token登录/注册时调用,会覆盖该用户之前的 token
* @param int $userId 用户 ID
* @param string $token 完整 user-token 字符串
*/
public static function setCurrentUserToken(int $userId, string $token): bool
{
if ($userId <= 0 || $token === '') {
return false;
}
$key = self::currentTokenPrefix() . $userId;
return Cache::set($key, $token, self::userTokenExpire());
}
/**
* 获取该用户当前在服务端登记的有效 user-token不存在或已过期返回 null
*/
public static function getCurrentUserToken(int $userId): ?string
{
if ($userId <= 0) {
return null;
}
$key = self::currentTokenPrefix() . $userId;
$value = Cache::get($key);
return $value !== null && $value !== '' ? (string) $value : null;
}
/**
* 校验请求中的 token 是否为该用户当前唯一有效 token
*/
public static function isCurrentUserToken(int $userId, string $token): bool
{
$current = self::getCurrentUserToken($userId);
return $current !== null && $current === $token;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use support\Request;
use support\Response;
use Tinywan\Jwt\JwtToken;
use plugin\saiadmin\basic\OpenController;
use app\api\util\ReturnCode;
use app\api\cache\AuthTokenCache;
/**
* API 鉴权 Token 接口
* 仅支持 GET必传参数signature、secret、device、time签名规则signature = md5(device . secret . time)
* 后续所有 /api 接口调用均需在请求头携带此接口返回的 auth-token
*/
class AuthTokenController extends OpenController
{
/**
* 获取 auth-token
* GET /api/authToken
* 参数signature签名、secret密钥、device设备标识、time时间戳四者均为必传且非空
*/
public function index(Request $request): Response
{
if (strtoupper($request->method()) !== 'GET') {
return $this->fail('仅支持 GET 请求', ReturnCode::PARAMS_ERROR);
}
$param = $request->get();
$signature = trim((string) ($param['signature'] ?? ''));
$secret = trim((string) ($param['secret'] ?? ''));
$device = trim((string) ($param['device'] ?? ''));
$time = trim((string) ($param['time'] ?? ''));
if ($signature === '' || $secret === '' || $device === '' || $time === '') {
return $this->fail('signature、secret、device、time 均为必传且不能为空', ReturnCode::PARAMS_ERROR);
}
$serverSecret = trim((string) config('api.auth_token_secret', ''));
if ($serverSecret === '') {
return $this->fail('服务未配置 API_AUTH_TOKEN_SECRET', ReturnCode::PARAMS_ERROR);
}
if ($secret !== $serverSecret) {
return $this->fail('密钥错误', ReturnCode::FORBIDDEN);
}
$tolerance = (int) config('api.auth_token_time_tolerance', 300);
$now = time();
$ts = is_numeric($time) ? (int) $time : 0;
if ($ts <= 0 || abs($now - $ts) > $tolerance) {
return $this->fail('时间戳无效或已过期', ReturnCode::PARAMS_ERROR);
}
$sign = $this->getAuthToken($device, $serverSecret, $time);
if ($sign !== $signature) {
return $this->fail('签名验证失败', ReturnCode::FORBIDDEN);
}
$exp = (int) config('api.auth_token_exp', 86400);
$tokenResult = JwtToken::generateToken([
'id' => 0,
'plat' => 'api',
'device' => $device,
'access_exp' => $exp,
]);
// 同一设备只保留最新 token覆盖后旧 token 失效
AuthTokenCache::setDeviceToken($device, $tokenResult['access_token'], $exp);
return $this->success([
'auth-token' => $tokenResult['access_token'],
'expires_in' => $tokenResult['expires_in'],
]);
}
/**
* 生成签名signature = md5(device . secret . time)
*
* @param string $device 设备标识
* @param string $secret 密钥(来自配置)
* @param string $time 时间戳
* @return string
*/
private function getAuthToken(string $device, string $secret, string $time): string
{
return md5($device . $secret . $time);
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use support\Request;
use support\Response;
use app\api\logic\GameLogic;
use app\api\logic\PlayStartLogic;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player\DicePlayer;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\basic\OpenController;
use plugin\saiadmin\exception\ApiException;
/**
* 游戏相关接口(购买抽奖券等)
*/
class GameController extends OpenController
{
/**
* 购买抽奖券
* POST /api/game/buyLotteryTickets
* header: auth-token, user-token由 CheckUserTokenMiddleware 注入 request->user_id
* body: count = 1 | 5 | 101次/100coin, 5次/500coin, 10次/1000coin
*/
public function buyLotteryTickets(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$count = (int) $request->post('count', 0);
if (!in_array($count, [1, 5, 10], true)) {
return $this->fail('购买抽奖券错误', ReturnCode::PARAMS_ERROR);
}
try {
$logic = new GameLogic();
$data = $logic->buyLotteryTickets($userId, $count);
return $this->success($data);
} catch (ApiException $e) {
$msg = $e->getMessage();
if ($msg === '平台币不足') {
$player = DicePlayer::find($userId);
$coin = $player ? (float) $player->coin : 0;
return $this->success(['coin' => $coin], $msg);
}
return $this->fail($msg, ReturnCode::BUSINESS_ERROR);
}
}
/**
* 获取彩金池(中奖配置表)
* GET /api/game/lotteryPool
* header: auth-token
* 返回 DiceRewardConfig 列表(彩金池/中奖配置)
*/
public function lotteryPool(Request $request): Response
{
$list = DiceRewardConfig::order('id', 'asc')->select()->toArray();
return $this->success($list);
}
/**
* 开始游戏(抽奖一局)
* POST /api/game/playStart
* header: auth-token, user-token由 CheckUserTokenMiddleware 注入 request->user_id
* body: rediction 必传0=无 1=中奖
*/
public function playStart(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$rediction = $request->post('rediction');
if ($rediction === '' || $rediction === null) {
return $this->fail('请传递 rediction 参数', ReturnCode::PARAMS_ERROR);
}
$direction = (int) $rediction;
if (!in_array($direction, [0, 1], true)) {
return $this->fail('rediction 必须为 0 或 1', ReturnCode::PARAMS_ERROR);
}
$player = DicePlayer::find($userId);
if (!$player) {
return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
}
$minEv = (float) DiceRewardConfig::min('real_ev');
$minCoin = abs($minEv + 100);
$coin = (float) $player->coin;
if ($coin < $minCoin) {
return $this->success([], '当前玩家余额小于DiceRewardConfigMin.real_ev+100无法继续游戏');
}
try {
$logic = new PlayStartLogic();
$data = $logic->run($userId, $direction);
return $this->success($data);
} catch (ApiException $e) {
return $this->fail($e->getMessage(), ReturnCode::BUSINESS_ERROR);
} catch (\Throwable $e) {
$timeoutRecord = null;
try {
$timeoutRecord = DicePlayRecord::create([
'player_id' => $userId,
'lottery_config_id' => 0,
'lottery_type' => 0,
'win_coin' => 0,
'direction' => $direction,
'reward_config_id' => 0,
'start_index' => 0,
'target_index' => 0,
'roll_array' => '[]',
'status' => PlayStartLogic::RECORD_STATUS_TIMEOUT,
]);
} catch (\Throwable $_) {
}
$payload = $timeoutRecord ? ['record' => $timeoutRecord->toArray()] : [];
return $this->success($payload, '服务超时');
}
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace app\api\controller;
use support\Request;
use support\Response;
use app\api\cache\UserCache;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use app\dice\model\play_record\DicePlayRecord;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use plugin\saiadmin\basic\OpenController;
/**
* API 用户登录/注册
* 需先携带 auth-token登录/注册成功后返回 user-token 与用户信息,用户信息已写入 Rediskey=base64(user_id)value=加密)
*/
class UserController extends OpenController
{
/**
* 登录
* POST /api/user/login
* body: phone (+60), password
*/
public function login(Request $request): Response
{
$phone = $request->post('phone', '');
$password = $request->post('password', '');
if ($phone === '' || $password === '') {
return $this->fail('请填写手机号和密码', ReturnCode::PARAMS_ERROR);
}
$logic = new UserLogic();
$data = $logic->login($phone, $password);
return $this->success([
'user' => $data['user'],
'user-token' => $data['user-token'],
'user_id' => $data['user_id'],
]);
}
/**
* 注册
* POST /api/user/register
* body: phone (+60), password, nickname(可选)
*/
public function register(Request $request): Response
{
$phone = $request->post('phone', '');
$password = $request->post('password', '');
$nickname = $request->post('nickname');
if ($phone === '' || $password === '') {
return $this->fail('请填写手机号和密码', ReturnCode::PARAMS_ERROR);
}
$logic = new UserLogic();
$data = $logic->register($phone, $password, $nickname ? (string) $nickname : null);
return $this->success([
'user' => $data['user'],
'user-token' => $data['user-token'],
'user_id' => $data['user_id'],
]);
}
/**
* 退出登录
* POST /api/user/logout
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->userToken
*/
public function logout(Request $request): Response
{
$token = $request->userToken ?? UserLogic::getTokenFromRequest($request);
if ($token === '' || !UserLogic::logout($token)) {
return $this->fail('退出失败或 token 已失效', ReturnCode::TOKEN_INVALID);
}
return $this->success('已退出登录');
}
/**
* 获取当前用户信息
* GET /api/user/info
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 返回id, username, phone, uid, name, coin, total_ticket_count
*/
public function info(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$user = UserLogic::getCachedUser($userId);
if (empty($user)) {
return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
}
$fields = ['id', 'username', 'phone', 'uid', 'name', 'coin', 'total_ticket_count'];
$info = [];
foreach ($fields as $field) {
if (array_key_exists($field, $user)) {
$info[$field] = $user[$field];
}
}
return $this->success($info);
}
/**
* 获取钱包余额(优先读缓存,缓存未命中时从库拉取并回写缓存)
* GET /api/user/balance
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
*/
public function balance(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$user = UserLogic::getCachedUser($userId);
if (empty($user)) {
return $this->fail('用户不存在', ReturnCode::NOT_FOUND);
}
$coin = $user['coin'] ?? 0;
if (is_string($coin) && is_numeric($coin)) {
$coin = str_contains($coin, '.') ? (float) $coin : (int) $coin;
}
return $this->success([
'coin' => $coin,
'phone' => $user['phone'] ?? '',
'username' => $user['username'] ?? '',
]);
}
/**
* 玩家钱包流水
* GET /api/user/walletRecord
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/
public function walletRecord(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10);
if ($page < 1) {
$page = 1;
}
if ($limit < 1) {
$limit = 10;
}
$query = DicePlayerWalletRecord::where('player_id', $userId)->order('id', 'desc');
$createTimeMin = $request->post('create_time_min', '');
$createTimeMax = $request->post('create_time_max', '');
if ($createTimeMin !== '' && $createTimeMin !== null) {
$query->where('create_time', '>=', $createTimeMin);
}
if ($createTimeMax !== '' && $createTimeMax !== null) {
$query->where('create_time', '<=', $createTimeMax);
}
$total = $query->count();
$list = $query->page($page, $limit)->select()->toArray();
$totalPage = $limit > 0 ? (int) ceil($total / $limit) : 0;
return $this->success([
'list' => $list,
'total_count' => $total,
'total_page' => $totalPage,
'current_page' => $page,
'limit' => $limit,
]);
}
/**
* 游玩记录
* GET /api/user/playGameRecord
* header: user-token由 CheckUserTokenMiddleware 校验并注入 request->user_id
* 参数: page 页码默认1, limit 每页条数默认10, create_time_min/create_time_max 创建时间范围(可选)
*/
public function playGameRecord(Request $request): Response
{
$userId = UserLogic::getUserIdFromRequest($request) ?? 0;
$page = (int) $request->post('page', 1);
$limit = (int) $request->post('limit', 10);
if ($page < 1) {
$page = 1;
}
if ($limit < 1) {
$limit = 10;
}
$query = DicePlayRecord::where('player_id', $userId)->order('id', 'desc');
$createTimeMin = $request->post('create_time_min', '');
$createTimeMax = $request->post('create_time_max', '');
if ($createTimeMin !== '' && $createTimeMin !== null) {
$query->where('create_time', '>=', $createTimeMin);
}
if ($createTimeMax !== '' && $createTimeMax !== null) {
$query->where('create_time', '<=', $createTimeMax);
}
$total = $query->count();
$list = $query->page($page, $limit)->select()->toArray();
$totalPage = $limit > 0 ? (int) ceil($total / $limit) : 0;
return $this->success([
'list' => $list,
'total_count' => $total,
'total_page' => $totalPage,
'current_page' => $page,
'limit' => $limit,
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace app\api\logic;
use app\api\cache\UserCache;
use app\dice\model\player\DicePlayer;
use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use plugin\saiadmin\exception\ApiException;
use support\think\Db;
/**
* 购买抽奖券套餐:次数 => [消耗coin, 购买次数paid, 赠送次数free]
* 仅支持 1、5、10 档
*/
class GameLogic
{
public const PACKAGES = [
1 => ['coin' => 100, 'paid' => 1, 'free' => 0], // 1次/100coin
5 => ['coin' => 500, 'paid' => 5, 'free' => 1], // 5张/500coin5购买+1赠送共6次
10 => ['coin' => 1000, 'paid' => 10, 'free' => 3], // 10张/1000coin10购买+3赠送共13次
];
/** 钱包流水类型:购买抽奖次数 */
public const WALLET_TYPE_BUY_DRAW = 2;
/**
* 购买抽奖券
* @param int $playerId 玩家ID即 user_id
* @param int $count 购买档位1 / 5 / 10
* @return array 更新后的 coin, total_ticket_count, paid_ticket_count, free_ticket_count
*/
public function buyLotteryTickets(int $playerId, int $count): array
{
if (!isset(self::PACKAGES[$count])) {
throw new ApiException('购买抽奖券错误');
}
$pack = self::PACKAGES[$count];
$cost = $pack['coin'];
$addPaid = $pack['paid'];
$addFree = $pack['free'];
$addTotal = $addPaid + $addFree;
$player = DicePlayer::find($playerId);
if (!$player) {
throw new ApiException('用户不存在');
}
$coinBefore = (float) $player->coin;
if ($coinBefore < $cost) {
throw new ApiException('平台币不足');
}
$coinAfter = $coinBefore - $cost;
$totalBefore = (int) ($player->total_ticket_count ?? 0);
$paidBefore = (int) ($player->paid_ticket_count ?? 0);
$freeBefore = (int) ($player->free_ticket_count ?? 0);
Db::transaction(function () use (
$player,
$playerId,
$cost,
$coinBefore,
$coinAfter,
$addTotal,
$addPaid,
$addFree,
$totalBefore,
$paidBefore,
$freeBefore
) {
$player->coin = $coinAfter;
$player->total_ticket_count = $totalBefore + $addTotal;
$player->paid_ticket_count = $paidBefore + $addPaid;
$player->free_ticket_count = $freeBefore + $addFree;
$player->save();
// 钱包流水记录
DicePlayerWalletRecord::create([
'player_id' => $playerId,
'coin' => -$cost,
'type' => self::WALLET_TYPE_BUY_DRAW,
'wallet_before' => $coinBefore,
'wallet_after' => $coinAfter,
'total_ticket_count' => $addTotal,
'paid_ticket_count' => $addPaid,
'free_ticket_count' => $addFree,
'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)",
]);
// 抽奖券获取记录
DicePlayerTicketRecord::create([
'player_id' => $playerId,
'use_coins' => $cost,
'total_ticket_count' => $addTotal,
'paid_ticket_count' => $addPaid,
'free_ticket_count' => $addFree,
'remark' => "购买抽奖券{$addTotal}次(付费{$addPaid}次+赠送{$addFree}次)",
]);
});
$updated = DicePlayer::find($playerId);
$userArr = $updated->hidden(['password'])->toArray();
UserCache::setUser($playerId, $userArr);
return [
'coin' => (float) $updated->coin,
'total_ticket_count' => (int) $updated->total_ticket_count,
'paid_ticket_count' => (int) $updated->paid_ticket_count,
'free_ticket_count' => (int) $updated->free_ticket_count,
];
}
}

View File

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

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace app\api\logic;
use app\dice\model\player\DicePlayer;
use app\api\cache\UserCache;
use plugin\saiadmin\exception\ApiException;
use Tinywan\Jwt\JwtToken;
/**
* API 用户登录/注册逻辑(基于 DicePlayer 表)
* 手机号格式限制:+60马来西亚
*/
class UserLogic
{
/** 手机号正则:+60 开头,后跟 910 位数字(马来西亚) */
private const PHONE_REGEX = '/^\+60\d{9,10}$/';
/** 与 DicePlayerLogic 保持一致的密码盐,用于登录校验与注册写入 */
private const PASSWORD_SALT = 'dice_player_salt_2024';
/** 状态:正常 */
private const STATUS_NORMAL = 1;
/**
* 手机号格式校验:+60 开头
*/
public static function validatePhone(string $phone): void
{
if (!preg_match(self::PHONE_REGEX, $phone)) {
throw new ApiException('手机号格式错误,仅支持 +60 开头的马来西亚号码(如 +60123456789');
}
}
/**
* 登录:手机号 + 密码,返回用户信息与 user-token并写入 Redis 缓存
*/
public function login(string $phone, string $password): array
{
self::validatePhone($phone);
$user = DicePlayer::where('phone', $phone)->find();
if (!$user) {
throw new ApiException('手机号未注册');
}
if ((int) $user->status !== self::STATUS_NORMAL) {
throw new ApiException('账号已被禁用');
}
$hashed = $this->hashPassword($password);
if ($user->password !== $hashed) {
throw new ApiException('密码错误');
}
$userArr = $user->hidden(['password'])->toArray();
UserCache::setUser((int) $user->id, $userArr);
$userToken = $this->generateUserToken((int) $user->id);
// 同一用户只保留最新一次登录的 token旧 token 自动失效
UserCache::setCurrentUserToken((int) $user->id, $userToken);
return [
'user' => $userArr,
'user-token' => $userToken,
'user_id' => (int) $user->id,
];
}
/**
* 注册:手机号 + 密码(+60创建玩家并返回用户信息与 user-token写入 Redis
*/
public function register(string $phone, string $password, ?string $nickname = null): array
{
self::validatePhone($phone);
if (strlen($password) < 6) {
throw new ApiException('密码至少 6 位');
}
$exists = DicePlayer::where('phone', $phone)->find();
if ($exists) {
throw new ApiException('该手机号已注册');
}
$user = new DicePlayer();
$user->phone = $phone;
$user->username = $phone;
if ($nickname !== null && $nickname !== '') {
$user->name = $nickname;
}
// name 未传时由 DicePlayer::onBeforeInsert 默认设为 uid
$user->password = $this->hashPassword($password);
$user->status = self::STATUS_NORMAL;
$user->save();
$userArr = $user->hidden(['password'])->toArray();
UserCache::setUser((int) $user->id, $userArr);
$userToken = $this->generateUserToken((int) $user->id);
// 同一用户只保留最新一次登录的 token旧 token 自动失效
UserCache::setCurrentUserToken((int) $user->id, $userToken);
return [
'user' => $userArr,
'user-token' => $userToken,
'user_id' => (int) $user->id,
];
}
/**
* 与 DicePlayerLogic 一致的密码加密md5(salt . password)
*/
private function hashPassword(string $password): string
{
return md5(self::PASSWORD_SALT . $password);
}
/**
* 生成 user-tokenJWTplat=api_userid=user_id
*/
private function generateUserToken(int $userId): string
{
$exp = config('api.user_token_exp', 604800);
$result = JwtToken::generateToken([
'id' => $userId,
'plat' => 'api_user',
'access_exp' => $exp,
]);
return $result['access_token'];
}
/**
* 从请求中解析 user-tokenheader: user-token 或 Authorization: Bearer
* @param object $request 需有 header(string $name) 方法
*/
public static function getTokenFromRequest(object $request): string
{
$token = $request->header('user-token') ?? '';
if ($token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
/**
* 从请求获取当前用户 ID优先 request->user_id否则从 header 的 user-token 解析
* 中间件未正确注入时仍可兜底解析
* @param object $request 需有 user_id 属性及 header() 方法
*/
public static function getUserIdFromRequest(object $request): ?int
{
$id = $request->user_id ?? null;
if ($id !== null && (int) $id > 0) {
return (int) $id;
}
$token = self::getTokenFromRequest($request);
if ($token === '') {
return null;
}
return self::getUserIdFromToken($token);
}
/**
* 根据 user-token 获取 user_id不写缓存仅解析 JWT
* 若 token 已通过退出接口加入黑名单,返回 null
*/
public static function getUserIdFromToken(string $userToken): ?int
{
if (UserCache::isTokenBlacklisted($userToken)) {
return null;
}
try {
$decoded = JwtToken::verify(1, $userToken);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api_user') {
return null;
}
$id = $extend['id'] ?? null;
if ($id === null) {
return null;
}
$userId = (int) $id;
// 同一用户只允许当前登记的 token 生效,重新登录/注册后旧 token 失效
if (!UserCache::isCurrentUserToken($userId, $userToken)) {
return null;
}
return $userId;
} catch (\Throwable $e) {
return null;
}
}
/**
* 退出登录:将当前 user-token 加入黑名单,使该 token 失效
*/
public static function logout(string $userToken): bool
{
try {
$decoded = JwtToken::verify(1, $userToken);
$exp = (int) ($decoded['exp'] ?? 0);
$ttl = $exp > time() ? $exp - time() : 86400;
return UserCache::addTokenToBlacklist($userToken, $ttl);
} catch (\Throwable $e) {
return false;
}
}
/**
* 从 Redis 获取用户信息key = base64(user_id)),未命中则查 DicePlayer 并回写缓存
*/
public static function getCachedUser(int $userId): array
{
$cached = UserCache::getUser($userId);
if (!empty($cached)) {
return $cached;
}
$user = DicePlayer::find($userId);
if (!$user) {
return [];
}
$arr = $user->hidden(['password'])->toArray();
UserCache::setUser($userId, $arr);
return $arr;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use support\Log;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use Tinywan\Jwt\JwtToken;
use Tinywan\Jwt\Exception\JwtTokenException;
use Tinywan\Jwt\Exception\JwtTokenExpiredException;
use app\api\util\ReturnCode;
use app\api\cache\AuthTokenCache;
use plugin\saiadmin\exception\ApiException;
/**
* 仅校验 auth-token 请求头
* 白名单路径(如 /api/authToken不校验其它接口必须携带有效 auth-token 或 Authorization: Bearer <token>,且必须通过 JWT 签名与 plat=api 校验
*/
class CheckAuthTokenMiddleware implements MiddlewareInterface
{
/** 不需要 auth-token 的路径 */
private const WHITELIST = [
'api/authToken',
];
/** JWT 至少为 xxx.yyy.zzz 三段 */
private const JWT_PARTS_MIN = 3;
public function process(Request $request, callable $handler): Response
{
$path = trim((string) $request->path(), '/');
if ($this->isWhitelist($path)) {
return $handler($request);
}
$token = $this->getAuthTokenFromRequest($request);
if ($token === '') {
throw new ApiException('请携带 auth-token', ReturnCode::UNAUTHORIZED);
}
if (!$this->looksLikeJwt($token)) {
throw new ApiException('auth-token 格式无效', ReturnCode::TOKEN_INVALID);
}
$decoded = $this->verifyAuthToken($token);
$extend = $decoded['extend'] ?? [];
if (($extend['plat'] ?? '') !== 'api') {
throw new ApiException('auth-token 无效(非 API 凭证)', ReturnCode::TOKEN_INVALID);
}
// 同一设备只允许一个 auth-token 生效,非当前 token 视为已失效
$device = (string) ($extend['device'] ?? '');
if ($device !== '' && !AuthTokenCache::isCurrentToken($device, $token)) {
throw new ApiException('auth-token 已失效(该设备已签发新凭证,请使用新 auth-token', ReturnCode::TOKEN_INVALID);
}
return $handler($request);
}
private function getAuthTokenFromRequest(Request $request): string
{
$token = $request->header('auth-token');
if ($token !== null && $token !== '') {
return trim((string) $token);
}
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
return trim(substr($auth, 7));
}
return '';
}
private function looksLikeJwt(string $token): bool
{
$parts = explode('.', $token);
return count($parts) >= self::JWT_PARTS_MIN;
}
/**
* 校验 auth-token 有效性签名、过期、iss 等),无效或过期必抛 ApiException
*/
private function verifyAuthToken(string $token): array
{
try {
return JwtToken::verify(1, $token);
} catch (JwtTokenExpiredException $e) {
Log::error('auth-token 已过期, 报错信息' . $e);
throw new ApiException('auth-token 已过期', ReturnCode::TOKEN_INVALID);
} catch (JwtTokenException $e) {
Log::error('auth-token 无效, 报错信息' . $e);
throw new ApiException($e->getMessage() ?: 'auth-token 无效', ReturnCode::TOKEN_INVALID);
} catch (\Throwable $e) {
Log::error('auth-token 校验失败, 报错信息' . $e);
throw new ApiException('auth-token 校验失败', ReturnCode::TOKEN_INVALID);
}
}
private function isWhitelist(string $path): bool
{
foreach (self::WHITELIST as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix . '/')) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace app\api\middleware;
use Webman\Http\Request;
use Webman\Http\Response;
use Webman\MiddlewareInterface;
use app\api\logic\UserLogic;
use app\api\util\ReturnCode;
use plugin\saiadmin\exception\ApiException;
/**
* 校验 user-token 请求头
* 从 header 读取 user-token 或 Authorization: Bearer <user-token>,校验通过后将 user_id、userToken 写入 request 供控制器使用
*/
class CheckUserTokenMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
$token = $request->header('user-token');
if (empty($token)) {
$auth = $request->header('authorization');
if ($auth && stripos($auth, 'Bearer ') === 0) {
$token = trim(substr($auth, 7));
}
}
if (empty($token)) {
throw new ApiException('请携带 user-token', ReturnCode::UNAUTHORIZED);
}
$userId = UserLogic::getUserIdFromToken($token);
if ($userId === null) {
throw new ApiException('user-token 无效或已过期', ReturnCode::TOKEN_INVALID);
}
$request->user_id = $userId;
$request->userToken = $token;
return $handler($request);
}
}

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace app\api\util;
/**
* API 统一状态码
* 与 HTTP 语义对齐,便于前端与网关处理
*/
class ReturnCode
{
/** 200 成功 */
public const SUCCESS = 200;
/** 400 请求参数错误(缺少参数、参数无效、格式错误等) */
public const PARAMS_ERROR = 400;
/** 401 未授权(未携带 auth-token 或 user-token */
public const UNAUTHORIZED = 401;
/** 402 token 无效或已过期(格式无效、签名错误、过期、非当前有效 token 等) */
public const TOKEN_INVALID = 402;
/** 403 鉴权失败(密钥错误、签名验证失败等) */
public const FORBIDDEN = 403;
/** 404 资源不存在(用户不存在等) */
public const NOT_FOUND = 404;
/** 422 业务逻辑错误(余额不足、购买失败、业务校验不通过等) */
public const BUSINESS_ERROR = 422;
/** 500 服务器内部错误 */
public const SERVER_ERROR = 500;
}

View File

@@ -0,0 +1,122 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\lottery_config;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\lottery_config\DiceLotteryConfigLogic;
use app\dice\validate\lottery_config\DiceLotteryConfigValidate;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 色子奖池配置控制器
*/
class DiceLotteryConfigController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DiceLotteryConfigLogic();
$this->validate = new DiceLotteryConfigValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('色子奖池配置列表', 'dice:lottery_config:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['name', ''],
['type', ''],
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('色子奖池配置读取', 'dice:lottery_config: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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('色子奖池配置添加', 'dice:lottery_config: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:lottery_config: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:lottery_config: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('删除失败');
}
}
}

View File

@@ -0,0 +1,180 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\play_record;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\play_record\DicePlayRecordLogic;
use app\dice\validate\play_record\DicePlayRecordValidate;
use app\dice\model\player\DicePlayer;
use app\dice\model\lottery_config\DiceLotteryConfig;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 玩家抽奖记录控制器
*/
class DicePlayRecordController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DicePlayRecordLogic();
$this->validate = new DicePlayRecordValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['username', ''],
['lottery_config_name', ''],
['lottery_type', ''],
['is_win', ''],
['win_coin_min', ''],
['win_coin_max', ''],
['reward_ui_text', ''],
['reward_tier', ''],
['direction', ''],
]);
$query = $this->logic->search($where);
$query->with([
'dicePlayer',
'diceRewardConfig',
'diceLotteryConfig',
]);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 获取玩家选项id、username
*/
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function getPlayerOptions(Request $request): Response
{
$list = DicePlayer::field('id,username')->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
})->toArray();
return $this->success($data);
}
/**
* 获取彩金池配置选项id、name
*/
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function getLotteryConfigOptions(Request $request): Response
{
$list = DiceLotteryConfig::field('id,name')->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'name' => $item['name'] ?? ''];
})->toArray();
return $this->success($data);
}
/**
* 获取奖励配置选项id、ui_text、tier
*/
#[Permission('玩家抽奖记录列表', 'dice:play_record:index:index')]
public function getRewardConfigOptions(Request $request): Response
{
$list = DiceRewardConfig::field('id,ui_text,tier')->select();
$data = $list->map(function ($item) {
return [
'id' => $item['id'],
'ui_text' => $item['ui_text'] ?? '',
'tier' => $item['tier'] ?? ''
];
})->toArray();
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('玩家抽奖记录读取', 'dice:play_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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('玩家抽奖记录添加', 'dice:play_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:play_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:play_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('删除失败');
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\player;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\player\DicePlayerLogic;
use app\dice\validate\player\DicePlayerValidate;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 大富翁-玩家控制器
*/
class DicePlayerController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DicePlayerLogic();
$this->validate = new DicePlayerValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('大富翁-玩家列表', 'dice:player:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['username', ''],
['name', ''],
['phone', ''],
['status', ''],
['coin', ''],
['is_up', ''],
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('大富翁-玩家读取', 'dice:player: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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('大富翁-玩家添加', 'dice:player: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:player: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:player:index:update')]
public function updateStatus(Request $request): Response
{
$id = $request->input('id');
$status = $request->input('status');
if ($id === null || $id === '') {
return $this->fail('缺少 id');
}
if ($status === null || $status === '') {
return $this->fail('缺少 status');
}
$this->logic->edit($id, ['status' => (int) $status]);
return $this->success('修改成功');
}
/**
* 删除数据
* @param Request $request
* @return Response
*/
#[Permission('大富翁-玩家删除', 'dice:player: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('删除失败');
}
}
}

View File

@@ -0,0 +1,150 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\player_ticket_record;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\player_ticket_record\DicePlayerTicketRecordLogic;
use app\dice\validate\player_ticket_record\DicePlayerTicketRecordValidate;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 抽奖券获取记录控制器
*/
class DicePlayerTicketRecordController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DicePlayerTicketRecordLogic();
$this->validate = new DicePlayerTicketRecordValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('抽奖券获取记录列表', 'dice:player_ticket_record:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['username', ''],
['use_coins_min', ''],
['use_coins_max', ''],
['total_ticket_count_min', ''],
['total_ticket_count_max', ''],
['paid_ticket_count_min', ''],
['paid_ticket_count_max', ''],
['free_ticket_count_min', ''],
['free_ticket_count_max', ''],
['create_time_min', ''],
['create_time_max', ''],
]);
$query = $this->logic->search($where);
$query->with([
'dicePlayer',
]);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 获取玩家选项id、username用于下拉
* @param Request $request
* @return Response
*/
#[Permission('抽奖券获取记录列表', 'dice:player_ticket_record:index:index')]
public function getPlayerOptions(Request $request): Response
{
$list = DicePlayer::field('id,username')->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
})->toArray();
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('抽奖券获取记录读取', 'dice:player_ticket_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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('抽奖券获取记录添加', 'dice:player_ticket_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:player_ticket_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:player_ticket_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('删除失败');
}
}
}

View File

@@ -0,0 +1,222 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\player_wallet_record;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\player_wallet_record\DicePlayerWalletRecordLogic;
use app\dice\validate\player_wallet_record\DicePlayerWalletRecordValidate;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
use Tinywan\Jwt\JwtToken;
/**
* 玩家钱包流水控制器
*/
class DicePlayerWalletRecordController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DicePlayerWalletRecordLogic();
$this->validate = new DicePlayerWalletRecordValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水列表', 'dice:player_wallet_record:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['type', ''],
['username', ''],
['coin_min', ''],
['coin_max', ''],
['create_time_min', ''],
['create_time_max', ''],
]);
$query = $this->logic->search($where);
$query->with([
'dicePlayer',
'operator',
]);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 获取玩家选项id、username用于下拉
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水列表', 'dice:player_wallet_record:index:index')]
public function getPlayerOptions(Request $request): Response
{
$list = DicePlayer::field('id,username')->select();
$data = $list->map(function ($item) {
return ['id' => $item['id'], 'username' => $item['username'] ?? ''];
})->toArray();
return $this->success($data);
}
/**
* 获取指定玩家当前平台币(用于钱包操作前)
* 类型 0=充值 1=提现 2=购买抽奖次数,操作前均为当前平台币
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水读取', 'dice:player_wallet_record:index:read')]
public function getPlayerWalletBefore(Request $request): Response
{
$playerId = $request->input('player_id');
if ($playerId === null || $playerId === '') {
return $this->fail('缺少 player_id');
}
$player = DicePlayer::field('coin')->where('id', $playerId)->find();
if (!$player) {
return $this->fail('玩家不存在');
}
return $this->success(['wallet_before' => (float) $player['coin']]);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水读取', 'dice:player_wallet_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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 管理员钱包操作(加点/扣点):更新玩家平台币并创建流水记录
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水添加', 'dice:player_wallet_record:index:save')]
public function adminOperate(Request $request): Response
{
$data = $request->post();
$playerId = $data['player_id'] ?? null;
$type = isset($data['type']) ? (int) $data['type'] : null;
$coin = isset($data['coin']) ? (float) $data['coin'] : null;
if ($playerId === null || $playerId === '') {
return $this->fail('请选择玩家');
}
if (!in_array($type, [3, 4], true)) {
return $this->fail('操作类型必须为 3=加点 或 4=扣点');
}
if ($coin === null || $coin <= 0) {
return $this->fail('平台币变动必须大于 0');
}
$data['player_id'] = $playerId;
$data['type'] = $type;
$data['coin'] = $coin;
$data['remark'] = $data['remark'] ?? '';
$adminId = null;
$checkAdmin = request()->header('check_admin');
if (!empty($checkAdmin['id'])) {
$adminId = (int) $checkAdmin['id'];
}
if (($adminId === null || $adminId <= 0)) {
try {
$token = JwtToken::getExtend();
if (!empty($token['id']) && ($token['plat'] ?? '') === 'saiadmin') {
$adminId = (int) $token['id'];
}
} catch (\Throwable $e) {
// JWT 无效或未携带
}
}
if ($adminId === null || $adminId <= 0) {
return $this->fail('请先登录');
}
try {
$this->logic->adminOperate($data, $adminId);
return $this->success('操作成功');
} catch (\Throwable $e) {
return $this->fail($e->getMessage());
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('玩家钱包流水添加', 'dice:player_wallet_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:player_wallet_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:player_wallet_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('删除失败');
}
}
}

View File

@@ -0,0 +1,126 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\controller\reward_config;
use plugin\saiadmin\basic\BaseController;
use app\dice\logic\reward_config\DiceRewardConfigLogic;
use app\dice\validate\reward_config\DiceRewardConfigValidate;
use plugin\saiadmin\service\Permission;
use support\Request;
use support\Response;
/**
* 奖励配置控制器
*/
class DiceRewardConfigController extends BaseController
{
/**
* 构造函数
*/
public function __construct()
{
$this->logic = new DiceRewardConfigLogic();
$this->validate = new DiceRewardConfigValidate;
parent::__construct();
}
/**
* 数据列表
* @param Request $request
* @return Response
*/
#[Permission('奖励配置列表', 'dice:reward_config:index:index')]
public function index(Request $request): Response
{
$where = $request->more([
['grid_number_min', ''],
['grid_number_max', ''],
['ui_text', ''],
['real_ev_min', ''],
['real_ev_max', ''],
['tier', ''],
]);
$query = $this->logic->search($where);
$data = $this->logic->getList($query);
return $this->success($data);
}
/**
* 读取数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置读取', 'dice:reward_config: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();
return $this->success($data);
} else {
return $this->fail('未查找到信息');
}
}
/**
* 保存数据
* @param Request $request
* @return Response
*/
#[Permission('奖励配置添加', 'dice:reward_config: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: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: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('删除失败');
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\lottery_config;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\lottery_config\DiceLotteryConfig;
/**
* 色子奖池配置逻辑层
*/
class DiceLotteryConfigLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DiceLotteryConfig();
}
}

View File

@@ -0,0 +1,59 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\play_record;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\play_record\DicePlayRecord;
/**
* 玩家抽奖记录逻辑层
*/
class DicePlayRecordLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DicePlayRecord();
}
/**
* 添加前roll_array 转为 JSON 字符串(数据库为 string 类型)
*/
public function add(array $data): mixed
{
$data = $this->normalizeRollArray($data);
return parent::add($data);
}
/**
* 修改前roll_array 转为 JSON 字符串(数据库为 string 类型)
*/
public function edit($id, array $data): mixed
{
$data = $this->normalizeRollArray($data);
return parent::edit($id, $data);
}
/**
* 将 roll_array 从数组转为 JSON 字符串
*/
private function normalizeRollArray(array $data): array
{
if (!array_key_exists('roll_array', $data)) {
return $data;
}
$val = $data['roll_array'];
if (is_array($val)) {
$data['roll_array'] = json_encode($val, JSON_UNESCAPED_UNICODE);
}
return $data;
}
}

View File

@@ -0,0 +1,61 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\player;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\player\DicePlayer;
/**
* 大富翁-玩家逻辑层
*/
class DicePlayerLogic extends BaseLogic
{
/** 密码加密盐(可与 config 统一) */
private const PASSWORD_SALT = 'dice_player_salt_2024';
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DicePlayer();
}
/**
* 添加数据(密码 md5+salt 加密)
*/
public function add(array $data): mixed
{
if (!empty($data['password'])) {
$data['password'] = $this->hashPassword($data['password']);
}
return parent::add($data);
}
/**
* 修改数据(仅当传入 password 时用 md5+salt 加密后更新)
*/
public function edit($id, array $data): mixed
{
if (isset($data['password']) && $data['password'] !== '') {
$data['password'] = $this->hashPassword($data['password']);
} else {
unset($data['password']);
}
return parent::edit($id, $data);
}
/**
* 密码加密md5(salt . password)
*/
private function hashPassword(string $password): string
{
return md5(self::PASSWORD_SALT . $password);
}
}

View File

@@ -0,0 +1,52 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\player_ticket_record;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\player_ticket_record\DicePlayerTicketRecord;
/**
* 抽奖券获取记录逻辑层
*/
class DicePlayerTicketRecordLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DicePlayerTicketRecord();
}
/**
* 添加前total_ticket_count = paid_ticket_count + free_ticket_count
*/
public function add(array $data): mixed
{
$data = $this->fillTotalTicketCount($data);
return parent::add($data);
}
/**
* 修改前total_ticket_count = paid_ticket_count + free_ticket_count
*/
public function edit($id, array $data): mixed
{
$data = $this->fillTotalTicketCount($data);
return parent::edit($id, $data);
}
private function fillTotalTicketCount(array $data): array
{
$paid = isset($data['paid_ticket_count']) ? (int) $data['paid_ticket_count'] : 0;
$free = isset($data['free_ticket_count']) ? (int) $data['free_ticket_count'] : 0;
$data['total_ticket_count'] = $paid + $free;
return $data;
}
}

View File

@@ -0,0 +1,91 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\player_wallet_record;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use app\dice\model\player_wallet_record\DicePlayerWalletRecord;
use app\dice\model\player\DicePlayer;
/**
* 玩家钱包流水逻辑层
*/
class DicePlayerWalletRecordLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DicePlayerWalletRecord();
}
/**
* 添加数据(补全抽奖次数字段默认值)
*/
public function add(array $data): mixed
{
$data['total_ticket_count'] = $data['total_ticket_count'] ?? 0;
$data['paid_ticket_count'] = $data['paid_ticket_count'] ?? 0;
$data['free_ticket_count'] = $data['free_ticket_count'] ?? 0;
return parent::add($data);
}
/**
* 管理员钱包操作(加点/扣点):更新玩家平台币并创建流水记录
* @param array $data player_id, type (3=加点 4=扣点), coin (正数), remark (可选)
* @param int $adminId 当前管理员 id
* @return mixed
* @throws ApiException
*/
public function adminOperate(array $data, int $adminId): mixed
{
$playerId = (int) ($data['player_id'] ?? 0);
$type = (int) ($data['type'] ?? 0);
$coin = (float) ($data['coin'] ?? 0);
if ($playerId <= 0 || !in_array($type, [3, 4], true)) {
throw new ApiException('参数错误:需要有效的 player_id 和 type3=加点4=扣点)');
}
if ($coin <= 0) {
throw new ApiException('平台币变动必须大于 0');
}
$player = DicePlayer::where('id', $playerId)->find();
if (!$player) {
throw new ApiException('玩家不存在');
}
$walletBefore = (float) ($player['coin'] ?? 0);
if ($type === 4 && $walletBefore < $coin) {
throw new ApiException('扣点数量不能大于当前余额');
}
$walletAfter = $type === 3 ? $walletBefore + $coin : $walletBefore - $coin;
$remark = trim((string) ($data['remark'] ?? ''));
if ($remark === '') {
$remark = $type === 3 ? '管理员加点' : '管理员扣点';
}
DicePlayer::where('id', $playerId)->update(['coin' => $walletAfter]);
$record = [
'player_id' => $playerId,
'coin' => $type === 3 ? $coin : -$coin,
'type' => $type,
'wallet_before' => $walletBefore,
'wallet_after' => $walletAfter,
'remark' => $remark,
'user_id' => $adminId,
'total_ticket_count' => 0,
'paid_ticket_count' => 0,
'free_ticket_count' => 0,
];
return $this->model->create($record);
}
}

View File

@@ -0,0 +1,27 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\logic\reward_config;
use plugin\saiadmin\basic\think\BaseLogic;
use plugin\saiadmin\exception\ApiException;
use plugin\saiadmin\utils\Helper;
use app\dice\model\reward_config\DiceRewardConfig;
/**
* 奖励配置逻辑层
*/
class DiceRewardConfigLogic extends BaseLogic
{
/**
* 构造函数
*/
public function __construct()
{
$this->model = new DiceRewardConfig();
}
}

View File

@@ -0,0 +1,51 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\lottery_config;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 色子奖池配置模型
*
* dice_lottery_config 色子奖池配置
*
* @property $id ID
* @property $name 名称
* @property $remark 备注
* @property $type 奖池类型
* @property $safety_line 安全线
* @property $create_time 创建时间
* @property $update_time 修改时间
* @property $t1_wight T1池权重
* @property $t2_wight T2池权重
* @property $t3_wight T3池权重
* @property $t4_wight T4池权重
* @property $t5_wight T5池权重
*/
class DiceLotteryConfig extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_lottery_config';
/**
* 名称 搜索
*/
public function searchNameAttr($query, $value)
{
$query->where('name', 'like', '%'.$value.'%');
}
}

View File

@@ -0,0 +1,173 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\play_record;
use app\dice\model\lottery_config\DiceLotteryConfig;
use app\dice\model\player\DicePlayer;
use app\dice\model\reward_config\DiceRewardConfig;
use plugin\saiadmin\basic\think\BaseModel;
use think\model\relation\BelongsTo;
/**
* 玩家抽奖记录模型
*
* dice_play_record 玩家抽奖记录
*
* @property $id ID
* @property $player_id 玩家id
* @property $lottery_config_id 彩金池配置
* @property $lottery_type 抽奖类型
* @property $is_win 中奖
* @property $win_coin 赢取平台币
* @property $direction 方向:0=顺时针,1=逆时针
* @property $reward_config_id 奖励配置id
* @property $lottery_id 奖池
* @property $start_index 起始索引
* @property $target_index 结束索引
* @property $roll_array 摇取点数,格式:[1,2,3,4,5]5个点数
* @property $lottery_name 奖池名
* @property $status 状态:0=超时/失败 1=成功
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayRecord extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_play_record';
/**
* 玩家
* 关联模型 dicePlayer
*/
public function dicePlayer(): BelongsTo
{
return $this->belongsTo(DicePlayer::class, 'player_id', 'id');
}
/**
* 中奖配置
* 关联模型 diceRewardConfig
*/
public function diceRewardConfig(): BelongsTo
{
return $this->belongsTo(DiceRewardConfig::class, 'reward_config_id', 'id');
}
/**
* 彩金配置
* 关联模型 diceLotteryConfig
*/
public function diceLotteryConfig(): BelongsTo
{
return $this->belongsTo(DiceLotteryConfig::class, 'lottery_config_id', 'id');
}
/** 按玩家用户名模糊dicePlayer.username */
public function searchUsernameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DicePlayer::where('username', 'like', '%' . $value . '%')->column('id');
if (!empty($ids)) {
$query->whereIn('player_id', $ids);
} else {
$query->whereRaw('1=0');
}
}
/** 按彩金池配置名称模糊diceLotteryConfig.name */
public function searchLotteryConfigNameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DiceLotteryConfig::where('name', 'like', '%' . $value . '%')->column('id');
if (!empty($ids)) {
$query->whereIn('lottery_config_id', $ids);
} else {
$query->whereRaw('1=0');
}
}
/** 抽奖类型 */
public function searchLotteryTypeAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('lottery_type', '=', $value);
}
}
/** 中奖 */
public function searchIsWinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('is_win', '=', $value);
}
}
/** 赢取平台币下限 */
public function searchWinCoinMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('win_coin', '>=', $value);
}
}
/** 赢取平台币上限 */
public function searchWinCoinMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('win_coin', '<=', $value);
}
}
/** 按奖励配置前端显示文本模糊diceRewardConfig.ui_text */
public function searchRewardUiTextAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DiceRewardConfig::where('ui_text', 'like', '%' . $value . '%')->column('id');
if (!empty($ids)) {
$query->whereIn('reward_config_id', $ids);
} else {
$query->whereRaw('1=0');
}
}
/** 按奖励档位diceRewardConfig.tier中奖名 T1-T5 */
public function searchRewardTierAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$ids = DiceRewardConfig::where('tier', '=', $value)->column('id');
if (!empty($ids)) {
$query->whereIn('reward_config_id', $ids);
} else {
$query->whereRaw('1=0');
}
}
/** 方向 0=顺时针 1=逆时针 */
public function searchDirectionAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('direction', '=', $value);
}
}
}

View File

@@ -0,0 +1,170 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\player;
use plugin\saiadmin\basic\think\BaseModel;
use app\dice\model\lottery_config\DiceLotteryConfig;
/**
* 大富翁-玩家模型
*
* dice_player 大富翁-玩家
*
* @property $id ID
* @property $username 用户名
* @property $phone 手机
* @property $uid uid
* @property $name 昵称
* @property $password 密码
* @property $status 状态
* @property $coin 平台币
* @property $is_up 倍率
* @property $t1_wight T1池权重
* @property $t2_wight T2池权重
* @property $t3_wight T3池权重
* @property $t4_wight T4池权重
* @property $t5_wight T5池权重
* @property $total_ticket_count 总抽奖次数
* @property $paid_ticket_count 购买抽奖次数
* @property $free_ticket_count 赠送抽奖次数
* @property $created_at 创建时间
* @property $updated_at 更新时间
* @property $deleted_at 删除时间
*/
class DicePlayer extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_player';
/**
* 新增前:生成唯一 uid昵称 name 默认使用 uid
* 用 try-catch 避免表尚未含 uid 时 getAttr/getData 抛 InvalidArgumentException
*/
public static function onBeforeInsert($model): void
{
parent::onBeforeInsert($model);
try {
$uid = $model->getAttr('uid');
} catch (\Throwable $e) {
$uid = null;
}
if ($uid === null || $uid === '') {
$uid = self::generateUid();
$model->setAttr('uid', $uid);
}
try {
$name = $model->getAttr('name');
} catch (\Throwable $e) {
$name = null;
}
if ($name === null || $name === '') {
$model->setAttr('name', $uid);
}
// 彩金池权重默认取 type=0 的奖池配置
self::setDefaultWeightsFromLotteryConfig($model);
}
/**
* 从 DiceLotteryConfig type=0 取 t1_wightt5_wight 作为玩家未设置时的默认值
*/
protected static function setDefaultWeightsFromLotteryConfig(DicePlayer $model): void
{
$config = DiceLotteryConfig::where('type', 0)->find();
if (!$config) {
return;
}
$fields = ['t1_wight', 't2_wight', 't3_wight', 't4_wight', 't5_wight'];
foreach ($fields as $field) {
try {
$val = $model->getAttr($field);
} catch (\Throwable $e) {
$val = null;
}
if ($val === null || $val === '') {
try {
$model->setAttr($field, $config->getAttr($field));
} catch (\Throwable $e) {
// 忽略字段不存在
}
}
}
}
/**
* 生成唯一标识 uid12 位十六进制)
*/
public static function generateUid(): string
{
return strtoupper(substr(bin2hex(random_bytes(6)), 0, 12));
}
/**
* 用户名 搜索
*/
public function searchUsernameAttr($query, $value)
{
$query->where('username', 'like', '%'.$value.'%');
}
/**
* 昵称 搜索
*/
public function searchNameAttr($query, $value)
{
$query->where('name', 'like', '%'.$value.'%');
}
/**
* 手机号 模糊搜索
*/
public function searchPhoneAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('phone', 'like', '%' . $value . '%');
}
}
/**
* 状态 搜索
*/
public function searchStatusAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('status', '=', $value);
}
}
/**
* 平台币 搜索
*/
public function searchCoinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('coin', '=', $value);
}
}
/**
* 倍率 搜索
*/
public function searchIs_upAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('is_up', '=', $value);
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\player_ticket_record;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\basic\think\BaseModel;
use think\model\relation\BelongsTo;
/**
* 抽奖券获取记录模型
*
* dice_player_ticket_record 抽奖券获取记录
*
* @property $id ID
* @property $player_id 玩家id
* @property $use_coins 消耗硬币
* @property $total_ticket_count 总抽奖次数
* @property $paid_ticket_count 购买抽奖次数
* @property $free_ticket_count 赠送抽奖次数
* @property $remark 备注
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayerTicketRecord extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_player_ticket_record';
/**
* 关联模型 dicePlayer
*/
public function dicePlayer(): BelongsTo
{
return $this->belongsTo(DicePlayer::class, 'player_id', 'id');
}
/**
* 按关联玩家用户名搜索dicePlayer.username
*/
public function searchUsernameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$playerIds = DicePlayer::where('username', 'like', '%' . $value . '%')->column('id');
if (!empty($playerIds)) {
$query->whereIn('player_id', $playerIds);
} else {
$query->whereRaw('1=0');
}
}
/** 消耗硬币下限 */
public function searchUseCoinsMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('use_coins', '>=', $value);
}
}
/** 消耗硬币上限 */
public function searchUseCoinsMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('use_coins', '<=', $value);
}
}
/** 总抽奖次数(total_ticket_count)下限 */
public function searchTotalTicketCountMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('total_ticket_count', '>=', $value);
}
}
/** 总抽奖次数(total_ticket_count)上限 */
public function searchTotalTicketCountMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('total_ticket_count', '<=', $value);
}
}
/** 购买抽奖次数(paid_ticket_count)下限 */
public function searchPaidTicketCountMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('paid_ticket_count', '>=', $value);
}
}
/** 购买抽奖次数(paid_ticket_count)上限 */
public function searchPaidTicketCountMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('paid_ticket_count', '<=', $value);
}
}
/** 赠送抽奖次数(free_ticket_count)下限 */
public function searchFreeTicketCountMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('free_ticket_count', '>=', $value);
}
}
/** 赠送抽奖次数(free_ticket_count)上限 */
public function searchFreeTicketCountMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('free_ticket_count', '<=', $value);
}
}
/** 创建时间起始 */
public function searchCreateTimeMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '>=', $value);
}
}
/** 创建时间结束 */
public function searchCreateTimeMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '<=', $value);
}
}
}

View File

@@ -0,0 +1,128 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\player_wallet_record;
use app\dice\model\player\DicePlayer;
use plugin\saiadmin\basic\think\BaseModel;
use plugin\saiadmin\app\model\system\SystemUser;
use think\model\relation\BelongsTo;
/**
* 玩家钱包流水模型
*
* dice_player_wallet_record 玩家钱包流水记录
*
* @property $id ID
* @property $player_id 用户id
* @property $coin 平台币变化
* @property $type 类型:0=充值 1=提现 2=购买抽奖次数
* @property $wallet_before 钱包操作前
* @property $wallet_after 钱包操作后
* @property $total_ticket_count 总抽奖次数
* @property $paid_ticket_count 购买抽奖次数
* @property $free_ticket_count 赠送抽奖次数
* @property $remark 备注
* @property $user_id 操作管理员idtype 3/4 时记录)
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DicePlayerWalletRecord extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_player_wallet_record';
/**
* 关联模型 dicePlayer
*/
public function dicePlayer(): BelongsTo
{
return $this->belongsTo(DicePlayer::class, 'player_id', 'id');
}
/**
* 关联操作管理员type 3/4 时有值)
*/
public function operator(): BelongsTo
{
return $this->belongsTo(SystemUser::class, 'user_id', 'id');
}
/**
* 类型 搜索
*/
public function searchTypeAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('type', '=', $value);
}
}
/**
* 按关联玩家用户名搜索dicePlayer.username
*/
public function searchUsernameAttr($query, $value)
{
if ($value === '' || $value === null) {
return;
}
$playerIds = DicePlayer::where('username', 'like', '%' . $value . '%')->column('id');
if (!empty($playerIds)) {
$query->whereIn('player_id', $playerIds);
} else {
$query->whereRaw('1=0');
}
}
/**
* 平台币下限 搜索withSearch 用 Str::studly 故方法名为 searchCoinMinAttr
*/
public function searchCoinMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('coin', '>=', $value);
}
}
/**
* 平台币上限 搜索withSearch 用 Str::studly 故方法名为 searchCoinMaxAttr
*/
public function searchCoinMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('coin', '<=', $value);
}
}
/**
* 创建时间起始 搜索
*/
public function searchCreateTimeMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '>=', $value);
}
}
/**
* 创建时间结束 搜索
*/
public function searchCreateTimeMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('create_time', '<=', $value);
}
}
}

View File

@@ -0,0 +1,86 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\model\reward_config;
use plugin\saiadmin\basic\think\BaseModel;
/**
* 奖励配置模型
*
* dice_reward_config 奖励配置
*
* @property $id ID
* @property $grid_number 色子点数
* @property $ui_text 前端显示文本
* @property $real_ev 真实资金结算
* @property $tier 所属档位
* @property $remark 备注
* @property $create_time 创建时间
* @property $update_time 修改时间
*/
class DiceRewardConfig extends BaseModel
{
/**
* 数据表主键
* @var string
*/
protected $pk = 'id';
/**
* 数据库表名称
* @var string
*/
protected $table = 'dice_reward_config';
/** 色子点数下限 */
public function searchGridNumberMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('grid_number', '>=', $value);
}
}
/** 色子点数上限 */
public function searchGridNumberMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('grid_number', '<=', $value);
}
}
/** 前端显示文本模糊 */
public function searchUiTextAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('ui_text', 'like', '%' . $value . '%');
}
}
/** 真实资金结算下限 */
public function searchRealEvMinAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('real_ev', '>=', $value);
}
}
/** 真实资金结算上限 */
public function searchRealEvMaxAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('real_ev', '<=', $value);
}
}
/** 所属档位 */
public function searchTierAttr($query, $value)
{
if ($value !== '' && $value !== null) {
$query->where('tier', '=', $value);
}
}
}

View File

@@ -0,0 +1,66 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\lottery_config;
use plugin\saiadmin\basic\BaseValidate;
/**
* 色子奖池配置验证器
*/
class DiceLotteryConfigValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'name' => 'require',
'type' => 'require',
't1_wight' => 'require',
't2_wight' => 'require',
't3_wight' => 'require',
't4_wight' => 'require',
't5_wight' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'name' => '名称必须填写',
'type' => '奖池类型必须填写',
't1_wight' => 'T1池权重必须填写',
't2_wight' => 'T2池权重必须填写',
't3_wight' => 'T3池权重必须填写',
't4_wight' => 'T4池权重必须填写',
't5_wight' => 'T5池权重必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'name',
'type',
't1_wight',
't2_wight',
't3_wight',
't4_wight',
't5_wight',
],
'update' => [
'name',
'type',
't1_wight',
't2_wight',
't3_wight',
't4_wight',
't5_wight',
],
];
}

View File

@@ -0,0 +1,94 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\play_record;
use plugin\saiadmin\basic\BaseValidate;
/**
* 玩家抽奖记录验证器
*/
class DicePlayRecordValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'player_id' => 'require',
'lottery_config_id' => 'require',
'lottery_type' => 'require',
'is_win' => 'require',
'win_coin' => 'require',
'reward_config_id' => 'require',
'roll_array' => 'require|checkRollArray',
];
/**
* 定义错误信息
*/
protected $message = [
'player_id' => '玩家必须填写',
'lottery_config_id' => '彩金池配置必须填写',
'lottery_type' => '抽奖类型必须填写',
'is_win' => '中奖必须填写',
'win_coin' => '赢取平台币必须填写',
'reward_config_id' => '奖励配置必须填写',
'roll_array.require' => '摇取点数必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'player_id',
'lottery_config_id',
'lottery_type',
'is_win',
'win_coin',
'reward_config_id',
'roll_array',
],
'update' => [
'player_id',
'lottery_config_id',
'lottery_type',
'is_win',
'win_coin',
'reward_config_id',
'roll_array',
],
];
/**
* 验证 roll_array必须为 5 个元素,每个值在 16 之间
* @param mixed $value
* @param mixed $rule
* @param array $data
* @param string $field
* @return bool|string
*/
protected function checkRollArray($value, $rule = '', array $data = [], string $field = '')
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : [];
}
if (!is_array($value)) {
return '摇取点数必须为数组';
}
if (count($value) !== 5) {
return '摇取点数必须为 5 个数';
}
foreach ($value as $i => $n) {
$v = is_numeric($n) ? (int) $n : null;
if ($v === null || $v < 1 || $v > 6) {
return '摇取点数第' . ($i + 1) . '个值必须在 16 之间';
}
}
return true;
}
}

View File

@@ -0,0 +1,61 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\player;
use plugin\saiadmin\basic\BaseValidate;
/**
* 大富翁-玩家验证器
*/
class DicePlayerValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'username' => 'require',
'name' => 'require',
'phone' => 'require',
'password' => 'require',
'status' => 'require',
'coin' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'username' => '用户名必须填写',
'name' => '昵称必须填写',
'phone' => '手机号必须填写',
'password' => '密码必须填写',
'status' => '状态必须填写',
'coin' => '平台币必须填写',
];
/**
* 定义场景update 时密码可选,不填则不修改)
*/
protected $scene = [
'save' => [
'username',
'name',
'phone',
'password',
'status',
'coin',
],
'update' => [
'username',
'name',
'phone',
'status',
'coin',
],
];
}

View File

@@ -0,0 +1,62 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\player_ticket_record;
use plugin\saiadmin\basic\BaseValidate;
/**
* 抽奖券获取记录验证器
*/
class DicePlayerTicketRecordValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'player_id' => 'require',
'use_coins' => 'require',
'total_ticket_count' => 'require',
'paid_ticket_count' => 'require',
'free_ticket_count' => 'require',
'remark' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'player_id' => '玩家id必须填写',
'use_coins' => '消耗硬币必须填写',
'total_ticket_count' => '总抽奖次数必须填写',
'paid_ticket_count' => '购买抽奖次数必须填写',
'free_ticket_count' => '赠送抽奖次数必须填写',
'remark' => '备注必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'player_id',
'use_coins',
'total_ticket_count',
'paid_ticket_count',
'free_ticket_count',
'remark',
],
'update' => [
'player_id',
'use_coins',
'total_ticket_count',
'paid_ticket_count',
'free_ticket_count',
'remark',
],
];
}

View File

@@ -0,0 +1,46 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\player_wallet_record;
use plugin\saiadmin\basic\BaseValidate;
/**
* 玩家钱包流水验证器
*/
class DicePlayerWalletRecordValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'player_id' => 'require',
'coin' => 'require',
'type' => 'require',
'wallet_before' => 'require',
'wallet_after' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'player_id' => '用户必须选择',
'coin' => '平台币变化必须填写',
'type' => '类型必须填写',
'wallet_before' => '钱包操作前不能为空',
'wallet_after' => '钱包操作后不能为空',
];
/**
* 定义场景
*/
protected $scene = [
'save' => ['player_id', 'coin', 'type', 'wallet_before', 'wallet_after'],
'update' => ['player_id', 'coin', 'type', 'wallet_before', 'wallet_after'],
];
}

View File

@@ -0,0 +1,54 @@
<?php
// +----------------------------------------------------------------------
// | saiadmin [ saiadmin快速开发框架 ]
// +----------------------------------------------------------------------
// | Author: your name
// +----------------------------------------------------------------------
namespace app\dice\validate\reward_config;
use plugin\saiadmin\basic\BaseValidate;
/**
* 奖励配置验证器
*/
class DiceRewardConfigValidate extends BaseValidate
{
/**
* 定义验证规则
*/
protected $rule = [
'grid_number' => 'require',
'ui_text' => 'require',
'real_ev' => 'require',
'tier' => 'require',
];
/**
* 定义错误信息
*/
protected $message = [
'grid_number' => '色子点数必须填写',
'ui_text' => '前端显示文本必须填写',
'real_ev' => '真实资金结算必须填写',
'tier' => '所属档位必须填写',
];
/**
* 定义场景
*/
protected $scene = [
'save' => [
'grid_number',
'ui_text',
'real_ev',
'tier',
],
'update' => [
'grid_number',
'ui_text',
'real_ev',
'tier',
],
];
}

View File

@@ -2,3 +2,15 @@
/** /**
* Here is your custom functions. * Here is your custom functions.
*/ */
/**
* mb_split 兼容:当 PHP 未启用 mbstring 或 mb_split 不可用时,用 preg_split 模拟
* 解决 Illuminate\Support\Str::studly() 在 Windows 等环境下报 Call to undefined function mb_split() 的问题
*/
if (!function_exists('mb_split')) {
function mb_split(string $pattern, string $string, int $limit = -1): array
{
$regex = '/' . str_replace('/', '\\/', $pattern) . '/u';
return preg_split($regex, $string, $limit === -1 ? -1 : $limit) ?: [];
}
}

24
server/config/api.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
/**
* API 鉴权与用户相关配置
*/
return [
// auth-token 签名密钥(与客户端约定,用于 /api/authToken 的 signature 校验,必填)
'auth_token_secret' => env('API_AUTH_TOKEN_SECRET', ''),
// auth-token 时间戳允许误差(秒),防重放,默认 300 秒
'auth_token_time_tolerance' => (int) env('API_AUTH_TOKEN_TIME_TOLERANCE', 300),
// auth-token 有效期(秒),默认 24 小时
'auth_token_exp' => (int) env('API_AUTH_TOKEN_EXP', 86400),
// auth-token 按设备存储的 Redis key 前缀(同一设备只保留最新一个 auth-token
'auth_token_device_prefix' => env('API_AUTH_TOKEN_DEVICE_PREFIX', 'api:auth_token:'),
// user-token 有效期(秒),默认 7 天
'user_token_exp' => (int) env('API_USER_TOKEN_EXP', 604800),
// 按用户存储当前有效 user-token 的 Redis key 前缀(同一用户仅保留最新一次登录的 token
'user_token_current_prefix' => env('API_USER_TOKEN_CURRENT_PREFIX', 'api:user:current_token:'),
// 用户信息 Redis 缓存过期时间(秒),默认 7 天
'user_cache_expire' => (int) env('API_USER_CACHE_EXPIRE', 604800),
// 用户缓存 Redis key 前缀
'user_cache_prefix' => env('API_USER_CACHE_PREFIX', 'api:user:'),
// 用户信息加密密钥(用于 Redis 中 value 的加密),建议 32 位
'user_encrypt_key' => env('API_USER_ENCRYPT_KEY', 'dafuweng_api_user_cache_key_32'),
];

View File

@@ -22,7 +22,7 @@ return [
'dirname' => function () { 'dirname' => function () {
return date('Ymd'); return date('Ymd');
}, },
'domain' => 'http://127.0.0.1:8787', 'domain' => 'http://127.0.0.1:6688',
'uri' => '/runtime', // 如果 domain + uri 不在 public 目录下请做好软链接否则生成的url无法访问 'uri' => '/runtime', // 如果 domain + uri 不在 public 目录下请做好软链接否则生成的url无法访问
'algo' => 'sha1', 'algo' => 'sha1',
], ],

View File

@@ -21,7 +21,7 @@ global $argv;
return [ return [
'webman' => [ 'webman' => [
'handler' => Http::class, 'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787', 'listen' => 'http://0.0.0.0:6688',
'count' => cpu_count() * 4, 'count' => cpu_count() * 4,
'user' => '', 'user' => '',
'group' => '', 'group' => '',

View File

@@ -13,9 +13,29 @@
*/ */
use Webman\Route; use Webman\Route;
use app\api\middleware\CheckAuthTokenMiddleware;
use app\api\middleware\CheckUserTokenMiddleware;
// 仅需 auth-token 的路由组authToken 接口在中间件内白名单跳过)
Route::group('/api', function () {
Route::any('/authToken', [app\api\controller\AuthTokenController::class, 'index']);
Route::any('/user/login', [app\api\controller\UserController::class, 'login']);
Route::any('/user/register', [app\api\controller\UserController::class, 'register']);
})->middleware([
CheckAuthTokenMiddleware::class,
]);
// 需 auth-token + user-token 的路由组
Route::group('/api', function () {
Route::any('/user/logout', [app\api\controller\UserController::class, 'logout']);
Route::any('/user/info', [app\api\controller\UserController::class, 'info']);
Route::any('/user/balance', [app\api\controller\UserController::class, 'balance']);
Route::any('/user/walletRecord', [app\api\controller\UserController::class, 'walletRecord']);
Route::any('/user/playGameRecord', [app\api\controller\UserController::class, 'playGameRecord']);
Route::any('/game/buyLotteryTickets', [app\api\controller\GameController::class, 'buyLotteryTickets']);
Route::any('/game/lotteryPool', [app\api\controller\GameController::class, 'lotteryPool']);
Route::any('/game/playStart', [app\api\controller\GameController::class, 'playStart']);
})->middleware([
CheckAuthTokenMiddleware::class,
CheckUserTokenMiddleware::class,
]);

View File

@@ -1,7 +1,7 @@
<?php <?php
return [ return [
// 默认缓存驱动 // 默认缓存驱动API 用户缓存依赖 Redis建议设为 redis 并配置 REDIS_*
'default' => env('CACHE_MODE', 'file'), 'default' => env('CACHE_MODE', 'redis'),
// 缓存连接方式配置 // 缓存连接方式配置
'stores' => [ 'stores' => [
// redis缓存 // redis缓存

View File

@@ -49,9 +49,12 @@ class LoginController extends BaseController
$code = $request->post('code', ''); $code = $request->post('code', '');
$uuid = $request->post('uuid', ''); $uuid = $request->post('uuid', '');
$captcha = new Captcha(); $captchaEnabled = config('plugin.saiadmin.saithink.captcha.enable', true);
if (!$captcha->checkCaptcha($uuid, $code)) { if ($captchaEnabled) {
return $this->fail('验证码错误'); $captcha = new Captcha();
if (!$captcha->checkCaptcha($uuid, $code)) {
return $this->fail('验证码错误');
}
} }
$logic = new SystemUserLogic(); $logic = new SystemUserLogic();
$data = $logic->login($username, $password, $type); $data = $logic->login($username, $password, $type);

View File

@@ -21,14 +21,14 @@ class BaseController extends OpenController
protected $adminInfo; protected $adminInfo;
/** /**
* 当前登陆管理员ID * 当前登陆管理员ID(未登录时为 null
*/ */
protected int $adminId; protected ?int $adminId = null;
/** /**
* 当前登陆管理员账号 * 当前登陆管理员账号(未登录时为空字符串)
*/ */
protected string $adminName; protected string $adminName = '';
/** /**
* 逻辑层注入 * 逻辑层注入

View File

@@ -42,11 +42,12 @@ class OpenController
/** /**
* 失败返回json内容 * 失败返回json内容
* @param string $msg * @param string $msg
* @param int $code 201=请携带token 202=缺少参数 203=token过期默认400
* @return Response * @return Response
*/ */
public function fail(string $msg = 'fail'): Response public function fail(string $msg = 'fail', int $code = 400): Response
{ {
return json(['code' => 400, 'message' => $msg]); return json(['code' => $code, 'message' => $msg]);
} }
/** /**

View File

@@ -8,9 +8,11 @@ return [
'access_exp' => 8 * 60 * 60, // 登录token有效期默认8小时 'access_exp' => 8 * 60 * 60, // 登录token有效期默认8小时
// 验证码存储模式 // 验证码配置
'captcha' => [ 'captcha' => [
// 验证码存储模式 session或者cache // 是否启用登录验证码。改为 false 即关闭;也可用环境变量 LOGIN_CAPTCHA_ENABLE=0 关闭
'enable' => filter_var(getenv('LOGIN_CAPTCHA_ENABLE') ?: '0', FILTER_VALIDATE_BOOLEAN),
// 验证码存储模式 session 或 cache
'mode' => getenv('CAPTCHA_MODE'), 'mode' => getenv('CAPTCHA_MODE'),
// 验证码过期时间 (秒) // 验证码过期时间 (秒)
'expire' => 300, 'expire' => 300,

View File

@@ -20,6 +20,11 @@ namespace support;
*/ */
class Request extends \Webman\Http\Request class Request extends \Webman\Http\Request
{ {
/** 由 CheckUserTokenMiddleware 注入:当前用户 ID */
public ?int $user_id = null;
/** 由 CheckUserTokenMiddleware 注入:当前 user-token 原始字符串 */
public ?string $userToken = null;
/** /**
* 获取参数增强方法 * 获取参数增强方法