[游戏管理]用户管理-优化钱包操作

This commit is contained in:
2026-04-17 14:34:22 +08:00
parent bf3d50a309
commit 2e0bcd3f23
6 changed files with 357 additions and 52 deletions

View File

@@ -20,7 +20,7 @@ class User extends Backend
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time', 'invite_code'];
protected array|string $preExcludeFields = ['id', 'uuid', 'create_time', 'update_time', 'invite_code', 'coin', 'total_deposit_coin', 'total_valid_bet_coin'];
protected array $withJoinTable = ['channel', 'admin'];
@@ -200,6 +200,159 @@ class User extends Backend
]);
}
/**
* 后台钱包加减点(不允许在用户编辑表单直接改余额)
*/
public function walletAdjust(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$userIdRaw = $request->post('user_id');
$userId = is_numeric(strval($userIdRaw)) ? intval(strval($userIdRaw)) : 0;
if ($userId <= 0) {
return $this->error(__('Parameter error'));
}
$opRaw = $request->post('op');
$op = is_string($opRaw) ? trim($opRaw) : '';
if (!in_array($op, ['credit', 'deduct'], true)) {
return $this->error('操作类型不正确');
}
$amountRaw = $request->post('amount');
$amountText = is_string($amountRaw) || is_numeric($amountRaw) ? trim(strval($amountRaw)) : '';
if ($amountText === '' || !is_numeric($amountText)) {
return $this->error('金额格式不正确');
}
if (bccomp($amountText, '0', 4) <= 0) {
return $this->error('金额必须大于0');
}
$remarkRaw = $request->post('remark');
$remark = is_string($remarkRaw) ? trim($remarkRaw) : '';
$adminName = is_string($this->auth->username ?? null) ? $this->auth->username : ('#' . strval($this->auth->id));
$amountForRemark = self::formatAmountForDisplay($amountText);
if ($remark === '') {
$actionText = $op === 'credit' ? '加点' : '扣点';
$remark = '后台管理员(' . $adminName . '' . $actionText . $amountForRemark . '(值)';
}
$user = $this->model->where('id', $userId)->find();
if (!$user) {
return $this->error(__('Record not found'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($user[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
$channelIdRaw = $user['channel_id'] ?? null;
$channelId = is_numeric(strval($channelIdRaw)) ? intval(strval($channelIdRaw)) : null;
$before = strval($user['coin'] ?? '0');
$delta = self::normalizeAmountScale($amountText, 4);
if ($op === 'credit') {
$after = bcadd($before, $delta, 4);
$bizType = 'admin_credit';
$direction = 1;
} else {
if (bccomp($before, $delta, 4) < 0) {
return $this->error('余额不足,扣点失败');
}
$after = bcsub($before, $delta, 4);
$bizType = 'admin_deduct';
$direction = 2;
}
$now = time();
$idem = 'admin_adjust_' . $userId . '_' . $this->auth->id . '_' . $now . '_' . random_int(1000, 9999);
Db::startTrans();
try {
Db::name('user')->where('id', $userId)->update([
'coin' => $after,
'update_time' => $now,
]);
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => $bizType,
'direction' => $direction,
'amount' => $delta,
'balance_before' => $before,
'balance_after' => $after,
'ref_type' => 'admin_user_wallet_adjust',
'ref_id' => null,
'idempotency_key' => $idem,
'operator_admin_id' => intval(strval($this->auth->id)),
'remark' => substr($remark, 0, 500),
'create_time' => $now,
]);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success('钱包调整成功', [
'user_id' => $userId,
'coin_before' => self::formatAmountForDisplay($before),
'coin_after' => self::formatAmountForDisplay($after),
'amount' => self::formatAmountForDisplay($delta),
'op' => $op,
]);
}
private static function normalizeAmountScale(string $amount, int $scale): string
{
$raw = trim(str_replace(',', '.', $amount));
if ($raw === '') {
return '0';
}
$negative = false;
if (str_starts_with($raw, '-')) {
$negative = true;
$raw = ltrim(substr($raw, 1));
}
if (!str_contains($raw, '.')) {
$v = ltrim($raw, '0');
$v = $v === '' ? '0' : $v;
return $negative ? ('-' . $v) : $v;
}
[$intPart, $fracPart] = explode('.', $raw, 2);
$intPart = ltrim($intPart, '0');
$intPart = $intPart === '' ? '0' : $intPart;
$fracPart = preg_replace('/\D+/', '', $fracPart) ?? '';
if (strlen($fracPart) > $scale) {
$fracPart = substr($fracPart, 0, $scale);
} else {
$fracPart = str_pad($fracPart, $scale, '0');
}
$v = $intPart . '.' . $fracPart;
return $negative ? ('-' . $v) : $v;
}
private static function formatAmountForDisplay(string $amount): string
{
$normalized = self::normalizeAmountScale($amount, 4);
$negative = false;
if (str_starts_with($normalized, '-')) {
$negative = true;
$normalized = substr($normalized, 1);
}
$parts = explode('.', $normalized, 2);
$intPart = $parts[0] ?? '0';
$fracPart = $parts[1] ?? '0000';
$displayFrac = substr($fracPart, 0, 2);
$v = $intPart . '.' . str_pad($displayFrac, 2, '0');
return $negative ? ('-' . $v) : $v;
}
/**
* 角色组 → 管理员树(仅当前账号可管理的角色组及其下管理员;用于游戏用户归属)
* 同一管理员若属于多个组,只挂在 id 最小的所属组下,避免树中重复 value

View File

@@ -9,7 +9,7 @@ export default {
head_image: 'Avatar',
remark: 'Remark',
coin: 'Coin balance',
coin_placeholder: 'decimal(18,4)',
coin_placeholder: 'Amounts are displayed with 2 decimals',
total_deposit_coin: 'Total deposit (coin)',
total_valid_bet_coin: 'Total valid bet (coin)',
risk_flags: 'Risk',
@@ -42,4 +42,12 @@ export default {
section_risk: 'Risk control',
section_streak: 'Streak (fallback)',
section_other: 'Other',
wallet_adjust_title: 'Wallet adjustment',
wallet_adjust_op: 'Operation',
wallet_adjust_credit: 'Credit',
wallet_adjust_deduct: 'Deduct',
wallet_adjust_amount: 'Amount',
wallet_adjust_amount_invalid: 'Please enter an amount greater than 0',
wallet_adjust_operator_admin: 'operator admin',
wallet_adjust_default_remark: 'Backend admin ({admin}) {action} {amount} (value)',
}

View File

@@ -9,7 +9,7 @@
head_image: '头像',
remark: '备注',
coin: '余额',
coin_placeholder: 'decimal(18,4),禁止业务用浮点存库',
coin_placeholder: '金额展示统一两位小数',
total_deposit_coin: '累计充值(币)',
total_valid_bet_coin: '累计有效投注(币)',
risk_flags: '风控',
@@ -42,4 +42,12 @@
section_risk: '风控',
section_streak: '连胜(兜底)',
section_other: '其他',
wallet_adjust_title: '钱包加减点',
wallet_adjust_op: '操作类型',
wallet_adjust_credit: '加点',
wallet_adjust_deduct: '扣点',
wallet_adjust_amount: '操作金额',
wallet_adjust_amount_invalid: '请输入大于0的金额',
wallet_adjust_operator_admin: '操作管理员',
wallet_adjust_default_remark: '后台管理员({admin}{action}{amount}(值)',
}

View File

@@ -10,24 +10,71 @@
<Table ref="tableRef"></Table>
<PopupForm />
<el-dialog v-model="walletDialogVisible" class="ba-operate-dialog wallet-adjust-dialog" :close-on-click-modal="false" width="520px">
<template #header>
<div class="title">{{ t('user.user.wallet_adjust_title') }}</div>
</template>
<el-scrollbar class="ba-table-form-scrollbar">
<div class="ba-operate-form wallet-adjust-form">
<el-form :label-position="config.layout.shrink ? 'top' : 'right'" :label-width="config.layout.shrink ? '' : '110px'">
<el-form-item :label="t('user.user.username')">
<el-input :model-value="walletForm.username" readonly />
</el-form-item>
<el-form-item :label="t('user.user.coin')">
<el-tag type="primary">{{ walletForm.current_coin }}</el-tag>
</el-form-item>
<el-form-item :label="t('user.user.wallet_adjust_op')">
<el-radio-group v-model="walletForm.op" class="wallet-adjust-op-group" @change="syncWalletRemark">
<el-radio label="credit">{{ t('user.user.wallet_adjust_credit') }}</el-radio>
<el-radio label="deduct">{{ t('user.user.wallet_adjust_deduct') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="t('user.user.wallet_adjust_amount')">
<el-input-number
class="wallet-adjust-amount-input"
v-model="walletForm.amount"
:min="0.01"
:step="0.01"
:precision="2"
@change="syncWalletRemark"
/>
</el-form-item>
<el-form-item :label="t('user.user.remark')">
<el-input v-model="walletForm.remark" type="textarea" :rows="3" />
</el-form-item>
</el-form>
</div>
</el-scrollbar>
<template #footer>
<div class="wallet-adjust-dialog-footer">
<el-button @click="walletDialogVisible = false">{{ t('Cancel') }}</el-button>
<el-button type="primary" :loading="walletSubmitting" @click="submitWalletAdjust">{{ t('Save') }}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, provide, useTemplateRef } from 'vue'
import { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import PopupForm from './popupForm.vue'
import { baTableApi } from '/@/api/common'
import { defaultOptButtons } from '/@/components/table'
import TableHeader from '/@/components/table/header/index.vue'
import Table from '/@/components/table/index.vue'
import baTableClass from '/@/utils/baTable'
import createAxios from '/@/utils/axios'
import { useConfig } from '/@/stores/config'
defineOptions({
name: 'user/user',
})
const { t } = useI18n()
const config = useConfig()
const tableRef = useTemplateRef('tableRef')
const optButtons: OptButton[] = defaultOptButtons(['edit', 'delete'])
@@ -40,7 +87,7 @@ function formatCoin(_row: anyObj, _column: any, cellValue: unknown) {
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(4)
return n.toFixed(2)
}
/** 杩斿洖澶氭爣绛炬枃妗堟暟缁勶紝渚?render: tags 浣跨敤 */
@@ -55,6 +102,73 @@ function formatRiskFlags(row: anyObj, _column: any, cellValue: unknown) {
return parts
}
function buildDisplayAmount(v: unknown): string {
if (v === null || v === undefined || v === '') return '0.00'
const n = parseFloat(String(v).trim().replace(',', '.'))
if (!Number.isFinite(n)) return '0.00'
return n.toFixed(2)
}
const walletDialogVisible = ref(false)
const walletSubmitting = ref(false)
const walletForm = reactive({
user_id: 0,
username: '',
current_coin: '0.00',
op: 'credit',
amount: 100,
remark: '',
})
function syncWalletRemark() {
const action = walletForm.op === 'credit' ? t('user.user.wallet_adjust_credit') : t('user.user.wallet_adjust_deduct')
walletForm.remark = t('user.user.wallet_adjust_default_remark', {
admin: t('user.user.wallet_adjust_operator_admin'),
action,
amount: Number(walletForm.amount || 0).toFixed(2),
})
}
function openWalletDialog(row: anyObj) {
walletForm.user_id = Number(row.id || 0)
walletForm.username = String(row.username ?? '-')
walletForm.current_coin = buildDisplayAmount(row.coin)
walletForm.op = 'credit'
walletForm.amount = 100
syncWalletRemark()
walletDialogVisible.value = true
}
async function submitWalletAdjust() {
if (!walletForm.user_id) return
if (!(walletForm.amount > 0)) {
ElMessage.error(t('user.user.wallet_adjust_amount_invalid'))
return
}
walletSubmitting.value = true
try {
const res = await createAxios({
url: '/admin/user.User/walletAdjust',
method: 'post',
data: {
user_id: walletForm.user_id,
op: walletForm.op,
amount: Number(walletForm.amount).toFixed(2),
remark: walletForm.remark,
},
})
if (res.code === 1) {
ElMessage.success(res.msg || t('Success'))
walletDialogVisible.value = false
await baTable.getData()
} else {
ElMessage.error(res.msg || t('Unknown error'))
}
} finally {
walletSubmitting.value = false
}
}
const baTable = new baTableClass(
new baTableApi('/admin/user.User/'),
{
@@ -105,7 +219,22 @@ const baTable = new baTableClass(
showOverflowTooltip: true,
operator: 'LIKE',
},
{ label: t('user.user.coin'), prop: 'coin', align: 'center', sortable: false, operator: 'RANGE', formatter: formatCoin },
{
label: t('user.user.coin'),
prop: 'coin',
align: 'center',
minWidth: 100,
sortable: false,
operator: 'RANGE',
render: 'tag',
formatter: formatCoin,
customRenderAttr: {
tag: ({ row }) => ({
class: 'wallet-balance-tag',
onClick: () => openWalletDialog(row),
}),
},
},
{
label: t('user.user.total_deposit_coin'),
prop: 'total_deposit_coin',
@@ -221,9 +350,9 @@ const baTable = new baTableClass(
{
defaultItems: {
status: '1',
coin: '0.0000',
total_deposit_coin: '0.0000',
total_valid_bet_coin: '0.0000',
coin: '0.00',
total_deposit_coin: '0.00',
total_valid_bet_coin: '0.00',
risk_flags: 0,
current_streak: 0,
last_bet_period_no: '',
@@ -246,6 +375,54 @@ onMounted(() => {
})
</script>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.wallet-balance-tag {
cursor: pointer;
}
.wallet-adjust-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.wallet-adjust-op-group {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.wallet-adjust-amount-input {
width: 100%;
}
.wallet-adjust-amount-input :deep(.el-input__wrapper) {
width: 100%;
}
.wallet-adjust-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 768px) {
.wallet-adjust-dialog :deep(.el-dialog) {
width: calc(100vw - 16px) !important;
margin-top: 4vh !important;
}
.wallet-adjust-op-group {
gap: 8px 12px;
}
.wallet-adjust-dialog-footer {
display: flex;
gap: 8px;
}
.wallet-adjust-dialog-footer .el-button {
flex: 1;
}
}
</style>

View File

@@ -99,30 +99,6 @@
:placeholder="t('user.user.register_invite_code_auto_placeholder')"
/>
<el-divider content-position="left">{{ t('user.user.section_finance') }}</el-divider>
<FormItem
:label="t('user.user.coin')"
type="number"
v-model="baTable.form.items!.coin"
prop="coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
:placeholder="t('user.user.coin_placeholder')"
/>
<FormItem
:label="t('user.user.total_deposit_coin')"
type="number"
v-model="baTable.form.items!.total_deposit_coin"
prop="total_deposit_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<FormItem
:label="t('user.user.total_valid_bet_coin')"
type="number"
v-model="baTable.form.items!.total_valid_bet_coin"
prop="total_valid_bet_coin"
:input-attr="{ step: 0.0001, min: 0, precision: 4 }"
/>
<el-divider content-position="left">{{ t('user.user.section_risk') }}</el-divider>
<el-form-item :label="t('user.user.risk_flags')">
<div class="risk-flag-row">
@@ -455,27 +431,10 @@ const validatorGameUserPassword = (rule: any, val: string, callback: (error?: Er
return callback()
}
const decimalRule = (fieldTitle: string): FormItemRule => ({
trigger: 'blur',
validator: (_rule, val, callback) => {
if (val === null || val === undefined || val === '') {
return callback()
}
const n = typeof val === 'number' ? val : parseFloat(String(val).trim().replace(',', '.'))
if (!Number.isFinite(n) || n < 0) {
return callback(new Error(t('Please enter the correct field', { field: fieldTitle })))
}
return callback()
},
})
const rules: Partial<Record<string, FormItemRule[]>> = reactive({
username: [buildValidatorData({ name: 'required', title: t('user.user.username') })],
password: [{ validator: validatorGameUserPassword, trigger: 'blur' }],
phone: [buildValidatorData({ name: 'required', title: t('user.user.phone') })],
coin: [decimalRule(t('user.user.coin'))],
total_deposit_coin: [decimalRule(t('user.user.total_deposit_coin'))],
total_valid_bet_coin: [decimalRule(t('user.user.total_valid_bet_coin'))],
admin_id: [buildValidatorData({ name: 'required', title: t('user.user.admin_affiliation') })],
create_time: [buildValidatorData({ name: 'date', title: t('user.user.create_time') })],
update_time: [buildValidatorData({ name: 'date', title: t('user.user.update_time') })],

View File

@@ -35,7 +35,7 @@ function formatAmount(_row: anyObj, _column: any, cellValue: unknown) {
if (!Number.isFinite(n)) {
return String(cellValue)
}
return n.toFixed(4)
return n.toFixed(2)
}
const bizReplace = {