feat(admin+api): 代理停用默认、结算加固与冒烟配置探针

- 代理层级默认授信比例与停用冻结/禁登全局默认

- 结算预览去重、比分校验、串关当场判负与市场类型校验

- 站内信 Banner/公告自动通知开关;开发环境动态 API 端口

- 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
2026-06-23 11:08:41 +08:00
parent fa06fee64c
commit ce84226219
47 changed files with 9284 additions and 118 deletions

View File

@@ -206,6 +206,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
'agent.suspend.settings_title': 'Default agent suspend behavior',
'agent.suspend.settings_hint': 'Each suspend/unfreeze action can override these; used as dialog defaults',
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
@@ -885,6 +887,10 @@ const adminPages: Record<string, string> = {
'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)',
'content.inbox_notify.deposit': 'Deposit results',
'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject',
'content.inbox_notify.banner': 'Homepage promo',
'content.inbox_notify.banner_hint': 'Auto-broadcast when publishing a banner with inbox notify checked',
'content.inbox_notify.announcement': 'Announcements / ticker',
'content.inbox_notify.announcement_hint': 'Auto-broadcast when publishing notice/ticker with inbox notify checked',
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
'content.inbox_notify.banner_note': 'Homepage promo',
'content.inbox_notify.announcement_note': 'Announcements / ticker',

View File

@@ -207,6 +207,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
'agent.suspend.settings_title': '停用代理默认行为',
'agent.suspend.settings_hint': '单次停用/解冻操作仍可单独勾选覆盖;此处为对话框默认勾选状态',
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整',
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
@@ -882,6 +884,10 @@ const adminPages: Record<string, string> = {
'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页',
'content.inbox_notify.deposit': '充值结果',
'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信',
'content.inbox_notify.banner': '首页推广',
'content.inbox_notify.banner_hint': '发布 Banner 且勾选邮箱通知时自动群发',
'content.inbox_notify.announcement': '公告 / 跑马灯',
'content.inbox_notify.announcement_hint': '发布通知或跑马灯且勾选邮箱通知时自动群发',
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
'content.inbox_notify.banner_note': '首页推广',
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',

View File

@@ -112,7 +112,11 @@ type SubAgentLevelState = {
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const hierarchySettings = ref({ maxAgentLevel: 0 });
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const agentSuspendDefaults = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
@@ -220,7 +224,9 @@ const creditContextLoading = ref(false);
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
const pageInitLoaded = ref(false);
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
const DEFAULT_SUB_AGENT_CREDIT_RATIO = computed(
() => hierarchySettings.value.defaultSubAgentCreditRatio || 50,
);
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
const freezeAgentTarget = ref<AgentRow | null>(null);
@@ -323,7 +329,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number):
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO.value);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
@@ -439,16 +445,33 @@ onActivated(() => {
}
});
async function loadAgentSuspendDefaults() {
try {
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendDefaults.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
} catch {
/* keep defaults */
}
}
async function loadUsersPageInit() {
try {
const { data } = await api.get('/admin/users/page-init');
const [pageInitRes] = await Promise.all([
api.get('/admin/users/page-init'),
loadAgentSuspendDefaults(),
]);
const { data } = pageInitRes;
const payload = data.data as {
hierarchySettings?: { maxAgentLevel: number };
hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number };
agentLevelCounts?: Record<number, number>;
};
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50,
};
}
if (payload.agentLevelCounts) {
@@ -1108,8 +1131,8 @@ const freezeAgentIsSuspend = computed(() => {
function toggleFreezeAgent(row: AgentRow) {
freezeAgentTarget.value = row;
freezeAgentForm.value = {
freezeDirectPlayers: false,
blockDirectPlayerLogin: false,
freezeDirectPlayers: agentSuspendDefaults.value.suspendFreezeDirectPlayers,
blockDirectPlayerLogin: agentSuspendDefaults.value.suspendBlockPlayerLogin,
unfreezeDirectPlayers: false,
};
freezeAgentVisible.value = true;

View File

@@ -75,6 +75,8 @@ const notifyInbox = ref(false);
interface InboxNotifySettings {
inboxEnabled: boolean;
deposit: boolean;
banner: boolean;
announcement: boolean;
}
interface MessageBroadcastItem {
@@ -100,6 +102,8 @@ interface BroadcastTranslationForm {
const inboxNotifySettings = ref<InboxNotifySettings>({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: true,
});
const inboxNotifySaving = ref(false);
@@ -218,6 +222,8 @@ async function loadInboxNotifySettings() {
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
banner: data.data?.banner !== false,
announcement: data.data?.announcement !== false,
};
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -232,10 +238,14 @@ async function saveInboxNotifySettings() {
const { data } = await api.put('/admin/contents/inbox-notify-settings', {
inboxEnabled: inboxNotifySettings.value.inboxEnabled,
deposit: inboxNotifySettings.value.deposit,
banner: inboxNotifySettings.value.banner,
announcement: inboxNotifySettings.value.announcement,
});
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
banner: data.data?.banner !== false,
announcement: data.data?.announcement !== false,
};
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
@@ -785,6 +795,30 @@ void load();
/>
</div>
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.banner') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.banner_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.banner"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.announcement') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.announcement_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.announcement"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<p v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes-title">{{ t('content.inbox_notify.manual_title') }}</p>
<ul v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes">
<li>{{ t('content.inbox_notify.banner_note') }}</li>

View File

@@ -27,7 +27,11 @@ const bettingLimits = ref({
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const hierarchySettings = ref({ maxAgentLevel: 0 });
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const agentSuspendSettings = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const resetAllowed = ref(false);
@@ -36,6 +40,7 @@ const resetConfirmPhrase = ref('');
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySaving = ref(false);
const suspendSaving = ref(false);
const platformDirectSaving = ref(false);
const resetLoading = ref(false);
const loading = ref(false);
@@ -47,7 +52,7 @@ async function loadSettings() {
const payload = data.data as {
playerSettings?: typeof playerSettings.value;
bettingLimits?: typeof bettingLimits.value;
hierarchySettings?: { maxAgentLevel: number };
hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number };
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
};
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
@@ -55,6 +60,7 @@ async function loadSettings() {
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50,
};
}
if (payload.platformDirect) {
@@ -93,11 +99,27 @@ async function savePlayerSettings() {
}
}
async function loadAgentSuspendSettings() {
try {
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendSettings.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
} catch {
/* keep defaults */
}
}
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
hierarchySettings.value = {
maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel,
defaultSubAgentCreditRatio:
data.data?.defaultSubAgentCreditRatio ?? hierarchySettings.value.defaultSubAgentCreditRatio,
};
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -107,6 +129,23 @@ async function saveHierarchySettings() {
}
}
async function saveAgentSuspendSettings() {
suspendSaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
agentSuspendSettings.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
suspendSaving.value = false;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
@@ -177,7 +216,7 @@ onMounted(() => {
void router.replace('/users');
return;
}
void Promise.all([loadSettings(), loadResetDatabaseStatus()]);
void Promise.all([loadSettings(), loadAgentSuspendSettings(), loadResetDatabaseStatus()]);
});
</script>
@@ -211,6 +250,17 @@ onMounted(() => {
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('agent.hierarchy.default_sub_credit_ratio_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">
{{ t('common.save') }}
@@ -219,6 +269,27 @@ onMounted(() => {
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.freeze.opt_freeze_direct_players')">
<el-switch
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
:loading="suspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
<el-form-item :label="t('agent.freeze.opt_block_player_login')">
<el-switch
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
:loading="suspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">

View File

@@ -5,6 +5,9 @@ import { visualizer } from 'rollup-plugin-visualizer';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import { resolveDevApiTarget } from '../../scripts/resolve-dev-api-target.mjs';
const devApiTarget = resolveDevApiTarget();
export default defineConfig(({ mode }) => {
const analyze = process.env.ANALYZE === '1' || mode === 'analyze';
@@ -76,8 +79,8 @@ export default defineConfig(({ mode }) => {
host: true,
port: 5174,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
'/api': { target: devApiTarget, changeOrigin: true },
'/uploads': { target: devApiTarget, changeOrigin: true },
},
},
};

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "nest build",
"dev": "node ../../scripts/ensure-port-free.mjs 3000 && nest start --watch",
"dev": "node ../../scripts/ensure-port-free.mjs && nest start --watch",
"start": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",

View File

@@ -85,6 +85,28 @@ describe('Admin RBAC (SEC010SEC012)', () => {
expect(guardAllows(user, 'settlement.resettle')).toBe(false);
});
it('FINANCE_ADMIN has deposit and cashback permissions but not reset database', () => {
const user = userWithRole('FINANCE_ADMIN');
expect(guardAllows(user, 'deposit.manage')).toBe(true);
expect(guardAllows(user, 'deposit.review')).toBe(true);
expect(guardAllows(user, 'cashback.confirm')).toBe(true);
expect(guardAllows(user, 'settings.reset_database')).toBe(false);
});
it('MATCH_ADMIN cannot manage deposits or confirm cashback', () => {
const user = userWithRole('MATCH_ADMIN');
expect(guardAllows(user, 'deposit.manage')).toBe(false);
expect(guardAllows(user, 'deposit.review')).toBe(false);
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
});
it('SUPPORT cannot confirm cashback or manage deposits', () => {
const user = userWithRole('SUPPORT');
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
expect(guardAllows(user, 'deposit.manage')).toBe(false);
expect(guardAllows(user, 'deposit.review')).toBe(false);
});
it('SUPER_ADMIN bypasses permission checks', () => {
const user = userWithRole('SUPER_ADMIN');
expect(guardAllows(user, 'wallet.deposit')).toBe(true);

View File

@@ -0,0 +1,255 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AdminController } from './admin.controller';
import { AgentsService } from '../../domains/agent/agents.service';
import { createPrismaMock } from '../../testing/prisma-mock';
function stubDeps() {
return {
users: {},
agents: { updateAgentAdmin: jest.fn() } as AgentsService | { updateAgentAdmin: jest.Mock },
wallet: {},
matches: {},
catalogArchive: {},
outright: {},
markets: {},
settlement: {},
cashback: {},
content: { create: jest.fn() },
i18n: {},
audit: { log: jest.fn().mockResolvedValue(undefined) },
bets: {},
prisma: {},
dashboardService: {},
systemConfig: { getInboxNotifySettings: jest.fn() },
bettingLimits: {},
databaseReset: {},
smokeTests: {},
depositService: {},
playerMessages: {
broadcastBannerPromotion: jest.fn(),
broadcastAnnouncementPromotion: jest.fn(),
},
staff: {},
presence: {},
depositCleanup: {},
};
}
function buildController(deps: ReturnType<typeof stubDeps>) {
return new AdminController(
deps.users as never,
deps.agents as never,
deps.wallet as never,
deps.matches as never,
deps.catalogArchive as never,
deps.outright as never,
deps.markets as never,
deps.settlement as never,
deps.cashback as never,
deps.content as never,
deps.i18n as never,
deps.audit as never,
deps.bets as never,
deps.prisma as never,
deps.dashboardService as never,
deps.systemConfig as never,
deps.bettingLimits as never,
deps.databaseReset as never,
deps.smokeTests as never,
deps.depositService as never,
deps.playerMessages as never,
deps.staff as never,
deps.presence as never,
deps.depositCleanup as never,
);
}
describe('AdminController createContent inbox notify gating', () => {
const deps = stubDeps();
const controller = buildController(deps);
const baseDto = {
contentType: 'BANNER' as const,
status: 'ACTIVE' as const,
notifyInbox: true,
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
};
beforeEach(() => {
jest.clearAllMocks();
deps.content.create.mockResolvedValue({ id: '88' });
deps.playerMessages.broadcastBannerPromotion.mockResolvedValue(3);
deps.playerMessages.broadcastAnnouncementPromotion.mockResolvedValue(5);
});
it('does not broadcast banner when inbox.notify.banner is false', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: false,
announcement: true,
});
const res = await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
expect((res.data as { notifiedCount?: number }).notifiedCount).toBeUndefined();
});
it('broadcasts banner when inbox.notify.banner is true', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: false,
});
const res = await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).toHaveBeenCalledWith({
contentId: 88n,
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
});
expect((res.data as { notifiedCount?: number }).notifiedCount).toBe(3);
});
it('does not broadcast announcement when inbox.notify.announcement is false', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: false,
});
await controller.createContent({
...baseDto,
contentType: 'NOTICE',
});
expect(deps.playerMessages.broadcastAnnouncementPromotion).not.toHaveBeenCalled();
});
it('skips notify when inbox feature is disabled even if notifyInbox is checked', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: false,
deposit: true,
banner: true,
announcement: true,
});
await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
});
});
describe('AdminController updateAgent suspend wiring', () => {
const agentId = 10n;
const operatorId = 1n;
const tx = {
user: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
userAuth: { create: jest.fn() },
userPreference: { create: jest.fn() },
userInvite: { create: jest.fn(), findUnique: jest.fn() },
agentProfile: { create: jest.fn(), findUnique: jest.fn() },
agentClosure: { create: jest.fn(), findMany: jest.fn() },
};
const prismaBase = {
...tx,
agentProfile: { ...tx.agentProfile, update: jest.fn() },
user: { ...tx.user, updateMany: jest.fn() },
};
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
getAgentSuspendSettings: jest.fn(),
};
const audit = { log: jest.fn().mockResolvedValue(undefined) };
let agentsService: AgentsService;
let controller: AdminController;
beforeEach(() => {
jest.clearAllMocks();
prisma.$transaction.mockImplementation(async (arg: unknown) => {
if (Array.isArray(arg)) return Promise.all(arg);
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
});
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: true,
suspendBlockPlayerLogin: true,
});
agentsService = new AgentsService(
prisma as never,
{ hashPassword: jest.fn() } as never,
systemConfig as never,
{} as never,
{ recalculateUsedCredit: jest.fn() } as never,
);
jest.spyOn(agentsService, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
prisma.agentProfile.findUnique.mockResolvedValue({
userId: agentId,
user: { username: 'agent-a', locale: 'zh-CN' },
parentAgentId: null,
});
prisma.user.updateMany.mockResolvedValue({ count: 2 });
prisma.agentProfile.update.mockResolvedValue({});
prisma.user.update.mockResolvedValue({});
const deps = stubDeps();
deps.agents = agentsService as never;
deps.audit = audit;
controller = buildController(deps);
});
it('forwards bare SUSPENDED body to AgentsService and applies global defaults', async () => {
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
expect(prisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parentId: agentId, userType: 'PLAYER' }),
data: { status: 'SUSPENDED' },
}),
);
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
}),
);
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({
operatorId,
action: 'UPDATE_AGENT',
module: 'AGENTS',
targetId: agentId.toString(),
}),
);
});
it('does not freeze players when global default is false and body omits flag', async () => {
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: false }),
}),
);
});
});

