feat(i18n): 管理端与玩家端三语支持(中/英/马来语)
- 管理后台 adminT 文案库、结算与代理端页面、表单校验 - 玩家端 vue-i18n 补全首页/公告/串关与 ms 文案 - Element Plus ms 语言包与共享 locale 工具
This commit is contained in:
7
apps/api/scripts/test-db.js
Normal file
7
apps/api/scripts/test-db.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient();
|
||||
p.match
|
||||
.findMany({ take: 1, include: { homeTeam: true, awayTeam: true } })
|
||||
.then((r) => console.log('OK', r.length))
|
||||
.catch((e) => console.error('ERR', e.message))
|
||||
.finally(() => p.$disconnect());
|
||||
@@ -372,6 +372,9 @@ export class AgentsService {
|
||||
cashbackRate?: number;
|
||||
},
|
||||
) {
|
||||
if (data.level !== 1 && data.level !== 2) {
|
||||
throw new BadRequestException('Agent level must be 1 or 2');
|
||||
}
|
||||
if (data.level === 2 && !data.parentAgentId) {
|
||||
throw new BadRequestException('Level 2 agent requires parent');
|
||||
}
|
||||
|
||||
@@ -4,8 +4,13 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
import { isQuarterHandicapOrTotal } from '../settlement/domain/settlement-calculator';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
canSelectForParlay,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
@@ -20,7 +25,11 @@ export class BetsService {
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
|
||||
private async validateSelection(
|
||||
selectionId: bigint,
|
||||
oddsVersion: bigint,
|
||||
options?: { forParlay?: boolean },
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
include: { market: { include: { match: true } } },
|
||||
@@ -32,10 +41,35 @@ export class BetsService {
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw new BadRequestException('Match not available for betting');
|
||||
}
|
||||
if (!isSupportedSport(selection.market.match.sportType)) {
|
||||
throw new BadRequestException('Only football betting is supported');
|
||||
}
|
||||
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
|
||||
throw new BadRequestException('Pre-match betting only; match has started');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw new BadRequestException('Odds changed, please confirm again');
|
||||
}
|
||||
|
||||
if (options?.forParlay) {
|
||||
const line = selection.market.lineValue ? Number(selection.market.lineValue) : null;
|
||||
const check = canSelectForParlay({
|
||||
marketType: selection.market.marketType,
|
||||
lineValue: line,
|
||||
allowParlay: selection.market.allowParlay,
|
||||
isOutright: selection.market.match.isOutright,
|
||||
});
|
||||
if (!check.ok) {
|
||||
const msg =
|
||||
check.reason === 'OUTRIGHT'
|
||||
? 'Outright cannot be in parlay'
|
||||
: check.reason === 'QUARTER_LINE'
|
||||
? 'Quarter line markets cannot be in parlay'
|
||||
: 'Market not allowed in parlay';
|
||||
throw new BadRequestException(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
@@ -54,7 +88,7 @@ export class BetsService {
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion);
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion, { forParlay: false });
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
@@ -117,21 +151,7 @@ export class BetsService {
|
||||
const matchIds = new Set<string>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
|
||||
|
||||
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
|
||||
throw new BadRequestException('Outright cannot be in parlay');
|
||||
}
|
||||
|
||||
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
|
||||
if (
|
||||
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
|
||||
sel.market.marketType,
|
||||
) &&
|
||||
isQuarterHandicapOrTotal(line)
|
||||
) {
|
||||
throw new BadRequestException('Quarter line markets cannot be in parlay');
|
||||
}
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
@@ -487,7 +488,7 @@ export class MatchesService {
|
||||
const map = Object.fromEntries(
|
||||
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
|
||||
);
|
||||
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
|
||||
return resolveTranslationFallback(map, locale);
|
||||
}
|
||||
|
||||
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
||||
@@ -518,10 +519,13 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
const now = new Date();
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
isOutright: false,
|
||||
sportType: 'FOOTBALL',
|
||||
startTime: { gt: now },
|
||||
...(leagueId ? { leagueId } : {}),
|
||||
},
|
||||
include: {
|
||||
@@ -553,12 +557,15 @@ export class MatchesService {
|
||||
},
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
if (match.sportType !== 'FOOTBALL') {
|
||||
throw new NotFoundException('Match not found');
|
||||
}
|
||||
return this.enrichMatch(match, locale);
|
||||
}
|
||||
|
||||
async listOutrights(locale = 'en-US') {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: { status: 'PUBLISHED', isOutright: true },
|
||||
where: { status: 'PUBLISHED', isOutright: true, sportType: 'FOOTBALL' },
|
||||
include: {
|
||||
markets: {
|
||||
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { SUPPORTED_LOCALES } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
|
||||
@@ -92,6 +93,9 @@ export class UsersService {
|
||||
}
|
||||
|
||||
async updateLocale(userId: bigint, locale: string) {
|
||||
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||
throw new BadRequestException('Unsupported locale');
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { locale },
|
||||
|
||||
@@ -156,6 +156,12 @@ export class MarketsService {
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
OUTRIGHT_WINNER: {
|
||||
period: 'OUTRIGHT',
|
||||
allowParlay: false,
|
||||
sortOrder: 1,
|
||||
selections: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[marketType];
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
|
||||
function pickContentTranslation<T extends { locale: string }>(
|
||||
translations: T[],
|
||||
locale: string,
|
||||
): T | undefined {
|
||||
const chain = [locale, 'en-US', 'zh-CN'];
|
||||
for (const loc of chain) {
|
||||
const hit = translations.find((tr) => tr.locale === loc);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return translations[0];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -19,10 +31,7 @@ export class ContentService {
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t =
|
||||
item.translations.find((tr) => tr.locale === locale) ||
|
||||
item.translations.find((tr) => tr.locale === 'en-US') ||
|
||||
item.translations[0];
|
||||
const t = pickContentTranslation(item.translations, locale);
|
||||
return { ...item, translation: t };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { DEFAULT_LOCALE } from '@thebet365/shared';
|
||||
|
||||
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
@@ -10,7 +8,7 @@ export class I18nService {
|
||||
|
||||
async getMessages(locale: string) {
|
||||
const messages = await this.prisma.i18nMessage.findMany({
|
||||
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
|
||||
where: { locale: { in: [locale, 'en-US', 'zh-CN', 'ms-MY'] } },
|
||||
});
|
||||
|
||||
const byKey: Record<string, Record<string, string>> = {};
|
||||
@@ -21,10 +19,8 @@ export class I18nService {
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, locales] of Object.entries(byKey)) {
|
||||
result[key] =
|
||||
locales[locale] ||
|
||||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
|
||||
key;
|
||||
const resolved = resolveTranslationFallback(locales, locale);
|
||||
result[key] = resolved || key;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -259,10 +259,7 @@ export function calculateParlayPayout(
|
||||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||||
}
|
||||
|
||||
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
|
||||
if (line == null) return false;
|
||||
return isQuarterLine(line);
|
||||
}
|
||||
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
|
||||
|
||||
export const FT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
|
||||
|
||||
Reference in New Issue
Block a user