重构
This commit is contained in:
@@ -1,14 +1,23 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
'^.+\\.ts$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
esModuleInterop: true,
|
||||
resolveJsonModule: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^@thebet365/shared$': '<rootDir>/../../../packages/shared/src/index.ts',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "wallet_transactions" ADD COLUMN IF NOT EXISTS "business_key" VARCHAR(128);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "wallet_transactions_business_key_key" ON "wallet_transactions"("business_key");
|
||||
|
||||
-- Deduplicate legacy settlement preview items before enforcing idempotency.
|
||||
WITH ranked_settlement_items AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "batch_id", "bet_id"
|
||||
ORDER BY "id"
|
||||
) AS row_num
|
||||
FROM "settlement_items"
|
||||
)
|
||||
DELETE FROM "settlement_items" si
|
||||
USING ranked_settlement_items r
|
||||
WHERE si."id" = r."id"
|
||||
AND r.row_num > 1;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "settlement_items_batch_id_bet_id_key" ON "settlement_items"("batch_id", "bet_id");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "show_on_player" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,81 @@
|
||||
-- Market templates, line identity, and multilingual labels.
|
||||
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "market_key" VARCHAR(64);
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "line_key" VARCHAR(180);
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "params_json" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "promo_label_i18n" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
|
||||
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "template_item_id" BIGINT;
|
||||
|
||||
UPDATE "markets"
|
||||
SET
|
||||
"market_key" = COALESCE("market_key", "market_type"),
|
||||
"line_key" = COALESCE(
|
||||
"line_key",
|
||||
"market_type" || ':' || COALESCE(to_char("line_value", 'FM999999990.00'), 'none')
|
||||
)
|
||||
WHERE "market_key" IS NULL OR "line_key" IS NULL;
|
||||
|
||||
ALTER TABLE "market_selections" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
|
||||
ALTER TABLE "bet_selections" ADD COLUMN IF NOT EXISTS "market_name_snapshot" VARCHAR(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_templates" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
|
||||
"name" VARCHAR(128) NOT NULL,
|
||||
"name_i18n" JSONB,
|
||||
"description" VARCHAR(500),
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_template_items" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"template_id" BIGINT NOT NULL,
|
||||
"market_type" VARCHAR(64) NOT NULL,
|
||||
"market_key" VARCHAR(64),
|
||||
"line_key" VARCHAR(180) NOT NULL,
|
||||
"period" VARCHAR(16) NOT NULL,
|
||||
"line_value" DECIMAL(8,2),
|
||||
"params_json" JSONB,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
|
||||
"allow_single" BOOLEAN NOT NULL DEFAULT true,
|
||||
"allow_parlay" BOOLEAN NOT NULL DEFAULT true,
|
||||
"show_on_player" BOOLEAN NOT NULL DEFAULT true,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"promo_label" VARCHAR(100),
|
||||
"promo_label_i18n" JSONB,
|
||||
"name_i18n" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "market_template_items_template_id_fkey"
|
||||
FOREIGN KEY ("template_id") REFERENCES "market_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "market_template_selections" (
|
||||
"id" BIGSERIAL PRIMARY KEY,
|
||||
"template_item_id" BIGINT NOT NULL,
|
||||
"selection_code" VARCHAR(64) NOT NULL,
|
||||
"selection_name" VARCHAR(255) NOT NULL,
|
||||
"name_i18n" JSONB,
|
||||
"odds" DECIMAL(18,6) NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "market_template_selections_template_item_id_fkey"
|
||||
FOREIGN KEY ("template_item_id") REFERENCES "market_template_items"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "markets_match_id_line_key_idx" ON "markets"("match_id", "line_key");
|
||||
CREATE INDEX IF NOT EXISTS "market_templates_sport_type_status_idx" ON "market_templates"("sport_type", "status");
|
||||
CREATE INDEX IF NOT EXISTS "market_templates_is_default_idx" ON "market_templates"("is_default");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_items_template_id_idx" ON "market_template_items"("template_id");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_items_market_type_idx" ON "market_template_items"("market_type");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_items_template_id_line_key_key" ON "market_template_items"("template_id", "line_key");
|
||||
CREATE INDEX IF NOT EXISTS "market_template_selections_template_item_id_idx" ON "market_template_selections"("template_item_id");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_selections_template_item_id_selection_code_key"
|
||||
ON "market_template_selections"("template_item_id", "selection_code");
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "match_scores"
|
||||
ADD COLUMN "home_corners" INTEGER,
|
||||
ADD COLUMN "away_corners" INTEGER,
|
||||
ADD COLUMN "home_cards" INTEGER,
|
||||
ADD COLUMN "away_cards" INTEGER;
|
||||
|
||||
ALTER TABLE "settlement_batches"
|
||||
ADD COLUMN "home_corners" INTEGER,
|
||||
ADD COLUMN "away_corners" INTEGER,
|
||||
ADD COLUMN "home_cards" INTEGER,
|
||||
ADD COLUMN "away_cards" INTEGER;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "matches" DROP COLUMN IF EXISTS "correct_score_enabled";
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "match_scores"
|
||||
ADD COLUMN "home_yellow_cards" INTEGER,
|
||||
ADD COLUMN "away_yellow_cards" INTEGER,
|
||||
ADD COLUMN "home_red_cards" INTEGER,
|
||||
ADD COLUMN "away_red_cards" INTEGER;
|
||||
|
||||
ALTER TABLE "settlement_batches"
|
||||
ADD COLUMN "home_yellow_cards" INTEGER,
|
||||
ADD COLUMN "away_yellow_cards" INTEGER,
|
||||
ADD COLUMN "home_red_cards" INTEGER,
|
||||
ADD COLUMN "away_red_cards" INTEGER;
|
||||
@@ -10,19 +10,19 @@ datasource db {
|
||||
// ============ Users & Auth ============
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
userType String @map("user_type") @db.VarChar(20)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
|
||||
inviteSponsorId BigInt? @map("invite_sponsor_id")
|
||||
usedInviteId BigInt? @map("used_invite_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
userType String @map("user_type") @db.VarChar(20)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
|
||||
inviteSponsorId BigInt? @map("invite_sponsor_id")
|
||||
usedInviteId BigInt? @map("used_invite_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
auth UserAuth?
|
||||
wallet Wallet?
|
||||
@@ -30,14 +30,14 @@ model User {
|
||||
adminRole AdminUserRole?
|
||||
bets Bet[]
|
||||
preferences UserPreference?
|
||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
|
||||
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
|
||||
invitedPlayers User[] @relation("InviteSponsor")
|
||||
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
|
||||
invites UserInvite[] @relation("UserInvites")
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
|
||||
invitedPlayers User[] @relation("InviteSponsor")
|
||||
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
|
||||
invites UserInvite[] @relation("UserInvites")
|
||||
|
||||
@@index([userType])
|
||||
@@index([parentId])
|
||||
@@ -56,8 +56,8 @@ model UserInvite {
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
cashbackRate Decimal? @map("cashback_rate") @db.Decimal(8, 4)
|
||||
|
||||
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
|
||||
registrations User[] @relation("UsedInvite")
|
||||
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
|
||||
registrations User[] @relation("UsedInvite")
|
||||
|
||||
@@index([sponsorId])
|
||||
@@index([status])
|
||||
@@ -66,16 +66,16 @@ model UserInvite {
|
||||
}
|
||||
|
||||
model UserAuth {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("user_auth")
|
||||
}
|
||||
@@ -95,18 +95,18 @@ model UserPreference {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([phoneCountryDial, phoneLocal])
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
permissions RolePermission[]
|
||||
adminUsers AdminUserRole[]
|
||||
@@ -115,23 +115,23 @@ model Role {
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128)
|
||||
name String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128)
|
||||
name String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
roles RolePermission[]
|
||||
roles RolePermission[]
|
||||
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId BigInt @map("role_id")
|
||||
permissionId BigInt @map("permission_id")
|
||||
roleId BigInt @map("role_id")
|
||||
permissionId BigInt @map("permission_id")
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
permission Permission @relation(fields: [permissionId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
permission Permission @relation(fields: [permissionId], references: [id])
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
@@ -142,8 +142,8 @@ model AdminUserRole {
|
||||
roleId BigInt @map("role_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
|
||||
@@map("admin_user_roles")
|
||||
}
|
||||
@@ -151,23 +151,23 @@ model AdminUserRole {
|
||||
// ============ Agent ============
|
||||
|
||||
model AgentProfile {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
level Int
|
||||
parentAgentId BigInt? @map("parent_agent_id")
|
||||
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
|
||||
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
|
||||
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
|
||||
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
level Int
|
||||
parentAgentId BigInt? @map("parent_agent_id")
|
||||
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
|
||||
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
|
||||
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
|
||||
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([parentAgentId])
|
||||
@@map("agent_profiles")
|
||||
@@ -215,8 +215,8 @@ model Wallet {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactions WalletTransaction[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactions WalletTransaction[]
|
||||
|
||||
@@map("wallets")
|
||||
}
|
||||
@@ -234,11 +234,12 @@ model WalletTransaction {
|
||||
frozenAfter Decimal @map("frozen_after") @db.Decimal(18, 4)
|
||||
referenceType String? @map("reference_type") @db.VarChar(32)
|
||||
referenceId String? @map("reference_id") @db.VarChar(64)
|
||||
businessKey String? @unique @map("business_key") @db.VarChar(128)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
remark String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([walletId])
|
||||
@@ -259,7 +260,7 @@ model League {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
matches Match[]
|
||||
matches Match[]
|
||||
|
||||
@@map("leagues")
|
||||
}
|
||||
@@ -296,41 +297,40 @@ model EntityTranslation {
|
||||
}
|
||||
|
||||
model Match {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
officialMatchNo Int? @map("official_match_no")
|
||||
stage String? @db.VarChar(32)
|
||||
groupName String? @map("group_name") @db.VarChar(8)
|
||||
liveMatchId BigInt? @unique @map("live_match_id")
|
||||
additionMatchId BigInt? @map("addition_match_id")
|
||||
channelId String? @map("channel_id") @db.VarChar(64)
|
||||
matchName String? @map("match_name") @db.VarChar(200)
|
||||
venueJson Json? @map("venue_json")
|
||||
kickoffJson Json? @map("kickoff_json")
|
||||
externalStatus String? @map("external_status") @db.VarChar(32)
|
||||
correctScoreEnabled Boolean @default(true) @map("correct_score_enabled")
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
officialMatchNo Int? @map("official_match_no")
|
||||
stage String? @db.VarChar(32)
|
||||
groupName String? @map("group_name") @db.VarChar(8)
|
||||
liveMatchId BigInt? @unique @map("live_match_id")
|
||||
additionMatchId BigInt? @map("addition_match_id")
|
||||
channelId String? @map("channel_id") @db.VarChar(64)
|
||||
matchName String? @map("match_name") @db.VarChar(200)
|
||||
venueJson Json? @map("venue_json")
|
||||
kickoffJson Json? @map("kickoff_json")
|
||||
externalStatus String? @map("external_status") @db.VarChar(32)
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||
score MatchScore?
|
||||
markets Market[]
|
||||
settlements SettlementBatch[]
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||
score MatchScore?
|
||||
markets Market[]
|
||||
settlements SettlementBatch[]
|
||||
|
||||
@@index([status])
|
||||
@@index([startTime])
|
||||
@@ -339,40 +339,56 @@ model Match {
|
||||
}
|
||||
|
||||
model MatchScore {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @unique @map("match_id")
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
winnerTeamId BigInt? @map("winner_team_id")
|
||||
recordedBy BigInt? @map("recorded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @unique @map("match_id")
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
homeCorners Int? @map("home_corners")
|
||||
awayCorners Int? @map("away_corners")
|
||||
homeYellowCards Int? @map("home_yellow_cards")
|
||||
awayYellowCards Int? @map("away_yellow_cards")
|
||||
homeRedCards Int? @map("home_red_cards")
|
||||
awayRedCards Int? @map("away_red_cards")
|
||||
homeCards Int? @map("home_cards")
|
||||
awayCards Int? @map("away_cards")
|
||||
winnerTeamId BigInt? @map("winner_team_id")
|
||||
recordedBy BigInt? @map("recorded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
|
||||
@@map("match_scores")
|
||||
}
|
||||
|
||||
model Market {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
marketKey String? @map("market_key") @db.VarChar(64)
|
||||
lineKey String? @map("line_key") @db.VarChar(180)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
paramsJson Json? @map("params_json")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
promoLabelI18n Json? @map("promo_label_i18n")
|
||||
nameI18n Json? @map("name_i18n")
|
||||
templateItemId BigInt? @map("template_item_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
selections MarketSelection[]
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
selections MarketSelection[]
|
||||
|
||||
@@index([matchId])
|
||||
@@index([matchId, lineKey])
|
||||
@@index([marketType])
|
||||
@@map("markets")
|
||||
}
|
||||
@@ -382,6 +398,7 @@ model MarketSelection {
|
||||
marketId BigInt @map("market_id")
|
||||
selectionCode String @map("selection_code") @db.VarChar(64)
|
||||
selectionName String @map("selection_name") @db.VarChar(255)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
oddsVersion BigInt @default(1) @map("odds_version")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
@@ -389,13 +406,80 @@ model MarketSelection {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
market Market @relation(fields: [marketId], references: [id])
|
||||
oddsLogs OddsChangeLog[]
|
||||
market Market @relation(fields: [marketId], references: [id])
|
||||
oddsLogs OddsChangeLog[]
|
||||
|
||||
@@index([marketId])
|
||||
@@map("market_selections")
|
||||
}
|
||||
|
||||
model MarketTemplate {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
name String @db.VarChar(128)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
description String? @db.VarChar(500)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
items MarketTemplateItem[]
|
||||
|
||||
@@index([sportType, status])
|
||||
@@index([isDefault])
|
||||
@@map("market_templates")
|
||||
}
|
||||
|
||||
model MarketTemplateItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateId BigInt @map("template_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
marketKey String? @map("market_key") @db.VarChar(64)
|
||||
lineKey String @map("line_key") @db.VarChar(180)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
paramsJson Json? @map("params_json")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
promoLabel String? @map("promo_label") @db.VarChar(100)
|
||||
promoLabelI18n Json? @map("promo_label_i18n")
|
||||
nameI18n Json? @map("name_i18n")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
template MarketTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
|
||||
selections MarketTemplateSelection[]
|
||||
|
||||
@@unique([templateId, lineKey])
|
||||
@@index([templateId])
|
||||
@@index([marketType])
|
||||
@@map("market_template_items")
|
||||
}
|
||||
|
||||
model MarketTemplateSelection {
|
||||
id BigInt @id @default(autoincrement())
|
||||
templateItemId BigInt @map("template_item_id")
|
||||
selectionCode String @map("selection_code") @db.VarChar(64)
|
||||
selectionName String @map("selection_name") @db.VarChar(255)
|
||||
nameI18n Json? @map("name_i18n")
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
item MarketTemplateItem @relation(fields: [templateItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([templateItemId, selectionCode])
|
||||
@@index([templateItemId])
|
||||
@@map("market_template_selections")
|
||||
}
|
||||
|
||||
model OddsChangeLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
selectionId BigInt @map("selection_id")
|
||||
@@ -405,7 +489,7 @@ model OddsChangeLog {
|
||||
changedBy BigInt? @map("changed_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
selection MarketSelection @relation(fields: [selectionId], references: [id])
|
||||
selection MarketSelection @relation(fields: [selectionId], references: [id])
|
||||
|
||||
@@index([selectionId])
|
||||
@@map("odds_change_logs")
|
||||
@@ -433,9 +517,9 @@ model Bet {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
cashbackClaims CashbackBet[]
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
cashbackClaims CashbackBet[]
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId])
|
||||
@@ -453,6 +537,7 @@ model BetSelection {
|
||||
selectionId BigInt @map("selection_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String? @db.VarChar(16)
|
||||
marketNameSnapshot String? @map("market_name_snapshot") @db.VarChar(255)
|
||||
selectionNameSnapshot String @map("selection_name_snapshot") @db.VarChar(255)
|
||||
handicapLine Decimal? @map("handicap_line") @db.Decimal(8, 2)
|
||||
totalLine Decimal? @map("total_line") @db.Decimal(8, 2)
|
||||
@@ -463,7 +548,7 @@ model BetSelection {
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([betId])
|
||||
@@index([matchId])
|
||||
@@ -473,41 +558,50 @@ model BetSelection {
|
||||
// ============ Settlement ============
|
||||
|
||||
model SettlementBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalBets Int @default(0) @map("total_bets")
|
||||
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
|
||||
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
isResettle Boolean @default(false) @map("is_resettle")
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
homeCorners Int? @map("home_corners")
|
||||
awayCorners Int? @map("away_corners")
|
||||
homeYellowCards Int? @map("home_yellow_cards")
|
||||
awayYellowCards Int? @map("away_yellow_cards")
|
||||
homeRedCards Int? @map("home_red_cards")
|
||||
awayRedCards Int? @map("away_red_cards")
|
||||
homeCards Int? @map("home_cards")
|
||||
awayCards Int? @map("away_cards")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalBets Int @default(0) @map("total_bets")
|
||||
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
|
||||
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
isResettle Boolean @default(false) @map("is_resettle")
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
items SettlementItem[]
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
items SettlementItem[]
|
||||
|
||||
@@index([matchId])
|
||||
@@map("settlement_batches")
|
||||
}
|
||||
|
||||
model SettlementItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
result String @db.VarChar(32)
|
||||
payout Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
result String @db.VarChar(32)
|
||||
payout Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch SettlementBatch @relation(fields: [batchId], references: [id])
|
||||
batch SettlementBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@unique([batchId, betId])
|
||||
@@index([batchId])
|
||||
@@index([betId])
|
||||
@@map("settlement_items")
|
||||
@@ -516,57 +610,59 @@ model SettlementItem {
|
||||
// ============ Cashback ============
|
||||
|
||||
model CashbackRule {
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(128)
|
||||
targetType String @map("target_type") @db.VarChar(32)
|
||||
targetId BigInt? @map("target_id")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
marketType String? @map("market_type") @db.VarChar(64)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(128)
|
||||
targetType String @map("target_type") @db.VarChar(32)
|
||||
targetId BigInt? @map("target_id")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
marketType String? @map("market_type") @db.VarChar(64)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("cashback_rules")
|
||||
}
|
||||
|
||||
model CashbackBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
|
||||
totalBetCount Int @default(0) @map("total_bet_count")
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
|
||||
totalBetCount Int @default(0) @map("total_bet_count")
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
items CashbackItem[]
|
||||
bets CashbackBet[]
|
||||
items CashbackItem[]
|
||||
bets CashbackBet[]
|
||||
|
||||
@@map("cashback_batches")
|
||||
}
|
||||
|
||||
model CashbackItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
betCount Int @default(0) @map("bet_count")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
betCount Int @default(0) @map("bet_count")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id])
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@map("cashback_items")
|
||||
}
|
||||
|
||||
/** 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次) */
|
||||
/**
|
||||
* 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次)
|
||||
*/
|
||||
model CashbackBet {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
@@ -576,8 +672,8 @@ model CashbackBet {
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@ -612,7 +708,7 @@ model ContentTranslation {
|
||||
body String? @db.Text
|
||||
imageUrl String? @map("image_url") @db.VarChar(500)
|
||||
|
||||
content Content @relation(fields: [contentId], references: [id])
|
||||
content Content @relation(fields: [contentId], references: [id])
|
||||
|
||||
@@unique([contentId, locale])
|
||||
@@map("content_translations")
|
||||
@@ -649,20 +745,20 @@ model UploadedFile {
|
||||
// ============ Manual Deposit / Recharge ============
|
||||
|
||||
model PaymentMethod {
|
||||
id BigInt @id @default(autoincrement())
|
||||
methodType String @map("method_type") @db.VarChar(20)
|
||||
bankName String? @map("bank_name") @db.VarChar(128)
|
||||
accountHolder String? @map("account_holder") @db.VarChar(128)
|
||||
accountNumber String? @map("account_number") @db.VarChar(128)
|
||||
usdtAddress String? @map("usdt_address") @db.VarChar(256)
|
||||
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
|
||||
displayName String? @map("display_name") @db.VarChar(128)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
createdBy BigInt? @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
methodType String @map("method_type") @db.VarChar(20)
|
||||
bankName String? @map("bank_name") @db.VarChar(128)
|
||||
accountHolder String? @map("account_holder") @db.VarChar(128)
|
||||
accountNumber String? @map("account_number") @db.VarChar(128)
|
||||
usdtAddress String? @map("usdt_address") @db.VarChar(256)
|
||||
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
|
||||
displayName String? @map("display_name") @db.VarChar(128)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
showOnPlayer Boolean @default(true) @map("show_on_player")
|
||||
createdBy BigInt? @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
depositOrders DepositOrder[]
|
||||
|
||||
@@ -699,28 +795,28 @@ model DepositOrder {
|
||||
// ============ System Config & Audit ============
|
||||
|
||||
model SystemConfig {
|
||||
id BigInt @id @default(autoincrement())
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue String @map("config_value") @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue String @map("config_value") @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
operatorId BigInt? @map("operator_id")
|
||||
operatorType String @map("operator_type") @db.VarChar(20)
|
||||
action String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
targetType String? @map("target_type") @db.VarChar(32)
|
||||
targetId String? @map("target_id") @db.VarChar(64)
|
||||
beforeData String? @map("before_data") @db.Text
|
||||
afterData String? @map("after_data") @db.Text
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id BigInt @id @default(autoincrement())
|
||||
operatorId BigInt? @map("operator_id")
|
||||
operatorType String @map("operator_type") @db.VarChar(20)
|
||||
action String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
targetType String? @map("target_type") @db.VarChar(32)
|
||||
targetId String? @map("target_id") @db.VarChar(64)
|
||||
beforeData String? @map("before_data") @db.Text
|
||||
afterData String? @map("after_data") @db.Text
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([operatorId])
|
||||
@@index([module])
|
||||
|
||||
@@ -25,6 +25,62 @@ const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-C
|
||||
en: m[3],
|
||||
}));
|
||||
|
||||
const CODE_TO_FLAG_ISO = {
|
||||
ALG: 'dz',
|
||||
ARG: 'ar',
|
||||
AUS: 'au',
|
||||
AUT: 'at',
|
||||
BEL: 'be',
|
||||
BIH: 'ba',
|
||||
BRA: 'br',
|
||||
CAN: 'ca',
|
||||
CIV: 'ci',
|
||||
COD: 'cd',
|
||||
COL: 'co',
|
||||
CPV: 'cv',
|
||||
CRO: 'hr',
|
||||
CUW: 'cw',
|
||||
CZE: 'cz',
|
||||
ECU: 'ec',
|
||||
EGY: 'eg',
|
||||
ENG: 'gb-eng',
|
||||
ESP: 'es',
|
||||
FRA: 'fr',
|
||||
GER: 'de',
|
||||
GHA: 'gh',
|
||||
HAI: 'ht',
|
||||
IRN: 'ir',
|
||||
IRQ: 'iq',
|
||||
JOR: 'jo',
|
||||
JPN: 'jp',
|
||||
KOR: 'kr',
|
||||
KSA: 'sa',
|
||||
MAR: 'ma',
|
||||
MEX: 'mx',
|
||||
NED: 'nl',
|
||||
NOR: 'no',
|
||||
NZL: 'nz',
|
||||
PAN: 'pa',
|
||||
PAR: 'py',
|
||||
POR: 'pt',
|
||||
QAT: 'qa',
|
||||
RSA: 'za',
|
||||
SCO: 'gb-sct',
|
||||
SEN: 'sn',
|
||||
SUI: 'ch',
|
||||
SWE: 'se',
|
||||
TUN: 'tn',
|
||||
TUR: 'tr',
|
||||
URU: 'uy',
|
||||
USA: 'us',
|
||||
UZB: 'uz',
|
||||
};
|
||||
|
||||
function flagUrlForCode(code) {
|
||||
const iso = CODE_TO_FLAG_ISO[code];
|
||||
return iso ? `https://flagcdn.com/${iso}.svg` : '';
|
||||
}
|
||||
|
||||
function pickNames(names) {
|
||||
return {
|
||||
zh: names?.zh ?? null,
|
||||
@@ -37,11 +93,12 @@ function pickNames(names) {
|
||||
}
|
||||
|
||||
function pickTeam(team) {
|
||||
const code = resolveCanonicalCode(team);
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
names: pickNames(team.names),
|
||||
image: team.image ?? '',
|
||||
image: code ? flagUrlForCode(code) : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -65,7 +122,7 @@ function slimMatch(m) {
|
||||
},
|
||||
homeTeam: pickTeam(m.homeTeam),
|
||||
awayTeam: pickTeam(m.awayTeam),
|
||||
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
|
||||
status: { state: m.status.state, isHot: 0 },
|
||||
venue: {
|
||||
names: pickNames(m.venue?.names),
|
||||
city: pickNames(m.venue?.city),
|
||||
@@ -76,7 +133,6 @@ function slimMatch(m) {
|
||||
}
|
||||
|
||||
function resolveCanonicalCode(team) {
|
||||
if (team.id == null) return null;
|
||||
const en = (team.name || team.names?.en || '').toLowerCase();
|
||||
const zh = team.names?.zh || '';
|
||||
const hit = outrightTeams.find(
|
||||
@@ -104,13 +160,16 @@ for (const m of raw.matches || []) {
|
||||
}
|
||||
|
||||
const zhiboToCode = {};
|
||||
const logoByCode = {};
|
||||
const unmatched = [];
|
||||
const missingFlagCodes = new Set();
|
||||
for (const [id, team] of teamById) {
|
||||
const code = resolveCanonicalCode(team);
|
||||
if (code) {
|
||||
zhiboToCode[id] = code;
|
||||
if (team.image) logoByCode[code] = team.image;
|
||||
const flagUrl = flagUrlForCode(code);
|
||||
if (!flagUrl) {
|
||||
missingFlagCodes.add(code);
|
||||
}
|
||||
} else {
|
||||
unmatched.push({ id, name: team.name });
|
||||
}
|
||||
@@ -125,9 +184,9 @@ const mapLines = Object.entries(zhiboToCode)
|
||||
.map(([id, code]) => ` ${id}: '${code}',`)
|
||||
.join('\n');
|
||||
|
||||
const logoLines = Object.entries(logoByCode)
|
||||
const flagIsoLines = Object.entries(CODE_TO_FLAG_ISO)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
|
||||
.map(([code, iso]) => ` ${code}: '${iso}',`)
|
||||
.join('\n');
|
||||
|
||||
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
|
||||
@@ -135,10 +194,17 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
${mapLines}
|
||||
};
|
||||
|
||||
/** zhibo 球队 logo(seed 时写入 teams.logo_url) */
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
|
||||
${logoLines}
|
||||
/** WC2026 默认国家/地区国旗(FlagCDN,seed 时写入 teams.logo_url) */
|
||||
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
|
||||
${flagIsoLines}
|
||||
};
|
||||
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
|
||||
code,
|
||||
\`https://flagcdn.com/\${iso}.svg\`,
|
||||
]),
|
||||
);
|
||||
`;
|
||||
|
||||
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
|
||||
@@ -150,3 +216,7 @@ if (unmatched.length) {
|
||||
console.warn('Unmatched teams:', unmatched);
|
||||
process.exit(1);
|
||||
}
|
||||
if (missingFlagCodes.size) {
|
||||
console.warn('Missing FlagCDN ISO mapping:', [...missingFlagCodes].sort());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,14 @@ type AdminUploadUser = {
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
function parseMatchStartTime(value: string): Date {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw appBadRequest('MATCH_START_TIME_INVALID');
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function uploadCategory(value?: string): UploadCategory {
|
||||
const category = (value || 'contents').trim();
|
||||
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
|
||||
@@ -503,9 +511,6 @@ class CreatePlatformMatchDto {
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
correctScoreEnabled?: boolean;
|
||||
}
|
||||
|
||||
class UpdatePlatformMatchDto {
|
||||
@@ -560,9 +565,6 @@ class UpdatePlatformMatchDto {
|
||||
@IsString()
|
||||
awayTeamLogoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
correctScoreEnabled?: boolean;
|
||||
}
|
||||
|
||||
class ReopenMatchDto {
|
||||
@@ -591,6 +593,12 @@ class UpdateMarketDto {
|
||||
@IsString()
|
||||
promoLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
promoLabelI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
@@ -598,6 +606,10 @@ class UpdateMarketDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lineValue?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showOnPlayer?: boolean;
|
||||
}
|
||||
|
||||
class UpdateSelectionDto {
|
||||
@@ -605,6 +617,9 @@ class UpdateSelectionDto {
|
||||
@IsString()
|
||||
selectionName?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
@@ -637,6 +652,38 @@ class ScoreDto {
|
||||
@IsNumber()
|
||||
ftAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCards?: number;
|
||||
|
||||
/** 冠军盘结算:获胜球队 ID */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -674,6 +721,136 @@ class MarketTemplatesDto {
|
||||
marketTypes!: string[];
|
||||
}
|
||||
|
||||
class MarketDraftSelectionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
selectionCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
selectionName?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1.01)
|
||||
odds!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
class MarketDraftDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
marketType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
marketKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lineKey?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
period?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
lineValue?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
paramsJson?: Record<string, unknown> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowSingle?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowParlay?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showOnPlayer?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
promoLabelI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
selections?: MarketDraftSelectionDto[];
|
||||
}
|
||||
|
||||
class MarketTemplateSaveDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
nameI18n?: Record<string, string> | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
items?: MarketDraftDto[];
|
||||
}
|
||||
|
||||
class ApplyMarketTemplateDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateId?: string;
|
||||
}
|
||||
|
||||
class BulkMatchMarketsDto {
|
||||
@IsArray()
|
||||
markets!: MarketDraftDto[];
|
||||
}
|
||||
|
||||
class UpdateOddsDto {
|
||||
@IsNumber()
|
||||
odds!: number;
|
||||
@@ -892,6 +1069,38 @@ class ResettlePreviewDto {
|
||||
@IsNumber()
|
||||
ftAway?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCorners?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayYellowCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayRedCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
homeCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
awayCards?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
@@ -1665,7 +1874,7 @@ export class AdminController {
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
startTime: parseMatchStartTime(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
@@ -1673,7 +1882,6 @@ export class AdminController {
|
||||
groupName: dto.groupName,
|
||||
homeTeamLogoUrl: dto.homeTeamLogoUrl,
|
||||
awayTeamLogoUrl: dto.awayTeamLogoUrl,
|
||||
correctScoreEnabled: dto.correctScoreEnabled,
|
||||
updatedBy: operatorId,
|
||||
});
|
||||
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
|
||||
@@ -1725,9 +1933,8 @@ export class AdminController {
|
||||
awayTeamEn: dto.awayTeamEn,
|
||||
awayTeamZh: dto.awayTeamZh,
|
||||
awayTeamMs: dto.awayTeamMs,
|
||||
startTime: new Date(dto.startTime),
|
||||
startTime: parseMatchStartTime(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
correctScoreEnabled: dto.correctScoreEnabled,
|
||||
displayOrder: dto.displayOrder,
|
||||
matchName: dto.matchName,
|
||||
stage: dto.stage,
|
||||
@@ -1775,7 +1982,7 @@ export class AdminController {
|
||||
@Post('matches/:id/reopen')
|
||||
@RequirePermissions(P.matches)
|
||||
async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) {
|
||||
const startTime = dto.startTime ? new Date(dto.startTime) : undefined;
|
||||
const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
|
||||
const match = await this.matches.reopenMatch(BigInt(id), startTime);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
@@ -1783,11 +1990,58 @@ export class AdminController {
|
||||
@Post('matches/:id/cancel')
|
||||
@RequirePermissions(P.matches)
|
||||
async cancelMatch(@Param('id') id: string) {
|
||||
await this.matches.cancelMatch(BigInt(id));
|
||||
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
||||
const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
|
||||
return jsonResponse(voided);
|
||||
}
|
||||
|
||||
@Get('market-definitions')
|
||||
@RequirePermissions(P.matches)
|
||||
async listMarketDefinitions() {
|
||||
return jsonResponse(this.markets.listMarketDefinitions());
|
||||
}
|
||||
|
||||
@Get('market-templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async listMarketTemplates() {
|
||||
const templates = await this.markets.listTemplates();
|
||||
return jsonResponse(templates);
|
||||
}
|
||||
|
||||
@Post('market-templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async createMarketTemplate(@Body() dto: MarketTemplateSaveDto) {
|
||||
const template = await this.markets.createTemplate(dto);
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Get('market-templates/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async getMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.getTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Put('market-templates/:id')
|
||||
@RequirePermissions(P.matches)
|
||||
async updateMarketTemplate(@Param('id') id: string, @Body() dto: MarketTemplateSaveDto) {
|
||||
const template = await this.markets.updateTemplate(BigInt(id), dto);
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('market-templates/:id/duplicate')
|
||||
@RequirePermissions(P.matches)
|
||||
async duplicateMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.duplicateTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('market-templates/:id/set-default')
|
||||
@RequirePermissions(P.matches)
|
||||
async setDefaultMarketTemplate(@Param('id') id: string) {
|
||||
const template = await this.markets.setDefaultTemplate(BigInt(id));
|
||||
return jsonResponse(template);
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/templates')
|
||||
@RequirePermissions(P.matches)
|
||||
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
||||
@@ -1795,6 +2049,27 @@ export class AdminController {
|
||||
return jsonResponse(markets);
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/apply-template')
|
||||
@RequirePermissions(P.matches)
|
||||
async applyMarketTemplate(@Param('id') id: string, @Body() dto: ApplyMarketTemplateDto) {
|
||||
const result = await this.markets.applyTemplateToMatch(
|
||||
BigInt(id),
|
||||
dto.templateId ? BigInt(dto.templateId) : null,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Put('matches/:id/markets/bulk')
|
||||
@RequirePermissions(P.matches)
|
||||
async bulkSaveMatchMarkets(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BulkMatchMarketsDto,
|
||||
) {
|
||||
const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Put('matches/:id/odds')
|
||||
@RequirePermissions(P.matches)
|
||||
async batchUpdateMatchOdds(
|
||||
@@ -1815,8 +2090,11 @@ export class AdminController {
|
||||
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
|
||||
const market = await this.markets.updateMarket(BigInt(id), {
|
||||
promoLabel: dto.promoLabel,
|
||||
promoLabelI18n: dto.promoLabelI18n,
|
||||
nameI18n: dto.nameI18n,
|
||||
status: dto.status,
|
||||
lineValue: dto.lineValue,
|
||||
showOnPlayer: dto.showOnPlayer,
|
||||
});
|
||||
return jsonResponse(market);
|
||||
}
|
||||
@@ -1832,6 +2110,7 @@ export class AdminController {
|
||||
BigInt(id),
|
||||
{
|
||||
selectionName: dto.selectionName,
|
||||
nameI18n: dto.nameI18n,
|
||||
odds: dto.odds,
|
||||
status: dto.status,
|
||||
},
|
||||
@@ -2057,6 +2336,16 @@ export class AdminController {
|
||||
dto.ftAway ?? 0,
|
||||
operatorId,
|
||||
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
{
|
||||
homeCorners: dto.homeCorners ?? null,
|
||||
awayCorners: dto.awayCorners ?? null,
|
||||
homeYellowCards: dto.homeYellowCards ?? null,
|
||||
awayYellowCards: dto.awayYellowCards ?? null,
|
||||
homeRedCards: dto.homeRedCards ?? null,
|
||||
awayRedCards: dto.awayRedCards ?? null,
|
||||
homeCards: dto.homeCards ?? null,
|
||||
awayCards: dto.awayCards ?? null,
|
||||
},
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
@@ -2074,6 +2363,14 @@ export class AdminController {
|
||||
htAway: dto?.htAway,
|
||||
ftHome: dto?.ftHome,
|
||||
ftAway: dto?.ftAway,
|
||||
homeCorners: dto?.homeCorners,
|
||||
awayCorners: dto?.awayCorners,
|
||||
homeYellowCards: dto?.homeYellowCards,
|
||||
awayYellowCards: dto?.awayYellowCards,
|
||||
homeRedCards: dto?.homeRedCards,
|
||||
awayRedCards: dto?.awayRedCards,
|
||||
homeCards: dto?.homeCards,
|
||||
awayCards: dto?.awayCards,
|
||||
winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
|
||||
page: dto?.page ? Math.max(1, dto.page) : 1,
|
||||
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
|
||||
@@ -2123,6 +2420,14 @@ export class AdminController {
|
||||
htAway: dto.htAway ?? 0,
|
||||
ftHome: dto.ftHome ?? 0,
|
||||
ftAway: dto.ftAway ?? 0,
|
||||
homeCorners: dto.homeCorners ?? null,
|
||||
awayCorners: dto.awayCorners ?? null,
|
||||
homeYellowCards: dto.homeYellowCards ?? null,
|
||||
awayYellowCards: dto.awayYellowCards ?? null,
|
||||
homeRedCards: dto.homeRedCards ?? null,
|
||||
awayRedCards: dto.awayRedCards ?? null,
|
||||
homeCards: dto.homeCards ?? null,
|
||||
awayCards: dto.awayCards ?? null,
|
||||
},
|
||||
operatorId,
|
||||
dto.reason,
|
||||
|
||||
@@ -93,6 +93,28 @@ class UpdateProfileDto {
|
||||
username?: string;
|
||||
}
|
||||
|
||||
function safeTimeZone(input?: string): string {
|
||||
const value = input?.trim();
|
||||
if (!value) return 'UTC';
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date());
|
||||
return value;
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
|
||||
function dayKeyInTimeZone(date: Date, timeZone: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const map = new Map(parts.map((part) => [part.type, part.value]));
|
||||
return `${map.get('year')}-${map.get('month')}-${map.get('day')}`;
|
||||
}
|
||||
|
||||
@ApiTags('Player')
|
||||
@Controller('player')
|
||||
@UseGuards(JwtAuthGuard, PlayerGuard)
|
||||
@@ -160,6 +182,7 @@ export class PlayerController {
|
||||
async home(
|
||||
@CurrentUser('locale') userLocale: string | undefined,
|
||||
@Headers('x-locale') headerLocale?: string,
|
||||
@Headers('x-time-zone') headerTimeZone?: string,
|
||||
) {
|
||||
const locale = userLocale || headerLocale || 'zh-CN';
|
||||
const [banners, announcements, allMatches] = await Promise.all([
|
||||
@@ -167,19 +190,13 @@ export class PlayerController {
|
||||
this.content.listActiveAnnouncements(locale),
|
||||
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
|
||||
]);
|
||||
const now = new Date();
|
||||
const isKickoffToday = (startTime: string) => {
|
||||
const d = new Date(startTime);
|
||||
return (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
);
|
||||
};
|
||||
const timeZone = safeTimeZone(headerTimeZone);
|
||||
const todayKey = dayKeyInTimeZone(new Date(), timeZone);
|
||||
const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot);
|
||||
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) =>
|
||||
isKickoffToday(m.startTime),
|
||||
);
|
||||
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) => {
|
||||
const kickoff = new Date(m.startTime);
|
||||
return !Number.isNaN(kickoff.getTime()) && dayKeyInTimeZone(kickoff, timeZone) === todayKey;
|
||||
});
|
||||
return jsonResponse({
|
||||
banners,
|
||||
announcements,
|
||||
|
||||
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal file
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('AgentCreditService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
agentClosure: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
findUnique: jest.fn(),
|
||||
aggregate: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
deposit: jest.fn(),
|
||||
withdraw: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AgentCreditService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AgentCreditService(prisma as never, funds as never);
|
||||
tx.walletTransaction.findUnique.mockResolvedValue(null);
|
||||
tx.user.findFirst.mockResolvedValue({ id: 10n, parentId: 1n, userType: 'PLAYER' });
|
||||
tx.user.findMany.mockResolvedValue([]);
|
||||
tx.agentClosure.findMany.mockResolvedValue([{ ancestorId: 1n, depth: 0 }]);
|
||||
tx.agentProfile.findMany.mockResolvedValue([]);
|
||||
tx.agentProfile.update.mockResolvedValue({});
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
user_id: 1n,
|
||||
parent_agent_id: null,
|
||||
credit_limit: new Decimal(1000),
|
||||
used_credit: new Decimal(0),
|
||||
max_single_deposit: null,
|
||||
max_daily_deposit: new Decimal(100),
|
||||
},
|
||||
]);
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: 1n,
|
||||
parentAgentId: null,
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(0),
|
||||
maxSingleDeposit: null,
|
||||
maxDailyDeposit: new Decimal(100),
|
||||
});
|
||||
tx.walletTransaction.aggregate.mockResolvedValue({ _sum: { amount: new Decimal(80) } });
|
||||
});
|
||||
|
||||
it('counts prior AGENT_DEPOSIT postings when enforcing the daily top-up limit', async () => {
|
||||
await expect(
|
||||
service.depositToPlayer(1n, 10n, 30, 'REQ-DAILY-LIMIT'),
|
||||
).rejects.toMatchObject(expectAppError('AGENT_DAILY_TOPUP_LIMIT'));
|
||||
|
||||
expect(tx.walletTransaction.aggregate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
operatorId: 1n,
|
||||
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(funds.deposit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recalculates the changed agent and every ancestor in closure order', async () => {
|
||||
tx.agentClosure.findMany.mockResolvedValue([
|
||||
{ ancestorId: 3n, depth: 0 },
|
||||
{ ancestorId: 2n, depth: 1 },
|
||||
{ ancestorId: 1n, depth: 2 },
|
||||
]);
|
||||
|
||||
await service.recalculateUsedCredit(3n);
|
||||
|
||||
expect(tx.agentProfile.update.mock.calls.map(([arg]) => arg.where.userId)).toEqual([
|
||||
3n,
|
||||
2n,
|
||||
1n,
|
||||
]);
|
||||
});
|
||||
});
|
||||
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal file
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
const AGENT_DAILY_DEPOSIT_TX_TYPES = ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] as const;
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentCreditService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private funds: FundsPostingService,
|
||||
) {}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
return { ...profile, availableCredit: available };
|
||||
}
|
||||
|
||||
async recalculateUsedCredit(agentId: bigint, tx?: TxClient) {
|
||||
const client = tx ?? this.prisma;
|
||||
const cascadeIds = await this.getCreditCascadeIds(agentId, client);
|
||||
let requestedUsedCredit = new Decimal(0);
|
||||
|
||||
for (const id of cascadeIds) {
|
||||
const usedCredit = await this.recalculateOneUsedCredit(id, client);
|
||||
if (id === agentId) requestedUsedCredit = usedCredit;
|
||||
}
|
||||
|
||||
return requestedUsedCredit;
|
||||
}
|
||||
|
||||
private async recalculateOneUsedCredit(agentId: bigint, client: PrismaClientLike) {
|
||||
const directPlayers = await client.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
|
||||
let directLiability = new Decimal(0);
|
||||
for (const p of directPlayers) {
|
||||
if (p.wallet) {
|
||||
directLiability = directLiability
|
||||
.add(p.wallet.availableBalance)
|
||||
.add(p.wallet.frozenBalance);
|
||||
}
|
||||
}
|
||||
|
||||
const childAgents = await client.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
});
|
||||
|
||||
let childExposure = new Decimal(0);
|
||||
for (const child of childAgents) {
|
||||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||||
childExposure = childExposure.add(exposure);
|
||||
}
|
||||
|
||||
const usedCredit = directLiability.add(childExposure);
|
||||
|
||||
await client.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: {
|
||||
usedCredit,
|
||||
directPlayerLiability: directLiability,
|
||||
childAgentExposure: childExposure,
|
||||
},
|
||||
});
|
||||
|
||||
return usedCredit;
|
||||
}
|
||||
|
||||
private async getCreditCascadeIds(agentId: bigint, client: PrismaClientLike) {
|
||||
const closureRows = await client.agentClosure.findMany({
|
||||
where: { descendantId: agentId },
|
||||
select: { ancestorId: true, depth: true },
|
||||
orderBy: { depth: 'asc' },
|
||||
});
|
||||
if (closureRows.length > 0) {
|
||||
return closureRows.map((row) => row.ancestorId);
|
||||
}
|
||||
|
||||
const ids: bigint[] = [];
|
||||
let current: bigint | null = agentId;
|
||||
while (current) {
|
||||
ids.push(current);
|
||||
const profile: { parentAgentId: bigint | null } | null = await client.agentProfile.findUnique({
|
||||
where: { userId: current },
|
||||
select: { parentAgentId: true },
|
||||
});
|
||||
current = profile?.parentAgentId ?? null;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async adjustCredit(
|
||||
agentId: bigint,
|
||||
amount: Decimal | number,
|
||||
operatorId: bigint,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
|
||||
|
||||
const creditBefore = profile.creditLimit;
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { creditLimit: creditAfter },
|
||||
});
|
||||
|
||||
await tx.agentCreditTransaction.create({
|
||||
data: {
|
||||
agentId,
|
||||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||||
amount: amt,
|
||||
creditBefore,
|
||||
creditAfter,
|
||||
operatorId,
|
||||
requestId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { creditAfter };
|
||||
}
|
||||
|
||||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!user?.parentId) return;
|
||||
|
||||
await this.recalculateUsedCredit(user.parentId);
|
||||
const profile = await this.getProfile(user.parentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
}
|
||||
|
||||
async adminDepositToPlayer(
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
const result = await this.funds.deposit({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark: remark ?? '管理员上分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'ADMIN_DEPOSIT',
|
||||
businessKey: requestId ? `admin-deposit:${operatorId}:${requestId}` : undefined,
|
||||
});
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async adminWithdrawFromPlayer(
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const result = await this.funds.withdraw({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark: remark ?? '管理员下分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'ADMIN_WITHDRAW',
|
||||
businessKey: requestId ? `admin-withdraw:${operatorId}:${requestId}` : undefined,
|
||||
});
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async getPlayerTransferContext(
|
||||
playerId: bigint,
|
||||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||||
) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
|
||||
if (options.actingAgentId) {
|
||||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||||
}
|
||||
|
||||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||||
|
||||
let credit: Record<string, unknown> | null = null;
|
||||
if (creditAgentId) {
|
||||
await this.recalculateUsedCredit(creditAgentId);
|
||||
const profile = await this.getProfile(creditAgentId);
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
let dailyDepositUsed: string | null = null;
|
||||
if (!options.forAdmin) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||||
}
|
||||
|
||||
const agentUser = await this.prisma.user.findUnique({
|
||||
where: { id: creditAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
credit = {
|
||||
agentId: creditAgentId.toString(),
|
||||
agentUsername: agentUser?.username ?? '',
|
||||
agentLevel: profile.level,
|
||||
creditLimit: dec(profile.creditLimit),
|
||||
usedCredit: dec(profile.usedCredit),
|
||||
availableCredit: dec(profile.availableCredit),
|
||||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||||
dailyDepositUsed,
|
||||
appliesDepositLimits: !options.forAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id.toString(),
|
||||
username: player.username,
|
||||
availableBalance: dec(player.wallet?.availableBalance),
|
||||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||||
},
|
||||
credit,
|
||||
};
|
||||
}
|
||||
|
||||
async depositToPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const businessKey = `agent-deposit:${agentId}:${requestId}`;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.walletTransaction.findUnique({ where: { businessKey } });
|
||||
if (existing) return;
|
||||
|
||||
await this.requireDirectPlayer(agentId, playerId, tx);
|
||||
await this.recalculateUsedCredit(agentId, tx);
|
||||
|
||||
const profile = await this.lockAgentProfile(tx, agentId);
|
||||
const available = new Decimal(profile.credit_limit).sub(profile.used_credit);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
|
||||
await this.assertAgentDepositLimits(agentId, amt, tx);
|
||||
|
||||
await this.funds.deposit({
|
||||
userId: playerId,
|
||||
amount: amt,
|
||||
operatorId: agentId,
|
||||
remark: remark ?? '代理上分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'AGENT_DEPOSIT',
|
||||
businessKey,
|
||||
tx,
|
||||
});
|
||||
await this.recalculateUsedCredit(agentId, tx);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async withdrawFromPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.funds.withdraw({
|
||||
userId: playerId,
|
||||
amount,
|
||||
operatorId: agentId,
|
||||
remark: remark ?? '代理下分',
|
||||
referenceId: requestId,
|
||||
transactionType: 'AGENT_WITHDRAW',
|
||||
businessKey: `agent-withdraw:${agentId}:${requestId}`,
|
||||
});
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async requireDirectPlayer(agentId: bigint, playerId: bigint, tx?: TxClient) {
|
||||
const client = tx ?? this.prisma;
|
||||
const player = await client.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { auth: true, wallet: true, preferences: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
if (player.parentId !== agentId) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
private resolveEffectiveDepositLimits(
|
||||
profile: {
|
||||
maxSingleDeposit: Decimal | null;
|
||||
maxDailyDeposit: Decimal | null;
|
||||
},
|
||||
parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
|
||||
) {
|
||||
let maxSingleDeposit = profile.maxSingleDeposit;
|
||||
let maxDailyDeposit = profile.maxDailyDeposit;
|
||||
|
||||
if (parent) {
|
||||
if (parent.maxSingleDeposit != null) {
|
||||
maxSingleDeposit =
|
||||
maxSingleDeposit != null
|
||||
? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
|
||||
: parent.maxSingleDeposit;
|
||||
}
|
||||
if (parent.maxDailyDeposit != null) {
|
||||
maxDailyDeposit =
|
||||
maxDailyDeposit != null
|
||||
? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
|
||||
: parent.maxDailyDeposit;
|
||||
}
|
||||
}
|
||||
|
||||
return { maxSingleDeposit, maxDailyDeposit };
|
||||
}
|
||||
|
||||
private async assertAgentDepositLimits(
|
||||
creditAgentId: bigint,
|
||||
amount: Decimal,
|
||||
tx?: TxClient,
|
||||
) {
|
||||
const client = tx ?? this.prisma;
|
||||
const profile = await client.agentProfile.findUnique({
|
||||
where: { userId: creditAgentId },
|
||||
});
|
||||
if (!profile) return;
|
||||
|
||||
const parent = profile.parentAgentId
|
||||
? await client.agentProfile.findUnique({
|
||||
where: { userId: profile.parentAgentId },
|
||||
})
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
|
||||
throw appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
|
||||
}
|
||||
|
||||
if (maxDailyDeposit) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await client.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||||
if (dailyTotal.gt(maxDailyDeposit)) {
|
||||
throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async lockAgentProfile(tx: TxClient, agentId: bigint) {
|
||||
const profiles = await tx.$queryRaw<
|
||||
Array<{
|
||||
user_id: bigint;
|
||||
parent_agent_id: bigint | null;
|
||||
credit_limit: Decimal;
|
||||
used_credit: Decimal;
|
||||
max_single_deposit: Decimal | null;
|
||||
max_daily_deposit: Decimal | null;
|
||||
}>
|
||||
>`
|
||||
SELECT user_id, parent_agent_id, credit_limit, used_credit, max_single_deposit, max_daily_deposit
|
||||
FROM agent_profiles
|
||||
WHERE user_id = ${agentId}
|
||||
FOR UPDATE
|
||||
`;
|
||||
if (!profiles.length) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
return profiles[0];
|
||||
}
|
||||
|
||||
private async assertChildCreditWithinParent(
|
||||
parentAgentId: bigint,
|
||||
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
|
||||
creditAfter: Decimal,
|
||||
) {
|
||||
const parent = await this.getProfile(parentAgentId);
|
||||
const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
|
||||
const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
|
||||
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
|
||||
const exposureDelta = newExposure.sub(oldExposure);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
if (creditAfter.gt(parent.creditLimit)) throw appBadRequest('CREDIT_EXCEEDS_PARENT');
|
||||
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
}
|
||||
}
|
||||
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal file
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { AgentNetworkService } from './agent-network.service';
|
||||
|
||||
describe('AgentNetworkService', () => {
|
||||
let prisma: {
|
||||
agentClosure: { findMany: jest.Mock };
|
||||
agentProfile: { findUnique: jest.Mock; findMany: jest.Mock };
|
||||
user: { findMany: jest.Mock; findFirst: jest.Mock };
|
||||
};
|
||||
let service: AgentNetworkService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
agentClosure: { findMany: jest.fn() },
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
};
|
||||
service = new AgentNetworkService(prisma as never);
|
||||
});
|
||||
|
||||
it('resolves portal scope from AgentClosure rows', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 2 });
|
||||
prisma.agentClosure.findMany.mockResolvedValue([
|
||||
{ descendantId: 1n, depth: 0 },
|
||||
{ descendantId: 2n, depth: 1 },
|
||||
{ descendantId: 4n, depth: 1 },
|
||||
{ descendantId: 3n, depth: 2 },
|
||||
]);
|
||||
|
||||
const scope = await service.resolveScope(1n);
|
||||
|
||||
expect(scope.rootAgentId).toBe(1n);
|
||||
expect(scope.rootLevel).toBe(2);
|
||||
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
|
||||
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
|
||||
expect(scope.directChildIds).toEqual([2n, 4n]);
|
||||
expect(scope.subtreeIdSet.has('3')).toBe(true);
|
||||
expect(prisma.agentProfile.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to AgentProfile adjacency when closure rows are absent', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 1 });
|
||||
prisma.agentClosure.findMany.mockResolvedValue([]);
|
||||
prisma.agentProfile.findMany.mockImplementation(({ where }: { where: { parentAgentId: bigint } }) => {
|
||||
const childrenByParent = new Map<bigint, Array<{ userId: bigint }>>([
|
||||
[1n, [{ userId: 2n }, { userId: 4n }]],
|
||||
[2n, [{ userId: 3n }]],
|
||||
[3n, []],
|
||||
[4n, []],
|
||||
]);
|
||||
return Promise.resolve(childrenByParent.get(where.parentAgentId) ?? []);
|
||||
});
|
||||
|
||||
const scope = await service.resolveScope(1n);
|
||||
|
||||
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
|
||||
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
|
||||
expect(scope.directChildIds).toEqual([2n, 4n]);
|
||||
});
|
||||
|
||||
it('rejects agent outside descendant scope', async () => {
|
||||
prisma.agentClosure.findMany.mockResolvedValue([
|
||||
{ descendantId: 1n, depth: 0 },
|
||||
{ descendantId: 2n, depth: 1 },
|
||||
]);
|
||||
|
||||
await expect(service.assertDescendantAgent(1n, 9n)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
|
||||
});
|
||||
expect(prisma.agentProfile.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns direct child profile for matching parent', async () => {
|
||||
const profile = { userId: 2n, parentAgentId: 1n };
|
||||
prisma.agentProfile.findUnique.mockResolvedValue(profile);
|
||||
|
||||
await expect(service.assertDirectChildAgent(1n, 2n)).resolves.toBe(profile);
|
||||
});
|
||||
|
||||
it('rejects non-direct child agent', async () => {
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 2n, parentAgentId: 9n });
|
||||
|
||||
await expect(service.assertDirectChildAgent(1n, 2n)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
337
apps/api/src/domains/agent/agent-network.service.ts
Normal file
337
apps/api/src/domains/agent/agent-network.service.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export interface AgentScope {
|
||||
rootAgentId: bigint;
|
||||
rootLevel: number;
|
||||
subtreeIds: bigint[];
|
||||
descendantIds: bigint[];
|
||||
directChildIds: bigint[];
|
||||
subtreeIdSet: Set<string>;
|
||||
}
|
||||
|
||||
type ClosureRow = {
|
||||
ancestorId?: bigint;
|
||||
descendantId: bigint;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
function uniqueBigints(values: bigint[]) {
|
||||
const seen = new Set<string>();
|
||||
const out: bigint[] = [];
|
||||
for (const value of values) {
|
||||
const key = value.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentNetworkService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
return this.buildAncestorChainMap(parentAgentIds, undefined);
|
||||
}
|
||||
|
||||
async buildScopedAncestorChainMap(
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
rootAgentId: bigint,
|
||||
) {
|
||||
return this.buildAncestorChainMap(parentAgentIds, rootAgentId);
|
||||
}
|
||||
|
||||
async resolveScope(rootAgentId: bigint): Promise<AgentScope> {
|
||||
const [profile, closureRows] = await Promise.all([
|
||||
this.prisma.agentProfile.findUnique({
|
||||
where: { userId: rootAgentId },
|
||||
}),
|
||||
this.findSubtreeClosureRows(rootAgentId),
|
||||
]);
|
||||
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
|
||||
const hasClosureRows = closureRows.length > 0;
|
||||
const subtreeIds = hasClosureRows
|
||||
? this.idsFromSubtreeClosure(rootAgentId, closureRows)
|
||||
: await this.getSubtreeAgentIdsFromProfiles(rootAgentId);
|
||||
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
|
||||
const directChildIds = hasClosureRows
|
||||
? uniqueBigints(
|
||||
closureRows
|
||||
.filter((row) => row.depth === 1)
|
||||
.map((row) => row.descendantId),
|
||||
)
|
||||
: await this.getDirectChildAgentIds(rootAgentId);
|
||||
|
||||
return {
|
||||
rootAgentId,
|
||||
rootLevel: profile.level,
|
||||
subtreeIds,
|
||||
descendantIds,
|
||||
directChildIds,
|
||||
subtreeIdSet: new Set(subtreeIds.map((id) => id.toString())),
|
||||
};
|
||||
}
|
||||
|
||||
assertAgentInScope(scope: AgentScope, agentId: bigint) {
|
||||
if (!scope.subtreeIdSet.has(agentId.toString())) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
}
|
||||
|
||||
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
|
||||
const scope = await this.resolveScope(rootAgentId);
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true, preferences: true, auth: true },
|
||||
});
|
||||
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
return this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
if (!subtreeIds.some((id) => id === targetAgentId)) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: targetAgentId },
|
||||
});
|
||||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||||
return profile;
|
||||
}
|
||||
|
||||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: subAgentId },
|
||||
});
|
||||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const closureRows = await this.findSubtreeClosureRows(agentId);
|
||||
if (closureRows.length > 0) {
|
||||
return this.idsFromSubtreeClosure(agentId, closureRows);
|
||||
}
|
||||
return this.getSubtreeAgentIdsFromProfiles(agentId);
|
||||
}
|
||||
|
||||
private async buildAncestorChainMap(
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
scopedRootAgentId: bigint | undefined,
|
||||
) {
|
||||
const agentIds = uniqueBigints(parentAgentIds.filter((id): id is bigint => id != null));
|
||||
const map = new Map<string, string[]>();
|
||||
if (agentIds.length === 0) return map;
|
||||
|
||||
const closureRows = await this.prisma.agentClosure.findMany({
|
||||
where: { descendantId: { in: agentIds } },
|
||||
select: { ancestorId: true, descendantId: true, depth: true },
|
||||
orderBy: [{ descendantId: 'asc' }, { depth: 'desc' }],
|
||||
});
|
||||
const rowsByDescendant = new Map<string, ClosureRow[]>();
|
||||
for (const row of closureRows) {
|
||||
const key = row.descendantId.toString();
|
||||
rowsByDescendant.set(key, [...(rowsByDescendant.get(key) ?? []), row]);
|
||||
}
|
||||
|
||||
const idsNeedingProfileFallback: bigint[] = [];
|
||||
const ancestorIds = new Set<bigint>();
|
||||
for (const id of agentIds) {
|
||||
const rows = rowsByDescendant.get(id.toString());
|
||||
if (!rows || rows.length === 0) {
|
||||
idsNeedingProfileFallback.push(id);
|
||||
continue;
|
||||
}
|
||||
const scopedRows = scopedRootAgentId
|
||||
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
|
||||
: rows;
|
||||
for (const row of scopedRows) {
|
||||
if (row.ancestorId) ancestorIds.add(row.ancestorId);
|
||||
}
|
||||
}
|
||||
|
||||
const users =
|
||||
ancestorIds.size > 0
|
||||
? await this.prisma.user.findMany({
|
||||
where: { id: { in: [...ancestorIds] } },
|
||||
select: { id: true, username: true },
|
||||
})
|
||||
: [];
|
||||
const usernameMap = new Map(users.map((u) => [u.id.toString(), u.username]));
|
||||
|
||||
for (const id of agentIds) {
|
||||
const rows = rowsByDescendant.get(id.toString());
|
||||
if (!rows || rows.length === 0) continue;
|
||||
|
||||
const scopedRows = scopedRootAgentId
|
||||
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
|
||||
: rows;
|
||||
const chain = scopedRows
|
||||
.map((row) => (row.ancestorId ? usernameMap.get(row.ancestorId.toString()) : undefined))
|
||||
.filter((username): username is string => Boolean(username));
|
||||
map.set(id.toString(), chain.length === scopedRows.length ? chain : []);
|
||||
}
|
||||
|
||||
if (idsNeedingProfileFallback.length > 0) {
|
||||
const fallbackMap = scopedRootAgentId
|
||||
? await this.buildScopedAncestorChainMapFromProfiles(idsNeedingProfileFallback, scopedRootAgentId)
|
||||
: await this.buildAncestorChainMapFromProfiles(idsNeedingProfileFallback);
|
||||
for (const [key, value] of fallbackMap) {
|
||||
map.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id && !map.has(id.toString())) {
|
||||
map.set(id.toString(), []);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private sliceRowsToScopedRoot(rows: ClosureRow[], rootAgentId: bigint) {
|
||||
const rootRow = rows.find((row) => row.ancestorId === rootAgentId);
|
||||
if (!rootRow) return [];
|
||||
return rows.filter((row) => row.depth <= rootRow.depth);
|
||||
}
|
||||
|
||||
private async buildAncestorChainMapFromProfiles(agentIds: bigint[]) {
|
||||
const cache = await this.loadAncestorProfileCache(agentIds, undefined);
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
const chain: string[] = [];
|
||||
let cur = startId ?? null;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) break;
|
||||
chain.unshift(hit.username);
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
|
||||
}
|
||||
|
||||
private async buildScopedAncestorChainMapFromProfiles(agentIds: bigint[], rootAgentId: bigint) {
|
||||
const rootKey = rootAgentId.toString();
|
||||
const cache = await this.loadAncestorProfileCache(agentIds, rootAgentId);
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
if (!startId) return [];
|
||||
const chain: string[] = [];
|
||||
let cur: bigint | null = startId;
|
||||
let reachedRoot = false;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) return [];
|
||||
chain.unshift(hit.username);
|
||||
if (cur.toString() === rootKey) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return reachedRoot ? chain : [];
|
||||
};
|
||||
|
||||
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
|
||||
}
|
||||
|
||||
private async loadAncestorProfileCache(agentIds: bigint[], stopBeforeParentOf?: bigint) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set(agentIds);
|
||||
if (stopBeforeParentOf) pending.add(stopBeforeParentOf);
|
||||
const stopKey = stopBeforeParentOf?.toString();
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (
|
||||
profile.parentAgentId &&
|
||||
profile.parentAgentId.toString() !== stopKey &&
|
||||
!cache.has(profile.parentAgentId.toString())
|
||||
) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cache;
|
||||
}
|
||||
|
||||
private async findSubtreeClosureRows(agentId: bigint) {
|
||||
return this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
select: { descendantId: true, depth: true },
|
||||
orderBy: [{ depth: 'asc' }, { descendantId: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
private idsFromSubtreeClosure(agentId: bigint, rows: ClosureRow[]) {
|
||||
return uniqueBigints([agentId, ...rows.map((row) => row.descendantId)]);
|
||||
}
|
||||
|
||||
private async getDirectChildAgentIds(agentId: bigint) {
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
select: { userId: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return children.map((child) => child.userId);
|
||||
}
|
||||
|
||||
private async getSubtreeAgentIdsFromProfiles(agentId: bigint) {
|
||||
const ids: bigint[] = [];
|
||||
const queue: bigint[] = [agentId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(current);
|
||||
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: current },
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const child of children) {
|
||||
queue.push(child.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { AgentNetworkService } from './agent-network.service';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AuthModule } from '../identity/auth.module';
|
||||
import { SystemConfigModule } from '../../shared/config/system-config.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AuthModule, SystemConfigModule],
|
||||
providers: [AgentsService],
|
||||
exports: [AgentsService],
|
||||
providers: [AgentsService, AgentNetworkService, AgentCreditService],
|
||||
exports: [AgentsService, AgentNetworkService, AgentCreditService],
|
||||
})
|
||||
export class AgentsModule {}
|
||||
|
||||
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal file
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { createPrismaMock } from '../../testing/prisma-mock';
|
||||
|
||||
describe('AgentsService', () => {
|
||||
const parentAgentId = 1n;
|
||||
const createdAgentId = 2n;
|
||||
|
||||
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 prisma = createPrismaMock(tx);
|
||||
const auth = {
|
||||
hashPassword: jest.fn(),
|
||||
};
|
||||
const systemConfig = {
|
||||
getAgentHierarchySettings: jest.fn(),
|
||||
};
|
||||
const network = {};
|
||||
const credit = {
|
||||
recalculateUsedCredit: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AgentsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AgentsService(
|
||||
prisma as never,
|
||||
auth as never,
|
||||
systemConfig as never,
|
||||
network as never,
|
||||
credit as never,
|
||||
);
|
||||
|
||||
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
|
||||
auth.hashPassword.mockResolvedValue('hashed-password');
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: parentAgentId,
|
||||
level: 1,
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(0),
|
||||
cashbackRate: new Decimal(10),
|
||||
maxSingleDeposit: null,
|
||||
maxDailyDeposit: null,
|
||||
});
|
||||
tx.user.create.mockResolvedValue({
|
||||
id: createdAgentId,
|
||||
username: 'agent-child',
|
||||
userType: 'AGENT',
|
||||
});
|
||||
tx.user.findUnique.mockResolvedValue(null);
|
||||
tx.user.update.mockResolvedValue({});
|
||||
tx.userInvite.findUnique.mockResolvedValue(null);
|
||||
tx.userInvite.create.mockResolvedValue({});
|
||||
tx.userAuth.create.mockResolvedValue({});
|
||||
tx.userPreference.create.mockResolvedValue({});
|
||||
tx.agentProfile.create.mockResolvedValue({});
|
||||
tx.agentClosure.create.mockResolvedValue({});
|
||||
tx.agentClosure.findMany.mockResolvedValue([
|
||||
{ ancestorId: parentAgentId, depth: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recalculates parent credit exposure after creating a child agent', async () => {
|
||||
await service.createAgent(99n, {
|
||||
username: 'agent-child',
|
||||
password: 'secret',
|
||||
level: 2,
|
||||
parentAgentId,
|
||||
creditLimit: 300,
|
||||
});
|
||||
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
|
||||
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { AuthService } from '../identity/auth.service';
|
||||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
@@ -10,6 +9,8 @@ import { generateBatchNo } from '../../shared/common/decorators';
|
||||
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
|
||||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||||
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
|
||||
import { AgentNetworkService, type AgentScope } from './agent-network.service';
|
||||
import { AgentCreditService } from './agent-credit.service';
|
||||
|
||||
function dec(v: Decimal | null | undefined) {
|
||||
return v?.toString() ?? '0';
|
||||
@@ -23,9 +24,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||||
export class AgentsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private auth: AuthService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private network: AgentNetworkService,
|
||||
private credit: AgentCreditService,
|
||||
) {}
|
||||
|
||||
async getMaxAgentLevel(): Promise<number> {
|
||||
@@ -39,52 +41,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set<bigint>();
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) pending.add(id);
|
||||
}
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (profile.parentAgentId && !cache.has(profile.parentAgentId.toString())) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
const chain: string[] = [];
|
||||
let cur = startId ?? null;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) break;
|
||||
chain.unshift(hit.username);
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return chain;
|
||||
};
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) map.set(id.toString(), build(id));
|
||||
}
|
||||
return map;
|
||||
return this.network.buildAgentAncestorChainMap(parentAgentIds);
|
||||
}
|
||||
|
||||
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
|
||||
@@ -142,100 +99,23 @@ export class AgentsService {
|
||||
parentAgentIds: (bigint | null | undefined)[],
|
||||
rootAgentId: bigint,
|
||||
) {
|
||||
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
|
||||
const pending = new Set<bigint>();
|
||||
const rootKey = rootAgentId.toString();
|
||||
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) pending.add(id);
|
||||
}
|
||||
pending.add(rootAgentId);
|
||||
|
||||
while (pending.size > 0) {
|
||||
const batch = [...pending];
|
||||
pending.clear();
|
||||
const profiles = await this.prisma.agentProfile.findMany({
|
||||
where: { userId: { in: batch } },
|
||||
select: {
|
||||
userId: true,
|
||||
parentAgentId: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
for (const profile of profiles) {
|
||||
cache.set(profile.userId.toString(), {
|
||||
username: profile.user.username,
|
||||
parentAgentId: profile.parentAgentId,
|
||||
});
|
||||
if (
|
||||
profile.parentAgentId &&
|
||||
profile.parentAgentId.toString() !== rootKey &&
|
||||
!cache.has(profile.parentAgentId.toString())
|
||||
) {
|
||||
pending.add(profile.parentAgentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const build = (startId: bigint | null | undefined): string[] => {
|
||||
if (!startId) return [];
|
||||
const chain: string[] = [];
|
||||
let cur: bigint | null = startId;
|
||||
let reachedRoot = false;
|
||||
while (cur) {
|
||||
const hit = cache.get(cur.toString());
|
||||
if (!hit) return [];
|
||||
chain.unshift(hit.username);
|
||||
if (cur.toString() === rootKey) {
|
||||
reachedRoot = true;
|
||||
break;
|
||||
}
|
||||
cur = hit.parentAgentId;
|
||||
}
|
||||
return reachedRoot ? chain : [];
|
||||
};
|
||||
|
||||
const map = new Map<string, string[]>();
|
||||
for (const id of parentAgentIds) {
|
||||
if (id) map.set(id.toString(), build(id));
|
||||
}
|
||||
return map;
|
||||
return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
|
||||
}
|
||||
|
||||
private async getAgentPortalScope(rootAgentId: bigint) {
|
||||
const profile = await this.getProfile(rootAgentId);
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
|
||||
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
|
||||
return {
|
||||
rootAgentId,
|
||||
rootLevel: profile.level,
|
||||
subtreeIds,
|
||||
descendantIds,
|
||||
subtreeIdSet,
|
||||
};
|
||||
private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
|
||||
return this.network.resolveScope(rootAgentId);
|
||||
}
|
||||
|
||||
private assertAgentInPortalSubtree(
|
||||
scope: { subtreeIdSet: Set<string> },
|
||||
scope: AgentScope,
|
||||
agentId: bigint,
|
||||
) {
|
||||
if (!scope.subtreeIdSet.has(agentId.toString())) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
this.network.assertAgentInScope(scope, agentId);
|
||||
}
|
||||
|
||||
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
|
||||
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
|
||||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true, preferences: true, auth: true },
|
||||
});
|
||||
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
|
||||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||||
}
|
||||
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
|
||||
return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
|
||||
}
|
||||
|
||||
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
|
||||
@@ -270,51 +150,11 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
return { ...profile, availableCredit: available };
|
||||
return this.credit.getProfile(agentId);
|
||||
}
|
||||
|
||||
async recalculateUsedCredit(agentId: bigint) {
|
||||
const directPlayers = await this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
|
||||
let directLiability = new Decimal(0);
|
||||
for (const p of directPlayers) {
|
||||
if (p.wallet) {
|
||||
directLiability = directLiability
|
||||
.add(p.wallet.availableBalance)
|
||||
.add(p.wallet.frozenBalance);
|
||||
}
|
||||
}
|
||||
|
||||
const childAgents = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
});
|
||||
|
||||
let childExposure = new Decimal(0);
|
||||
for (const child of childAgents) {
|
||||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||||
childExposure = childExposure.add(exposure);
|
||||
}
|
||||
|
||||
const usedCredit = directLiability.add(childExposure);
|
||||
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: {
|
||||
usedCredit,
|
||||
directPlayerLiability: directLiability,
|
||||
childAgentExposure: childExposure,
|
||||
},
|
||||
});
|
||||
|
||||
return usedCredit;
|
||||
return this.credit.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
async adjustCredit(
|
||||
@@ -324,45 +164,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
|
||||
|
||||
const creditBefore = profile.creditLimit;
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { creditLimit: creditAfter },
|
||||
});
|
||||
|
||||
await tx.agentCreditTransaction.create({
|
||||
data: {
|
||||
agentId,
|
||||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||||
amount: amt,
|
||||
creditBefore,
|
||||
creditAfter,
|
||||
operatorId,
|
||||
requestId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.recalculateUsedCredit(profile.parentAgentId);
|
||||
}
|
||||
|
||||
return { creditAfter };
|
||||
return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
|
||||
}
|
||||
|
||||
/** 代理只能操作直属玩家(parentId === 当前代理) */
|
||||
@@ -462,19 +264,7 @@ export class AgentsService {
|
||||
|
||||
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit) */
|
||||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (!user?.parentId) return;
|
||||
|
||||
await this.recalculateUsedCredit(user.parentId);
|
||||
const profile = await this.getProfile(user.parentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
}
|
||||
|
||||
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
|
||||
@@ -485,23 +275,7 @@ export class AgentsService {
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||||
const result = await this.wallet.deposit(
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark ?? '管理员上分',
|
||||
requestId,
|
||||
'ADMIN_DEPOSIT',
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
|
||||
}
|
||||
|
||||
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
|
||||
@@ -512,22 +286,7 @@ export class AgentsService {
|
||||
remark?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const result = await this.wallet.withdraw(
|
||||
playerId,
|
||||
amount,
|
||||
operatorId,
|
||||
remark ?? '管理员下分',
|
||||
requestId,
|
||||
'ADMIN_WITHDRAW',
|
||||
);
|
||||
const player = await this.prisma.user.findUnique({
|
||||
where: { id: playerId },
|
||||
select: { parentId: true },
|
||||
});
|
||||
if (player?.parentId) {
|
||||
await this.recalculateUsedCredit(player.parentId);
|
||||
}
|
||||
return result;
|
||||
return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
|
||||
}
|
||||
|
||||
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
|
||||
@@ -535,70 +294,7 @@ export class AgentsService {
|
||||
playerId: bigint,
|
||||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||||
) {
|
||||
const player = await this.prisma.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
include: { wallet: true },
|
||||
});
|
||||
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
|
||||
|
||||
if (options.actingAgentId) {
|
||||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||||
}
|
||||
|
||||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||||
|
||||
let credit: Record<string, unknown> | null = null;
|
||||
if (creditAgentId) {
|
||||
await this.recalculateUsedCredit(creditAgentId);
|
||||
const profile = await this.getProfile(creditAgentId);
|
||||
const parent = profile.parentAgentId
|
||||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||||
: null;
|
||||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||||
|
||||
let dailyDepositUsed: string | null = null;
|
||||
if (!options.forAdmin) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||||
}
|
||||
|
||||
const agentUser = await this.prisma.user.findUnique({
|
||||
where: { id: creditAgentId },
|
||||
select: { username: true },
|
||||
});
|
||||
|
||||
credit = {
|
||||
agentId: creditAgentId.toString(),
|
||||
agentUsername: agentUser?.username ?? '',
|
||||
agentLevel: profile.level,
|
||||
creditLimit: dec(profile.creditLimit),
|
||||
usedCredit: dec(profile.usedCredit),
|
||||
availableCredit: dec(profile.availableCredit),
|
||||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||||
dailyDepositUsed,
|
||||
appliesDepositLimits: !options.forAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
player: {
|
||||
id: player.id.toString(),
|
||||
username: player.username,
|
||||
availableBalance: dec(player.wallet?.availableBalance),
|
||||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||||
},
|
||||
credit,
|
||||
};
|
||||
return this.credit.getPlayerTransferContext(playerId, options);
|
||||
}
|
||||
|
||||
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
|
||||
@@ -624,9 +320,9 @@ export class AgentsService {
|
||||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||||
where: {
|
||||
operatorId: creditAgentId,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
|
||||
createdAt: { gte: today },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
});
|
||||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||||
@@ -660,22 +356,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
const profile = await this.getProfile(agentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
|
||||
if (available.lt(amt)) {
|
||||
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||||
}
|
||||
|
||||
await this.assertAgentDepositLimits(agentId, amt);
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
|
||||
}
|
||||
|
||||
async withdrawFromPlayer(
|
||||
@@ -685,19 +366,7 @@ export class AgentsService {
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
await this.requireDirectPlayer(agentId, playerId);
|
||||
|
||||
await this.wallet.withdraw(
|
||||
playerId,
|
||||
amount,
|
||||
agentId,
|
||||
remark ?? '代理下分',
|
||||
requestId,
|
||||
'AGENT_WITHDRAW',
|
||||
);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
|
||||
}
|
||||
|
||||
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
|
||||
@@ -1508,7 +1177,7 @@ export class AgentsService {
|
||||
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const locale = data.locale ?? 'zh-CN';
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
@@ -1564,13 +1233,16 @@ export class AgentsService {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.parentAgentId) {
|
||||
await this.recalculateUsedCredit(data.parentAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
if (data.parentAgentId) {
|
||||
await this.recalculateUsedCredit(data.parentAgentId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async createPlayer(
|
||||
@@ -1707,18 +1379,7 @@ export class AgentsService {
|
||||
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
|
||||
const requestId =
|
||||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||||
await this.assertPlayerParentCreditForDeposit(user.id, initial);
|
||||
await this.wallet.deposit(
|
||||
user.id,
|
||||
initial,
|
||||
operatorId,
|
||||
remarkResult.remark,
|
||||
requestId,
|
||||
'ADMIN_DEPOSIT',
|
||||
);
|
||||
if (parentId) {
|
||||
await this.recalculateUsedCredit(parentId);
|
||||
}
|
||||
await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
|
||||
}
|
||||
|
||||
return user;
|
||||
@@ -1799,10 +1460,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
return this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
return this.network.getChildAgents(agentId);
|
||||
}
|
||||
|
||||
async listChildAgentsSummary(parentAgentId: bigint) {
|
||||
@@ -1980,15 +1638,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
|
||||
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
|
||||
if (!subtreeIds.some((id) => id === targetAgentId)) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: targetAgentId },
|
||||
});
|
||||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||||
return profile;
|
||||
return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
|
||||
}
|
||||
|
||||
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
|
||||
@@ -2242,13 +1892,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: subAgentId },
|
||||
});
|
||||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||||
throw appForbidden('NOT_SUB_AGENT');
|
||||
}
|
||||
return profile;
|
||||
return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||
}
|
||||
|
||||
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
|
||||
@@ -2275,27 +1919,7 @@ export class AgentsService {
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
const ids: bigint[] = [];
|
||||
const queue: bigint[] = [agentId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
ids.push(current);
|
||||
|
||||
const children = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: current },
|
||||
select: { userId: true },
|
||||
});
|
||||
for (const child of children) {
|
||||
queue.push(child.userId);
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
return this.network.getSubtreeAgentIds(agentId);
|
||||
}
|
||||
|
||||
async getReportSummary(agentId: bigint) {
|
||||
|
||||
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal file
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { BetsService } from './bets.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('BetsService', () => {
|
||||
const tx = {
|
||||
bet: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
marketSelection: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
freezeBet: jest.fn(),
|
||||
};
|
||||
const bettingLimits = {
|
||||
validateBet: jest.fn(),
|
||||
};
|
||||
|
||||
let service: BetsService;
|
||||
|
||||
function selection(id: bigint, oddsVersion: bigint, matchId = 88n) {
|
||||
return {
|
||||
id,
|
||||
marketId: id + 100n,
|
||||
selectionCode: id === 1n ? 'HOME' : 'AWAY',
|
||||
selectionName: `Selection ${id}`,
|
||||
nameI18n: null,
|
||||
odds: new Decimal(id === 1n ? 1.9 : 2.1),
|
||||
oddsVersion,
|
||||
status: 'OPEN',
|
||||
market: {
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
nameI18n: null,
|
||||
lineValue: null,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
showOnPlayer: true,
|
||||
status: 'OPEN',
|
||||
match: {
|
||||
status: 'PUBLISHED',
|
||||
sportType: 'FOOTBALL',
|
||||
isOutright: false,
|
||||
startTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new BetsService(prisma as never, funds as never, bettingLimits as never);
|
||||
tx.bet.findUnique.mockResolvedValue(null);
|
||||
tx.bet.create.mockResolvedValue({ id: 123n, betNo: 'BET-OK', selections: [] });
|
||||
bettingLimits.validateBet.mockResolvedValue(undefined);
|
||||
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
|
||||
Promise.resolve(selection(where.id, where.id)),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a parlay with multiple legs from the same match', async () => {
|
||||
await expect(
|
||||
service.placeParlayBet(
|
||||
7n,
|
||||
3n,
|
||||
[
|
||||
{ selectionId: 1n, oddsVersion: 1n },
|
||||
{ selectionId: 2n, oddsVersion: 2n },
|
||||
],
|
||||
50,
|
||||
'REQ-SAME-MATCH',
|
||||
),
|
||||
).rejects.toMatchObject(expectAppError('PARLAY_SAME_MATCH_FORBIDDEN'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a parlay only when every leg is from a different match', async () => {
|
||||
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
|
||||
Promise.resolve(selection(where.id, where.id, where.id === 1n ? 88n : 99n)),
|
||||
);
|
||||
|
||||
await service.placeParlayBet(
|
||||
7n,
|
||||
3n,
|
||||
[
|
||||
{ selectionId: 1n, oddsVersion: 1n },
|
||||
{ selectionId: 2n, oddsVersion: 2n },
|
||||
],
|
||||
50,
|
||||
'REQ-SAME-MATCH',
|
||||
);
|
||||
|
||||
expect(tx.bet.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
betType: 'PARLAY',
|
||||
selections: expect.objectContaining({
|
||||
create: expect.arrayContaining([
|
||||
expect.objectContaining({ matchId: 88n, selectionId: 1n }),
|
||||
expect.objectContaining({ matchId: 99n, selectionId: 2n }),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(bettingLimits.validateBet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ betType: 'PARLAY', tx }),
|
||||
);
|
||||
expect(funds.freezeBet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 7n, stake: expect.any(Decimal), tx }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a single bet when the market disables single betting', async () => {
|
||||
tx.marketSelection.findUnique.mockResolvedValue({
|
||||
...selection(1n, 1n),
|
||||
market: {
|
||||
...selection(1n, 1n).market,
|
||||
allowSingle: false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-SINGLE-DISABLED'),
|
||||
).rejects.toMatchObject(expectAppError('SINGLE_MARKET_NOT_ALLOWED'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revalidates odds version inside the placement transaction', async () => {
|
||||
tx.marketSelection.findUnique.mockResolvedValue(selection(1n, 2n));
|
||||
|
||||
await expect(
|
||||
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-ODDS-CHANGED'),
|
||||
).rejects.toMatchObject(expectAppError('ODDS_CHANGED'));
|
||||
|
||||
expect(tx.bet.create).not.toHaveBeenCalled();
|
||||
expect(funds.freezeBet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../../shared/common/decorators';
|
||||
@@ -10,12 +10,17 @@ import {
|
||||
PARLAY_MIN_LEGS,
|
||||
PARLAY_MAX_LEGS,
|
||||
canSelectForParlay,
|
||||
hasDuplicateParlayMatch,
|
||||
defaultMarketName,
|
||||
defaultSelectionName,
|
||||
isPreMatchKickoff,
|
||||
isSupportedSport,
|
||||
resolveMarketText,
|
||||
resolveTranslationFallback,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
type MatchContext = {
|
||||
matchLabel: string;
|
||||
leagueName: string;
|
||||
@@ -25,6 +30,7 @@ type BetSelectionRow = {
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
period: string | null;
|
||||
marketNameSnapshot: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
handicapLine: Decimal | null;
|
||||
totalLine: Decimal | null;
|
||||
@@ -32,6 +38,16 @@ type BetSelectionRow = {
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type SelectionWithMarketLabel = {
|
||||
selectionCode: string;
|
||||
selectionName: string;
|
||||
nameI18n: unknown;
|
||||
market: {
|
||||
marketType: string;
|
||||
nameI18n: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
oddsVersion: bigint;
|
||||
@@ -42,16 +58,17 @@ interface BetSelectionInput {
|
||||
export class BetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private bettingLimits: BettingLimitsService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(
|
||||
selectionId: bigint,
|
||||
oddsVersion: bigint,
|
||||
options?: { forParlay?: boolean },
|
||||
options?: { forParlay?: boolean; tx?: TxClient },
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
const client: PrismaClientLike = options?.tx ?? this.prisma;
|
||||
const selection = await client.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
@@ -59,6 +76,10 @@ export class BetsService {
|
||||
if (!selection) throw appBadRequest('SELECTION_NOT_FOUND');
|
||||
if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED');
|
||||
if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED');
|
||||
if (selection.market.showOnPlayer === false) throw appBadRequest('MARKET_CLOSED');
|
||||
if (!options?.forParlay && selection.market.allowSingle === false) {
|
||||
throw appBadRequest('SINGLE_MARKET_NOT_ALLOWED');
|
||||
}
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw appBadRequest('MATCH_NOT_BETTING');
|
||||
}
|
||||
@@ -68,14 +89,6 @@ export class BetsService {
|
||||
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
|
||||
throw appBadRequest('PRE_MATCH_ONLY');
|
||||
}
|
||||
// Block correct-score bets when the match has the CS toggle turned off
|
||||
const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
if (
|
||||
CS_MARKET_TYPES.includes(selection.market.marketType) &&
|
||||
!(selection.market.match.correctScoreEnabled ?? true)
|
||||
) {
|
||||
throw appBadRequest('CORRECT_SCORE_DISABLED');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw appBadRequest('ODDS_CHANGED');
|
||||
}
|
||||
@@ -112,57 +125,68 @@ export class BetsService {
|
||||
) {
|
||||
if (stake <= 0) throw appBadRequest('INVALID_STAKE');
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
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);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'SINGLE',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion, {
|
||||
forParlay: false,
|
||||
tx,
|
||||
});
|
||||
const locale = await this.resolveUserLocale(userId, tx);
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'SINGLE',
|
||||
stake: stakeDec,
|
||||
totalOdds: odds,
|
||||
stake,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: {
|
||||
matchId: selection.market.matchId,
|
||||
marketId: selection.marketId,
|
||||
selectionId: selection.id,
|
||||
marketType: selection.market.marketType,
|
||||
period: selection.market.period,
|
||||
selectionNameSnapshot: selection.selectionName,
|
||||
handicapLine: selection.market.lineValue,
|
||||
totalLine: selection.market.lineValue,
|
||||
odds,
|
||||
oddsVersion,
|
||||
tx,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'SINGLE',
|
||||
stake: stakeDec,
|
||||
totalOdds: odds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: {
|
||||
matchId: selection.market.matchId,
|
||||
marketId: selection.marketId,
|
||||
selectionId: selection.id,
|
||||
marketType: selection.market.marketType,
|
||||
period: selection.market.period,
|
||||
marketNameSnapshot: this.marketNameSnapshot(selection, locale),
|
||||
selectionNameSnapshot: this.selectionNameSnapshot(selection, locale),
|
||||
handicapLine: selection.market.lineValue,
|
||||
totalLine: selection.market.lineValue,
|
||||
odds,
|
||||
oddsVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
|
||||
return created;
|
||||
});
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
} catch (error) {
|
||||
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
|
||||
if (existing) return existing;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async placeParlayBet(
|
||||
@@ -177,73 +201,100 @@ export class BetsService {
|
||||
throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS });
|
||||
}
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
|
||||
selections.push(sel);
|
||||
}
|
||||
|
||||
const matchIds = selections.map((s) => s.market.matchId);
|
||||
if (hasDuplicateParlayMatch(matchIds)) {
|
||||
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
|
||||
}
|
||||
|
||||
let totalOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
totalOdds = totalOdds.mul(sel.odds.toString());
|
||||
}
|
||||
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
betType: 'PARLAY',
|
||||
stake,
|
||||
potentialReturn,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, {
|
||||
forParlay: true,
|
||||
tx,
|
||||
});
|
||||
selections.push(sel);
|
||||
}
|
||||
const matchIds = new Set<string>();
|
||||
for (const sel of selections) {
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
|
||||
}
|
||||
matchIds.add(matchKey);
|
||||
}
|
||||
const locale = await this.resolveUserLocale(userId, tx);
|
||||
|
||||
let totalOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
totalOdds = totalOdds.mul(sel.odds.toString());
|
||||
}
|
||||
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
await this.bettingLimits.validateBet({
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'PARLAY',
|
||||
stake: stakeDec,
|
||||
totalOdds,
|
||||
stake,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: selections.map((sel, i) => ({
|
||||
matchId: sel.market.matchId,
|
||||
marketId: sel.marketId,
|
||||
selectionId: sel.id,
|
||||
marketType: sel.market.marketType,
|
||||
period: sel.market.period,
|
||||
selectionNameSnapshot: sel.selectionName,
|
||||
handicapLine: sel.market.lineValue,
|
||||
totalLine: sel.market.lineValue,
|
||||
odds: sel.odds,
|
||||
oddsVersion: legs[i].oddsVersion,
|
||||
sortOrder: i,
|
||||
})),
|
||||
tx,
|
||||
});
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'PARLAY',
|
||||
stake: stakeDec,
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: selections.map((sel, i) => ({
|
||||
matchId: sel.market.matchId,
|
||||
marketId: sel.marketId,
|
||||
selectionId: sel.id,
|
||||
marketType: sel.market.marketType,
|
||||
period: sel.market.period,
|
||||
marketNameSnapshot: this.marketNameSnapshot(sel, locale),
|
||||
selectionNameSnapshot: this.selectionNameSnapshot(sel, locale),
|
||||
handicapLine: sel.market.lineValue,
|
||||
totalLine: sel.market.lineValue,
|
||||
odds: sel.odds,
|
||||
oddsVersion: legs[i].oddsVersion,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
|
||||
return created;
|
||||
});
|
||||
} catch (error) {
|
||||
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
|
||||
if (existing) return existing;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
private async findExistingRequestBetOnUniqueError(
|
||||
userId: bigint,
|
||||
requestId: string,
|
||||
error: unknown,
|
||||
) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
return this.prisma.bet.findUnique({ where: { userId_requestId: { userId, requestId } } });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getUserBets(
|
||||
@@ -392,6 +443,7 @@ export class BetsService {
|
||||
matchLabel: ctx?.matchLabel ?? '—',
|
||||
leagueName: ctx?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
marketName: s.marketNameSnapshot ?? null,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
odds: this.dec(s.odds),
|
||||
@@ -584,6 +636,7 @@ export class BetsService {
|
||||
matchLabel: preview?.matchLabel ?? '—',
|
||||
leagueName: preview?.leagueName ?? '',
|
||||
marketType: s.marketType,
|
||||
marketName: s.marketNameSnapshot,
|
||||
period: s.period,
|
||||
selectionName: s.selectionNameSnapshot,
|
||||
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
|
||||
@@ -596,4 +649,37 @@ export class BetsService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveUserLocale(userId: bigint, client: PrismaClientLike) {
|
||||
const userDelegate = (client as PrismaClientLike & {
|
||||
user?: {
|
||||
findUnique?: PrismaService['user']['findUnique'];
|
||||
};
|
||||
}).user;
|
||||
if (!userDelegate?.findUnique) return 'zh-CN';
|
||||
const user = await userDelegate.findUnique({
|
||||
where: { id: userId },
|
||||
select: { locale: true, preferences: { select: { locale: true } } },
|
||||
});
|
||||
return user?.preferences?.locale || user?.locale || 'zh-CN';
|
||||
}
|
||||
|
||||
private marketNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
|
||||
const market = selection.market;
|
||||
return (
|
||||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType, locale)) ||
|
||||
market.marketType
|
||||
);
|
||||
}
|
||||
|
||||
private selectionNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
|
||||
return (
|
||||
resolveMarketText(
|
||||
selection.nameI18n,
|
||||
locale,
|
||||
defaultSelectionName(selection.market.marketType, selection.selectionCode, locale) ||
|
||||
selection.selectionName,
|
||||
) || selection.selectionName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BettingLimitsService } from './betting-limits.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('BettingLimitsService', () => {
|
||||
const prisma = {
|
||||
@@ -27,7 +28,7 @@ describe('BettingLimitsService', () => {
|
||||
stake: 0.5,
|
||||
potentialReturn: new Decimal(1),
|
||||
}),
|
||||
).rejects.toThrow('Minimum stake is 1');
|
||||
).rejects.toMatchObject(expectAppError('MIN_STAKE'));
|
||||
});
|
||||
|
||||
it('rejects stake above single max', async () => {
|
||||
@@ -38,7 +39,7 @@ describe('BettingLimitsService', () => {
|
||||
stake: 60000,
|
||||
potentialReturn: new Decimal(70000),
|
||||
}),
|
||||
).rejects.toThrow('Maximum stake is 50000');
|
||||
).rejects.toMatchObject(expectAppError('MAX_STAKE'));
|
||||
});
|
||||
|
||||
it('rejects potential return above payout cap', async () => {
|
||||
@@ -49,6 +50,6 @@ describe('BettingLimitsService', () => {
|
||||
stake: 100,
|
||||
potentialReturn: new Decimal(600000),
|
||||
}),
|
||||
).rejects.toThrow('Potential return exceeds limit');
|
||||
).rejects.toMatchObject(expectAppError('MAX_PAYOUT'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
|
||||
export type BettingLimits = {
|
||||
minStake: number;
|
||||
maxStakeSingle: number;
|
||||
@@ -34,8 +38,12 @@ const DEFAULTS: BettingLimits = {
|
||||
export class BettingLimitsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private async getNumber(key: string, fallback: number): Promise<number> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
private async getNumber(
|
||||
key: string,
|
||||
fallback: number,
|
||||
client: PrismaClientLike = this.prisma,
|
||||
): Promise<number> {
|
||||
const row = await client.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return fallback;
|
||||
const n = Number(row.configValue);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
@@ -49,14 +57,15 @@ export class BettingLimitsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getLimits(): Promise<BettingLimits> {
|
||||
async getLimits(tx?: TxClient): Promise<BettingLimits> {
|
||||
const client = tx ?? this.prisma;
|
||||
return {
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
|
||||
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake, client),
|
||||
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle, client),
|
||||
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay, client),
|
||||
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle, client),
|
||||
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay, client),
|
||||
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit, client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,8 +94,10 @@ export class BettingLimitsService {
|
||||
betType: 'SINGLE' | 'PARLAY';
|
||||
stake: number;
|
||||
potentialReturn: Decimal;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
const limits = await this.getLimits();
|
||||
const client = params.tx ?? this.prisma;
|
||||
const limits = await this.getLimits(params.tx);
|
||||
const stake = params.stake;
|
||||
|
||||
if (stake < limits.minStake) {
|
||||
@@ -110,7 +121,7 @@ export class BettingLimitsService {
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const agg = await this.prisma.bet.aggregate({
|
||||
const agg = await client.bet.aggregate({
|
||||
where: {
|
||||
userId: params.userId,
|
||||
placedAt: { gte: startOfDay, lte: endOfDay },
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let matches: { betStatsForMatches: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: CatalogArchiveService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,8 +34,8 @@ describe('CatalogArchiveService', () => {
|
||||
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
|
||||
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
|
||||
};
|
||||
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matches as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new CatalogArchiveService(prisma as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
const baseMatch = {
|
||||
@@ -71,21 +71,13 @@ describe('CatalogArchiveService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('archive with force soft-deletes and cancels match', async () => {
|
||||
it('archive with force rejects draft matches', async () => {
|
||||
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
|
||||
prisma.match.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.archiveMatch(matchId, { force: true });
|
||||
|
||||
expect(result.matchId).toBe(matchId.toString());
|
||||
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.market.updateMany).toHaveBeenCalled();
|
||||
expect(prisma.match.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: matchId },
|
||||
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
|
||||
}),
|
||||
);
|
||||
await expect(service.archiveMatch(matchId, { force: true })).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ code: 'MATCH_DELETE_DRAFT_ONLY' }),
|
||||
});
|
||||
expect(prisma.match.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('league preview blocks when child match is not terminal', async () => {
|
||||
@@ -102,7 +94,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -129,7 +121,7 @@ describe('CatalogArchiveService', () => {
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
@@ -163,7 +155,7 @@ describe('CatalogArchiveService', () => {
|
||||
awayTeam: { code: 'B' },
|
||||
},
|
||||
]);
|
||||
matches.betStatsForMatches.mockResolvedValue(
|
||||
matchBetStats.betStatsForMatches.mockResolvedValue(
|
||||
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
|
||||
|
||||
@@ -39,7 +39,7 @@ export type LeagueArchivePreview = {
|
||||
export class CatalogArchiveService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private matches: MatchesService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
|
||||
@@ -114,7 +114,7 @@ export class CatalogArchiveService {
|
||||
});
|
||||
|
||||
const matchIds = matches.map((m) => m.id);
|
||||
const stats = await this.matches.betStatsForMatches(matchIds);
|
||||
const stats = await this.matchBetStats.betStatsForMatches(matchIds);
|
||||
const previewBatches = matchIds.length
|
||||
? await this.prisma.settlementBatch.findMany({
|
||||
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
|
||||
|
||||
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
71
apps/api/src/domains/catalog/match-bet-stats.service.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
|
||||
describe('MatchBetStatsService', () => {
|
||||
let prisma: {
|
||||
betSelection: { findMany: jest.Mock };
|
||||
};
|
||||
let service: MatchBetStatsService;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
betSelection: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
service = new MatchBetStatsService(prisma as never);
|
||||
});
|
||||
|
||||
it('returns empty stats without querying when match ids are empty', async () => {
|
||||
const stats = await service.betStatsForMatches([]);
|
||||
|
||||
expect(stats.size).toBe(0);
|
||||
expect(prisma.betSelection.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deduplicates bets and fills zero stats for matches without bets', async () => {
|
||||
const matchId = BigInt(10);
|
||||
const emptyMatchId = BigInt(11);
|
||||
prisma.betSelection.findMany.mockResolvedValue([
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(100),
|
||||
bet: { stake: new Decimal(50), status: 'PENDING' },
|
||||
},
|
||||
{
|
||||
matchId,
|
||||
betId: BigInt(101),
|
||||
bet: { stake: new Decimal(25), status: 'WON' },
|
||||
},
|
||||
{
|
||||
matchId: null,
|
||||
betId: BigInt(102),
|
||||
bet: { stake: new Decimal(99), status: 'PENDING' },
|
||||
},
|
||||
]);
|
||||
|
||||
const stats = await service.betStatsForMatches([matchId, emptyMatchId]);
|
||||
|
||||
expect(prisma.betSelection.findMany).toHaveBeenCalledWith({
|
||||
where: { matchId: { in: [matchId, emptyMatchId] } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
expect(stats.get(matchId.toString())).toEqual({
|
||||
betCount: 2,
|
||||
totalStake: '75',
|
||||
pendingCount: 1,
|
||||
});
|
||||
expect(stats.get(emptyMatchId.toString())).toEqual({
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
73
apps/api/src/domains/catalog/match-bet-stats.service.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MatchBetStatsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketsModule } from '../odds/markets.module';
|
||||
import { CatalogArchiveService } from './catalog-archive.service';
|
||||
import { MatchBetStatsService } from './match-bet-stats.service';
|
||||
import { MatchesService } from './matches.service';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
@Module({
|
||||
imports: [MarketsModule],
|
||||
providers: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, OutrightService, CatalogArchiveService],
|
||||
providers: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
exports: [MatchesService, MatchBetStatsService, OutrightService, CatalogArchiveService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
jest.mock('@thebet365/shared', () => ({
|
||||
isPreMatchKickoff: jest.fn(() => true),
|
||||
PARLAY_MARKET_TYPES: [],
|
||||
resolveTranslationFallback: jest.fn(
|
||||
(translations: Map<string, string>, locale: string) =>
|
||||
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
|
||||
),
|
||||
}));
|
||||
|
||||
import { MatchesService } from './matches.service';
|
||||
|
||||
describe('MatchesService publish/unpublish', () => {
|
||||
@@ -11,6 +20,7 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.Mock };
|
||||
};
|
||||
let outright: { syncWithLeaguePublished: jest.Mock };
|
||||
let matchBetStats: { betStatsForMatches: jest.Mock };
|
||||
let service: MatchesService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -28,7 +38,8 @@ describe('MatchesService publish/unpublish', () => {
|
||||
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
||||
};
|
||||
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new MatchesService(prisma as never, outright as never);
|
||||
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
|
||||
service = new MatchesService(prisma as never, outright as never, matchBetStats as never);
|
||||
});
|
||||
|
||||
describe('updatePlatformLeague unpublish', () => {
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
|
||||
import {
|
||||
defaultMarketName,
|
||||
defaultSelectionName,
|
||||
isPreMatchKickoff,
|
||||
isSettlementSupportedMarketType,
|
||||
PARLAY_MARKET_TYPES,
|
||||
resolveMarketText,
|
||||
resolveTranslationFallback,
|
||||
sanitizeLocalizedText,
|
||||
} from '@thebet365/shared';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
export type MatchBetStatsSummary = {
|
||||
betCount: number;
|
||||
totalStake: string;
|
||||
pendingCount: number;
|
||||
};
|
||||
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
@@ -25,17 +29,11 @@ import {
|
||||
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
|
||||
import { OutrightService } from './outright.service';
|
||||
|
||||
export type { MatchBetStatsSummary } from './match-bet-stats.service';
|
||||
|
||||
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
|
||||
|
||||
const PLAYER_PARLAY_MARKET_TYPES = [
|
||||
MarketType.FT_HANDICAP,
|
||||
MarketType.FT_OVER_UNDER,
|
||||
MarketType.FT_1X2,
|
||||
MarketType.FT_ODD_EVEN,
|
||||
MarketType.HT_HANDICAP,
|
||||
MarketType.HT_OVER_UNDER,
|
||||
MarketType.HT_1X2,
|
||||
] as const;
|
||||
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
|
||||
|
||||
export type ListPublishedOptions = {
|
||||
/** 响应中是否包含 markets(列表页默认 false,仅摘要) */
|
||||
@@ -49,6 +47,7 @@ export class MatchesService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private outright: OutrightService,
|
||||
private matchBetStats: MatchBetStatsService,
|
||||
) {}
|
||||
|
||||
async createLeague(code: string, translations: Record<string, string>) {
|
||||
@@ -89,7 +88,6 @@ export class MatchesService {
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
createdBy?: bigint;
|
||||
status?: string;
|
||||
@@ -115,7 +113,6 @@ export class MatchesService {
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status,
|
||||
@@ -624,59 +621,7 @@ export class MatchesService {
|
||||
async betStatsForMatches(
|
||||
matchIds: bigint[],
|
||||
): Promise<Map<string, MatchBetStatsSummary>> {
|
||||
const result = new Map<string, MatchBetStatsSummary>();
|
||||
if (!matchIds.length) return result;
|
||||
|
||||
const legs = await this.prisma.betSelection.findMany({
|
||||
where: { matchId: { in: matchIds } },
|
||||
select: {
|
||||
matchId: true,
|
||||
betId: true,
|
||||
bet: { select: { stake: true, status: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byMatch = new Map<
|
||||
string,
|
||||
Map<string, { stake: Decimal; status: string }>
|
||||
>();
|
||||
for (const leg of legs) {
|
||||
if (leg.matchId == null) continue;
|
||||
const mid = leg.matchId.toString();
|
||||
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
|
||||
const bets = byMatch.get(mid)!;
|
||||
if (!bets.has(leg.betId.toString())) {
|
||||
bets.set(leg.betId.toString(), {
|
||||
stake: leg.bet.stake,
|
||||
status: leg.bet.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of matchIds) {
|
||||
const mid = id.toString();
|
||||
const bets = byMatch.get(mid);
|
||||
if (!bets) {
|
||||
result.set(mid, {
|
||||
betCount: 0,
|
||||
totalStake: '0',
|
||||
pendingCount: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let totalStake = new Decimal(0);
|
||||
let pendingCount = 0;
|
||||
for (const b of bets.values()) {
|
||||
totalStake = totalStake.add(b.stake);
|
||||
if (b.status === 'PENDING') pendingCount += 1;
|
||||
}
|
||||
result.set(mid, {
|
||||
betCount: bets.size,
|
||||
totalStake: totalStake.toString(),
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return this.matchBetStats.betStatsForMatches(matchIds);
|
||||
}
|
||||
|
||||
private async upsertTeamByCode(data: {
|
||||
@@ -718,7 +663,6 @@ export class MatchesService {
|
||||
awayTeamMs?: string;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string;
|
||||
stage?: string;
|
||||
@@ -833,7 +777,6 @@ export class MatchesService {
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? true,
|
||||
displayOrder: data.displayOrder ?? 0,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
@@ -881,7 +824,6 @@ export class MatchesService {
|
||||
status: match.status,
|
||||
isOutright: match.isOutright,
|
||||
isHot: match.isHot,
|
||||
correctScoreEnabled: match.correctScoreEnabled,
|
||||
displayOrder: match.displayOrder,
|
||||
startTime: match.startTime.toISOString(),
|
||||
leagueId: match.leagueId.toString(),
|
||||
@@ -909,21 +851,36 @@ export class MatchesService {
|
||||
htAway: scoreRow.htAwayScore ?? 0,
|
||||
ftHome: scoreRow.ftHomeScore ?? 0,
|
||||
ftAway: scoreRow.ftAwayScore ?? 0,
|
||||
homeCorners: scoreRow.homeCorners ?? null,
|
||||
awayCorners: scoreRow.awayCorners ?? null,
|
||||
homeYellowCards: scoreRow.homeYellowCards ?? null,
|
||||
awayYellowCards: scoreRow.awayYellowCards ?? null,
|
||||
homeRedCards: scoreRow.homeRedCards ?? null,
|
||||
awayRedCards: scoreRow.awayRedCards ?? null,
|
||||
homeCards: scoreRow.homeCards ?? null,
|
||||
awayCards: scoreRow.awayCards ?? null,
|
||||
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
|
||||
}
|
||||
: null,
|
||||
markets: markets.map((m) => ({
|
||||
id: m.id.toString(),
|
||||
marketType: m.marketType,
|
||||
marketKey: m.marketKey ?? m.marketType,
|
||||
lineKey: m.lineKey ?? null,
|
||||
period: m.period,
|
||||
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
|
||||
paramsJson: m.paramsJson ?? null,
|
||||
status: m.status,
|
||||
showOnPlayer: m.showOnPlayer,
|
||||
promoLabel: m.promoLabel ?? '',
|
||||
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
|
||||
nameI18n: sanitizeLocalizedText(m.nameI18n),
|
||||
sortOrder: m.sortOrder,
|
||||
selections: m.selections.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: sanitizeLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
@@ -949,7 +906,6 @@ export class MatchesService {
|
||||
groupName?: string;
|
||||
homeTeamLogoUrl?: string;
|
||||
awayTeamLogoUrl?: string;
|
||||
correctScoreEnabled?: boolean;
|
||||
updatedBy?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -1006,7 +962,6 @@ export class MatchesService {
|
||||
matchName,
|
||||
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
|
||||
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
|
||||
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
|
||||
updatedBy: data.updatedBy,
|
||||
},
|
||||
});
|
||||
@@ -1261,7 +1216,6 @@ export class MatchesService {
|
||||
startTime: Date;
|
||||
status?: string;
|
||||
isHot?: boolean;
|
||||
correctScoreEnabled?: boolean;
|
||||
displayOrder?: number;
|
||||
matchName?: string | null;
|
||||
stage?: string | null;
|
||||
@@ -1270,10 +1224,18 @@ export class MatchesService {
|
||||
awayTeam?: { code: string; logoUrl?: string | null };
|
||||
league?: { logoUrl?: string | null };
|
||||
score?: {
|
||||
htHomeScore: number;
|
||||
htAwayScore: number;
|
||||
ftHomeScore: number;
|
||||
ftAwayScore: number;
|
||||
htHomeScore: number | null;
|
||||
htAwayScore: number | null;
|
||||
ftHomeScore: number | null;
|
||||
ftAwayScore: number | null;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
} | null;
|
||||
markets?: Array<Record<string, unknown>>;
|
||||
};
|
||||
@@ -1295,7 +1257,6 @@ export class MatchesService {
|
||||
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
|
||||
startTime: m.startTime.toISOString(),
|
||||
isHot: m.isHot ?? false,
|
||||
correctScoreEnabled: m.correctScoreEnabled ?? true,
|
||||
displayOrder: m.displayOrder ?? 0,
|
||||
matchName: m.matchName ?? null,
|
||||
stage: m.stage ?? null,
|
||||
@@ -1303,10 +1264,18 @@ export class MatchesService {
|
||||
status: m.status ?? 'PUBLISHED',
|
||||
score: m.score
|
||||
? {
|
||||
htHome: m.score.htHomeScore,
|
||||
htAway: m.score.htAwayScore,
|
||||
ftHome: m.score.ftHomeScore,
|
||||
ftAway: m.score.ftAwayScore,
|
||||
htHome: m.score.htHomeScore ?? null,
|
||||
htAway: m.score.htAwayScore ?? null,
|
||||
ftHome: m.score.ftHomeScore ?? null,
|
||||
ftAway: m.score.ftAwayScore ?? null,
|
||||
homeCorners: m.score.homeCorners ?? null,
|
||||
awayCorners: m.score.awayCorners ?? null,
|
||||
homeYellowCards: m.score.homeYellowCards ?? null,
|
||||
awayYellowCards: m.score.awayYellowCards ?? null,
|
||||
homeRedCards: m.score.homeRedCards ?? null,
|
||||
awayRedCards: m.score.awayRedCards ?? null,
|
||||
homeCards: m.score.homeCards ?? null,
|
||||
awayCards: m.score.awayCards ?? null,
|
||||
}
|
||||
: null,
|
||||
bettingOpen: this.isMatchBettingOpen({
|
||||
@@ -1327,29 +1296,49 @@ export class MatchesService {
|
||||
}),
|
||||
};
|
||||
if (m.markets && !options?.omitMarkets) {
|
||||
const csEnabled = m.correctScoreEnabled ?? true;
|
||||
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
|
||||
return {
|
||||
...base,
|
||||
markets: m.markets
|
||||
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
|
||||
.filter(
|
||||
(market) =>
|
||||
((market.showOnPlayer as boolean | undefined) ?? true) &&
|
||||
isSettlementSupportedMarketType(market.marketType as string),
|
||||
)
|
||||
.map((market) => ({
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
|
||||
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
id: (market.id as bigint).toString(),
|
||||
marketType: market.marketType as string,
|
||||
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
|
||||
lineKey: (market.lineKey as string | null | undefined) ?? null,
|
||||
period: market.period as string,
|
||||
status: (market.status as string) ?? 'OPEN',
|
||||
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
|
||||
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
|
||||
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
|
||||
marketDisplayName:
|
||||
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
|
||||
(market.marketType as string),
|
||||
promoLabel:
|
||||
resolveMarketText(
|
||||
market.promoLabelI18n,
|
||||
locale,
|
||||
(market.promoLabel as string | null | undefined) ?? '',
|
||||
) || null,
|
||||
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
|
||||
id: (s.id as bigint).toString(),
|
||||
selectionCode: s.selectionCode as string,
|
||||
selectionName: s.selectionName as string,
|
||||
selectionDisplayName:
|
||||
resolveMarketText(
|
||||
s.nameI18n,
|
||||
locale,
|
||||
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
|
||||
(s.selectionName as string),
|
||||
) || (s.selectionName as string),
|
||||
status: (s.status as string) ?? 'OPEN',
|
||||
odds: Number(s.odds),
|
||||
oddsVersion: (s.oddsVersion as bigint).toString(),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
return base;
|
||||
@@ -1392,7 +1381,7 @@ export class MatchesService {
|
||||
}
|
||||
|
||||
private playerMarketInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
include: {
|
||||
selections: {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
@@ -1404,7 +1393,7 @@ export class MatchesService {
|
||||
|
||||
/** 仅 status 字段,用于列表页计算 bettingOpen,不返回给客户端 */
|
||||
private playerMarketStatusInclude = {
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
|
||||
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
|
||||
select: {
|
||||
status: true,
|
||||
selections: { select: { status: true } },
|
||||
@@ -1421,6 +1410,7 @@ export class MatchesService {
|
||||
|
||||
const marketWhere = {
|
||||
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
|
||||
showOnPlayer: true,
|
||||
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
|
||||
};
|
||||
|
||||
@@ -1576,8 +1566,8 @@ export class MatchesService {
|
||||
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
|
||||
};
|
||||
const entry = labels[marketType];
|
||||
if (!entry) return marketType;
|
||||
return entry[locale] ?? entry['en-US'] ?? marketType;
|
||||
if (!entry) return defaultMarketName(marketType, locale);
|
||||
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
|
||||
}
|
||||
|
||||
async enrichBetsForHistory(
|
||||
@@ -1594,6 +1584,7 @@ export class MatchesService {
|
||||
selections: Array<{
|
||||
matchId: bigint | null;
|
||||
marketType: string;
|
||||
marketNameSnapshot?: string | null;
|
||||
selectionNameSnapshot: string;
|
||||
odds: unknown;
|
||||
resultStatus?: string | null;
|
||||
@@ -1662,7 +1653,7 @@ export class MatchesService {
|
||||
const m = mid ? matchMeta.get(mid) : undefined;
|
||||
return {
|
||||
marketType: sel.marketType,
|
||||
marketLabel: this.marketLabelKey(sel.marketType, locale),
|
||||
marketLabel: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
|
||||
selectionName: sel.selectionNameSnapshot,
|
||||
odds: sel.odds,
|
||||
resultStatus: sel.resultStatus,
|
||||
|
||||
@@ -92,19 +92,30 @@ export async function syncWc2026OutrightMarket(
|
||||
startTime: new Date('2027-07-01T00:00:00Z'),
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
isHot: true,
|
||||
isHot: false,
|
||||
displayOrder: 0,
|
||||
},
|
||||
});
|
||||
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: {
|
||||
status: 'PUBLISHED',
|
||||
publishTime: match.publishTime ?? new Date(),
|
||||
closeTime: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const shouldPublish =
|
||||
match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED';
|
||||
if (match.isHot || shouldPublish) {
|
||||
const updateData: {
|
||||
isHot: boolean;
|
||||
status?: string;
|
||||
publishTime?: Date;
|
||||
closeTime?: null;
|
||||
} = { isHot: false };
|
||||
if (shouldPublish) {
|
||||
updateData.status = 'PUBLISHED';
|
||||
updateData.publishTime = match.publishTime ?? new Date();
|
||||
updateData.closeTime = null;
|
||||
}
|
||||
match = await prisma.match.update({
|
||||
where: { id: match.id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let market = await prisma.market.findFirst({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { WalletModule } from '../ledger/wallet.module';
|
||||
import { AgentsModule } from '../agent/agents.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
imports: [WalletModule, AgentsModule],
|
||||
providers: [DepositService],
|
||||
exports: [DepositService],
|
||||
})
|
||||
|
||||
119
apps/api/src/domains/deposit/deposit.service.spec.ts
Normal file
119
apps/api/src/domains/deposit/deposit.service.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { DepositService } from './deposit.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('DepositService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
depositOrder: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
bet: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {
|
||||
deposit: jest.fn(),
|
||||
withdraw: jest.fn(),
|
||||
};
|
||||
const credit = {
|
||||
recalculateUsedCredit: jest.fn(),
|
||||
};
|
||||
|
||||
let service: DepositService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new DepositService(prisma as never, funds as never, credit as never);
|
||||
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
orderNo: 'DEP-1',
|
||||
playerId: 7n,
|
||||
amount: new Decimal(100),
|
||||
approvedAmount: new Decimal(100),
|
||||
status: 'APPROVED',
|
||||
reviewedAt: new Date(),
|
||||
screenshotUrl: '/uploads/a.png',
|
||||
});
|
||||
tx.bet.findMany.mockResolvedValue([]);
|
||||
tx.user.findFirst.mockResolvedValue(null);
|
||||
tx.agentProfile.findUnique.mockResolvedValue(null);
|
||||
credit.recalculateUsedCredit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
|
||||
tx.depositOrder.findUnique.mockResolvedValue({
|
||||
id: 1n,
|
||||
orderNo: 'DEP-1',
|
||||
playerId: 7n,
|
||||
amount: new Decimal(100),
|
||||
approvedAmount: null,
|
||||
status: 'PENDING',
|
||||
reviewedAt: null,
|
||||
screenshotUrl: '/uploads/a.png',
|
||||
});
|
||||
tx.user.findFirst.mockResolvedValue({ parentId: 3n });
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(100),
|
||||
});
|
||||
|
||||
await service.approveDepositOrder(1n, 2n, 100, 'Bank receipt checked');
|
||||
|
||||
expect(tx.depositOrder.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'APPROVED',
|
||||
approvedAmount: new Decimal(100),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(funds.deposit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 7n,
|
||||
amount: new Decimal(100),
|
||||
operatorId: 2n,
|
||||
remark: 'Bank receipt checked',
|
||||
referenceId: 'DEP-1',
|
||||
transactionType: 'PLAYER_DEPOSIT',
|
||||
businessKey: 'deposit:DEP-1:approve',
|
||||
tx,
|
||||
}),
|
||||
);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenCalledTimes(2);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(1, 3n, tx);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(2, 3n, tx);
|
||||
});
|
||||
|
||||
it('blocks approved deposit revoke when bets exist after approval', async () => {
|
||||
tx.bet.findMany.mockResolvedValue([{ id: 99n }]);
|
||||
|
||||
await expect(
|
||||
service.reopenDepositOrderForReview(1n, 2n),
|
||||
).rejects.toMatchObject(expectAppError('DEPOSIT_REVOKE_SETTLED_BETS'));
|
||||
|
||||
expect(funds.withdraw).not.toHaveBeenCalled();
|
||||
expect(tx.depositOrder.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete a funded deposit order', async () => {
|
||||
await expect(
|
||||
service.deleteDepositOrder(1n, 2n),
|
||||
).rejects.toMatchObject(expectAppError('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN'));
|
||||
|
||||
expect(tx.depositOrder.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,8 @@ import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentCreditService } from '../agent/agent-credit.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
||||
|
||||
@@ -20,7 +21,8 @@ const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
|
||||
export class DepositService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private credit: AgentCreditService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
@@ -413,6 +415,7 @@ export class DepositService {
|
||||
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
|
||||
|
||||
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
|
||||
const parentAgentId = await this.assertParentCreditForDeposit(tx, order.playerId, creditAmount);
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -426,14 +429,19 @@ export class DepositService {
|
||||
});
|
||||
|
||||
// Credit player wallet
|
||||
await this.wallet.deposit(
|
||||
order.playerId,
|
||||
creditAmount,
|
||||
await this.funds.deposit({
|
||||
userId: order.playerId,
|
||||
amount: creditAmount,
|
||||
operatorId,
|
||||
remark ?? `Deposit order ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT',
|
||||
);
|
||||
remark: remark ?? `Deposit order ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT',
|
||||
tx,
|
||||
businessKey: `deposit:${order.orderNo}:approve`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -469,86 +477,76 @@ export class DepositService {
|
||||
remark: string,
|
||||
) {
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
remark,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
);
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
businessKey: `deposit:${order.orderNo}:reverse`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内):作废期间待结算注单并扣回入账 */
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内且未产生下注):扣回入账并恢复待审核 */
|
||||
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await this.prisma.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
const reviewedAt = order.reviewedAt;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await this.lockDepositOrder(tx, orderId);
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
await this.lockPlayerWallet(tx, order.playerId);
|
||||
const reviewedAt = order.reviewedAt;
|
||||
const betsAfterReview = await tx.bet.findMany({
|
||||
where: {
|
||||
userId: order.playerId,
|
||||
placedAt: { gte: reviewedAt },
|
||||
status: { not: 'VOID' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
|
||||
if (settled.length > 0) {
|
||||
if (betsAfterReview.length > 0) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
|
||||
}
|
||||
|
||||
for (const bet of betsAfterReview) {
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
bet.stake,
|
||||
bet.betNo,
|
||||
'VOID',
|
||||
tx,
|
||||
);
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
`Revoke approved deposit ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
remark: `Revoke approved deposit ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
tx,
|
||||
);
|
||||
businessKey: `deposit:${order.orderNo}:reopen-reverse`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -562,14 +560,17 @@ export class DepositService {
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, voidedBets: betsAfterReview.length };
|
||||
return { success: true, voidedBets: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
|
||||
/** 删除充值订单记录及截图,仅允许删除未资金化订单。 */
|
||||
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'APPROVED') {
|
||||
throw appBadRequest('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN');
|
||||
}
|
||||
|
||||
const screenshotUrl = order.screenshotUrl;
|
||||
await this.prisma.depositOrder.delete({ where: { id: orderId } });
|
||||
@@ -577,4 +578,51 @@ export class DepositService {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async lockDepositOrder(tx: Prisma.TransactionClient, orderId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM deposit_orders WHERE id = ${orderId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}
|
||||
|
||||
private async lockPlayerWallet(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM wallets WHERE user_id = ${playerId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('WALLET_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findPlayerParentAgentId(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const player = await tx.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
return player?.parentId ?? null;
|
||||
}
|
||||
|
||||
private async assertParentCreditForDeposit(
|
||||
tx: Prisma.TransactionClient,
|
||||
playerId: bigint,
|
||||
amount: Decimal,
|
||||
) {
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, playerId);
|
||||
if (!parentAgentId) return null;
|
||||
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
const profile = await tx.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
select: { creditLimit: true, usedCredit: true },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
if (available.lt(amount)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
return parentAgentId;
|
||||
}
|
||||
}
|
||||
|
||||
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
78
apps/api/src/domains/ledger/funds-posting.service.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
describe('FundsPostingService', () => {
|
||||
const wallet = {
|
||||
deposit: jest.fn(),
|
||||
freezeForBet: jest.fn(),
|
||||
settleBet: jest.fn(),
|
||||
applyResettleDelta: jest.fn(),
|
||||
};
|
||||
let service: FundsPostingService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new FundsPostingService(wallet as never);
|
||||
});
|
||||
|
||||
it('freezes bet funds with a stable business key', async () => {
|
||||
await service.freezeBet({
|
||||
userId: 1n,
|
||||
stake: new Decimal(50),
|
||||
betNo: 'BET-001',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.freezeForBet).toHaveBeenCalledWith(
|
||||
1n,
|
||||
expect.any(Decimal),
|
||||
'BET-001',
|
||||
expect.anything(),
|
||||
'bet:BET-001:freeze',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles bets with batch and bet identity in the business key', async () => {
|
||||
await service.settleBet({
|
||||
userId: 2n,
|
||||
stake: new Decimal(100),
|
||||
payout: new Decimal(180),
|
||||
betNo: 'BET-002',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'WIN',
|
||||
tx: {} as never,
|
||||
});
|
||||
|
||||
expect(wallet.settleBet).toHaveBeenCalledWith(
|
||||
2n,
|
||||
expect.any(Decimal),
|
||||
expect.any(Decimal),
|
||||
'BET-002',
|
||||
'WIN',
|
||||
expect.anything(),
|
||||
'settle:SETTLE-001:BET-002',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes caller-provided business keys for transfer postings', async () => {
|
||||
await service.deposit({
|
||||
userId: 3n,
|
||||
amount: 25,
|
||||
operatorId: 9n,
|
||||
referenceId: 'REQ-1',
|
||||
transactionType: 'ADMIN_DEPOSIT',
|
||||
businessKey: 'admin-deposit:9:REQ-1',
|
||||
});
|
||||
|
||||
expect(wallet.deposit).toHaveBeenCalledWith(
|
||||
3n,
|
||||
25,
|
||||
9n,
|
||||
undefined,
|
||||
'REQ-1',
|
||||
'ADMIN_DEPOSIT',
|
||||
undefined,
|
||||
'admin-deposit:9:REQ-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
124
apps/api/src/domains/ledger/funds-posting.service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
@Injectable()
|
||||
export class FundsPostingService {
|
||||
constructor(private wallet: WalletService) {}
|
||||
|
||||
deposit(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.deposit(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_DEPOSIT',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
withdraw(command: {
|
||||
userId: bigint;
|
||||
amount: Decimal | number;
|
||||
operatorId: bigint;
|
||||
remark?: string;
|
||||
referenceId?: string;
|
||||
transactionType?: string;
|
||||
businessKey?: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.withdraw(
|
||||
command.userId,
|
||||
command.amount,
|
||||
command.operatorId,
|
||||
command.remark,
|
||||
command.referenceId,
|
||||
command.transactionType ?? 'MANUAL_WITHDRAW',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
freezeBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal | number;
|
||||
betNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.freezeForBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`bet:${command.betNo}:freeze`,
|
||||
);
|
||||
}
|
||||
|
||||
settleBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
payout: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE';
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.payout,
|
||||
command.betNo,
|
||||
command.result,
|
||||
command.tx,
|
||||
`settle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
voidBet(command: {
|
||||
userId: bigint;
|
||||
stake: Decimal;
|
||||
betNo: string;
|
||||
businessKey: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.settleBet(
|
||||
command.userId,
|
||||
command.stake,
|
||||
command.stake,
|
||||
command.betNo,
|
||||
'VOID',
|
||||
command.tx,
|
||||
command.businessKey,
|
||||
);
|
||||
}
|
||||
|
||||
applyResettleDelta(command: {
|
||||
userId: bigint;
|
||||
delta: Decimal;
|
||||
betNo: string;
|
||||
batchNo: string;
|
||||
tx?: TxClient;
|
||||
}) {
|
||||
return this.wallet.applyResettleDelta(
|
||||
command.userId,
|
||||
command.delta,
|
||||
command.betNo,
|
||||
command.tx,
|
||||
`resettle:${command.batchNo}:${command.betNo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { FundsPostingService } from './funds-posting.service';
|
||||
|
||||
@Module({
|
||||
providers: [WalletService],
|
||||
exports: [WalletService],
|
||||
providers: [WalletService, FundsPostingService],
|
||||
exports: [WalletService, FundsPostingService],
|
||||
})
|
||||
export class WalletModule {}
|
||||
|
||||
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
75
apps/api/src/domains/ledger/wallet.service.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { WalletService } from './wallet.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('WalletService', () => {
|
||||
const tx = {
|
||||
$queryRaw: jest.fn(),
|
||||
wallet: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
|
||||
let service: WalletService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new WalletService(prisma as never);
|
||||
tx.walletTransaction.findUnique.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('rejects settlement when frozen funds are lower than the stake', async () => {
|
||||
tx.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 1n,
|
||||
available_balance: new Decimal(10),
|
||||
frozen_balance: new Decimal(40),
|
||||
version: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
service.settleBet(
|
||||
99n,
|
||||
new Decimal(50),
|
||||
new Decimal(0),
|
||||
'BET-LOW-FROZEN',
|
||||
'LOSE',
|
||||
undefined,
|
||||
'settle:BATCH:BET-LOW-FROZEN',
|
||||
),
|
||||
).rejects.toMatchObject(expectAppError('WALLET_FROZEN_INSUFFICIENT'));
|
||||
|
||||
expect(tx.wallet.update).not.toHaveBeenCalled();
|
||||
expect(tx.walletTransaction.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists player-approved deposits in transfer transactions', async () => {
|
||||
const listPrisma = {
|
||||
walletTransaction: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
};
|
||||
const listService = new WalletService(listPrisma as never);
|
||||
|
||||
await listService.listTransferTransactions({});
|
||||
|
||||
expect(listPrisma.walletTransaction.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
transactionType: expect.objectContaining({
|
||||
in: expect.arrayContaining(['PLAYER_DEPOSIT']),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ import { generateTransactionId } from '../../shared/common/decorators';
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
type WalletPostingOptions = {
|
||||
tx?: TxClient;
|
||||
businessKey?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@@ -31,6 +36,11 @@ export class WalletService {
|
||||
return wallets[0];
|
||||
}
|
||||
|
||||
private async findPosting(client: TxClient, businessKey?: string | null) {
|
||||
if (!businessKey) return null;
|
||||
return client.walletTransaction.findUnique({ where: { businessKey } });
|
||||
}
|
||||
|
||||
async deposit(
|
||||
userId: bigint,
|
||||
amount: Decimal | number,
|
||||
@@ -38,16 +48,24 @@ export class WalletService {
|
||||
remark?: string,
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_DEPOSIT',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
const balanceAfter = balanceBefore.add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -55,7 +73,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -68,13 +86,17 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'DEPOSIT',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
|
||||
return { balanceAfter };
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async withdraw(
|
||||
@@ -85,12 +107,19 @@ export class WalletService {
|
||||
referenceId?: string,
|
||||
transactionType = 'MANUAL_WITHDRAW',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return { balanceAfter: existing.balanceAfter };
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
|
||||
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
const balanceAfter = balanceBefore.sub(amt);
|
||||
@@ -116,6 +145,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'WITHDRAW',
|
||||
referenceId,
|
||||
businessKey,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
@@ -128,18 +158,30 @@ export class WalletService {
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
|
||||
async freezeForBet(
|
||||
userId: bigint,
|
||||
stake: Decimal | number,
|
||||
betId: string,
|
||||
tx?: TxClient,
|
||||
businessKey = `bet:${betId}:freeze`,
|
||||
) {
|
||||
const amt = new Decimal(stake);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
if (avail.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
|
||||
|
||||
const balanceAfter = avail.sub(amt);
|
||||
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
@@ -148,7 +190,7 @@ export class WalletService {
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
await client.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
@@ -161,9 +203,13 @@ export class WalletService {
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (tx) return run(tx);
|
||||
return this.prisma.$transaction(run);
|
||||
}
|
||||
|
||||
async settleBet(
|
||||
@@ -173,8 +219,12 @@ export class WalletService {
|
||||
betId: string,
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
@@ -185,16 +235,22 @@ export class WalletService {
|
||||
};
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const frozen = new Decimal(w.frozen_balance);
|
||||
const frozenAfter = frozen.sub(stake);
|
||||
if (frozenAfter.lt(0)) {
|
||||
throw appBadRequest('WALLET_FROZEN_INSUFFICIENT');
|
||||
}
|
||||
const balanceAfter = avail.add(payout);
|
||||
|
||||
await client.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenBalance: frozenAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
@@ -209,9 +265,10 @@ export class WalletService {
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: frozen,
|
||||
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
businessKey,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -226,11 +283,18 @@ export class WalletService {
|
||||
delta: Decimal,
|
||||
betNo: string,
|
||||
tx?: TxClient,
|
||||
businessKey?: string,
|
||||
) {
|
||||
if (delta.eq(0)) return;
|
||||
|
||||
const run = async (client: TxClient) => {
|
||||
const existing = await this.findPosting(client, businessKey);
|
||||
if (existing) return;
|
||||
|
||||
const w = await this.lockWallet(client, userId);
|
||||
const afterLockExisting = await this.findPosting(client, businessKey);
|
||||
if (afterLockExisting) return;
|
||||
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const balanceAfter = avail.add(delta);
|
||||
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
|
||||
@@ -256,6 +320,7 @@ export class WalletService {
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'BET',
|
||||
referenceId: betNo,
|
||||
businessKey,
|
||||
remark: 'Resettlement adjustment',
|
||||
},
|
||||
});
|
||||
@@ -817,6 +882,7 @@ export class WalletService {
|
||||
'AGENT_DEPOSIT',
|
||||
'AGENT_WITHDRAW',
|
||||
'INITIAL_DEPOSIT',
|
||||
'PLAYER_DEPOSIT',
|
||||
];
|
||||
const where: Prisma.WalletTransactionWhereInput = {
|
||||
transactionType: params.transactionType?.trim()
|
||||
|
||||
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal file
137
apps/api/src/domains/odds/markets.service.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { MarketsService } from './markets.service';
|
||||
|
||||
function selection(selectionCode: string, odds = 1.9) {
|
||||
return {
|
||||
selectionCode,
|
||||
selectionName: selectionCode,
|
||||
odds,
|
||||
status: 'OPEN',
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
const response = (error as { getResponse?: () => unknown }).getResponse?.();
|
||||
return (response as { code?: string } | undefined)?.code;
|
||||
}
|
||||
|
||||
function createPrismaMock() {
|
||||
let marketId = 100n;
|
||||
let selectionId = 1000n;
|
||||
return {
|
||||
match: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 1n }),
|
||||
},
|
||||
market: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => ({ id: marketId++, ...data })),
|
||||
update: jest.fn().mockImplementation(async ({ where, data }) => ({ id: where.id, ...data })),
|
||||
findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, selections: [] })),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
marketSelection: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockImplementation(async ({ data }) => ({ id: selectionId++, ...data })),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
oddsChangeLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MarketsService line value rules', () => {
|
||||
it('exposes usesLineValue in market definitions', () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
const definitions = service.listMarketDefinitions();
|
||||
|
||||
expect(definitions.find((d) => d.marketType === 'FT_HANDICAP')?.usesLineValue).toBe(true);
|
||||
expect(definitions.find((d) => d.marketType === 'FT_1X2')?.usesLineValue).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects line markets without a numeric line value', async () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
let caughtCode: string | undefined;
|
||||
try {
|
||||
await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: null,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
caughtCode = errorCode(error);
|
||||
}
|
||||
expect(caughtCode).toBe('MARKET_LINE_REQUIRED');
|
||||
});
|
||||
|
||||
it('rejects non-line markets with a line value', async () => {
|
||||
const service = new MarketsService(createPrismaMock() as never);
|
||||
|
||||
let caughtCode: string | undefined;
|
||||
try {
|
||||
await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
lineValue: 1,
|
||||
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
} catch (error) {
|
||||
caughtCode = errorCode(error);
|
||||
}
|
||||
expect(caughtCode).toBe('MARKET_LINE_NOT_ALLOWED');
|
||||
});
|
||||
|
||||
it('allows non-line markets with null line value', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new MarketsService(prisma as never);
|
||||
|
||||
const result = await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
lineValue: null,
|
||||
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ updated: 1, closed: 0 });
|
||||
expect(prisma.market.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
marketType: 'FT_1X2',
|
||||
lineKey: 'FT_1X2:none',
|
||||
lineValue: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps distinct line keys for copied line markets', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new MarketsService(prisma as never);
|
||||
|
||||
const result = await service.saveMatchMarkets(1n, [
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.5,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
lineValue: -0.25,
|
||||
selections: [selection('HOME'), selection('AWAY')],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ updated: 2, closed: 0 });
|
||||
expect(prisma.market.create.mock.calls.map(([arg]) => arg.data.lineKey)).toEqual([
|
||||
'FT_HANDICAP:-0.50',
|
||||
'FT_HANDICAP:-0.25',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,189 +1,291 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from '../settlement/domain/settlement-calculator';
|
||||
DEFAULT_MARKET_TYPES,
|
||||
FOOTBALL_MARKET_CATALOG,
|
||||
buildMarketLineKey,
|
||||
buildMarketTemplate,
|
||||
defaultSelectionName,
|
||||
isSettlementSupportedMarketType,
|
||||
marketUsesLineValue,
|
||||
resolveMarketText,
|
||||
sanitizeLocalizedText,
|
||||
type LocalizedText,
|
||||
} from '@thebet365/shared';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
|
||||
type LocalizedInput = Record<string, string | undefined | null>;
|
||||
|
||||
type SelectionDraft = {
|
||||
id?: string;
|
||||
selectionCode: string;
|
||||
selectionName?: string;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
odds: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
type MarketDraft = {
|
||||
id?: string;
|
||||
marketType: string;
|
||||
marketKey?: string | null;
|
||||
lineKey?: string | null;
|
||||
period?: string;
|
||||
lineValue?: number | null;
|
||||
paramsJson?: Record<string, unknown> | null;
|
||||
status?: string;
|
||||
allowSingle?: boolean;
|
||||
allowParlay?: boolean;
|
||||
showOnPlayer?: boolean;
|
||||
sortOrder?: number;
|
||||
promoLabel?: string | null;
|
||||
promoLabelI18n?: LocalizedInput | null;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
selections?: SelectionDraft[];
|
||||
};
|
||||
|
||||
type TemplateDraft = {
|
||||
name?: string;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
description?: string | null;
|
||||
isDefault?: boolean;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
items?: MarketDraft[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MarketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
||||
listMarketDefinitions() {
|
||||
return Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => ({
|
||||
marketType,
|
||||
marketKey: entry.marketKey,
|
||||
period: entry.period,
|
||||
sortOrder: entry.sortOrder,
|
||||
defaultLineValue: entry.defaultLineValue,
|
||||
allowSingle: entry.allowSingle,
|
||||
allowParlay: entry.allowParlay,
|
||||
showOnPlayer: entry.showOnPlayer,
|
||||
settlementSupported: entry.settlementSupported,
|
||||
settlementKind: entry.settlementKind,
|
||||
renderType: entry.renderType,
|
||||
usesLineValue: marketUsesLineValue(marketType),
|
||||
nameI18n: entry.nameI18n,
|
||||
selectionTemplate: entry.selectionTemplate,
|
||||
}));
|
||||
}
|
||||
|
||||
async listTemplates() {
|
||||
await this.ensureDefaultTemplate();
|
||||
const templates = await this.prisma.marketTemplate.findMany({
|
||||
where: { sportType: 'FOOTBALL' },
|
||||
include: { items: { include: { selections: true } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return templates.map((t) => this.mapTemplate(t));
|
||||
}
|
||||
|
||||
async getTemplate(templateId: bigint) {
|
||||
await this.ensureDefaultTemplate();
|
||||
const template = await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: {
|
||||
items: {
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
return this.mapTemplate(template);
|
||||
}
|
||||
|
||||
async createTemplate(data: TemplateDraft) {
|
||||
const nameI18n = sanitizeLocalizedText(data.nameI18n);
|
||||
const name = data.name?.trim() || resolveMarketText(nameI18n, 'zh-CN', '足球盘口模板');
|
||||
const template = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
name,
|
||||
nameI18n: this.jsonOrNull(nameI18n),
|
||||
description: data.description?.trim() || null,
|
||||
isDefault: data.isDefault ?? false,
|
||||
status: data.status ?? 'ACTIVE',
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
if (data.items?.length) {
|
||||
await this.replaceTemplateItems(template.id, data.items);
|
||||
}
|
||||
if (data.isDefault) await this.setDefaultTemplate(template.id);
|
||||
return this.getTemplate(template.id);
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: bigint, data: TemplateDraft) {
|
||||
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
const nameI18n =
|
||||
data.nameI18n !== undefined
|
||||
? sanitizeLocalizedText(data.nameI18n)
|
||||
: this.asLocalizedText(existing.nameI18n);
|
||||
await this.prisma.marketTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
...(data.name !== undefined
|
||||
? { name: data.name.trim() || resolveMarketText(nameI18n, 'zh-CN', existing.name) }
|
||||
: {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(nameI18n) } : {}),
|
||||
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
|
||||
...(data.isDefault !== undefined ? { isDefault: data.isDefault } : {}),
|
||||
},
|
||||
});
|
||||
if (data.items) await this.replaceTemplateItems(templateId, data.items);
|
||||
if (data.isDefault) await this.setDefaultTemplate(templateId);
|
||||
return this.getTemplate(templateId);
|
||||
}
|
||||
|
||||
async duplicateTemplate(templateId: bigint) {
|
||||
const source = await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
if (!source) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
const copy = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: source.sportType,
|
||||
name: `${source.name} Copy`,
|
||||
nameI18n: source.nameI18n ?? undefined,
|
||||
description: source.description,
|
||||
isDefault: false,
|
||||
status: source.status,
|
||||
sortOrder: source.sortOrder + 1,
|
||||
},
|
||||
});
|
||||
await this.replaceTemplateItems(
|
||||
copy.id,
|
||||
source.items.map((item) => ({
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel,
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
return this.getTemplate(copy.id);
|
||||
}
|
||||
|
||||
async setDefaultTemplate(templateId: bigint) {
|
||||
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.marketTemplate.updateMany({
|
||||
where: { sportType: 'FOOTBALL', id: { not: templateId } },
|
||||
data: { isDefault: false },
|
||||
}),
|
||||
this.prisma.marketTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: { isDefault: true, status: 'ACTIVE' },
|
||||
}),
|
||||
]);
|
||||
return this.getTemplate(templateId);
|
||||
}
|
||||
|
||||
async applyTemplateToMatch(matchId: bigint, templateId?: bigint | null) {
|
||||
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
||||
if (!match) throw appNotFound('MATCH_NOT_FOUND');
|
||||
const template = templateId
|
||||
? await this.prisma.marketTemplate.findUnique({
|
||||
where: { id: templateId },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
})
|
||||
: await this.getDefaultTemplateRecord();
|
||||
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
|
||||
|
||||
const created = [];
|
||||
const results = [];
|
||||
for (const item of template.items) {
|
||||
if (item.status === 'DELETED') continue;
|
||||
const draft: MarketDraft = {
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel,
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
};
|
||||
results.push(await this.upsertMatchMarket(matchId, draft, undefined, item.id));
|
||||
}
|
||||
return { templateId: template.id.toString(), applied: results.length };
|
||||
}
|
||||
|
||||
for (const marketType of marketTypes) {
|
||||
const existing = await this.prisma.market.findFirst({
|
||||
where: { matchId, marketType },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const config = this.getMarketConfig(marketType);
|
||||
const market = await this.prisma.market.create({
|
||||
data: {
|
||||
matchId,
|
||||
marketType,
|
||||
period: config.period,
|
||||
lineValue: config.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: config.allowParlay,
|
||||
sortOrder: config.sortOrder,
|
||||
selections: {
|
||||
create: config.selections.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds ?? 1.01,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
created.push(market);
|
||||
async saveMatchMarkets(matchId: bigint, items: MarketDraft[], operatorId?: bigint) {
|
||||
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
||||
if (!match) throw appNotFound('MATCH_NOT_FOUND');
|
||||
const saved = [];
|
||||
const activeIds = new Set<string>();
|
||||
for (const item of items) {
|
||||
const market = await this.upsertMatchMarket(matchId, item, operatorId);
|
||||
if (!market) throw appNotFound('MARKET_NOT_FOUND');
|
||||
activeIds.add(market.id.toString());
|
||||
saved.push(market);
|
||||
}
|
||||
|
||||
return created;
|
||||
const existing = await this.prisma.market.findMany({ where: { matchId }, select: { id: true } });
|
||||
const missing = existing.filter((m) => !activeIds.has(m.id.toString())).map((m) => m.id);
|
||||
if (missing.length) {
|
||||
await this.prisma.market.updateMany({
|
||||
where: { id: { in: missing } },
|
||||
data: { status: 'CLOSED', showOnPlayer: false },
|
||||
});
|
||||
}
|
||||
return { updated: saved.length, closed: missing.length };
|
||||
}
|
||||
|
||||
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
|
||||
const sideLabel = side === 'home' ? '主队' : '客队';
|
||||
const value = side === 'home' ? line : -line;
|
||||
const lineText = value > 0 ? `+${value}` : `${value}`;
|
||||
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
|
||||
}
|
||||
|
||||
private formatOuName(side: 'over' | 'under', line: number, half = false) {
|
||||
const sideLabel = side === 'over' ? '大' : '小';
|
||||
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
|
||||
}
|
||||
|
||||
private formatScoreName(code: string) {
|
||||
return code.replace('SCORE_', '').replace('_', '-');
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
const configs: Record<string, {
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
allowParlay: boolean;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds?: number }>;
|
||||
}> = {
|
||||
FT_1X2: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
HT_1X2: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
FT_HANDICAP: {
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
HT_HANDICAP: {
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
|
||||
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_OVER_UNDER: {
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
|
||||
],
|
||||
},
|
||||
HT_OVER_UNDER: {
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
|
||||
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
|
||||
],
|
||||
},
|
||||
FT_ODD_EVEN: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_CORRECT_SCORE: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 8,
|
||||
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 8.0,
|
||||
})),
|
||||
},
|
||||
HT_CORRECT_SCORE: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 9,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
SH_CORRECT_SCORE: {
|
||||
period: 'SH',
|
||||
allowParlay: true,
|
||||
sortOrder: 10,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: this.formatScoreName(code),
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
OUTRIGHT_WINNER: {
|
||||
period: 'OUTRIGHT',
|
||||
allowParlay: false,
|
||||
sortOrder: 1,
|
||||
selections: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[marketType];
|
||||
if (!config) throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
|
||||
return config;
|
||||
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
||||
const items = marketTypes.map((marketType, i) => {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
|
||||
});
|
||||
const result = await this.saveMatchMarkets(matchId, items);
|
||||
return { created: result.updated };
|
||||
}
|
||||
|
||||
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
|
||||
@@ -226,24 +328,42 @@ export class MarketsService {
|
||||
|
||||
async updateMarket(
|
||||
marketId: bigint,
|
||||
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
|
||||
data: {
|
||||
promoLabel?: string | null;
|
||||
promoLabelI18n?: LocalizedInput | null;
|
||||
nameI18n?: LocalizedInput | null;
|
||||
status?: string;
|
||||
lineValue?: number | null;
|
||||
showOnPlayer?: boolean;
|
||||
},
|
||||
) {
|
||||
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
|
||||
if (!market) throw appNotFound('MARKET_NOT_FOUND');
|
||||
const lineValue = data.lineValue !== undefined ? data.lineValue : market.lineValue == null ? null : Number(market.lineValue);
|
||||
if (data.lineValue !== undefined) {
|
||||
this.assertLineValueAllowed(market.marketType, lineValue);
|
||||
}
|
||||
const lineKey =
|
||||
data.lineValue !== undefined
|
||||
? buildMarketLineKey(market.marketType, lineValue, (market.paramsJson as Record<string, unknown> | null) ?? null)
|
||||
: market.lineKey;
|
||||
|
||||
return this.prisma.market.update({
|
||||
where: { id: marketId },
|
||||
data: {
|
||||
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
|
||||
...(data.promoLabelI18n !== undefined ? { promoLabelI18n: this.jsonOrNull(sanitizeLocalizedText(data.promoLabelI18n)) } : {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
|
||||
...(data.lineValue !== undefined ? { lineValue, lineKey } : {}),
|
||||
...(data.showOnPlayer !== undefined ? { showOnPlayer: data.showOnPlayer } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateSelection(
|
||||
selectionId: bigint,
|
||||
data: { selectionName?: string; odds?: number; status?: string },
|
||||
data: { selectionName?: string; nameI18n?: LocalizedInput | null; odds?: number; status?: string },
|
||||
operatorId?: bigint,
|
||||
) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
@@ -253,15 +373,355 @@ export class MarketsService {
|
||||
|
||||
if (data.odds != null) {
|
||||
if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED');
|
||||
return this.updateOdds(selectionId, data.odds, operatorId);
|
||||
await this.updateOdds(selectionId, data.odds, operatorId);
|
||||
}
|
||||
|
||||
return this.prisma.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: {
|
||||
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
|
||||
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureDefaultTemplate() {
|
||||
const existing = await this.prisma.marketTemplate.findFirst({
|
||||
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
|
||||
});
|
||||
if (existing) return existing;
|
||||
const template = await this.prisma.marketTemplate.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
name: '默认足球盘口模板',
|
||||
nameI18n: this.jsonOrNull({
|
||||
'zh-CN': '默认足球盘口模板',
|
||||
'en-US': 'Default Football Market Template',
|
||||
'ms-MY': 'Templat Pasaran Bola Sepak Lalai',
|
||||
}),
|
||||
isDefault: true,
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
await this.replaceTemplateItems(
|
||||
template.id,
|
||||
DEFAULT_MARKET_TYPES.map((marketType, i) => {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
|
||||
}),
|
||||
);
|
||||
return template;
|
||||
}
|
||||
|
||||
private async getDefaultTemplateRecord() {
|
||||
await this.ensureDefaultTemplate();
|
||||
return this.prisma.marketTemplate.findFirst({
|
||||
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
|
||||
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
private async replaceTemplateItems(templateId: bigint, items: MarketDraft[]) {
|
||||
await this.prisma.marketTemplateItem.deleteMany({ where: { templateId } });
|
||||
for (const [index, item] of items.entries()) {
|
||||
const normalized = this.normalizeDraft(item, index);
|
||||
await this.prisma.marketTemplateItem.create({
|
||||
data: {
|
||||
templateId,
|
||||
marketType: normalized.marketType,
|
||||
marketKey: normalized.marketKey,
|
||||
lineKey: normalized.lineKey,
|
||||
period: normalized.period,
|
||||
lineValue: normalized.lineValue,
|
||||
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
|
||||
status: normalized.status,
|
||||
allowSingle: normalized.allowSingle,
|
||||
allowParlay: normalized.allowParlay,
|
||||
showOnPlayer: normalized.showOnPlayer,
|
||||
sortOrder: normalized.sortOrder,
|
||||
promoLabel: normalized.promoLabel,
|
||||
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
|
||||
nameI18n: this.jsonOrNull(normalized.nameI18n),
|
||||
selections: {
|
||||
create: normalized.selections.map((s) => ({
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.jsonOrNull(s.nameI18n),
|
||||
odds: s.odds,
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertMatchMarket(
|
||||
matchId: bigint,
|
||||
draft: MarketDraft,
|
||||
operatorId?: bigint,
|
||||
templateItemId?: bigint,
|
||||
) {
|
||||
const normalized = this.normalizeDraft(draft, draft.sortOrder ?? 0);
|
||||
const existing = draft.id
|
||||
? await this.prisma.market.findFirst({ where: { id: BigInt(draft.id), matchId }, include: { selections: true } })
|
||||
: await this.prisma.market.findFirst({ where: { matchId, lineKey: normalized.lineKey }, include: { selections: true } });
|
||||
|
||||
const showOnPlayer = normalized.showOnPlayer && isSettlementSupportedMarketType(normalized.marketType);
|
||||
const data = {
|
||||
marketType: normalized.marketType,
|
||||
marketKey: normalized.marketKey,
|
||||
lineKey: normalized.lineKey,
|
||||
period: normalized.period,
|
||||
lineValue: normalized.lineValue,
|
||||
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
|
||||
status: normalized.status,
|
||||
allowSingle: normalized.allowSingle,
|
||||
allowParlay: normalized.allowParlay,
|
||||
showOnPlayer,
|
||||
sortOrder: normalized.sortOrder,
|
||||
promoLabel: normalized.promoLabel,
|
||||
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
|
||||
nameI18n: this.jsonOrNull(normalized.nameI18n),
|
||||
templateItemId: templateItemId ?? undefined,
|
||||
};
|
||||
|
||||
const market = existing
|
||||
? await this.prisma.market.update({ where: { id: existing.id }, data })
|
||||
: await this.prisma.market.create({ data: { matchId, ...data } });
|
||||
|
||||
await this.syncMarketSelections(market.id, normalized.selections, operatorId);
|
||||
return this.prisma.market.findUnique({
|
||||
where: { id: market.id },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
private async syncMarketSelections(
|
||||
marketId: bigint,
|
||||
selections: Array<SelectionDraft & { selectionName: string; nameI18n: LocalizedText; status: string; sortOrder: number }>,
|
||||
operatorId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.marketSelection.findMany({ where: { marketId } });
|
||||
const seen = new Set<string>();
|
||||
for (const sel of selections) {
|
||||
if (!sel.odds || sel.odds <= 1) throw appBadRequest('ODDS_MIN');
|
||||
const selectionId = sel.id;
|
||||
const current = selectionId
|
||||
? existing.find((s) => s.id === BigInt(selectionId))
|
||||
: existing.find((s) => s.selectionCode === sel.selectionCode);
|
||||
if (current) {
|
||||
seen.add(current.id.toString());
|
||||
const oddsChanged = Number(current.odds) !== Number(sel.odds);
|
||||
const newVersion = oddsChanged ? current.oddsVersion + BigInt(1) : current.oddsVersion;
|
||||
if (oddsChanged) {
|
||||
await this.prisma.oddsChangeLog.create({
|
||||
data: {
|
||||
selectionId: current.id,
|
||||
oldOdds: current.odds,
|
||||
newOdds: sel.odds,
|
||||
oddsVersion: newVersion,
|
||||
changedBy: operatorId,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.prisma.marketSelection.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
selectionCode: sel.selectionCode,
|
||||
selectionName: sel.selectionName,
|
||||
nameI18n: this.jsonOrNull(sel.nameI18n),
|
||||
odds: sel.odds,
|
||||
oddsVersion: newVersion,
|
||||
status: sel.status,
|
||||
sortOrder: sel.sortOrder,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const created = await this.prisma.marketSelection.create({
|
||||
data: {
|
||||
marketId,
|
||||
selectionCode: sel.selectionCode,
|
||||
selectionName: sel.selectionName,
|
||||
nameI18n: this.jsonOrNull(sel.nameI18n),
|
||||
odds: sel.odds,
|
||||
status: sel.status,
|
||||
sortOrder: sel.sortOrder,
|
||||
},
|
||||
});
|
||||
seen.add(created.id.toString());
|
||||
}
|
||||
}
|
||||
const toClose = existing.filter((s) => !seen.has(s.id.toString())).map((s) => s.id);
|
||||
if (toClose.length) {
|
||||
await this.prisma.marketSelection.updateMany({
|
||||
where: { id: { in: toClose } },
|
||||
data: { status: 'CLOSED' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeDraft(item: MarketDraft, fallbackOrder: number) {
|
||||
const config = this.getMarketConfig(item.marketType);
|
||||
const lineValue = item.lineValue !== undefined ? item.lineValue : config.defaultLineValue;
|
||||
this.assertLineValueAllowed(item.marketType, lineValue);
|
||||
const paramsJson = item.paramsJson ?? config.defaultParams ?? null;
|
||||
const lineKey = item.lineKey?.trim() || buildMarketLineKey(item.marketType, lineValue, paramsJson);
|
||||
const nameI18n = {
|
||||
...config.nameI18n,
|
||||
...sanitizeLocalizedText(item.nameI18n),
|
||||
};
|
||||
const promoLabelI18n = sanitizeLocalizedText(item.promoLabelI18n);
|
||||
const sourceSelections = item.selections?.length
|
||||
? item.selections
|
||||
: config.selectionTemplate.map((s, index) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: s.nameI18n,
|
||||
odds: s.odds,
|
||||
status: 'OPEN',
|
||||
sortOrder: index,
|
||||
}));
|
||||
return {
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey?.trim() || config.marketKey,
|
||||
lineKey,
|
||||
period: item.period || config.period,
|
||||
lineValue,
|
||||
paramsJson,
|
||||
status: item.status || 'OPEN',
|
||||
allowSingle: item.allowSingle ?? config.allowSingle,
|
||||
allowParlay: item.allowParlay ?? config.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer ?? config.showOnPlayer,
|
||||
sortOrder: item.sortOrder ?? config.sortOrder ?? fallbackOrder,
|
||||
promoLabel: item.promoLabel?.trim() || null,
|
||||
promoLabelI18n,
|
||||
nameI18n,
|
||||
selections: sourceSelections.map((s, index) => {
|
||||
const defaultName = defaultSelectionName(item.marketType, s.selectionCode, 'zh-CN');
|
||||
const rawNameI18n = sanitizeLocalizedText(s.nameI18n);
|
||||
const nameI18n = Object.keys(rawNameI18n).length
|
||||
? rawNameI18n
|
||||
: { 'zh-CN': s.selectionName?.trim() || defaultName };
|
||||
return {
|
||||
id: 'id' in s ? s.id : undefined,
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName?.trim() || resolveMarketText(nameI18n, 'zh-CN', defaultName),
|
||||
nameI18n,
|
||||
odds: Number(s.odds),
|
||||
status: s.status || 'OPEN',
|
||||
sortOrder: s.sortOrder ?? index,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private defaultDraftFromConfig(marketType: string, lineValue: number | null, index: number): MarketDraft {
|
||||
const config = this.getMarketConfig(marketType);
|
||||
this.assertLineValueAllowed(marketType, lineValue);
|
||||
return {
|
||||
marketType,
|
||||
marketKey: config.marketKey,
|
||||
lineKey: buildMarketLineKey(marketType, lineValue, null),
|
||||
period: config.period,
|
||||
lineValue,
|
||||
status: 'OPEN',
|
||||
allowSingle: config.allowSingle,
|
||||
allowParlay: config.allowParlay,
|
||||
showOnPlayer: config.showOnPlayer,
|
||||
sortOrder: config.sortOrder ?? index,
|
||||
nameI18n: config.nameI18n,
|
||||
selections: config.selectionTemplate.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
nameI18n: s.nameI18n,
|
||||
odds: s.odds,
|
||||
status: 'OPEN',
|
||||
sortOrder: i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private mapTemplate(template: Prisma.MarketTemplateGetPayload<{ include: { items: { include: { selections: true } } } }>) {
|
||||
return {
|
||||
id: template.id.toString(),
|
||||
sportType: template.sportType,
|
||||
name: template.name,
|
||||
nameI18n: this.asLocalizedText(template.nameI18n),
|
||||
description: template.description,
|
||||
isDefault: template.isDefault,
|
||||
status: template.status,
|
||||
sortOrder: template.sortOrder,
|
||||
items: template.items
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((item) => ({
|
||||
id: item.id.toString(),
|
||||
marketType: item.marketType,
|
||||
marketKey: item.marketKey,
|
||||
lineKey: item.lineKey,
|
||||
period: item.period,
|
||||
lineValue: item.lineValue == null ? null : Number(item.lineValue),
|
||||
paramsJson: item.paramsJson,
|
||||
status: item.status,
|
||||
allowSingle: item.allowSingle,
|
||||
allowParlay: item.allowParlay,
|
||||
showOnPlayer: item.showOnPlayer,
|
||||
sortOrder: item.sortOrder,
|
||||
promoLabel: item.promoLabel ?? '',
|
||||
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
|
||||
nameI18n: this.asLocalizedText(item.nameI18n),
|
||||
selections: item.selections
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
selectionCode: s.selectionCode,
|
||||
selectionName: s.selectionName,
|
||||
nameI18n: this.asLocalizedText(s.nameI18n),
|
||||
odds: Number(s.odds),
|
||||
status: s.status,
|
||||
sortOrder: s.sortOrder,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
try {
|
||||
return buildMarketTemplate(marketType);
|
||||
} catch {
|
||||
throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
|
||||
}
|
||||
}
|
||||
|
||||
private assertLineValueAllowed(marketType: string, lineValue: number | null | undefined) {
|
||||
if (marketUsesLineValue(marketType)) {
|
||||
if (typeof lineValue !== 'number' || !Number.isFinite(lineValue)) {
|
||||
throw appBadRequest('MARKET_LINE_REQUIRED');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (lineValue != null) {
|
||||
throw appBadRequest('MARKET_LINE_NOT_ALLOWED');
|
||||
}
|
||||
}
|
||||
|
||||
private jsonOrNull(value: LocalizedText | Record<string, unknown> | null | undefined) {
|
||||
if (!value || !Object.keys(value).length) return Prisma.JsonNull;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
private jsonValueOrNull(value: Record<string, unknown> | null | undefined) {
|
||||
if (value == null) return Prisma.JsonNull;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
private asLocalizedText(value: unknown): LocalizedText {
|
||||
return sanitizeLocalizedText(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../../ledger/funds-posting.service';
|
||||
import { SystemConfigService } from '../../../shared/config/system-config.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../../shared/common/decorators';
|
||||
@@ -33,7 +33,7 @@ type BetCashbackLine = {
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
@@ -498,29 +498,33 @@ export class CashbackService {
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.wallet.deposit(
|
||||
item.userId,
|
||||
item.amount,
|
||||
operatorId,
|
||||
`Cashback batch ${batch.batchNo}`,
|
||||
batch.batchNo,
|
||||
'CASHBACK_DEPOSIT',
|
||||
);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.funds.deposit({
|
||||
userId: item.userId,
|
||||
amount: item.amount,
|
||||
operatorId,
|
||||
remark: `Cashback batch ${batch.batchNo}`,
|
||||
referenceId: batch.batchNo,
|
||||
transactionType: 'CASHBACK_DEPOSIT',
|
||||
tx,
|
||||
businessKey: `cashback:${batch.batchNo}:${item.userId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (betIds.length > 0) {
|
||||
await this.prisma.bet.updateMany({
|
||||
where: { id: { in: betIds } },
|
||||
data: { isCashbacked: true },
|
||||
if (betIds.length > 0) {
|
||||
await tx.bet.updateMany({
|
||||
where: { id: { in: betIds } },
|
||||
data: { isCashbacked: true },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
canSelectForParlay,
|
||||
hasDuplicateParlayMatch,
|
||||
isQuarterHandicapOrTotal,
|
||||
PARLAY_MAX_LEGS,
|
||||
PARLAY_MIN_LEGS,
|
||||
@@ -460,13 +459,13 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
{
|
||||
id: 'B007',
|
||||
suite: 'betting',
|
||||
name: '同场串关应拒绝',
|
||||
name: '同场串关允许',
|
||||
uatRef: 'B007',
|
||||
run: () => {
|
||||
expectTrue('same match blocked', hasDuplicateParlayMatch(['1', '1']), { matchIds: ['1', '1'] });
|
||||
expectFalse('different matches ok', hasDuplicateParlayMatch(['1', '2', '3']), {
|
||||
matchIds: ['1', '2', '3'],
|
||||
});
|
||||
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
expectTrue('first same-match leg ok', first.ok, { matchId: '1' });
|
||||
expectTrue('second same-match leg ok', second.ok, { matchId: '1' });
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -169,6 +169,97 @@ describe('SettlementCalculator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Additional score-derived markets', () => {
|
||||
it('settles home and away team total goals', () => {
|
||||
const s = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TEAM_TOTAL_HOME',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 1.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TEAM_TOTAL_AWAY',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 1.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('settles half time / full time combinations', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'HT_FT',
|
||||
selectionCode: 'HOME_HOME',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'HT_FT',
|
||||
selectionCode: 'DRAW_HOME',
|
||||
score,
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
|
||||
it('settles total goals ranges', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TOTAL_GOALS',
|
||||
selectionCode: 'TG_2_3',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_TOTAL_GOALS',
|
||||
selectionCode: 'TG_7_PLUS',
|
||||
score,
|
||||
}),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
|
||||
it('settles corners handicap and totals from match stats', () => {
|
||||
const s = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
const stats = { homeCorners: 7, awayCorners: 4 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORNERS_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -2.5,
|
||||
score: s,
|
||||
stats,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORNERS_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 10.5,
|
||||
score: s,
|
||||
stats,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('settles cards total from match stats', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CARDS_OVER_UNDER',
|
||||
selectionCode: 'UNDER',
|
||||
totalLine: 5.5,
|
||||
score,
|
||||
stats: { homeCards: 2, awayCards: 3 },
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parlay', () => {
|
||||
it('S016: all win', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
|
||||
@@ -9,12 +9,24 @@ export interface ScoreInput {
|
||||
ftAway: number;
|
||||
}
|
||||
|
||||
export interface MatchStatsInput {
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
}
|
||||
|
||||
export interface SettlementInput {
|
||||
marketType: string;
|
||||
selectionCode: string;
|
||||
handicapLine?: number | null;
|
||||
totalLine?: number | null;
|
||||
score: ScoreInput;
|
||||
stats?: MatchStatsInput | null;
|
||||
templateScores?: string[];
|
||||
/** 冠军盘:获胜球队 code,如 FRA、BRA */
|
||||
winnerTeamCode?: string | null;
|
||||
@@ -143,8 +155,41 @@ function settleCorrectScore(
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' {
|
||||
if (home > away) return 'HOME';
|
||||
if (home < away) return 'AWAY';
|
||||
return 'DRAW';
|
||||
}
|
||||
|
||||
function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult {
|
||||
const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`;
|
||||
return selectionCode === expected ? 'WIN' : 'LOSE';
|
||||
}
|
||||
|
||||
function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult {
|
||||
if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE';
|
||||
|
||||
const exact = selectionCode.match(/^GOALS_(\d+)$/);
|
||||
if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE';
|
||||
const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/);
|
||||
if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE';
|
||||
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number {
|
||||
if (value == null || Number.isNaN(value)) {
|
||||
throw new Error(`SETTLEMENT_STAT_MISSING:${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
|
||||
const stats = input.stats ?? {};
|
||||
const templates = input.templateScores ?? [];
|
||||
|
||||
switch (marketType) {
|
||||
@@ -189,6 +234,35 @@ export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
const total = score.htHome + score.htAway;
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_TEAM_TOTAL_HOME':
|
||||
return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER');
|
||||
case 'FT_TEAM_TOTAL_AWAY':
|
||||
return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER');
|
||||
case 'HT_FT':
|
||||
return settleHtFt(score, selectionCode);
|
||||
case 'FT_TOTAL_GOALS':
|
||||
return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode);
|
||||
case 'FT_CORNERS_HANDICAP': {
|
||||
const homeCorners = requireStat(stats.homeCorners, 'homeCorners');
|
||||
const awayCorners = requireStat(stats.awayCorners, 'awayCorners');
|
||||
const line = handicapLine ?? 0;
|
||||
const isHome = selectionCode === 'HOME';
|
||||
const corners = isHome ? homeCorners : awayCorners;
|
||||
const opp = isHome ? awayCorners : homeCorners;
|
||||
return settleHandicap(corners, opp, isHome ? line : -line, isHome);
|
||||
}
|
||||
case 'FT_CORNERS_OVER_UNDER': {
|
||||
const total =
|
||||
requireStat(stats.homeCorners, 'homeCorners') +
|
||||
requireStat(stats.awayCorners, 'awayCorners');
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_CARDS_OVER_UNDER': {
|
||||
const total =
|
||||
requireStat(stats.homeCards, 'homeCards') +
|
||||
requireStat(stats.awayCards, 'awayCards');
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_CORRECT_SCORE':
|
||||
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
|
||||
case 'HT_CORRECT_SCORE':
|
||||
@@ -262,18 +336,8 @@ export function calculateParlayPayout(
|
||||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||||
}
|
||||
|
||||
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',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
|
||||
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
|
||||
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
|
||||
];
|
||||
|
||||
export const HT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
|
||||
];
|
||||
export {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
isQuarterHandicapOrTotal,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from './settlement-calculator';
|
||||
} from '@thebet365/shared';
|
||||
|
||||
const SNAPSHOT_1X2: Record<string, string> = {
|
||||
主胜: 'HOME',
|
||||
@@ -31,7 +31,7 @@ export function resolveSelectionCode(
|
||||
}
|
||||
|
||||
export function templateScoresForMarket(marketType: string): string[] {
|
||||
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
|
||||
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
let matchFindFirst: jest.Mock;
|
||||
let matchUpdate: jest.Mock;
|
||||
let teamFindUnique: jest.Mock;
|
||||
let marketFindMany: jest.Mock;
|
||||
let marketSelectionFindFirst: jest.Mock;
|
||||
let marketSelectionFindMany: jest.Mock;
|
||||
let matchScoreFindUnique: jest.Mock;
|
||||
@@ -90,6 +91,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
|
||||
matchUpdate = jest.fn().mockResolvedValue({});
|
||||
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
|
||||
marketFindMany = jest.fn().mockResolvedValue([]);
|
||||
marketSelectionFindFirst = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
|
||||
@@ -111,18 +113,30 @@ describe('SettlementService outright winner flow', () => {
|
||||
betFindMany = jest.fn();
|
||||
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
|
||||
fn({
|
||||
matchScore: { upsert: matchScoreUpsert },
|
||||
bet: { update: jest.fn().mockResolvedValue({}) },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: { findMany: marketSelectionFindMany },
|
||||
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
|
||||
bet: {
|
||||
findMany: betFindMany,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
betSelection: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementItem: { create: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: {
|
||||
findUnique: settlementBatchFindUnique,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
}),
|
||||
);
|
||||
|
||||
const prisma = {
|
||||
match: { findFirst: matchFindFirst, update: matchUpdate },
|
||||
match: { count: jest.fn().mockResolvedValue(0), findFirst: matchFindFirst, update: matchUpdate },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: {
|
||||
findFirst: marketSelectionFindFirst,
|
||||
findMany: marketSelectionFindMany,
|
||||
@@ -169,20 +183,233 @@ describe('SettlementService outright winner flow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('previewSettlement requires stats for visible stats markets', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketFindMany.mockResolvedValue([{ marketType: 'FT_CORNERS_OVER_UNDER' }]);
|
||||
betFindMany.mockResolvedValue([]);
|
||||
|
||||
try {
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 1,
|
||||
ftAway: 1,
|
||||
});
|
||||
throw new Error('Expected previewSettlement to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'SETTLEMENT_FACTS_REQUIRED',
|
||||
params: expect.objectContaining({ fields: 'homeCorners,awayCorners' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives total cards from yellow and red cards for settlement previews', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketFindMany.mockResolvedValue([{ marketType: 'FT_CARDS_OVER_UNDER' }]);
|
||||
betFindMany.mockResolvedValue([]);
|
||||
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 2,
|
||||
ftAway: 1,
|
||||
homeYellowCards: 2,
|
||||
homeRedCards: 1,
|
||||
awayYellowCards: 4,
|
||||
awayRedCards: 0,
|
||||
});
|
||||
|
||||
expect(settlementBatchCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
homeYellowCards: 2,
|
||||
homeRedCards: 1,
|
||||
awayYellowCards: 4,
|
||||
awayRedCards: 0,
|
||||
homeCards: 3,
|
||||
awayCards: 4,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a parlay pending until every match in the ticket is settled', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketSelectionFindMany.mockResolvedValue([
|
||||
{ id: BigInt(501), selectionCode: 'HOME' },
|
||||
{ id: BigInt(502), selectionCode: 'AWAY' },
|
||||
]);
|
||||
betFindMany.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2001),
|
||||
betNo: 'PARLAY-PENDING',
|
||||
betType: 'PARLAY',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(60),
|
||||
user: { id: BigInt(60) },
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(3001),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(3002),
|
||||
matchId: BigInt(101),
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const preview = await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 0,
|
||||
ftAway: 1,
|
||||
});
|
||||
|
||||
expect(preview.pendingOtherMatches).toBe(1);
|
||||
expect(preview.lostOnThisMatch).toBe(0);
|
||||
expect(preview.items.items).toEqual([
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-PENDING',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
payout: '0',
|
||||
note: '本场腿已出结果,待其他场次结算后统一结算',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('previews a parlay result only after the other legs already have results', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'CLOSED',
|
||||
deletedAt: null,
|
||||
});
|
||||
marketSelectionFindMany.mockResolvedValue([
|
||||
{ id: BigInt(501), selectionCode: 'HOME' },
|
||||
{ id: BigInt(502), selectionCode: 'AWAY' },
|
||||
]);
|
||||
betFindMany.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2002),
|
||||
betNo: 'PARLAY-READY',
|
||||
betType: 'PARLAY',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(61),
|
||||
user: { id: BigInt(61) },
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(3003),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(3004),
|
||||
matchId: BigInt(101),
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: 'WIN',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const preview = await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 0,
|
||||
ftAway: 1,
|
||||
});
|
||||
|
||||
expect(preview.pendingOtherMatches).toBe(0);
|
||||
expect(preview.items.items).toEqual([
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-READY',
|
||||
result: 'LOST',
|
||||
payout: '0',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
|
||||
const txBetUpdate = jest.fn().mockResolvedValue({});
|
||||
const txBetUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
|
||||
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
|
||||
await fn({
|
||||
matchScore: { upsert: matchScoreUpsert },
|
||||
bet: { update: txBetUpdate },
|
||||
team: { findUnique: teamFindUnique },
|
||||
market: { findMany: marketFindMany },
|
||||
marketSelection: { findMany: marketSelectionFindMany },
|
||||
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
|
||||
bet: {
|
||||
findMany: betFindMany,
|
||||
update: txBetUpdate,
|
||||
updateMany: txBetUpdateMany,
|
||||
},
|
||||
betSelection: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementItem: { create: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: {
|
||||
findUnique: settlementBatchFindUnique,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
});
|
||||
});
|
||||
settlementBatchFindUnique.mockResolvedValue({
|
||||
id: batchId,
|
||||
batchNo: 'SETTLE-001',
|
||||
matchId,
|
||||
status: 'PREVIEW',
|
||||
htHomeScore: 0,
|
||||
@@ -209,31 +436,31 @@ describe('SettlementService outright winner flow', () => {
|
||||
}),
|
||||
);
|
||||
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
|
||||
expect(wallet.settleBet.mock.calls[0]).toEqual([
|
||||
winningBet.userId,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'BET-WIN',
|
||||
'WIN',
|
||||
expect.anything(),
|
||||
]);
|
||||
expect(wallet.settleBet.mock.calls[1]).toEqual([
|
||||
losingBet.userId,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
'BET-LOSE',
|
||||
'LOSE',
|
||||
expect.anything(),
|
||||
]);
|
||||
expect(txBetUpdate).toHaveBeenCalledWith(
|
||||
expect(wallet.settleBet.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
where: { id: winningBetId },
|
||||
userId: winningBet.userId,
|
||||
betNo: 'BET-WIN',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'WIN',
|
||||
}),
|
||||
);
|
||||
expect(wallet.settleBet.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: losingBet.userId,
|
||||
betNo: 'BET-LOSE',
|
||||
batchNo: 'SETTLE-001',
|
||||
result: 'LOSE',
|
||||
}),
|
||||
);
|
||||
expect(txBetUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: winningBetId, status: 'PENDING' },
|
||||
data: expect.objectContaining({ status: 'WON' }),
|
||||
}),
|
||||
);
|
||||
expect(txBetUpdate).toHaveBeenCalledWith(
|
||||
expect(txBetUpdateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: losingBetId },
|
||||
where: { id: losingBetId, status: 'PENDING' },
|
||||
data: expect.objectContaining({ status: 'LOST' }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
ScoreInput,
|
||||
MatchStatsInput,
|
||||
SelectionResult,
|
||||
} from './domain/settlement-calculator';
|
||||
import {
|
||||
@@ -18,6 +20,15 @@ import {
|
||||
} from './domain/settlement-helpers';
|
||||
|
||||
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
|
||||
const STAT_MARKET_REQUIREMENTS = {
|
||||
FT_CORNERS_HANDICAP: ['homeCorners', 'awayCorners'],
|
||||
FT_CORNERS_OVER_UNDER: ['homeCorners', 'awayCorners'],
|
||||
FT_CARDS_OVER_UNDER: ['homeCards', 'awayCards'],
|
||||
} as const satisfies Record<string, readonly (keyof MatchStatsInput)[]>;
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
type PrismaClientLike = PrismaService | TxClient;
|
||||
type SettlementScoreSource = ScoreInput & MatchStatsInput & { winnerTeamId?: bigint | null };
|
||||
|
||||
type BetSelectionLeg = {
|
||||
id: bigint;
|
||||
@@ -36,13 +47,17 @@ type BetSelectionLeg = {
|
||||
export class SettlementService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
|
||||
private async resolveWinnerTeamCode(
|
||||
winnerTeamId: bigint | null | undefined,
|
||||
tx?: TxClient,
|
||||
): Promise<string | null> {
|
||||
if (!winnerTeamId) return null;
|
||||
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
const team = await client.team.findUnique({ where: { id: winnerTeamId } });
|
||||
return team?.code ?? null;
|
||||
}
|
||||
|
||||
@@ -68,6 +83,7 @@ export class SettlementService {
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
) {
|
||||
return {
|
||||
@@ -76,6 +92,7 @@ export class SettlementService {
|
||||
handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
stats: statsInput,
|
||||
templateScores: templateScoresForMarket(sel.marketType),
|
||||
winnerTeamCode,
|
||||
};
|
||||
@@ -85,9 +102,71 @@ export class SettlementService {
|
||||
sel: BetSelectionLeg,
|
||||
selectionCode: string,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
): SelectionResult {
|
||||
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
|
||||
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, statsInput, winnerTeamCode));
|
||||
}
|
||||
|
||||
private cardTotalFromBreakdown(
|
||||
total: number | null | undefined,
|
||||
yellow: number | null | undefined,
|
||||
red: number | null | undefined,
|
||||
): number | null {
|
||||
const hasBreakdown = yellow != null || red != null;
|
||||
if (!hasBreakdown) return total ?? null;
|
||||
if (yellow == null || red == null) return null;
|
||||
return yellow + red;
|
||||
}
|
||||
|
||||
private statsInputFromSource(source: MatchStatsInput): MatchStatsInput {
|
||||
const homeYellowCards = source.homeYellowCards ?? null;
|
||||
const awayYellowCards = source.awayYellowCards ?? null;
|
||||
const homeRedCards = source.homeRedCards ?? null;
|
||||
const awayRedCards = source.awayRedCards ?? null;
|
||||
return {
|
||||
homeCorners: source.homeCorners ?? null,
|
||||
awayCorners: source.awayCorners ?? null,
|
||||
homeYellowCards,
|
||||
awayYellowCards,
|
||||
homeRedCards,
|
||||
awayRedCards,
|
||||
homeCards: this.cardTotalFromBreakdown(source.homeCards, homeYellowCards, homeRedCards),
|
||||
awayCards: this.cardTotalFromBreakdown(source.awayCards, awayYellowCards, awayRedCards),
|
||||
};
|
||||
}
|
||||
|
||||
private collectRequiredStatFields(marketTypes: Iterable<string>) {
|
||||
const fields = new Set<keyof MatchStatsInput>();
|
||||
for (const marketType of marketTypes) {
|
||||
const required = STAT_MARKET_REQUIREMENTS[marketType as keyof typeof STAT_MARKET_REQUIREMENTS];
|
||||
if (!required) continue;
|
||||
for (const field of required) fields.add(field);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async assertRequiredStatsAvailable(
|
||||
matchId: bigint,
|
||||
statsInput: MatchStatsInput,
|
||||
betMarketTypes: Iterable<string>,
|
||||
client?: PrismaClientLike,
|
||||
) {
|
||||
const db = client ?? this.prisma;
|
||||
const visibleMarkets = await db.market.findMany({
|
||||
where: { matchId, showOnPlayer: true },
|
||||
select: { marketType: true },
|
||||
});
|
||||
const required = this.collectRequiredStatFields([
|
||||
...visibleMarkets.map((market) => market.marketType),
|
||||
...betMarketTypes,
|
||||
]);
|
||||
const missing = [...required].filter((field) => statsInput[field] == null);
|
||||
if (missing.length) {
|
||||
throw appBadRequest('SETTLEMENT_FACTS_REQUIRED', {
|
||||
fields: missing.join(','),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private walletResultFromSelection(
|
||||
@@ -118,6 +197,7 @@ export class SettlementService {
|
||||
ftAway: number,
|
||||
operatorId: bigint,
|
||||
winnerTeamId?: bigint,
|
||||
statsInput: MatchStatsInput = {},
|
||||
) {
|
||||
const match = await this.prisma.match.findFirst({
|
||||
where: { id: matchId, deletedAt: null },
|
||||
@@ -146,6 +226,7 @@ export class SettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
const stats = this.statsInputFromSource(statsInput);
|
||||
await this.prisma.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: {
|
||||
@@ -154,6 +235,14 @@ export class SettlementService {
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
homeCorners: match.isOutright ? null : stats.homeCorners,
|
||||
awayCorners: match.isOutright ? null : stats.awayCorners,
|
||||
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
|
||||
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
|
||||
homeRedCards: match.isOutright ? null : stats.homeRedCards,
|
||||
awayRedCards: match.isOutright ? null : stats.awayRedCards,
|
||||
homeCards: match.isOutright ? null : stats.homeCards,
|
||||
awayCards: match.isOutright ? null : stats.awayCards,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -162,6 +251,14 @@ export class SettlementService {
|
||||
htAwayScore: match.isOutright ? 0 : htAway,
|
||||
ftHomeScore: match.isOutright ? 0 : ftHome,
|
||||
ftAwayScore: match.isOutright ? 0 : ftAway,
|
||||
homeCorners: match.isOutright ? null : stats.homeCorners,
|
||||
awayCorners: match.isOutright ? null : stats.awayCorners,
|
||||
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
|
||||
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
|
||||
homeRedCards: match.isOutright ? null : stats.homeRedCards,
|
||||
awayRedCards: match.isOutright ? null : stats.awayRedCards,
|
||||
homeCards: match.isOutright ? null : stats.homeCards,
|
||||
awayCards: match.isOutright ? null : stats.awayCards,
|
||||
winnerTeamId: match.isOutright ? winnerTeamId : null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -172,7 +269,15 @@ export class SettlementService {
|
||||
data: { status: 'PENDING_SETTLEMENT' },
|
||||
});
|
||||
|
||||
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
|
||||
return {
|
||||
matchId,
|
||||
htHome,
|
||||
htAway,
|
||||
ftHome,
|
||||
ftAway,
|
||||
...stats,
|
||||
winnerTeamId: winnerTeamId?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async previewSettlement(
|
||||
@@ -185,6 +290,14 @@ export class SettlementService {
|
||||
htAway?: number;
|
||||
ftHome?: number;
|
||||
ftAway?: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -208,6 +321,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: scoreSource.homeCorners ?? null,
|
||||
awayCorners: scoreSource.awayCorners ?? null,
|
||||
homeYellowCards: scoreSource.homeYellowCards ?? null,
|
||||
awayYellowCards: scoreSource.awayYellowCards ?? null,
|
||||
homeRedCards: scoreSource.homeRedCards ?? null,
|
||||
awayRedCards: scoreSource.awayRedCards ?? null,
|
||||
homeCards: scoreSource.homeCards ?? null,
|
||||
awayCards: scoreSource.awayCards ?? null,
|
||||
status: 'PREVIEW',
|
||||
totalBets: computation.pendingBets.length,
|
||||
totalPayout: computation.totalPayout,
|
||||
@@ -248,6 +369,14 @@ export class SettlementService {
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
homeCorners: batch.homeCorners,
|
||||
awayCorners: batch.awayCorners,
|
||||
homeYellowCards: batch.homeYellowCards,
|
||||
awayYellowCards: batch.awayYellowCards,
|
||||
homeRedCards: batch.homeRedCards,
|
||||
awayRedCards: batch.awayRedCards,
|
||||
homeCards: batch.homeCards,
|
||||
awayCards: batch.awayCards,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
});
|
||||
const itemsPage = this.paginatePreviewItems(computation.items, opts);
|
||||
@@ -343,12 +472,21 @@ export class SettlementService {
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint | null;
|
||||
},
|
||||
operatorId: bigint,
|
||||
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
|
||||
) {
|
||||
const client = tx ?? this.prisma;
|
||||
const stats = this.statsInputFromSource(scoreSource);
|
||||
await client.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: {
|
||||
@@ -357,6 +495,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: stats.homeCorners,
|
||||
awayCorners: stats.awayCorners,
|
||||
homeYellowCards: stats.homeYellowCards,
|
||||
awayYellowCards: stats.awayYellowCards,
|
||||
homeRedCards: stats.homeRedCards,
|
||||
awayRedCards: stats.awayRedCards,
|
||||
homeCards: stats.homeCards,
|
||||
awayCards: stats.awayCards,
|
||||
winnerTeamId: scoreSource.winnerTeamId ?? null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -365,6 +511,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreSource.htAway,
|
||||
ftHomeScore: scoreSource.ftHome,
|
||||
ftAwayScore: scoreSource.ftAway,
|
||||
homeCorners: stats.homeCorners,
|
||||
awayCorners: stats.awayCorners,
|
||||
homeYellowCards: stats.homeYellowCards,
|
||||
awayYellowCards: stats.awayYellowCards,
|
||||
homeRedCards: stats.homeRedCards,
|
||||
awayRedCards: stats.awayRedCards,
|
||||
homeCards: stats.homeCards,
|
||||
awayCards: stats.awayCards,
|
||||
winnerTeamId: scoreSource.winnerTeamId ?? null,
|
||||
recordedBy: operatorId,
|
||||
},
|
||||
@@ -379,6 +533,14 @@ export class SettlementService {
|
||||
htAway?: number;
|
||||
ftHome?: number;
|
||||
ftAway?: number;
|
||||
homeCorners?: number | null;
|
||||
awayCorners?: number | null;
|
||||
homeYellowCards?: number | null;
|
||||
awayYellowCards?: number | null;
|
||||
homeRedCards?: number | null;
|
||||
awayRedCards?: number | null;
|
||||
homeCards?: number | null;
|
||||
awayCards?: number | null;
|
||||
winnerTeamId?: bigint;
|
||||
},
|
||||
) {
|
||||
@@ -387,6 +549,14 @@ export class SettlementService {
|
||||
opts?.htAway !== undefined ||
|
||||
opts?.ftHome !== undefined ||
|
||||
opts?.ftAway !== undefined ||
|
||||
opts?.homeCorners !== undefined ||
|
||||
opts?.awayCorners !== undefined ||
|
||||
opts?.homeYellowCards !== undefined ||
|
||||
opts?.awayYellowCards !== undefined ||
|
||||
opts?.homeRedCards !== undefined ||
|
||||
opts?.awayRedCards !== undefined ||
|
||||
opts?.homeCards !== undefined ||
|
||||
opts?.awayCards !== undefined ||
|
||||
opts?.winnerTeamId !== undefined;
|
||||
|
||||
if (hasRequestScore) {
|
||||
@@ -402,11 +572,22 @@ export class SettlementService {
|
||||
});
|
||||
if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
|
||||
}
|
||||
const stats = this.statsInputFromSource({
|
||||
homeCorners: opts?.homeCorners ?? null,
|
||||
awayCorners: opts?.awayCorners ?? null,
|
||||
homeYellowCards: opts?.homeYellowCards ?? null,
|
||||
awayYellowCards: opts?.awayYellowCards ?? null,
|
||||
homeRedCards: opts?.homeRedCards ?? null,
|
||||
awayRedCards: opts?.awayRedCards ?? null,
|
||||
homeCards: opts?.homeCards ?? null,
|
||||
awayCards: opts?.awayCards ?? null,
|
||||
});
|
||||
return {
|
||||
htHome: opts?.htHome ?? 0,
|
||||
htAway: opts?.htAway ?? 0,
|
||||
ftHome: opts?.ftHome ?? 0,
|
||||
ftAway: opts?.ftAway ?? 0,
|
||||
...stats,
|
||||
winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null,
|
||||
};
|
||||
}
|
||||
@@ -419,21 +600,24 @@ export class SettlementService {
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
homeCorners: score.homeCorners,
|
||||
awayCorners: score.awayCorners,
|
||||
homeYellowCards: score.homeYellowCards,
|
||||
awayYellowCards: score.awayYellowCards,
|
||||
homeRedCards: score.homeRedCards,
|
||||
awayRedCards: score.awayRedCards,
|
||||
homeCards: score.homeCards,
|
||||
awayCards: score.awayCards,
|
||||
winnerTeamId: score.winnerTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async computePreviewComputation(
|
||||
matchId: bigint,
|
||||
scoreSource?: {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
winnerTeamId?: bigint | null;
|
||||
},
|
||||
scoreSource?: SettlementScoreSource,
|
||||
) {
|
||||
let scoreInput: ScoreInput;
|
||||
let statsInput: MatchStatsInput;
|
||||
let winnerTeamCode: string | null;
|
||||
|
||||
if (scoreSource) {
|
||||
@@ -443,6 +627,7 @@ export class SettlementService {
|
||||
ftHome: scoreSource.ftHome,
|
||||
ftAway: scoreSource.ftAway,
|
||||
};
|
||||
statsInput = this.statsInputFromSource(scoreSource);
|
||||
winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null);
|
||||
} else {
|
||||
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
@@ -453,6 +638,7 @@ export class SettlementService {
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
statsInput = this.statsInputFromSource(score);
|
||||
winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
|
||||
}
|
||||
|
||||
@@ -465,6 +651,11 @@ export class SettlementService {
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
matchId,
|
||||
statsInput,
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
@@ -487,7 +678,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
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;
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
|
||||
@@ -504,7 +695,7 @@ export class SettlementService {
|
||||
);
|
||||
return {
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
|
||||
};
|
||||
});
|
||||
const parlay = calculateParlayPayout(bet.stake, legResults);
|
||||
@@ -528,7 +719,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
}
|
||||
|
||||
@@ -536,6 +727,7 @@ export class SettlementService {
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -552,16 +744,6 @@ export class SettlementService {
|
||||
} else if (preview.betResult === 'WON') {
|
||||
totalPayout = totalPayout.add(preview.payout);
|
||||
}
|
||||
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
|
||||
lostOnThisMatch += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
betType: 'PARLAY',
|
||||
result: 'LOST',
|
||||
payout: preview.payout,
|
||||
note: '本场已有输腿,串关整单作废',
|
||||
});
|
||||
} else {
|
||||
pendingOtherMatches += 1;
|
||||
items.push({
|
||||
@@ -570,7 +752,7 @@ export class SettlementService {
|
||||
betType: 'PARLAY',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
payout: new Decimal(0),
|
||||
note: '本场腿已出结果,待其他场次结算后派彩',
|
||||
note: '本场腿已出结果,待其他场次结算后统一结算',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -593,24 +775,12 @@ export class SettlementService {
|
||||
bet: { stake: Decimal; selections: BetSelectionLeg[] },
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
):
|
||||
| { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
|
||||
| { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
|
||||
| { kind: 'PENDING_OTHER_MATCHES' } {
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() !== matchId.toString()) continue;
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
if (result === 'LOSE' || result === 'HALF_LOSE') {
|
||||
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
|
||||
}
|
||||
}
|
||||
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() === matchId.toString()) {
|
||||
@@ -620,7 +790,7 @@ export class SettlementService {
|
||||
);
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
|
||||
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
|
||||
});
|
||||
} else if (sel.resultStatus) {
|
||||
legResults.push({
|
||||
@@ -641,6 +811,7 @@ export class SettlementService {
|
||||
|
||||
private async loadSelectionCodes(
|
||||
bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
|
||||
tx?: TxClient,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const ids = new Set<bigint>();
|
||||
for (const bet of bets) {
|
||||
@@ -648,7 +819,8 @@ export class SettlementService {
|
||||
}
|
||||
if (!ids.size) return new Map();
|
||||
|
||||
const rows = await this.prisma.marketSelection.findMany({
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
const rows = await client.marketSelection.findMany({
|
||||
where: { id: { in: [...ids] } },
|
||||
select: { id: true, selectionCode: true },
|
||||
});
|
||||
@@ -670,36 +842,63 @@ export class SettlementService {
|
||||
await this.assertOutrightLeagueFixturesSettled(batch.match);
|
||||
}
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
};
|
||||
const existingScore = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId: batch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.settlementBatch.updateMany({
|
||||
where: { id: batchId, status: 'PREVIEW' },
|
||||
data: { status: 'CONFIRMING', operatorId },
|
||||
});
|
||||
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const currentBatch = await tx.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
|
||||
if (currentBatch.match.status !== 'PENDING_SETTLEMENT') {
|
||||
throw appBadRequest('MATCH_NOT_SETTLEABLE');
|
||||
}
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: currentBatch.htHomeScore ?? 0,
|
||||
htAway: currentBatch.htAwayScore ?? 0,
|
||||
ftHome: currentBatch.ftHomeScore ?? 0,
|
||||
ftAway: currentBatch.ftAwayScore ?? 0,
|
||||
};
|
||||
const statsInput = this.statsInputFromSource(currentBatch);
|
||||
const existingScore = await tx.matchScore.findUnique({
|
||||
where: { matchId: currentBatch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(
|
||||
existingScore?.winnerTeamId ?? null,
|
||||
tx,
|
||||
);
|
||||
|
||||
const pendingBets = await tx.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: currentBatch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
|
||||
});
|
||||
const selectionCodes = await this.loadSelectionCodes(pendingBets, tx);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
currentBatch.matchId,
|
||||
statsInput,
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
let settledCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
{
|
||||
htHome: scoreInput.htHome,
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
},
|
||||
operatorId,
|
||||
@@ -713,34 +912,37 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
const betStatus = this.betStatusFromSelection(result);
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result, effectiveOdds: sel.odds },
|
||||
});
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout,
|
||||
bet.betNo,
|
||||
this.walletResultFromSelection(result),
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result: this.walletResultFromSelection(result),
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -758,7 +960,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
legResults.push({ odds: sel.odds, result });
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
@@ -773,29 +975,33 @@ export class SettlementService {
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
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.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -813,7 +1019,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result },
|
||||
@@ -840,29 +1046,33 @@ export class SettlementService {
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
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.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
settledCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -879,11 +1089,16 @@ export class SettlementService {
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
data: {
|
||||
status: 'CONFIRMED',
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
totalBets: settledCount,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.match.update({
|
||||
where: { id: batch.matchId },
|
||||
where: { id: currentBatch.matchId },
|
||||
data: { status: 'SETTLED' },
|
||||
});
|
||||
});
|
||||
@@ -1090,6 +1305,7 @@ export class SettlementService {
|
||||
},
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
statsInput: MatchStatsInput,
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
) {
|
||||
@@ -1099,7 +1315,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
return {
|
||||
payout,
|
||||
@@ -1118,7 +1334,7 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
|
||||
result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
legUpdates.set(sel.id.toString(), result);
|
||||
} else {
|
||||
if (!sel.resultStatus) {
|
||||
@@ -1142,7 +1358,7 @@ export class SettlementService {
|
||||
|
||||
async previewResettlement(
|
||||
matchId: bigint,
|
||||
scoreInput: ScoreInput,
|
||||
scoreInput: ScoreInput & MatchStatsInput,
|
||||
operatorId: bigint,
|
||||
reason?: string,
|
||||
winnerTeamId?: bigint,
|
||||
@@ -1158,6 +1374,7 @@ export class SettlementService {
|
||||
const winnerTeamCode = winnerTeamId
|
||||
? await this.resolveWinnerTeamCode(winnerTeamId)
|
||||
: null;
|
||||
const statsInput = this.statsInputFromSource(scoreInput);
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
@@ -1168,6 +1385,11 @@ export class SettlementService {
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
matchId,
|
||||
statsInput,
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
const items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
@@ -1187,6 +1409,7 @@ export class SettlementService {
|
||||
bet,
|
||||
matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -1215,6 +1438,14 @@ export class SettlementService {
|
||||
htAwayScore: scoreInput.htAway,
|
||||
ftHomeScore: scoreInput.ftHome,
|
||||
ftAwayScore: scoreInput.ftAway,
|
||||
homeCorners: statsInput.homeCorners ?? null,
|
||||
awayCorners: statsInput.awayCorners ?? null,
|
||||
homeYellowCards: statsInput.homeYellowCards ?? null,
|
||||
awayYellowCards: statsInput.awayYellowCards ?? null,
|
||||
homeRedCards: statsInput.homeRedCards ?? null,
|
||||
awayRedCards: statsInput.awayRedCards ?? null,
|
||||
homeCards: statsInput.homeCards ?? null,
|
||||
awayCards: statsInput.awayCards ?? null,
|
||||
status: 'PREVIEW',
|
||||
totalBets: items.length,
|
||||
totalPayout: totalTopup,
|
||||
@@ -1233,6 +1464,7 @@ export class SettlementService {
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId,
|
||||
},
|
||||
operatorId,
|
||||
@@ -1259,36 +1491,60 @@ export class SettlementService {
|
||||
if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
|
||||
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: batch.htHomeScore ?? 0,
|
||||
htAway: batch.htAwayScore ?? 0,
|
||||
ftHome: batch.ftHomeScore ?? 0,
|
||||
ftAway: batch.ftAwayScore ?? 0,
|
||||
};
|
||||
const existingScore = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId: batch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
|
||||
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets);
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.settlementBatch.updateMany({
|
||||
where: { id: batchId, status: 'PREVIEW' },
|
||||
data: { status: 'CONFIRMING', operatorId },
|
||||
});
|
||||
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
|
||||
|
||||
const currentBatch = await tx.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: currentBatch.htHomeScore ?? 0,
|
||||
htAway: currentBatch.htAwayScore ?? 0,
|
||||
ftHome: currentBatch.ftHomeScore ?? 0,
|
||||
ftAway: currentBatch.ftAwayScore ?? 0,
|
||||
};
|
||||
const statsInput = this.statsInputFromSource(currentBatch);
|
||||
const existingScore = await tx.matchScore.findUnique({
|
||||
where: { matchId: currentBatch.matchId },
|
||||
});
|
||||
const winnerTeamCode = await this.resolveWinnerTeamCode(
|
||||
existingScore?.winnerTeamId ?? null,
|
||||
tx,
|
||||
);
|
||||
|
||||
const settledBets = await tx.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
|
||||
selections: { some: { matchId: currentBatch.matchId } },
|
||||
},
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
const selectionCodes = await this.loadSelectionCodes(settledBets, tx);
|
||||
await this.assertRequiredStatsAvailable(
|
||||
currentBatch.matchId,
|
||||
statsInput,
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
let affectedCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
{
|
||||
htHome: scoreInput.htHome,
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
...statsInput,
|
||||
winnerTeamId: existingScore?.winnerTeamId ?? null,
|
||||
},
|
||||
operatorId,
|
||||
@@ -1299,8 +1555,9 @@ export class SettlementService {
|
||||
const oldPayout = new Decimal(bet.actualReturn);
|
||||
const outcome = await this.computeBetOutcome(
|
||||
bet,
|
||||
batch.matchId,
|
||||
currentBatch.matchId,
|
||||
scoreInput,
|
||||
statsInput,
|
||||
winnerTeamCode,
|
||||
selectionCodes,
|
||||
);
|
||||
@@ -1328,9 +1585,16 @@ export class SettlementService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
|
||||
await this.funds.applyResettleDelta({
|
||||
userId: bet.userId,
|
||||
delta,
|
||||
betNo: bet.betNo,
|
||||
batchNo: currentBatch.batchNo,
|
||||
tx,
|
||||
});
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
affectedCount += 1;
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
@@ -1345,7 +1609,12 @@ export class SettlementService {
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
data: {
|
||||
status: 'CONFIRMED',
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
totalBets: affectedCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,18 +1626,57 @@ export class SettlementService {
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
return this.voidMatchBetsInTransaction(matchId);
|
||||
}
|
||||
|
||||
async cancelMatchAndVoidBets(matchId: bigint) {
|
||||
return this.voidMatchBetsInTransaction(matchId, { cancelMatch: true });
|
||||
}
|
||||
|
||||
private async voidMatchBetsInTransaction(
|
||||
matchId: bigint,
|
||||
options: { cancelMatch?: boolean } = {},
|
||||
) {
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
const voidedCount = await this.prisma.$transaction(async (tx) => {
|
||||
if (options.cancelMatch) {
|
||||
await tx.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
}
|
||||
|
||||
const bets = await tx.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return count;
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
|
||||
await this.prisma.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
for (const agentId of agentIds) {
|
||||
await this.agents.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
return { voidedCount: bets.length };
|
||||
return { voidedCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import Decimal from 'decimal.js';
|
||||
import {
|
||||
calculatePayout,
|
||||
settleSelection,
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
type ScoreInput,
|
||||
} from './domain/settlement-calculator';
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
export type SmartScoreStrategy =
|
||||
| 'MIN_PAYOUT'
|
||||
@@ -39,8 +41,8 @@ export type ScoreEvaluation = {
|
||||
};
|
||||
|
||||
function templateForMarket(marketType: string): string[] {
|
||||
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
|
||||
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
|
||||
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ZhiboMatchExport, ZhiboTeamExport } from '../../domains/catalog/zh
|
||||
import {
|
||||
leagueCodeFromExport,
|
||||
resolveInternalStatus,
|
||||
resolveIsHot,
|
||||
resolveStartTime,
|
||||
teamCodeFromExport,
|
||||
toKickoffJson,
|
||||
@@ -263,7 +262,7 @@ async function upsertWc2026GroupMatch(prisma: PrismaClient, item: ZhiboMatchExpo
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
startTime,
|
||||
isHot: resolveIsHot(item),
|
||||
isHot: false,
|
||||
displayOrder: item.sortOrder,
|
||||
status,
|
||||
publishTime: status === 'PUBLISHED' ? new Date() : undefined,
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11173,
|
||||
@@ -46,11 +46,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -105,7 +105,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50467,
|
||||
@@ -122,7 +122,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -190,11 +190,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -249,7 +249,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11261,
|
||||
@@ -262,11 +262,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -334,11 +334,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
|
||||
"image": "https://flagcdn.com/mx.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -393,7 +393,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
|
||||
"image": "https://flagcdn.com/za.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11261,
|
||||
@@ -406,11 +406,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
|
||||
"image": "https://flagcdn.com/kr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -465,7 +465,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11153,
|
||||
@@ -478,11 +478,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -537,7 +537,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11036,
|
||||
@@ -550,11 +550,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -609,7 +609,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11153,
|
||||
@@ -622,11 +622,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -681,7 +681,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11267,
|
||||
@@ -694,11 +694,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -753,7 +753,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
|
||||
"image": "https://flagcdn.com/ch.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11166,
|
||||
@@ -766,11 +766,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
|
||||
"image": "https://flagcdn.com/ca.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -825,7 +825,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": ""
|
||||
"image": "https://flagcdn.com/ba.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": null,
|
||||
@@ -838,11 +838,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": ""
|
||||
"image": "https://flagcdn.com/qa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -897,7 +897,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11182,
|
||||
@@ -910,11 +910,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -969,7 +969,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11030,
|
||||
@@ -982,11 +982,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1041,7 +1041,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11182,
|
||||
@@ -1054,11 +1054,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1113,7 +1113,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11270,
|
||||
@@ -1126,11 +1126,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1185,7 +1185,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
|
||||
"image": "https://flagcdn.com/gb-sct.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11150,
|
||||
@@ -1198,11 +1198,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
|
||||
"image": "https://flagcdn.com/br.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1257,7 +1257,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
|
||||
"image": "https://flagcdn.com/ma.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11270,
|
||||
@@ -1270,11 +1270,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
|
||||
"image": "https://flagcdn.com/ht.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1329,7 +1329,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11148,
|
||||
@@ -1342,11 +1342,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1383,11 +1383,11 @@
|
||||
"zh": "2026 世界杯"
|
||||
},
|
||||
"kickoff": {
|
||||
"utcTimeStart": 1781323200,
|
||||
"utcTimeStop": 1781330400,
|
||||
"utcIso": "2026-06-13T04:00:00Z",
|
||||
"chinaTime": "2026-06-13 12:00:00 Asia/Shanghai",
|
||||
"venueTime": "2026-06-12 21:00:00 America/Los_Angeles",
|
||||
"utcTimeStart": 1781409600,
|
||||
"utcTimeStop": 1781416800,
|
||||
"utcIso": "2026-06-14T04:00:00Z",
|
||||
"chinaTime": "2026-06-14 12:00:00 Asia/Shanghai",
|
||||
"venueTime": "2026-06-13 21:00:00 America/Los_Angeles",
|
||||
"venueTimezone": "America/Los_Angeles"
|
||||
},
|
||||
"homeTeam": {
|
||||
@@ -1401,7 +1401,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50468,
|
||||
@@ -1418,7 +1418,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1473,7 +1473,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11273,
|
||||
@@ -1486,11 +1486,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1558,11 +1558,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1630,11 +1630,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
|
||||
"image": "https://flagcdn.com/us.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1689,7 +1689,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
|
||||
"image": "https://flagcdn.com/py.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11273,
|
||||
@@ -1702,11 +1702,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
|
||||
"image": "https://flagcdn.com/au.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1761,7 +1761,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 19979,
|
||||
@@ -1774,11 +1774,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1846,11 +1846,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1905,7 +1905,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50469,
|
||||
@@ -1922,7 +1922,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -1977,7 +1977,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 19979,
|
||||
@@ -1990,11 +1990,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2049,7 +2049,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
|
||||
"image": "https://flagcdn.com/ec.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11038,
|
||||
@@ -2062,11 +2062,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
|
||||
"image": "https://flagcdn.com/de.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2121,7 +2121,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
|
||||
"image": "https://flagcdn.com/cw.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50469,
|
||||
@@ -2138,7 +2138,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "off",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2193,7 +2193,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11266,
|
||||
@@ -2206,11 +2206,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2265,7 +2265,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11190,
|
||||
@@ -2278,11 +2278,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2337,7 +2337,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11032,
|
||||
@@ -2350,11 +2350,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2409,7 +2409,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11266,
|
||||
@@ -2422,11 +2422,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2481,7 +2481,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
|
||||
"image": "https://flagcdn.com/jp.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11032,
|
||||
@@ -2494,11 +2494,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
|
||||
"image": "https://flagcdn.com/se.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2553,7 +2553,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
|
||||
"image": "https://flagcdn.com/tn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11034,
|
||||
@@ -2566,11 +2566,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
|
||||
"image": "https://flagcdn.com/nl.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2625,7 +2625,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11109,
|
||||
@@ -2638,11 +2638,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2697,7 +2697,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 12310,
|
||||
@@ -2710,11 +2710,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2769,7 +2769,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11154,
|
||||
@@ -2782,11 +2782,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2841,7 +2841,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11109,
|
||||
@@ -2854,11 +2854,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2913,7 +2913,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
|
||||
"image": "https://flagcdn.com/eg.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11154,
|
||||
@@ -2926,11 +2926,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
|
||||
"image": "https://flagcdn.com/ir.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -2985,7 +2985,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
|
||||
"image": "https://flagcdn.com/nz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11033,
|
||||
@@ -2998,11 +2998,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
|
||||
"image": "https://flagcdn.com/be.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3057,7 +3057,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11161,
|
||||
@@ -3070,11 +3070,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3129,7 +3129,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11139,
|
||||
@@ -3142,11 +3142,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3201,7 +3201,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11254,
|
||||
@@ -3214,11 +3214,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3273,7 +3273,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11161,
|
||||
@@ -3286,11 +3286,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3345,7 +3345,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
|
||||
"image": "https://flagcdn.com/cv.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11254,
|
||||
@@ -3358,11 +3358,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
|
||||
"image": "https://flagcdn.com/sa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3417,7 +3417,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
|
||||
"image": "https://flagcdn.com/uy.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11144,
|
||||
@@ -3430,11 +3430,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
|
||||
"image": "https://flagcdn.com/es.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3489,7 +3489,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11184,
|
||||
@@ -3502,11 +3502,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3561,7 +3561,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11029,
|
||||
@@ -3574,11 +3574,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3633,7 +3633,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11238,
|
||||
@@ -3646,11 +3646,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3705,7 +3705,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11184,
|
||||
@@ -3718,11 +3718,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3777,7 +3777,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
|
||||
"image": "https://flagcdn.com/no.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11037,
|
||||
@@ -3790,11 +3790,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
|
||||
"image": "https://flagcdn.com/fr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3849,7 +3849,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
|
||||
"image": "https://flagcdn.com/sn.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11238,
|
||||
@@ -3862,11 +3862,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
|
||||
"image": "https://flagcdn.com/iq.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3921,7 +3921,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 20195,
|
||||
@@ -3934,11 +3934,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -3993,7 +3993,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11244,
|
||||
@@ -4006,11 +4006,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4065,7 +4065,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11035,
|
||||
@@ -4078,11 +4078,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4137,7 +4137,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 20195,
|
||||
@@ -4150,11 +4150,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4209,7 +4209,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
|
||||
"image": "https://flagcdn.com/dz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11035,
|
||||
@@ -4222,11 +4222,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
|
||||
"image": "https://flagcdn.com/at.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4281,7 +4281,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
|
||||
"image": "https://flagcdn.com/jo.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11138,
|
||||
@@ -4294,11 +4294,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
|
||||
"image": "https://flagcdn.com/ar.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4353,7 +4353,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50470,
|
||||
@@ -4370,7 +4370,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4425,7 +4425,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11147,
|
||||
@@ -4438,11 +4438,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4497,7 +4497,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11239,
|
||||
@@ -4510,11 +4510,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4569,7 +4569,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 50470,
|
||||
@@ -4586,7 +4586,7 @@
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4641,7 +4641,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
|
||||
"image": "https://flagcdn.com/co.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11137,
|
||||
@@ -4654,11 +4654,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
|
||||
"image": "https://flagcdn.com/pt.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4726,11 +4726,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
|
||||
"image": "https://flagcdn.com/uz.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4785,7 +4785,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11140,
|
||||
@@ -4798,11 +4798,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4857,7 +4857,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11169,
|
||||
@@ -4870,11 +4870,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -4929,7 +4929,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11179,
|
||||
@@ -4942,11 +4942,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5001,7 +5001,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11140,
|
||||
@@ -5014,11 +5014,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5073,7 +5073,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
|
||||
"image": "https://flagcdn.com/pa.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11116,
|
||||
@@ -5086,11 +5086,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
|
||||
"image": "https://flagcdn.com/gb-eng.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
@@ -5145,7 +5145,7 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
|
||||
"image": "https://flagcdn.com/hr.svg"
|
||||
},
|
||||
"awayTeam": {
|
||||
"id": 11179,
|
||||
@@ -5158,11 +5158,11 @@
|
||||
"km": null,
|
||||
"ms": null
|
||||
},
|
||||
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
|
||||
"image": "https://flagcdn.com/gh.svg"
|
||||
},
|
||||
"status": {
|
||||
"state": "scheduled",
|
||||
"isHot": 1000
|
||||
"isHot": 0
|
||||
},
|
||||
"venue": {
|
||||
"names": {
|
||||
|
||||
@@ -50,54 +50,61 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
|
||||
50470: 'COD',
|
||||
};
|
||||
|
||||
/** zhibo 球队 logo(seed 时写入 teams.logo_url) */
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
|
||||
ALG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png',
|
||||
ARG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png',
|
||||
AUS: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png',
|
||||
AUT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png',
|
||||
BEL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png',
|
||||
BIH: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png',
|
||||
BRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png',
|
||||
CAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png',
|
||||
CIV: 'https://flagcdn.com/ci.svg',
|
||||
COD: 'https://flagcdn.com/cd.svg',
|
||||
COL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png',
|
||||
CPV: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png',
|
||||
CRO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png',
|
||||
CUW: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png',
|
||||
CZE: 'https://flagcdn.com/cz.svg',
|
||||
ECU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png',
|
||||
EGY: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png',
|
||||
ENG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png',
|
||||
ESP: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png',
|
||||
FRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png',
|
||||
GER: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png',
|
||||
GHA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png',
|
||||
HAI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png',
|
||||
IRN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png',
|
||||
IRQ: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png',
|
||||
JOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png',
|
||||
JPN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png',
|
||||
KOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png',
|
||||
KSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png',
|
||||
MAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png',
|
||||
MEX: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png',
|
||||
NED: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png',
|
||||
NOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png',
|
||||
NZL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png',
|
||||
PAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png',
|
||||
PAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png',
|
||||
POR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png',
|
||||
QAT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png',
|
||||
RSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png',
|
||||
SCO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png',
|
||||
SEN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png',
|
||||
SUI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png',
|
||||
SWE: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png',
|
||||
TUN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png',
|
||||
TUR: 'https://flagcdn.com/tr.svg',
|
||||
URU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png',
|
||||
USA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png',
|
||||
UZB: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png',
|
||||
/** WC2026 默认国家/地区国旗(FlagCDN,seed 时写入 teams.logo_url) */
|
||||
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
|
||||
ALG: 'dz',
|
||||
ARG: 'ar',
|
||||
AUS: 'au',
|
||||
AUT: 'at',
|
||||
BEL: 'be',
|
||||
BIH: 'ba',
|
||||
BRA: 'br',
|
||||
CAN: 'ca',
|
||||
CIV: 'ci',
|
||||
COD: 'cd',
|
||||
COL: 'co',
|
||||
CPV: 'cv',
|
||||
CRO: 'hr',
|
||||
CUW: 'cw',
|
||||
CZE: 'cz',
|
||||
ECU: 'ec',
|
||||
EGY: 'eg',
|
||||
ENG: 'gb-eng',
|
||||
ESP: 'es',
|
||||
FRA: 'fr',
|
||||
GER: 'de',
|
||||
GHA: 'gh',
|
||||
HAI: 'ht',
|
||||
IRN: 'ir',
|
||||
IRQ: 'iq',
|
||||
JOR: 'jo',
|
||||
JPN: 'jp',
|
||||
KOR: 'kr',
|
||||
KSA: 'sa',
|
||||
MAR: 'ma',
|
||||
MEX: 'mx',
|
||||
NED: 'nl',
|
||||
NOR: 'no',
|
||||
NZL: 'nz',
|
||||
PAN: 'pa',
|
||||
PAR: 'py',
|
||||
POR: 'pt',
|
||||
QAT: 'qa',
|
||||
RSA: 'za',
|
||||
SCO: 'gb-sct',
|
||||
SEN: 'sn',
|
||||
SUI: 'ch',
|
||||
SWE: 'se',
|
||||
TUN: 'tn',
|
||||
TUR: 'tr',
|
||||
URU: 'uy',
|
||||
USA: 'us',
|
||||
UZB: 'uz',
|
||||
};
|
||||
|
||||
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
|
||||
code,
|
||||
`https://flagcdn.com/${iso}.svg`,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -1,129 +1,12 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { DEFAULT_MARKET_TYPES, buildMarketTemplate } from '@thebet365/shared';
|
||||
|
||||
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
|
||||
export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
const configs: Array<{
|
||||
marketType: string;
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds: number }>;
|
||||
}> = [
|
||||
{
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主胜', odds: 2.5 },
|
||||
{ code: 'DRAW', name: '和', odds: 3.2 },
|
||||
{ code: 'AWAY', name: '客胜', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_HANDICAP',
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_ODD_EVEN',
|
||||
period: 'FT',
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: '单', odds: 1.9 },
|
||||
{ code: 'EVEN', name: '双', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_1X2',
|
||||
period: 'HT',
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主', odds: 3.0 },
|
||||
{ code: 'DRAW', name: '半场和', odds: 2.0 },
|
||||
{ code: 'AWAY', name: '半场客', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_HANDICAP',
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_OVER_UNDER',
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
period: 'FT',
|
||||
sortOrder: 8,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
|
||||
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
|
||||
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
|
||||
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
|
||||
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
|
||||
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
|
||||
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'HT_CORRECT_SCORE',
|
||||
period: 'HT',
|
||||
sortOrder: 9,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
period: 'SH',
|
||||
sortOrder: 10,
|
||||
selections: [
|
||||
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
|
||||
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
|
||||
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
|
||||
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
|
||||
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
|
||||
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const configs = DEFAULT_MARKET_TYPES.map((marketType) => ({
|
||||
marketType,
|
||||
...buildMarketTemplate(marketType),
|
||||
}));
|
||||
|
||||
for (const cfg of configs) {
|
||||
const exists = await prisma.market.findFirst({
|
||||
@@ -133,7 +16,7 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
if (exists) {
|
||||
const needRefresh =
|
||||
cfg.marketType.includes('CORRECT_SCORE') &&
|
||||
exists._count.selections < cfg.selections.length;
|
||||
exists._count.selections < cfg.selectionTemplate.length;
|
||||
if (needRefresh) {
|
||||
await prisma.market.delete({ where: { id: exists.id } });
|
||||
} else {
|
||||
@@ -145,13 +28,13 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
|
||||
matchId,
|
||||
marketType: cfg.marketType,
|
||||
period: cfg.period,
|
||||
lineValue: cfg.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: true,
|
||||
lineValue: cfg.defaultLineValue,
|
||||
allowSingle: cfg.allowSingle,
|
||||
allowParlay: cfg.allowParlay,
|
||||
sortOrder: cfg.sortOrder,
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: cfg.selections.map((s, i) => ({
|
||||
create: cfg.selectionTemplate.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
calculatePayout,
|
||||
isQuarterHandicapOrTotal,
|
||||
} from './domains/settlement/domain/settlement-calculator';
|
||||
import { hasDuplicateParlayMatch } from '@thebet365/shared';
|
||||
import { canSelectForParlay } from '@thebet365/shared';
|
||||
|
||||
/**
|
||||
* Agent credit & wallet integration scenarios (A001-A007)
|
||||
@@ -83,9 +83,11 @@ describe('Bet Validation Rules (B001-B010)', () => {
|
||||
expect(submitted === current).toBe(false);
|
||||
});
|
||||
|
||||
it('B007: same match legs rejected in parlay', () => {
|
||||
expect(hasDuplicateParlayMatch(['1', '1'])).toBe(true);
|
||||
expect(hasDuplicateParlayMatch(['1', '2', '3'])).toBe(false);
|
||||
it('B007: same match legs are allowed in parlay', () => {
|
||||
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('B008: quarter line in parlay rejected', () => {
|
||||
|
||||
@@ -6,6 +6,21 @@ import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './shared/common/filters';
|
||||
import { getUploadRoot } from './shared/uploads/upload-paths';
|
||||
|
||||
function envFlag(name: string) {
|
||||
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
function resolveCorsOrigin() {
|
||||
const raw = process.env.CORS_ORIGINS?.trim();
|
||||
if (raw) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return process.env.NODE_ENV === 'production' ? false : true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
@@ -14,7 +29,7 @@ async function bootstrap() {
|
||||
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.enableCors({ origin: resolveCorsOrigin(), credentials: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
@@ -23,18 +38,23 @@ async function bootstrap() {
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TheBet365 API')
|
||||
.setDescription('足球投注平台 MVP API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
|
||||
const swaggerEnabled = process.env.NODE_ENV !== 'production' || envFlag('ENABLE_SWAGGER');
|
||||
if (swaggerEnabled) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TheBet365 API')
|
||||
.setDescription('足球投注平台 MVP API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
if (swaggerEnabled) {
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
41
apps/api/src/shared/match-time.spec.ts
Normal file
41
apps/api/src/shared/match-time.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
formatLocalMatchDateTime,
|
||||
isAfterLocalToday,
|
||||
isInLocalToday,
|
||||
isoToPlatformPickerDateTime,
|
||||
platformPickerDateTimeToIso,
|
||||
} from '@thebet365/shared';
|
||||
|
||||
describe('match time helpers', () => {
|
||||
it('converts Malaysia picker time to UTC ISO and back', () => {
|
||||
const iso = platformPickerDateTimeToIso('2026-06-11T19:00:00');
|
||||
|
||||
expect(iso).toBe('2026-06-11T11:00:00.000Z');
|
||||
expect(isoToPlatformPickerDateTime(iso)).toBe('2026-06-11T19:00:00');
|
||||
});
|
||||
|
||||
it('formats the same kickoff in different local time zones with GMT offsets', () => {
|
||||
const malaysia = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
|
||||
variant: 'full',
|
||||
timeZone: 'Asia/Kuala_Lumpur',
|
||||
});
|
||||
|
||||
const newYork = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
|
||||
variant: 'full',
|
||||
timeZone: 'America/New_York',
|
||||
});
|
||||
|
||||
expect(malaysia).toContain('07:00');
|
||||
expect(malaysia).toContain('GMT+8');
|
||||
expect(newYork).toContain('07:00');
|
||||
expect(newYork).toContain('GMT-4');
|
||||
expect(malaysia).not.toBe(newYork);
|
||||
});
|
||||
|
||||
it('uses the player local day for today and early buckets', () => {
|
||||
const now = new Date('2026-06-11T15:00:00-04:00');
|
||||
|
||||
expect(isInLocalToday('2026-06-12T02:00:00.000Z', now, 'America/New_York')).toBe(true);
|
||||
expect(isAfterLocalToday('2026-06-12T05:00:00.000Z', now, 'America/New_York')).toBe(true);
|
||||
});
|
||||
});
|
||||
18
apps/api/src/testing/prisma-mock.ts
Normal file
18
apps/api/src/testing/prisma-mock.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { ApiErrorCode } from '@thebet365/shared';
|
||||
|
||||
export function transactionPassthrough<T extends object>(client: T) {
|
||||
return jest.fn(async <R>(fn: (tx: T) => Promise<R>) => fn(client));
|
||||
}
|
||||
|
||||
export function createPrismaMock<T extends object>(client: T): T & { $transaction: jest.Mock } {
|
||||
return {
|
||||
...client,
|
||||
$transaction: transactionPassthrough(client),
|
||||
};
|
||||
}
|
||||
|
||||
export function expectAppError(code: ApiErrorCode) {
|
||||
return expect.objectContaining({
|
||||
response: expect.objectContaining({ code }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user