View File

@@ -1110,6 +1110,14 @@ class InboxNotifySettingsDto {
@IsOptional()
@IsBoolean()
deposit?: boolean;
@IsOptional()
@IsBoolean()
banner?: boolean;
@IsOptional()
@IsBoolean()
announcement?: boolean;
}
class BroadcastTranslationDto {
@@ -3444,21 +3452,21 @@ export class AdminController {
const item = await this.content.create(createDto);
let notifiedCount: number | undefined;
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
if (inboxEnabled) {
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled) {
const translations = createDto.translations.map((tr) => ({
locale: tr.locale,
title: tr.title,
body: tr.body,
}));
if (createDto.contentType === 'BANNER') {
if (createDto.contentType === 'BANNER' && inboxNotify.banner) {
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
contentId: BigInt(item.id),
translations,
});
} else if (
createDto.contentType === 'NOTICE' ||
createDto.contentType === 'TICKER'
(createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') &&
inboxNotify.announcement
) {
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
contentId: BigInt(item.id),

View File

@@ -32,12 +32,30 @@ describe('AgentsService', () => {
},
};
const prisma = createPrismaMock(tx);
const prismaBase = {
...tx,
agentProfile: {
...tx.agentProfile,
update: jest.fn(),
},
user: {
...tx.user,
updateMany: jest.fn(),
},
};
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
prisma.$transaction.mockImplementation(async (arg: unknown) => {
if (Array.isArray(arg)) {
return Promise.all(arg);
}
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
});
const auth = {
hashPassword: jest.fn(),
};
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
getAgentSuspendSettings: jest.fn(),
};
const network = {};
const credit = {
@@ -56,13 +74,20 @@ describe('AgentsService', () => {
credit as never,
);
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
systemConfig.getAgentHierarchySettings.mockResolvedValue({
maxAgentLevel: 3,
defaultSubAgentCreditRatio: 50,
});
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: true,
suspendBlockPlayerLogin: true,
});
auth.hashPassword.mockResolvedValue('hashed-password');
tx.agentProfile.findUnique.mockResolvedValue({
prisma.agentProfile.findUnique.mockResolvedValue({
userId: parentAgentId,
level: 1,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
creditLimit: new Decimal(10000),
usedCredit: new Decimal(2000),
cashbackRate: new Decimal(10),
maxSingleDeposit: null,
maxDailyDeposit: null,
@@ -91,10 +116,41 @@ describe('AgentsService', () => {
password: 'secret',
level: 2,
parentAgentId,
creditLimit: 300,
});
expect(tx.agentProfile.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ creditLimit: 4000 }),
}),
);
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
});
it('applies global suspend defaults when suspending without explicit flags', async () => {
jest.spyOn(service, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
prisma.agentProfile.findUnique.mockResolvedValue({
userId: 10n,
user: { username: 'agent-a', locale: 'zh-CN' },
parentAgentId: null,
});
prisma.user.updateMany.mockResolvedValue({ count: 2 });
prisma.agentProfile.update.mockResolvedValue({});
prisma.user.update.mockResolvedValue({});
await service.updateAgentAdmin(10n, { status: 'SUSPENDED' });
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
expect(prisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parentId: 10n, userType: 'PLAYER' }),
data: { status: 'SUSPENDED' },
}),
);
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
}),
);
});
});

