fix(admin,api): 上分超额提示而非静默截断,并返回中文业务错误
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { ref, computed } from 'vue';
|
|||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useAdminLocale } from './useAdminLocale';
|
import { useAdminLocale } from './useAdminLocale';
|
||||||
|
import { resolveApiError } from '../i18n/form-validation';
|
||||||
import {
|
import {
|
||||||
depositAmountCap,
|
depositAmountCap,
|
||||||
parsePlayerAvailable,
|
parsePlayerAvailable,
|
||||||
@@ -36,6 +37,20 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
|
|||||||
|
|
||||||
const transferAmountDisabled = computed(() => transferAmountRange.value.max === 0);
|
const transferAmountDisabled = computed(() => transferAmountRange.value.max === 0);
|
||||||
|
|
||||||
|
/** 有上限时超出额度(勿用 el-input-number :max,否则会静默截断到上限) */
|
||||||
|
const transferAmountExceedsCap = computed(() => {
|
||||||
|
const max = transferAmountRange.value.max;
|
||||||
|
if (max === undefined) return false;
|
||||||
|
return transferAmount.value > max;
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferAmountCapError = computed(() => {
|
||||||
|
if (!transferAmountExceedsCap.value) return '';
|
||||||
|
return transferType.value === 'deposit'
|
||||||
|
? t('err.insufficient_credit')
|
||||||
|
: t('transfer.context.withdraw_exceed');
|
||||||
|
});
|
||||||
|
|
||||||
function transferTitle() {
|
function transferTitle() {
|
||||||
const name = transferTarget.value?.username ?? transferTarget.value?.id ?? '';
|
const name = transferTarget.value?.username ?? transferTarget.value?.id ?? '';
|
||||||
return transferType.value === 'deposit'
|
return transferType.value === 'deposit'
|
||||||
@@ -63,8 +78,7 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
|
|||||||
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
|
transferAmount.value = cap > 0 ? Math.min(100, cap) : 0;
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
|
||||||
transferVisible.value = false;
|
transferVisible.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
transferContextLoading.value = false;
|
transferContextLoading.value = false;
|
||||||
@@ -105,10 +119,7 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
|
|||||||
transferVisible.value = false;
|
transferVisible.value = false;
|
||||||
await onSuccess?.();
|
await onSuccess?.();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
ElMessage.error(resolveApiError(e, t, transferType.value === 'deposit' ? 'msg.topup_failed' : 'msg.transfer_failed'));
|
||||||
const fallback =
|
|
||||||
transferType.value === 'deposit' ? t('msg.topup_failed') : t('msg.transfer_failed');
|
|
||||||
ElMessage.error(err.response?.data?.error ?? fallback);
|
|
||||||
} finally {
|
} finally {
|
||||||
transferLoading.value = false;
|
transferLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -125,6 +136,8 @@ export function useAdminPlayerTransfer(onSuccess?: () => void | Promise<void>) {
|
|||||||
transferContextLoading,
|
transferContextLoading,
|
||||||
transferAmountRange,
|
transferAmountRange,
|
||||||
transferAmountDisabled,
|
transferAmountDisabled,
|
||||||
|
transferAmountExceedsCap,
|
||||||
|
transferAmountCapError,
|
||||||
transferTitle,
|
transferTitle,
|
||||||
openTransfer,
|
openTransfer,
|
||||||
submitTransfer,
|
submitTransfer,
|
||||||
|
|||||||
@@ -15,7 +15,18 @@ export function resolveFormError(e: unknown, t: (key: string) => string): string
|
|||||||
return t('msg.form_invalid');
|
return t('msg.form_invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 API 错误响应提取可读文案(Nest 全局过滤器返回 `error` 字段) */
|
/** Nest 默认 4xx 响应里 error 常为 "Bad Request" 等泛化文案,真实原因在 message */
|
||||||
|
const GENERIC_HTTP_ERRORS = new Set([
|
||||||
|
'Bad Request',
|
||||||
|
'Unauthorized',
|
||||||
|
'Forbidden',
|
||||||
|
'Not Found',
|
||||||
|
'Conflict',
|
||||||
|
'Unprocessable Entity',
|
||||||
|
'Internal Server Error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 从 API 错误响应提取可读文案(兼容 GlobalExceptionFilter 与 Nest 默认格式) */
|
||||||
export function resolveApiError(
|
export function resolveApiError(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
@@ -23,8 +34,13 @@ export function resolveApiError(
|
|||||||
): string {
|
): string {
|
||||||
const data = (err as { response?: { data?: { error?: string | string[]; message?: string | string[] } } })
|
const data = (err as { response?: { data?: { error?: string | string[]; message?: string | string[] } } })
|
||||||
?.response?.data;
|
?.response?.data;
|
||||||
const raw = data?.error ?? data?.message;
|
const msgRaw = data?.message;
|
||||||
if (Array.isArray(raw)) return raw.join(';');
|
const errRaw = data?.error;
|
||||||
if (typeof raw === 'string' && raw.trim()) return raw;
|
const message = Array.isArray(msgRaw) ? msgRaw.join(';') : msgRaw;
|
||||||
|
const error = Array.isArray(errRaw) ? errRaw.join(';') : errRaw;
|
||||||
|
if (typeof message === 'string' && message.trim()) {
|
||||||
|
if (!error || GENERIC_HTTP_ERRORS.has(error)) return message;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string' && error.trim() && !GENERIC_HTTP_ERRORS.has(error)) return error;
|
||||||
return t(fallbackKey);
|
return t(fallbackKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, onMounted, computed } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import { resolveFormError } from '../i18n/form-validation';
|
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { clearStaffSession } from '../stores/auth';
|
import { clearStaffSession } from '../stores/auth';
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ import AgentCreditContext from '../components/AgentCreditContext.vue';
|
|||||||
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
import { useAdminPlayerTransfer } from '../composables/useAdminPlayerTransfer';
|
||||||
import {
|
import {
|
||||||
fetchAdminAgentCreditContext,
|
fetchAdminAgentCreditContext,
|
||||||
|
maxCreditIncreaseAmount,
|
||||||
type AgentCreditAdjustContext,
|
type AgentCreditAdjustContext,
|
||||||
} from '../utils/agent-credit-context';
|
} from '../utils/agent-credit-context';
|
||||||
|
|
||||||
@@ -571,8 +572,7 @@ async function openCredit(userId: string) {
|
|||||||
try {
|
try {
|
||||||
creditContext.value = await fetchAdminAgentCreditContext(userId);
|
creditContext.value = await fetchAdminAgentCreditContext(userId);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
ElMessage.error(resolveApiError(e, t, 'msg.load_failed'));
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
|
||||||
creditVisible.value = false;
|
creditVisible.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
creditContextLoading.value = false;
|
creditContextLoading.value = false;
|
||||||
@@ -584,6 +584,11 @@ async function submitCredit() {
|
|||||||
ElMessage.warning(t('msg.credit_zero'));
|
ElMessage.warning(t('msg.credit_zero'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const maxInc = maxCreditIncreaseAmount(creditContext.value);
|
||||||
|
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
|
||||||
|
ElMessage.warning(t('err.insufficient_credit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
creditLoading.value = true;
|
creditLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
||||||
@@ -596,8 +601,7 @@ async function submitCredit() {
|
|||||||
load();
|
load();
|
||||||
refreshExpandedParents();
|
refreshExpandedParents();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
creditLoading.value = false;
|
creditLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -737,6 +741,8 @@ const {
|
|||||||
transferContextLoading,
|
transferContextLoading,
|
||||||
transferAmountRange,
|
transferAmountRange,
|
||||||
transferAmountDisabled,
|
transferAmountDisabled,
|
||||||
|
transferAmountExceedsCap,
|
||||||
|
transferAmountCapError,
|
||||||
transferTitle,
|
transferTitle,
|
||||||
openTransfer,
|
openTransfer,
|
||||||
submitTransfer,
|
submitTransfer,
|
||||||
@@ -1340,11 +1346,10 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-form-item :label="t('common.col_id')">
|
<el-form-item :label="t('common.col_id')">
|
||||||
<span>{{ transferTarget?.id }}</span>
|
<span>{{ transferTarget?.id }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.amount')">
|
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="transferAmount"
|
v-model="transferAmount"
|
||||||
:min="transferAmountRange.min"
|
:min="transferAmountRange.min"
|
||||||
:max="transferAmountRange.max"
|
|
||||||
:disabled="transferAmountDisabled"
|
:disabled="transferAmountDisabled"
|
||||||
:step="10"
|
:step="10"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
@@ -1360,7 +1365,7 @@ function creditTypeLabel(type: string) {
|
|||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="transferLoading"
|
:loading="transferLoading"
|
||||||
:disabled="transferAmountDisabled"
|
:disabled="transferAmountDisabled || transferAmountExceedsCap"
|
||||||
@click="submitTransfer"
|
@click="submitTransfer"
|
||||||
>
|
>
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||||
import { resolveFormError } from '../i18n/form-validation';
|
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
|
||||||
const { t } = useAdminLocale();
|
const { t } = useAdminLocale();
|
||||||
@@ -27,6 +27,7 @@ import AdminTableEmpty from '../components/AdminTableEmpty.vue';
|
|||||||
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
import AgentCreditContext from '../components/AgentCreditContext.vue';
|
||||||
import {
|
import {
|
||||||
fetchAdminAgentCreditContext,
|
fetchAdminAgentCreditContext,
|
||||||
|
maxCreditIncreaseAmount,
|
||||||
type AgentCreditAdjustContext,
|
type AgentCreditAdjustContext,
|
||||||
} from '../utils/agent-credit-context';
|
} from '../utils/agent-credit-context';
|
||||||
|
|
||||||
@@ -201,6 +202,11 @@ async function submitCredit() {
|
|||||||
ElMessage.warning(t('msg.credit_zero'));
|
ElMessage.warning(t('msg.credit_zero'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const maxInc = maxCreditIncreaseAmount(creditContext.value);
|
||||||
|
if (creditForm.value.amount > 0 && maxInc !== undefined && creditForm.value.amount > maxInc) {
|
||||||
|
ElMessage.warning(t('err.insufficient_credit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
creditLoading.value = true;
|
creditLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
await api.post(`/admin/agents/${editingId.value}/credit`, {
|
||||||
@@ -212,8 +218,7 @@ async function submitCredit() {
|
|||||||
creditVisible.value = false;
|
creditVisible.value = false;
|
||||||
load();
|
load();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const err = e as { response?: { data?: { error?: string } } };
|
ElMessage.error(resolveApiError(e, t, 'msg.credit_adjust_failed'));
|
||||||
ElMessage.error(err.response?.data?.error ?? t('msg.credit_adjust_failed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
creditLoading.value = false;
|
creditLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const {
|
|||||||
transferContextLoading,
|
transferContextLoading,
|
||||||
transferAmountRange,
|
transferAmountRange,
|
||||||
transferAmountDisabled,
|
transferAmountDisabled,
|
||||||
|
transferAmountExceedsCap,
|
||||||
|
transferAmountCapError,
|
||||||
transferTitle,
|
transferTitle,
|
||||||
openTransfer,
|
openTransfer,
|
||||||
submitTransfer,
|
submitTransfer,
|
||||||
@@ -757,11 +759,10 @@ function statusLabel(s: string) {
|
|||||||
<el-form-item :label="t('common.col_id')">
|
<el-form-item :label="t('common.col_id')">
|
||||||
<span>{{ transferTarget?.id }}</span>
|
<span>{{ transferTarget?.id }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.amount')">
|
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="transferAmount"
|
v-model="transferAmount"
|
||||||
:min="transferAmountRange.min"
|
:min="transferAmountRange.min"
|
||||||
:max="transferAmountRange.max"
|
|
||||||
:disabled="transferAmountDisabled"
|
:disabled="transferAmountDisabled"
|
||||||
:step="10"
|
:step="10"
|
||||||
:precision="2"
|
:precision="2"
|
||||||
@@ -777,7 +778,7 @@ function statusLabel(s: string) {
|
|||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="transferLoading"
|
:loading="transferLoading"
|
||||||
:disabled="transferAmountDisabled"
|
:disabled="transferAmountDisabled || transferAmountExceedsCap"
|
||||||
@click="submitTransfer"
|
@click="submitTransfer"
|
||||||
>
|
>
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
|
|||||||
@@ -143,6 +143,20 @@ const transferAmountDisabled = computed(() => {
|
|||||||
return max === 0;
|
return max === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 勿用 el-input-number :max,否则会静默截断到上限 */
|
||||||
|
const transferAmountExceedsCap = computed(() => {
|
||||||
|
const max = transferAmountRange.value.max;
|
||||||
|
if (max === undefined) return false;
|
||||||
|
return transferAmount.value > max;
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferAmountCapError = computed(() => {
|
||||||
|
if (!transferAmountExceedsCap.value) return '';
|
||||||
|
return transferType.value === 'deposit'
|
||||||
|
? t('err.insufficient_credit')
|
||||||
|
: t('transfer.context.withdraw_exceed');
|
||||||
|
});
|
||||||
|
|
||||||
/* ─── Init ─── */
|
/* ─── Init ─── */
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
@@ -818,17 +832,20 @@ function statusTagType(s: string) {
|
|||||||
<el-form-item :label="t('common.col_id')">
|
<el-form-item :label="t('common.col_id')">
|
||||||
<span>{{ transferTarget?.id }}</span>
|
<span>{{ transferTarget?.id }}</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item :label="t('user.field.amount')">
|
<el-form-item :label="t('user.field.amount')" :error="transferAmountCapError">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="transferAmount"
|
v-model="transferAmount"
|
||||||
:min="transferAmountRange.min" :max="transferAmountRange.max"
|
:min="transferAmountRange.min"
|
||||||
:disabled="transferAmountDisabled" :step="10" :precision="2" style="width: 100%"
|
:disabled="transferAmountDisabled"
|
||||||
|
:step="10"
|
||||||
|
:precision="2"
|
||||||
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
<el-button @click="transferVisible = false">{{ t('common.cancel') }}</el-button>
|
||||||
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
|
<el-button type="primary" :loading="transferLoading" :disabled="transferAmountDisabled || transferAmountExceedsCap" @click="submitTransfer">{{ t('common.confirm') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { GlobalExceptionFilter } from './shared/common/filters';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||||
|
|
||||||
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
|
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
|
||||||
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
|
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
|
||||||
|
|||||||
@@ -20,7 +20,16 @@ export class GlobalExceptionFilter implements ExceptionFilter {
|
|||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const res = exception.getResponse();
|
const res = exception.getResponse();
|
||||||
message = typeof res === 'string' ? res : (res as { message?: string }).message || message;
|
if (typeof res === 'string') {
|
||||||
|
message = res;
|
||||||
|
} else if (typeof res === 'object' && res !== null) {
|
||||||
|
const body = res as { message?: string | string[] };
|
||||||
|
if (Array.isArray(body.message)) {
|
||||||
|
message = body.message.join(';');
|
||||||
|
} else if (typeof body.message === 'string' && body.message.trim()) {
|
||||||
|
message = body.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
message = exception.message;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user