diff --git a/app/admin/controller/user/User.php b/app/admin/controller/user/User.php index ad886ef..9287fd3 100644 --- a/app/admin/controller/user/User.php +++ b/app/admin/controller/user/User.php @@ -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 diff --git a/web/src/lang/backend/en/user/user.ts b/web/src/lang/backend/en/user/user.ts index 6a8a0a7..41efcc0 100644 --- a/web/src/lang/backend/en/user/user.ts +++ b/web/src/lang/backend/en/user/user.ts @@ -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)', } diff --git a/web/src/lang/backend/zh-cn/user/user.ts b/web/src/lang/backend/zh-cn/user/user.ts index dc0dfae..0221e8b 100644 --- a/web/src/lang/backend/zh-cn/user/user.ts +++ b/web/src/lang/backend/zh-cn/user/user.ts @@ -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}(值)', } diff --git a/web/src/views/backend/user/user/index.vue b/web/src/views/backend/user/user/index.vue index a4f3731..82aa40d 100644 --- a/web/src/views/backend/user/user/index.vue +++ b/web/src/views/backend/user/user/index.vue @@ -10,24 +10,71 @@