View File

@@ -40,6 +40,15 @@ export class AgentsService {
return agentLevel < maxLevel;
}
/** 与 Admin 创建下级代理预填逻辑一致:可用授信 × 比例(%),非 100% 时向下取整到百位 */
computeDefaultSubAgentCredit(availableCredit: number, ratioPercent: number): number {
if (availableCredit <= 0) return 0;
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
const raw = availableCredit * pct;
const rounded = pct >= 1 ? availableCredit : Math.floor(raw / 100) * 100;
return Math.min(availableCredit, Math.max(0, rounded));
}
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
return this.network.buildAgentAncestorChainMap(parentAgentIds);
}
@@ -923,11 +932,17 @@ export class AgentsService {
});
}
// Handle status change (per-action cascade freeze / login block)
// Handle status change (per-action cascade freeze / login block; 未传参时用全局默认)
if (data.status) {
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
let freezeDirectPlayers = false;
if (data.status === 'SUSPENDED') {
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
const suspendDefaults = await this.systemConfig.getAgentSuspendSettings();
const blockDirectPlayerLogin =
data.blockDirectPlayerLogin ?? suspendDefaults.suspendBlockPlayerLogin;
freezeDirectPlayers =
data.freezeDirectPlayers ?? suspendDefaults.suspendFreezeDirectPlayers;
profilePatch.blockDirectPlayerLogin = blockDirectPlayerLogin === true;
} else if (data.status === 'ACTIVE') {
profilePatch.blockDirectPlayerLogin = false;
}
@@ -943,7 +958,7 @@ export class AgentsService {
}),
]);
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
if (data.status === 'SUSPENDED' && freezeDirectPlayers) {
await this.prisma.user.updateMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
data: { status: 'SUSPENDED' },
@@ -1156,20 +1171,31 @@ export class AgentsService {
) {
await this.validateAgentLevel(data.level, data.parentAgentId);
let resolvedCreditLimit = data.creditLimit;
let resolvedCashbackRate = data.cashbackRate ?? 0;
if (data.parentAgentId) {
const parentProfile = await this.prisma.agentProfile.findUnique({
where: { userId: data.parentAgentId },
select: { cashbackRate: true },
select: { cashbackRate: true, creditLimit: true, usedCredit: true },
});
resolvedCashbackRate =
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
if (resolvedCreditLimit === undefined && parentProfile) {
const hierarchy = await this.systemConfig.getAgentHierarchySettings();
const available = new Decimal(parentProfile.creditLimit).sub(parentProfile.usedCredit);
resolvedCreditLimit = this.computeDefaultSubAgentCredit(
available.toNumber(),
hierarchy.defaultSubAgentCreditRatio,
);
}
await this.assertChildAgentWithinParent(data.parentAgentId, {
creditLimit: data.creditLimit ?? 0,
creditLimit: resolvedCreditLimit ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit: data.maxSingleDeposit,
maxDailyDeposit: data.maxDailyDeposit,
});
} else if (resolvedCreditLimit === undefined) {
resolvedCreditLimit = 0;
}
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
@@ -1208,7 +1234,7 @@ export class AgentsService {
userId: user.id,
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
creditLimit: resolvedCreditLimit ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit,
maxDailyDeposit,

View File

@@ -47,6 +47,8 @@ describe('DepositService', () => {
getInboxNotifySettings: jest.fn().mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: true,
}),
};

View File

@@ -0,0 +1,83 @@
import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('AuthService player login', () => {
const prisma = {
user: {
findUnique: jest.fn(),
},
userAuth: {
update: jest.fn(),
},
};
const jwt = { sign: jest.fn().mockReturnValue('token') };
const config = { get: jest.fn().mockReturnValue('1h') };
const systemConfig = {};
const invites = {};
const sms = {};
const audit = { log: jest.fn() };
let service: AuthService;
beforeEach(() => {
jest.clearAllMocks();
service = new AuthService(
prisma as never,
jwt as never,
config as never,
systemConfig as never,
invites as never,
sms as never,
audit as never,
);
});
it('blocks player login when parent agent is suspended with blockDirectPlayerLogin', async () => {
const passwordHash = await bcrypt.hash('Player@123', 4);
prisma.user.findUnique
.mockResolvedValueOnce({
id: 10n,
username: 'player1',
userType: 'PLAYER',
status: 'ACTIVE',
parentId: 20n,
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
adminRole: null,
})
.mockResolvedValueOnce({
userType: 'AGENT',
status: 'SUSPENDED',
agentProfile: { blockDirectPlayerLogin: true },
});
await expect(service.login('player1', 'Player@123', 'player')).rejects.toMatchObject(
expectAppError('PARENT_AGENT_SUSPENDED'),
);
expect(jwt.sign).not.toHaveBeenCalled();
});
it('allows player login when parent agent suspended without blockDirectPlayerLogin', async () => {
const passwordHash = await bcrypt.hash('Player@123', 4);
prisma.user.findUnique
.mockResolvedValueOnce({
id: 10n,
username: 'player1',
userType: 'PLAYER',
status: 'ACTIVE',
parentId: 20n,
locale: 'zh-CN',
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
adminRole: null,
})
.mockResolvedValueOnce({
userType: 'AGENT',
status: 'SUSPENDED',
agentProfile: { blockDirectPlayerLogin: false },
});
prisma.userAuth.update.mockResolvedValue({});
const result = await service.login('player1', 'Player@123', 'player');
expect(result.token).toBe('token');
});
});

View File

@@ -0,0 +1,126 @@
import { Decimal } from '@prisma/client/runtime/library';
import { CashbackService } from './cashback.service';
describe('CashbackService previewBatch', () => {
const tx = {
cashbackBatch: {
findMany: jest.fn(),
create: jest.fn(),
},
cashbackItem: {
create: jest.fn(),
},
cashbackBet: {
create: jest.fn(),
},
};
const prisma = {
cashbackBatch: {
findFirst: jest.fn(),
},
cashbackBet: {
findMany: jest.fn(),
},
bet: {
findMany: jest.fn(),
},
cashbackRule: {
findMany: jest.fn(),
},
agentProfile: {
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
},
wallet: {
findMany: jest.fn(),
},
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {};
const systemConfig = {
getPlatformDirectCashbackSettings: jest.fn(),
};
let service: CashbackService;
const platformPlayerId = 100n;
const adminInvitePlayerId = 101n;
const adminSponsorId = 200n;
const periodStart = new Date('2026-06-01T00:00:00.000Z');
const periodEnd = new Date('2026-06-01T23:59:59.999Z');
beforeEach(() => {
jest.clearAllMocks();
service = new CashbackService(prisma as never, funds as never, systemConfig as never);
systemConfig.getPlatformDirectCashbackSettings.mockResolvedValue({
platformDirectRate: 0.02,
adminInviteRate: 0.05,
});
prisma.cashbackBatch.findFirst.mockResolvedValue(null);
prisma.cashbackBet.findMany.mockResolvedValue([]);
prisma.cashbackRule.findMany.mockResolvedValue([]);
prisma.agentProfile.findMany.mockResolvedValue([]);
tx.cashbackBatch.findMany.mockResolvedValue([]);
tx.cashbackItem.create.mockResolvedValue({});
tx.cashbackBet.create.mockResolvedValue({});
tx.cashbackBatch.create.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 1n, batchNo: 'CB-TEST', ...data }),
);
});
it('uses platformDirectRate for platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 1n,
userId: platformPlayerId,
stake: new Decimal(1000),
status: 'WON',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: { id: platformPlayerId, parentId: null, inviteSponsorId: null },
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany.mockResolvedValue([
{ id: platformPlayerId, username: 'pd1', parent: null },
]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: platformPlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('20');
expect(result.totalAmount.toString()).toBe('20');
});
it('uses adminInviteRate for admin-invited platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 2n,
userId: adminInvitePlayerId,
stake: new Decimal(1000),
status: 'LOST',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: {
id: adminInvitePlayerId,
parentId: null,
inviteSponsorId: adminSponsorId,
},
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany
.mockResolvedValueOnce([{ id: adminSponsorId, userType: 'ADMIN' }])
.mockResolvedValueOnce([{ id: adminInvitePlayerId, username: 'inv1', parent: null }]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: adminInvitePlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('50');
expect(result.totalAmount.toString()).toBe('50');
});
});

View File

@@ -1,4 +1,5 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
import type { AgentsService } from '../../agent/agents.service';
import type { BetsService } from '../../betting/bets.service';
import type { SettlementService } from '../../settlement/settlement.service';
@@ -12,7 +13,7 @@ import {
teardownBetFlowFixture,
} from './smoke-test.bet-flow.fixture';
export const BET_FLOW_PROBE_COUNT = 5;
export const BET_FLOW_PROBE_COUNT = 13;
export type BetFlowProbeDeps = {
prisma: PrismaService;
@@ -22,6 +23,38 @@ export type BetFlowProbeDeps = {
agents: AgentsService;
};
async function upsertSystemConfig(
prisma: PrismaService,
key: string,
value: string,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, configValue: value, description: 'smoke test' },
update: { configValue: value },
});
}
async function restoreSystemConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: { configKey: snap.key, configValue: snap.value, description: 'smoke test restore' },
update: { configValue: snap.value },
});
}
}
}
async function confirmMatchSettlement(
deps: BetFlowProbeDeps,
fx: BetFlowFixtureIds,
@@ -201,6 +234,401 @@ export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[]
}
},
},
{
id: 'BF006',
suite: 'bet-flow',
name: '录分校验:半场比分不能大于全场',
description: 'HT>FT 时 recordScore 返回 SETTLEMENT_SCORE_INVALID',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
await expectAppErrorThrows(
'recordScore invalid HT>FT',
async () => {
await deps.settlement.recordScore(fx.matchId, 2, 0, 1, 0, fx.operatorId);
},
'SETTLEMENT_SCORE_INVALID',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF007',
suite: 'bet-flow',
name: '确认结算:拒绝已作废的旧 PREVIEW 批次',
description: '新 preview 后旧 batch 变为 CANCELLEDconfirm 旧 batchId 失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewA = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
const stale = await deps.prisma.settlementBatch.findUnique({
where: { id: previewA.batch.id },
});
expectEqual('old batch cancelled', stale?.status, 'CANCELLED');
await expectAppErrorThrows(
'confirm cancelled batch',
async () => {
await deps.settlement.confirmSettlement(previewA.batch.id, fx.operatorId);
},
'SETTLEMENT_BATCH_ALREADY_CONFIRMED',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF008',
suite: 'bet-flow',
name: '串动作废VOID 腿续算,已有 LOSE 腿不退本',
description: '跨场串关一场 LOSE 后另一场取消payout=0 而非退 stake',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'HOME',
selectionName: 'Home B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 1,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections.find((s) => s.selectionCode === 'AWAY')!;
try {
const parlay = await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-void-${fx.runId}`,
);
await deps.prisma.match.update({
where: { id: matchB.id },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewB = await deps.settlement.previewSettlement(matchB.id, fx.operatorId, {
htHome: 1,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
await deps.settlement.confirmSettlement(previewB.batch.id, fx.operatorId);
let w = await deps.wallet.getWallet(fx.playerId);
expectEqual('frozen while pending', w.frozenBalance.toString(), '100');
await deps.settlement.voidMatchBets(fx.matchId);
const settled = await deps.prisma.bet.findUnique({ where: { id: parlay.id } });
expectEqual('settled status', settled?.status, 'LOST');
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after void parlay', w.availableBalance.toString(), '900');
expectEqual('frozen after void parlay', w.frozenBalance.toString(), '0');
} finally {
const bets = await deps.prisma.bet.findMany({
where: { userId: fx.playerId },
select: { id: true },
});
const betIds = bets.map((b) => b.id);
if (betIds.length) {
await deps.prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await deps.prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
const batchIds = (
await deps.prisma.settlementBatch.findMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
select: { id: true },
})
).map((b) => b.id);
if (batchIds.length) {
await deps.prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
await deps.prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
}
await deps.prisma.matchScore.deleteMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
});
const marketIds = (
await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
})
).map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF009',
suite: 'bet-flow',
name: '赔率版本不一致BetsService 拒绝下注',
uatRef: 'B003',
description: '提交旧 oddsVersion 应返回 ODDS_CHANGED',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.marketSelection.update({
where: { id: fx.homeSelectionId },
data: { oddsVersion: { increment: 1 } },
});
await expectAppErrorThrows(
'placeSingleBet odds changed',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-odds-${fx.runId}`,
);
},
'ODDS_CHANGED',
);
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
expectEqual('bet count', count, 0);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF010',
suite: 'bet-flow',
name: '赛前限制:开赛后拒绝下注',
description: 'startTime 已过后 placeSingleBet 返回 PRE_MATCH_ONLY',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { startTime: new Date(Date.now() - 60_000) },
});
await expectAppErrorThrows(
'placeSingleBet after kickoff',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-prematch-${fx.runId}`,
);
},
'PRE_MATCH_ONLY',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF011',
suite: 'bet-flow',
name: '每日投注上限:超额拒单',
description: 'dailyStakeLimit=150 时第二笔 100 注单失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.dailyStakeLimit, '150', snapshots);
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-daily-1-${fx.runId}`,
);
await expectAppErrorThrows(
'placeSingleBet daily limit',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.drawSelectionId,
fx.drawOddsVersion,
100,
`smoke-daily-2-${fx.runId}`,
);
},
'DAILY_STAKE_LIMIT',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF012',
suite: 'bet-flow',
name: '串关限额:超 maxStakeParlay 拒单',
description: 'maxStakeParlay=80 时下 100 串关失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections[0]!;
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.maxStakeParlay, '80', snapshots);
await expectAppErrorThrows(
'placeParlayBet max stake',
async () => {
await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-max-${fx.runId}`,
);
},
'MAX_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
const markets = await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
});
const marketIds = markets.map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF013',
suite: 'bet-flow',
name: '改 SystemConfig 后拒单minStake',
description: 'minStake=200 时下 100 单关返回 MIN_STAKE',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.minStake, '200', snapshots);
await expectAppErrorThrows(
'placeSingleBet min stake',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-min-${fx.runId}`,
);
},
'MIN_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF005',
suite: 'bet-flow',

View File

@@ -380,6 +380,36 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
},
},
{
id: 'S020',
suite: 'settlement',
name: '串关 VOID 腿 + LOSE 腿:整单全输',
description: '比赛取消腿按 odds=1.0 续算,已有 LOSE 腿时 payout=0',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 2.0, result: 'LOSE' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
},
},
{
id: 'S021',
suite: 'settlement',
name: '串关 VOID 腿 + WIN 腿:按 WIN 腿赔率结算',
description: '作废腿不参与升赔,其余 WIN 腿正常连乘',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 1.5, result: 'WIN' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 150, { stake: 100, legs });
},
},
{
id: 'OUT001',
suite: 'settlement',
@@ -431,17 +461,6 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
},
// —— Betting rules ——
{
id: 'B003',
suite: 'betting',
name: '赔率版本不一致应拒绝',
uatRef: 'B003',
run: () => {
const submitted = BigInt(1);
const current = BigInt(2);
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
},
},
{
id: 'B006',
suite: 'betting',
@@ -760,7 +779,10 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
];
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
settlement: {
name: '结算引擎',
description: '独赢、波胆、让球、大小、串关、冠军盘、作废腿续算',
},
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
@@ -771,4 +793,8 @@ export const SMOKE_SUITE_META: Record<string, { name: string; description: strin
name: '下注结算链路',
description: '真实 DB下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
},
config: {
name: '系统配置',
description: '真实 DBSystemConfig 接线后 AgentsService 行为(临时数据自动清理)',
},
};

View File

@@ -0,0 +1,197 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
} from '../../../shared/config/system-config.service';
import type { AgentsService } from '../../agent/agents.service';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import { expectEqual, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseDef } from './smoke-test.cases';
export const CONFIG_PROBE_COUNT = 2;
export type ConfigProbeDeps = {
prisma: PrismaService;
agents: AgentsService;
};
async function upsertBooleanConfig(
prisma: PrismaService,
key: string,
value: boolean,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: value ? 'true' : 'false',
description: 'smoke config probe',
},
update: { configValue: value ? 'true' : 'false' },
});
}
async function restoreConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: {
configKey: snap.key,
configValue: snap.value,
description: 'smoke config restore',
},
update: { configValue: snap.value },
});
}
}
}
export function createConfigProbes(deps: ConfigProbeDeps): SmokeTestCaseDef[] {
return [
{
id: 'CFG001',
suite: 'config',
name: '代理停用默认:全局 freeze 级联冻结直属玩家',
description: 'AgentsService.updateAgentAdmin 未传 flag 时读取 agent.suspend_freeze_direct_players',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
let playerId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
true,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
false,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
const player = await deps.prisma.user.create({
data: {
username: `smoke_cfg_pl_${runId}`,
userType: 'PLAYER',
status: 'ACTIVE',
parentId: agent.id,
auth: { create: { passwordHash: 'smoke' } },
},
});
playerId = player.id;
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const frozen = await deps.prisma.user.findUnique({ where: { id: player.id } });
expectEqual('player status after suspend', frozen?.status, 'SUSPENDED');
} finally {
if (playerId) {
await deps.prisma.userAuth.deleteMany({ where: { userId: playerId } });
await deps.prisma.user.deleteMany({ where: { id: playerId } });
}
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
{
id: 'CFG002',
suite: 'config',
name: '代理停用默认:全局 block 禁止直属玩家登录',
description: 'AgentsService.updateAgentAdmin 未传 flag 时写入 blockDirectPlayerLogin',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
false,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
true,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg2_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const profile = await deps.prisma.agentProfile.findUnique({
where: { userId: agent.id },
});
expectTrue(
'blockDirectPlayerLogin persisted',
profile?.blockDirectPlayerLogin === true,
{ value: profile?.blockDirectPlayerLogin },
);
} finally {
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
];
}

View File

@@ -7,6 +7,7 @@ import { SettlementService } from '../../settlement/settlement.service';
import { WalletService } from '../../ledger/wallet.service';
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes';
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
import {
beginSmokeSteps,
@@ -49,6 +50,7 @@ export class SmokeTestService {
}
counts.set('database', DATABASE_PROBE_COUNT);
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
counts.set('config', CONFIG_PROBE_COUNT);
return [...counts.entries()].map(([id, caseCount]) => ({
id,
@@ -77,6 +79,7 @@ export class SmokeTestService {
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
const runDb = !allow || allow.has('database');
const runBetFlow = !allow || allow.has('bet-flow');
const runConfig = !allow || allow.has('config');
const results: SmokeTestCaseResult[] = [];
@@ -102,6 +105,15 @@ export class SmokeTestService {
}
}
if (runConfig) {
for (const probe of createConfigProbes({
prisma: this.prisma,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
const finished = Date.now();
const passed = results.filter((r) => r.status === 'PASS').length;
const failed = results.filter((r) => r.status === 'FAIL').length;

View File

@@ -118,8 +118,8 @@ function settleOverUnder(
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1) return 'HALF_WIN';
if (loseCount === 1) return 'HALF_LOSE';
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
return 'PUSH';
}

View File

@@ -148,7 +148,9 @@ describe('SettlementService outright winner flow', () => {
settlementBatch: {
create: settlementBatchCreate,
findUnique: settlementBatchFindUnique,
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
bet: { findMany: betFindMany },
$transaction: transaction,
@@ -247,7 +249,7 @@ describe('SettlementService outright winner flow', () => {
);
});
it('keeps a parlay pending until every match in the ticket is settled', async () => {
it('shows parlay as lost when this match leg loses even if other legs are pending', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
@@ -304,14 +306,13 @@ describe('SettlementService outright winner flow', () => {
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(1);
expect(preview.lostOnThisMatch).toBe(0);
expect(preview.pendingOtherMatches).toBe(0);
expect(preview.lostOnThisMatch).toBe(1);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-PENDING',
result: 'PENDING_OTHER_MATCHES',
result: 'LOST',
payout: '0',
note: '本场腿已出结果,待其他场次结算后统一结算',
}),
]);
});
@@ -404,6 +405,7 @@ describe('SettlementService outright winner flow', () => {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
match: { update: txMatchUpdate },
});
@@ -474,3 +476,442 @@ describe('SettlementService outright winner flow', () => {
expect(result).toEqual({ success: true, batchId: batchId.toString() });
});
});
describe('SettlementService hardening', () => {
const matchId = BigInt(200);
const operatorId = BigInt(1);
const batchId = BigInt(900);
const otherBatchId = BigInt(901);
const fixtureMatch = {
id: matchId,
isOutright: false,
status: 'PENDING_SETTLEMENT',
deletedAt: null,
};
const pendingSingleBet = {
id: BigInt(3001),
betNo: 'BET-SINGLE',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(70),
user: { id: BigInt(70) },
selections: [
{
id: BigInt(4001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
],
};
function buildService(overrides: {
prisma?: Record<string, unknown>;
wallet?: Record<string, jest.Mock>;
transactionClient?: Record<string, unknown>;
} = {}) {
const wallet = {
settleBet: jest.fn().mockResolvedValue(undefined),
voidBet: jest.fn().mockResolvedValue(undefined),
...(overrides.wallet ?? {}),
};
const agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
const transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) => {
const defaultClient = {
team: { findUnique: jest.fn().mockResolvedValue(null) },
market: { findMany: jest.fn().mockResolvedValue([]) },
marketSelection: { findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]) },
matchScore: {
upsert: jest.fn().mockResolvedValue({}),
findUnique: jest.fn().mockResolvedValue({
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
winnerTeamId: null,
}),
},
bet: {
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
betSelection: { update: jest.fn().mockResolvedValue({}), findMany: jest.fn().mockResolvedValue([]) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: jest.fn().mockResolvedValue({
id: batchId,
batchNo: 'STL-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
}),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
match: { update: jest.fn().mockResolvedValue({}) },
...(overrides.transactionClient ?? {}),
};
await fn(defaultClient);
});
const prisma = {
match: {
count: jest.fn().mockResolvedValue(0),
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'CLOSED' }),
update: jest.fn().mockResolvedValue({}),
},
team: { findUnique: jest.fn().mockResolvedValue(null) },
market: { findMany: jest.fn().mockResolvedValue([]) },
marketSelection: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]),
},
matchScore: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
settlementBatch: {
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
findUnique: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
bet: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: transaction,
...(overrides.prisma ?? {}),
};
return {
service: new SettlementService(prisma as never, wallet as never, agents as never),
prisma,
wallet,
transaction,
};
}
it('recordScore rejects half-time scores greater than full-time', async () => {
const { service } = buildService();
try {
await service.recordScore(matchId, 2, 0, 1, 0, operatorId);
throw new Error('Expected recordScore to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_SCORE_INVALID' }),
);
}
});
it('previewSettlement cancels older preview batches before creating a new one', async () => {
const updateMany = jest.fn().mockResolvedValue({ count: 1 });
const { service } = buildService({
prisma: {
bet: { findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: {
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
updateMany,
},
},
});
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
expect(updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { matchId, status: 'PREVIEW', isResettle: false },
data: { status: 'CANCELLED' },
}),
);
});
it('confirmSettlement rejects stale preview batches', async () => {
const staleBatch = {
id: otherBatchId,
batchNo: 'STL-OLD',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
};
const { service } = buildService({
prisma: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue(staleBatch),
},
},
transactionClient: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue(staleBatch),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
},
});
try {
await service.confirmSettlement(otherBatchId, operatorId);
throw new Error('Expected confirmSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_BATCH_STALE' }),
);
}
});
it('confirmSettlement throws when bet status update fails', async () => {
const { service } = buildService({
prisma: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue({
id: batchId,
batchNo: 'STL-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
}),
},
},
transactionClient: {
bet: {
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
},
});
try {
await service.confirmSettlement(batchId, operatorId);
throw new Error('Expected confirmSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({
code: 'SETTLEMENT_BET_UPDATE_FAILED',
params: expect.objectContaining({ betNo: 'BET-SINGLE' }),
}),
);
}
});
it('voidMatchBets settles cross-match parlay as lost when voided leg joins an existing losing leg', async () => {
const parlayBet = {
id: BigInt(5001),
betNo: 'PARLAY-VOID',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(80),
selections: [
{
id: BigInt(6001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(6002),
matchId: BigInt(201),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'LOSE',
sortOrder: 1,
},
],
};
const betSelectionUpdate = jest.fn().mockResolvedValue({});
const betSelectionFindMany = jest
.fn()
.mockResolvedValue([
{ ...parlayBet.selections[0], resultStatus: 'VOID', odds: new Decimal(2) },
{ ...parlayBet.selections[1], resultStatus: 'LOSE', odds: new Decimal(2) },
]);
const betUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
const funds = { settleBet: jest.fn().mockResolvedValue(undefined), voidBet: jest.fn() };
const transaction = jest.fn(async (fn: (client: unknown) => Promise<unknown>) =>
fn({
match: { update: jest.fn().mockResolvedValue({}) },
bet: {
findMany: jest.fn().mockResolvedValue([parlayBet]),
updateMany: betUpdateMany,
},
betSelection: {
update: betSelectionUpdate,
findMany: betSelectionFindMany,
},
}),
);
const service = new SettlementService(
{ $transaction: transaction } as never,
funds as never,
{ recalculateUsedCredit: jest.fn() } as never,
);
const result = await service.voidMatchBets(matchId);
expect(result.voidedCount).toBe(1);
expect(funds.voidBet).not.toHaveBeenCalled();
expect(funds.settleBet).toHaveBeenCalledWith(
expect.objectContaining({
betNo: 'PARLAY-VOID',
result: 'LOSE',
batchNo: `void:${matchId}`,
}),
);
expect(funds.settleBet.mock.calls[0][0].payout.toString()).toBe('0');
});
it('previewResettlement treats single multi-leg tickets like parlays', async () => {
const multiLegSingle = {
id: BigInt(7001),
betNo: 'SINGLE-MULTI',
betType: 'SINGLE',
status: 'WON',
stake: new Decimal(100),
actualReturn: new Decimal(360),
agentId: null,
userId: BigInt(90),
selections: [
{
id: BigInt(8001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 0,
},
{
id: BigInt(8002),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 1,
},
],
};
const { service } = buildService({
prisma: {
match: {
count: jest.fn().mockResolvedValue(0),
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'SETTLED' }),
update: jest.fn().mockResolvedValue({}),
},
bet: { findMany: jest.fn().mockResolvedValue([multiLegSingle]) },
settlementBatch: {
create: jest.fn().mockImplementation(({ data }) =>
Promise.resolve({ id: batchId, ...data }),
),
},
},
});
const preview = await service.previewResettlement(
matchId,
{
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
},
operatorId,
);
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
betNo: 'SINGLE-MULTI',
newStatus: 'LOST',
}),
]),
);
expect(preview.items[0].newPayout.toString()).toBe('0');
});
it('previewSettlement rejects unsupported market types on pending bets', async () => {
const { service } = buildService({
prisma: {
bet: {
findMany: jest.fn().mockResolvedValue([
{
...pendingSingleBet,
selections: [
{
...pendingSingleBet.selections[0],
marketType: 'UNKNOWN_MARKET',
},
],
},
]),
},
},
});
try {
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
throw new Error('Expected previewSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_MARKET_UNSUPPORTED' }),
);
}
});
});

View File

@@ -18,6 +18,7 @@ import {
resolveSelectionCode,
templateScoresForMarket,
} from './domain/settlement-helpers';
import { isSettlementSupportedMarketType } from '@thebet365/shared';
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
const STAT_MARKET_REQUIREMENTS = {
@@ -189,6 +190,95 @@ export class SettlementService {
}
}
private assertScoreConsistency(score: ScoreInput, isOutright: boolean) {
if (isOutright) return;
if (
score.htHome < 0 ||
score.htAway < 0 ||
score.ftHome < 0 ||
score.ftAway < 0 ||
score.htHome > score.ftHome ||
score.htAway > score.ftAway
) {
throw appBadRequest('SETTLEMENT_SCORE_INVALID');
}
}
private assertSupportedMarketTypes(
bets: Array<{ selections: Array<{ marketType: string }> }>,
) {
for (const bet of bets) {
for (const sel of bet.selections) {
if (!isSettlementSupportedMarketType(sel.marketType)) {
throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', {
marketType: sel.marketType,
});
}
}
}
}
private assertOutrightWinnerForBets(
bets: Array<{ selections: Array<{ marketType: string }> }>,
winnerTeamCode: string | null,
isOutright: boolean,
) {
const hasOutrightLeg =
isOutright ||
bets.some((bet) =>
bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'),
);
if (hasOutrightLeg && !winnerTeamCode) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
}
}
private requireBetUpdate(updated: { count: number }, betNo: string) {
if (updated.count !== 1) {
throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo });
}
}
private async assertLatestPreviewBatch(
batchId: bigint,
matchId: bigint,
tx?: TxClient,
) {
const client: PrismaClientLike = tx ?? this.prisma;
const latest = await client.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW', isResettle: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (latest && latest.id !== batchId) {
throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() });
}
}
private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) {
const client: PrismaClientLike = tx ?? this.prisma;
await client.settlementBatch.updateMany({
where: { matchId, status: 'PREVIEW', isResettle: false },
data: { status: 'CANCELLED' },
});
}
private parlayBetStatusFromResult(
parlayResult: ReturnType<typeof calculateParlayPayout>,
): 'LOST' | 'PUSH' | 'WON' {
if (parlayResult.betResult === 'LOST') return 'LOST';
if (parlayResult.betResult === 'PUSH') return 'PUSH';
return 'WON';
}
private walletResultFromParlayBetResult(
betResult: 'WON' | 'LOST' | 'PUSH',
): 'WIN' | 'LOSE' | 'PUSH' {
if (betResult === 'LOST') return 'LOSE';
if (betResult === 'PUSH') return 'PUSH';
return 'WIN';
}
async recordScore(
matchId: bigint,
htHome: number,
@@ -226,6 +316,13 @@ export class SettlementService {
}
}
if (!match.isOutright) {
this.assertScoreConsistency(
{ htHome, htAway, ftHome, ftAway },
match.isOutright,
);
}
const stats = this.statsInputFromSource(statsInput);
await this.prisma.matchScore.upsert({
where: { matchId },
@@ -312,7 +409,19 @@ export class SettlementService {
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreSource.htHome,
htAway: scoreSource.htAway,
ftHome: scoreSource.ftHome,
ftAway: scoreSource.ftAway,
},
match.isOutright,
);
}
const computation = await this.computePreviewComputation(matchId, scoreSource);
await this.cancelStalePreviewBatches(matchId);
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
@@ -733,6 +842,8 @@ export class SettlementService {
statsInput,
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false);
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
@@ -758,6 +869,7 @@ export class SettlementService {
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
if (result === 'LOSE') lostOnThisMatch += 1;
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
@@ -776,6 +888,7 @@ export class SettlementService {
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
if (parlay.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -809,6 +922,7 @@ export class SettlementService {
selectionCodes,
);
if (preview.kind === 'SETTLED') {
if (preview.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -865,11 +979,18 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
if (legResult === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
result: legResult,
});
} else if (sel.resultStatus) {
if (sel.resultStatus === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
@@ -966,6 +1087,13 @@ export class SettlementService {
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(
pendingBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx);
let settledCount = 0;
await this.upsertMatchScoreRecord(
@@ -1001,7 +1129,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await tx.betSelection.update({
where: { id: sel.id },
@@ -1045,12 +1173,7 @@ export class SettlementService {
});
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -1060,7 +1183,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -1068,12 +1191,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1116,12 +1234,7 @@ export class SettlementService {
result: s.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -1131,7 +1244,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -1139,12 +1252,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1386,7 +1494,7 @@ export class SettlementService {
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
if (bet.betType === 'SINGLE') {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
@@ -1401,6 +1509,26 @@ export class SettlementService {
};
}
if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
for (const sel of bet.selections) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
legUpdates.set(sel.id.toString(), result);
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
@@ -1423,14 +1551,11 @@ export class SettlementService {
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
return { payout: parlayResult.payout, betStatus, legUpdates };
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
async previewResettlement(
@@ -1448,6 +1573,18 @@ export class SettlementService {
throw appBadRequest('RESETTLE_SETTLED_ONLY');
}
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
},
match.isOutright,
);
}
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
@@ -1467,6 +1604,8 @@ export class SettlementService {
statsInput,
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright);
const items: Array<{
betId: bigint;
betNo: string;
@@ -1612,6 +1751,12 @@ export class SettlementService {
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(
settledBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
let affectedCount = 0;
await this.upsertMatchScoreRecord(
@@ -1715,6 +1860,7 @@ export class SettlementService {
options: { cancelMatch?: boolean } = {},
) {
const agentIds = new Set<bigint>();
const voidBatchNo = `void:${matchId}`;
const voidedCount = await this.prisma.$transaction(async (tx) => {
if (options.cancelMatch) {
@@ -1726,21 +1872,82 @@ export class SettlementService {
const bets = await tx.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
let count = 0;
for (const bet of bets) {
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1;
await this.funds.voidBet({
if (isSingleOneLeg) {
const sel = bet.selections[0];
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
await this.funds.voidBet({
userId: bet.userId,
stake: bet.stake,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);
count += 1;
continue;
}
const legsOnMatch = bet.selections.filter(
(sel) => sel.matchId?.toString() === matchId.toString(),
);
if (!legsOnMatch.length) continue;
for (const sel of legsOnMatch) {
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
}
const updatedLegs = await tx.betSelection.findMany({
where: { betId: bet.id },
orderBy: { sortOrder: 'asc' },
});
if (!updatedLegs.every((sel) => sel.resultStatus != null)) {
continue;
}
const legResults = updatedLegs.map((sel) => ({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout: parlayResult.payout,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
batchNo: voidBatchNo,
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);

View File

@@ -19,6 +19,10 @@ export type InboxNotifySettings = {
inboxEnabled: boolean;
/** 充值审核通过/拒绝时发送站内信 */
deposit: boolean;
/** 发布 Banner 内容时发送站内信推广 */
banner: boolean;
/** 发布公告/滚动条内容时发送站内信推广 */
announcement: boolean;
};
export type PlatformDirectCashbackSettings = {
@@ -41,9 +45,9 @@ export type PlayerAccountSettings = {
};
export type AgentSuspendSettings = {
/** 停用代理时是否允许级联冻结直属玩家(需管理员显式勾选 */
/** 停用代理时默认级联冻结直属玩家(单次操作仍可覆盖 */
suspendFreezeDirectPlayers: boolean;
/** 上级代理停用时是否禁止直属玩家登录 */
/** 停用代理时默认禁止直属玩家登录(单次操作仍可覆盖) */
suspendBlockPlayerLogin: boolean;
};
@@ -239,11 +243,13 @@ export class SystemConfigService {
}
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
const [inboxEnabled, deposit] = await Promise.all([
const [inboxEnabled, deposit, banner, announcement] = await Promise.all([
this.getBoolean(INBOX_FEATURE_ENABLED, true),
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
this.getBoolean(INBOX_NOTIFY_BANNER, true),
this.getBoolean(INBOX_NOTIFY_ANNOUNCEMENT, true),
]);
return { inboxEnabled, deposit };
return { inboxEnabled, deposit, banner, announcement };
}
async updateInboxNotifySettings(data: Partial<InboxNotifySettings>) {
@@ -261,6 +267,20 @@ export class SystemConfigService {
'充值审核结果是否通过站内邮箱通知玩家',
);
}
if (data.banner !== undefined) {
await this.setBoolean(
INBOX_NOTIFY_BANNER,
data.banner,
'发布 Banner 内容时是否发送站内信推广',
);
}
if (data.announcement !== undefined) {
await this.setBoolean(
INBOX_NOTIFY_ANNOUNCEMENT,
data.announcement,
'发布公告/滚动条内容时是否发送站内信推广',
);
}
return this.getInboxNotifySettings();
}

View File

@@ -1,6 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { resolveDevApiTarget } from '../../scripts/resolve-dev-api-target.mjs';
const devApiTarget = resolveDevApiTarget();
export default defineConfig({
plugins: [vue()],
@@ -38,8 +41,8 @@ export default defineConfig({
host: true,
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
'/api': { target: devApiTarget, changeOrigin: true },
'/uploads': { target: devApiTarget, changeOrigin: true },
},
},
});