feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

@@ -0,0 +1,703 @@
-- CreateTable
CREATE TABLE "users" (
"id" BIGSERIAL NOT NULL,
"username" VARCHAR(64) NOT NULL,
"user_type" VARCHAR(20) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"parent_id" BIGINT,
"agent_level" INTEGER,
"locale" VARCHAR(10) NOT NULL DEFAULT 'en-US',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_auth" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"password_hash" VARCHAR(255) NOT NULL,
"login_fail_count" INTEGER NOT NULL DEFAULT 0,
"locked_until" TIMESTAMP(3),
"last_login_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_auth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_preferences" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"locale" VARCHAR(10) NOT NULL DEFAULT 'en-US',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" BIGSERIAL NOT NULL,
"code" VARCHAR(64) NOT NULL,
"name" VARCHAR(128) NOT NULL,
"description" VARCHAR(255),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" BIGSERIAL NOT NULL,
"code" VARCHAR(128) NOT NULL,
"name" VARCHAR(128) NOT NULL,
"module" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"role_id" BIGINT NOT NULL,
"permission_id" BIGINT NOT NULL,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("role_id","permission_id")
);
-- CreateTable
CREATE TABLE "admin_user_roles" (
"user_id" BIGINT NOT NULL,
"role_id" BIGINT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateTable
CREATE TABLE "agent_profiles" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"level" INTEGER NOT NULL,
"parent_agent_id" BIGINT,
"credit_limit" DECIMAL(18,4) NOT NULL DEFAULT 0,
"used_credit" DECIMAL(18,4) NOT NULL DEFAULT 0,
"direct_player_liability" DECIMAL(18,4) NOT NULL DEFAULT 0,
"child_agent_exposure" DECIMAL(18,4) NOT NULL DEFAULT 0,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"max_single_deposit" DECIMAL(18,4),
"max_daily_deposit" DECIMAL(18,4),
"cashback_rate" DECIMAL(8,4) NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_profiles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_closure" (
"ancestor_id" BIGINT NOT NULL,
"descendant_id" BIGINT NOT NULL,
"depth" INTEGER NOT NULL,
CONSTRAINT "agent_closure_pkey" PRIMARY KEY ("ancestor_id","descendant_id")
);
-- CreateTable
CREATE TABLE "agent_credit_transactions" (
"id" BIGSERIAL NOT NULL,
"agent_id" BIGINT NOT NULL,
"transaction_type" VARCHAR(32) NOT NULL,
"amount" DECIMAL(18,4) NOT NULL,
"credit_before" DECIMAL(18,4) NOT NULL,
"credit_after" DECIMAL(18,4) NOT NULL,
"reference_type" VARCHAR(32),
"reference_id" VARCHAR(64),
"operator_id" BIGINT,
"request_id" VARCHAR(128),
"remark" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_credit_transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallets" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"available_balance" DECIMAL(18,4) NOT NULL DEFAULT 0,
"frozen_balance" DECIMAL(18,4) NOT NULL DEFAULT 0,
"currency" VARCHAR(16) NOT NULL DEFAULT 'USD',
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"version" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallet_transactions" (
"id" BIGSERIAL NOT NULL,
"transaction_id" VARCHAR(64) NOT NULL,
"user_id" BIGINT NOT NULL,
"wallet_id" BIGINT NOT NULL,
"transaction_type" VARCHAR(32) NOT NULL,
"amount" DECIMAL(18,4) NOT NULL,
"balance_before" DECIMAL(18,4) NOT NULL,
"balance_after" DECIMAL(18,4) NOT NULL,
"frozen_before" DECIMAL(18,4) NOT NULL,
"frozen_after" DECIMAL(18,4) NOT NULL,
"reference_type" VARCHAR(32),
"reference_id" VARCHAR(64),
"operator_id" BIGINT,
"remark" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wallet_transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "leagues" (
"id" BIGSERIAL NOT NULL,
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
"code" VARCHAR(64) NOT NULL,
"display_order" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "leagues_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "teams" (
"id" BIGSERIAL NOT NULL,
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
"code" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "entity_translations" (
"id" BIGSERIAL NOT NULL,
"entity_type" VARCHAR(32) NOT NULL,
"entity_id" BIGINT NOT NULL,
"locale" VARCHAR(10) NOT NULL,
"field_name" VARCHAR(32) NOT NULL,
"value" VARCHAR(500) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "entity_translations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "matches" (
"id" BIGSERIAL NOT NULL,
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
"league_id" BIGINT NOT NULL,
"home_team_id" BIGINT NOT NULL,
"away_team_id" BIGINT NOT NULL,
"start_time" TIMESTAMP(3) NOT NULL,
"status" VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
"is_hot" BOOLEAN NOT NULL DEFAULT false,
"display_order" INTEGER NOT NULL DEFAULT 0,
"publish_time" TIMESTAMP(3),
"close_time" TIMESTAMP(3),
"is_outright" BOOLEAN NOT NULL DEFAULT false,
"created_by" BIGINT,
"updated_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "matches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "match_scores" (
"id" BIGSERIAL NOT NULL,
"match_id" BIGINT NOT NULL,
"ht_home_score" INTEGER,
"ht_away_score" INTEGER,
"ft_home_score" INTEGER,
"ft_away_score" INTEGER,
"winner_team_id" BIGINT,
"recorded_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "match_scores_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "markets" (
"id" BIGSERIAL NOT NULL,
"match_id" BIGINT NOT NULL,
"market_type" VARCHAR(64) NOT NULL,
"period" VARCHAR(16) NOT NULL,
"line_value" DECIMAL(8,2),
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
"allow_single" BOOLEAN NOT NULL DEFAULT true,
"allow_parlay" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "markets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "market_selections" (
"id" BIGSERIAL NOT NULL,
"market_id" BIGINT NOT NULL,
"selection_code" VARCHAR(64) NOT NULL,
"selection_name" VARCHAR(255) NOT NULL,
"odds" DECIMAL(18,6) NOT NULL,
"odds_version" BIGINT NOT NULL DEFAULT 1,
"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,
CONSTRAINT "market_selections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "odds_change_logs" (
"id" BIGSERIAL NOT NULL,
"selection_id" BIGINT NOT NULL,
"old_odds" DECIMAL(18,6) NOT NULL,
"new_odds" DECIMAL(18,6) NOT NULL,
"odds_version" BIGINT NOT NULL,
"changed_by" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "odds_change_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bets" (
"id" BIGSERIAL NOT NULL,
"bet_no" VARCHAR(64) NOT NULL,
"user_id" BIGINT NOT NULL,
"agent_id" BIGINT,
"bet_type" VARCHAR(20) NOT NULL,
"stake" DECIMAL(18,4) NOT NULL,
"total_odds" DECIMAL(18,6),
"potential_return" DECIMAL(18,4),
"actual_return" DECIMAL(18,4) NOT NULL DEFAULT 0,
"status" VARCHAR(32) NOT NULL DEFAULT 'PENDING',
"settlement_status" VARCHAR(32),
"currency" VARCHAR(16) NOT NULL DEFAULT 'USD',
"request_id" VARCHAR(128) NOT NULL,
"placed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"settled_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bet_selections" (
"id" BIGSERIAL NOT NULL,
"bet_id" BIGINT NOT NULL,
"match_id" BIGINT,
"market_id" BIGINT NOT NULL,
"selection_id" BIGINT NOT NULL,
"market_type" VARCHAR(64) NOT NULL,
"period" VARCHAR(16),
"selection_name_snapshot" VARCHAR(255) NOT NULL,
"handicap_line" DECIMAL(8,2),
"total_line" DECIMAL(8,2),
"odds" DECIMAL(18,6) NOT NULL,
"odds_version" BIGINT NOT NULL,
"result_status" VARCHAR(32),
"effective_odds" DECIMAL(18,6),
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bet_selections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settlement_batches" (
"id" BIGSERIAL NOT NULL,
"match_id" BIGINT NOT NULL,
"batch_no" VARCHAR(64) NOT NULL,
"ht_home_score" INTEGER,
"ht_away_score" INTEGER,
"ft_home_score" INTEGER,
"ft_away_score" INTEGER,
"status" VARCHAR(20) NOT NULL DEFAULT 'PREVIEW',
"total_bets" INTEGER NOT NULL DEFAULT 0,
"total_payout" DECIMAL(18,4) NOT NULL DEFAULT 0,
"total_refund" DECIMAL(18,4) NOT NULL DEFAULT 0,
"operator_id" BIGINT,
"confirmed_at" TIMESTAMP(3),
"is_resettle" BOOLEAN NOT NULL DEFAULT false,
"reason" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "settlement_batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "settlement_items" (
"id" BIGSERIAL NOT NULL,
"batch_id" BIGINT NOT NULL,
"bet_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"result" VARCHAR(32) NOT NULL,
"payout" DECIMAL(18,4) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "settlement_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cashback_rules" (
"id" BIGSERIAL NOT NULL,
"name" VARCHAR(128) NOT NULL,
"target_type" VARCHAR(32) NOT NULL,
"target_id" BIGINT,
"rate" DECIMAL(8,4) NOT NULL,
"market_type" VARCHAR(64),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "cashback_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cashback_batches" (
"id" BIGSERIAL NOT NULL,
"batch_no" VARCHAR(64) NOT NULL,
"period_start" TIMESTAMP(3) NOT NULL,
"period_end" TIMESTAMP(3) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'PREVIEW',
"total_amount" DECIMAL(18,4) NOT NULL DEFAULT 0,
"player_count" INTEGER NOT NULL DEFAULT 0,
"operator_id" BIGINT,
"confirmed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cashback_batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "cashback_items" (
"id" BIGSERIAL NOT NULL,
"batch_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"effective_stake" DECIMAL(18,4) NOT NULL,
"rate" DECIMAL(8,4) NOT NULL,
"amount" DECIMAL(18,4) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "cashback_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "contents" (
"id" BIGSERIAL NOT NULL,
"content_type" VARCHAR(32) NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"status" VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
"link_type" VARCHAR(32),
"link_target" VARCHAR(500),
"start_time" TIMESTAMP(3),
"end_time" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "contents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "content_translations" (
"id" BIGSERIAL NOT NULL,
"content_id" BIGINT NOT NULL,
"locale" VARCHAR(10) NOT NULL,
"title" VARCHAR(255),
"body" TEXT,
"image_url" VARCHAR(500),
CONSTRAINT "content_translations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "i18n_messages" (
"id" BIGSERIAL NOT NULL,
"msg_key" VARCHAR(128) NOT NULL,
"locale" VARCHAR(10) NOT NULL,
"value" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "i18n_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "system_configs" (
"id" BIGSERIAL NOT NULL,
"config_key" VARCHAR(128) NOT NULL,
"config_value" TEXT NOT NULL,
"description" VARCHAR(255),
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" BIGSERIAL NOT NULL,
"operator_id" BIGINT,
"operator_type" VARCHAR(20) NOT NULL,
"action" VARCHAR(128) NOT NULL,
"module" VARCHAR(64) NOT NULL,
"target_type" VARCHAR(32),
"target_id" VARCHAR(64),
"before_data" TEXT,
"after_data" TEXT,
"ip_address" VARCHAR(45),
"user_agent" VARCHAR(500),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE INDEX "users_user_type_idx" ON "users"("user_type");
-- CreateIndex
CREATE INDEX "users_parent_id_idx" ON "users"("parent_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_auth_user_id_key" ON "user_auth"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "roles_code_key" ON "roles"("code");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_code_key" ON "permissions"("code");
-- CreateIndex
CREATE UNIQUE INDEX "admin_user_roles_user_id_key" ON "admin_user_roles"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_profiles_user_id_key" ON "agent_profiles"("user_id");
-- CreateIndex
CREATE INDEX "agent_profiles_parent_agent_id_idx" ON "agent_profiles"("parent_agent_id");
-- CreateIndex
CREATE INDEX "agent_closure_descendant_id_idx" ON "agent_closure"("descendant_id");
-- CreateIndex
CREATE INDEX "agent_credit_transactions_agent_id_idx" ON "agent_credit_transactions"("agent_id");
-- CreateIndex
CREATE UNIQUE INDEX "agent_credit_transactions_operator_id_request_id_key" ON "agent_credit_transactions"("operator_id", "request_id");
-- CreateIndex
CREATE UNIQUE INDEX "wallets_user_id_key" ON "wallets"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "wallet_transactions_transaction_id_key" ON "wallet_transactions"("transaction_id");
-- CreateIndex
CREATE INDEX "wallet_transactions_user_id_idx" ON "wallet_transactions"("user_id");
-- CreateIndex
CREATE INDEX "wallet_transactions_wallet_id_idx" ON "wallet_transactions"("wallet_id");
-- CreateIndex
CREATE INDEX "wallet_transactions_created_at_idx" ON "wallet_transactions"("created_at");
-- CreateIndex
CREATE UNIQUE INDEX "leagues_code_key" ON "leagues"("code");
-- CreateIndex
CREATE UNIQUE INDEX "teams_code_key" ON "teams"("code");
-- CreateIndex
CREATE INDEX "entity_translations_entity_type_entity_id_idx" ON "entity_translations"("entity_type", "entity_id");
-- CreateIndex
CREATE UNIQUE INDEX "entity_translations_entity_type_entity_id_locale_field_name_key" ON "entity_translations"("entity_type", "entity_id", "locale", "field_name");
-- CreateIndex
CREATE INDEX "matches_status_idx" ON "matches"("status");
-- CreateIndex
CREATE INDEX "matches_start_time_idx" ON "matches"("start_time");
-- CreateIndex
CREATE INDEX "matches_league_id_idx" ON "matches"("league_id");
-- CreateIndex
CREATE UNIQUE INDEX "match_scores_match_id_key" ON "match_scores"("match_id");
-- CreateIndex
CREATE INDEX "markets_match_id_idx" ON "markets"("match_id");
-- CreateIndex
CREATE INDEX "markets_market_type_idx" ON "markets"("market_type");
-- CreateIndex
CREATE INDEX "market_selections_market_id_idx" ON "market_selections"("market_id");
-- CreateIndex
CREATE INDEX "odds_change_logs_selection_id_idx" ON "odds_change_logs"("selection_id");
-- CreateIndex
CREATE UNIQUE INDEX "bets_bet_no_key" ON "bets"("bet_no");
-- CreateIndex
CREATE INDEX "bets_user_id_idx" ON "bets"("user_id");
-- CreateIndex
CREATE INDEX "bets_agent_id_idx" ON "bets"("agent_id");
-- CreateIndex
CREATE INDEX "bets_status_idx" ON "bets"("status");
-- CreateIndex
CREATE INDEX "bets_placed_at_idx" ON "bets"("placed_at");
-- CreateIndex
CREATE UNIQUE INDEX "bets_user_id_request_id_key" ON "bets"("user_id", "request_id");
-- CreateIndex
CREATE INDEX "bet_selections_bet_id_idx" ON "bet_selections"("bet_id");
-- CreateIndex
CREATE INDEX "bet_selections_match_id_idx" ON "bet_selections"("match_id");
-- CreateIndex
CREATE UNIQUE INDEX "settlement_batches_batch_no_key" ON "settlement_batches"("batch_no");
-- CreateIndex
CREATE INDEX "settlement_batches_match_id_idx" ON "settlement_batches"("match_id");
-- CreateIndex
CREATE INDEX "settlement_items_batch_id_idx" ON "settlement_items"("batch_id");
-- CreateIndex
CREATE INDEX "settlement_items_bet_id_idx" ON "settlement_items"("bet_id");
-- CreateIndex
CREATE UNIQUE INDEX "cashback_batches_batch_no_key" ON "cashback_batches"("batch_no");
-- CreateIndex
CREATE INDEX "cashback_items_batch_id_idx" ON "cashback_items"("batch_id");
-- CreateIndex
CREATE INDEX "cashback_items_user_id_idx" ON "cashback_items"("user_id");
-- CreateIndex
CREATE INDEX "contents_content_type_status_idx" ON "contents"("content_type", "status");
-- CreateIndex
CREATE UNIQUE INDEX "content_translations_content_id_locale_key" ON "content_translations"("content_id", "locale");
-- CreateIndex
CREATE UNIQUE INDEX "i18n_messages_msg_key_locale_key" ON "i18n_messages"("msg_key", "locale");
-- CreateIndex
CREATE UNIQUE INDEX "system_configs_config_key_key" ON "system_configs"("config_key");
-- CreateIndex
CREATE INDEX "audit_logs_operator_id_idx" ON "audit_logs"("operator_id");
-- CreateIndex
CREATE INDEX "audit_logs_module_idx" ON "audit_logs"("module");
-- CreateIndex
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_auth" ADD CONSTRAINT "user_auth_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_user_roles" ADD CONSTRAINT "admin_user_roles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "admin_user_roles" ADD CONSTRAINT "admin_user_roles_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_profiles" ADD CONSTRAINT "agent_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallet_transactions" ADD CONSTRAINT "wallet_transactions_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_league_id_fkey" FOREIGN KEY ("league_id") REFERENCES "leagues"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_home_team_id_fkey" FOREIGN KEY ("home_team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "matches" ADD CONSTRAINT "matches_away_team_id_fkey" FOREIGN KEY ("away_team_id") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "match_scores" ADD CONSTRAINT "match_scores_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "markets" ADD CONSTRAINT "markets_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "market_selections" ADD CONSTRAINT "market_selections_market_id_fkey" FOREIGN KEY ("market_id") REFERENCES "markets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "odds_change_logs" ADD CONSTRAINT "odds_change_logs_selection_id_fkey" FOREIGN KEY ("selection_id") REFERENCES "market_selections"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bets" ADD CONSTRAINT "bets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bet_selections" ADD CONSTRAINT "bet_selections_bet_id_fkey" FOREIGN KEY ("bet_id") REFERENCES "bets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlement_batches" ADD CONSTRAINT "settlement_batches_match_id_fkey" FOREIGN KEY ("match_id") REFERENCES "matches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "settlement_items" ADD CONSTRAINT "settlement_items_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "settlement_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "cashback_items" ADD CONSTRAINT "cashback_items_batch_id_fkey" FOREIGN KEY ("batch_id") REFERENCES "cashback_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "content_translations" ADD CONSTRAINT "content_translations_content_id_fkey" FOREIGN KEY ("content_id") REFERENCES "contents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -55,6 +55,8 @@ model UserPreference {
id BigInt @id @default(autoincrement())
userId BigInt @unique @map("user_id")
locale String @default("en-US") @db.VarChar(10)
phone String? @db.VarChar(32)
email String? @db.VarChar(128)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -3,6 +3,569 @@ import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
async function seedDemoMarkets(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 },
],
},
];
for (const cfg of configs) {
const exists = await prisma.market.findFirst({
where: { matchId, marketType: cfg.marketType },
include: { _count: { select: { selections: true } } },
});
if (exists) {
const needRefresh =
cfg.marketType.includes('CORRECT_SCORE') &&
exists._count.selections < cfg.selections.length;
if (needRefresh) {
await prisma.market.delete({ where: { id: exists.id } });
} else {
continue;
}
}
await prisma.market.create({
data: {
matchId,
marketType: cfg.marketType,
period: cfg.period,
lineValue: cfg.lineValue,
allowSingle: true,
allowParlay: true,
sortOrder: cfg.sortOrder,
status: 'OPEN',
selections: {
create: cfg.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
}
async function upsertLeagueName(leagueId: bigint, names: Record<string, string>) {
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'LEAGUE',
entityId: leagueId,
locale,
fieldName: 'name',
},
},
create: { entityType: 'LEAGUE', entityId: leagueId, locale, fieldName: 'name', value },
update: { value },
});
}
}
async function upsertTeam(
code: string,
names: Record<string, string>,
) {
const team = await prisma.team.upsert({
where: { code },
create: { code },
update: {},
});
for (const [locale, value] of Object.entries(names)) {
await prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
},
},
create: { entityType: 'TEAM', entityId: team.id, locale, fieldName: 'name', value },
update: { value },
});
}
return team;
}
async function ensurePublishedMatch(opts: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
displayOrder?: number;
}) {
let match = await prisma.match.findFirst({
where: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
status: 'PUBLISHED',
},
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: opts.leagueId,
homeTeamId: opts.homeTeamId,
awayTeamId: opts.awayTeamId,
startTime: opts.startTime,
status: 'PUBLISHED',
isHot: opts.isHot ?? false,
displayOrder: opts.displayOrder ?? 0,
publishTime: new Date(),
},
});
} else {
match = await prisma.match.update({
where: { id: match.id },
data: {
startTime: opts.startTime,
isHot: opts.isHot ?? match.isHot,
displayOrder: opts.displayOrder ?? match.displayOrder,
},
});
}
await seedDemoMarkets(match.id);
return match;
}
function hoursFromNow(hours: number) {
return new Date(Date.now() + hours * 3600 * 1000);
}
async function seedSportsDemo() {
const epl = await prisma.league.upsert({
where: { code: 'EPL' },
create: { code: 'EPL' },
update: {},
});
await upsertLeagueName(epl.id, { 'zh-CN': '英超', 'en-US': 'Premier League' });
const wc = await prisma.league.upsert({
where: { code: 'WC2026' },
create: { code: 'WC2026' },
update: {},
});
await upsertLeagueName(wc.id, {
'zh-CN': '2026世界杯(在加拿大,墨西哥和美国)',
'en-US': '2026 FIFA World Cup',
});
const teams: Array<[string, Record<string, string>]> = [
['MUN', { 'zh-CN': '曼联', 'en-US': 'Man United' }],
['CHE', { 'zh-CN': '切尔西', 'en-US': 'Chelsea' }],
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }],
['RSA', { 'zh-CN': '南非', 'en-US': 'South Africa' }],
['CZE', { 'zh-CN': '捷克', 'en-US': 'Czech Republic' }],
['KOR', { 'zh-CN': '韩国', 'en-US': 'South Korea' }],
['CAN', { 'zh-CN': '加拿大', 'en-US': 'Canada' }],
['BIH', { 'zh-CN': '波黑', 'en-US': 'Bosnia' }],
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }],
['PAR', { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }],
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }],
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }],
['SCO', { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }],
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }],
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }],
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }],
];
const teamMap = new Map<string, { id: bigint }>();
for (const [code, names] of teams) {
teamMap.set(code, await upsertTeam(code, names));
}
const get = (code: string) => {
const t = teamMap.get(code);
if (!t) throw new Error(`Team ${code} missing`);
return t;
};
// 英超:明日开赛 → 早盘
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('MUN').id,
awayTeamId: get('CHE').id,
startTime: hoursFromNow(26),
isHot: true,
displayOrder: 1,
});
// 英超:今晚开赛 → 今日
await ensurePublishedMatch({
leagueId: epl.id,
homeTeamId: get('CHE').id,
awayTeamId: get('MUN').id,
startTime: hoursFromNow(8),
isHot: false,
displayOrder: 2,
});
const wcFixtures: Array<{
home: string;
away: string;
start: Date;
hot?: boolean;
order: number;
}> = [
{ home: 'MEX', away: 'RSA', start: new Date('2026-06-12T03:00:00Z'), hot: true, order: 1 },
{ home: 'CZE', away: 'KOR', start: new Date('2026-06-12T07:00:00Z'), order: 2 },
{ home: 'CAN', away: 'BIH', start: new Date('2026-06-13T00:00:00Z'), order: 3 },
{ home: 'USA', away: 'PAR', start: new Date('2026-06-13T03:00:00Z'), hot: true, order: 4 },
{ home: 'SUI', away: 'BRA', start: new Date('2026-06-14T16:00:00Z'), order: 5 },
{ home: 'SCO', away: 'TUR', start: new Date('2026-06-14T19:00:00Z'), order: 6 },
{ home: 'FRA', away: 'ARG', start: new Date('2026-06-15T20:00:00Z'), hot: true, order: 7 },
];
for (const f of wcFixtures) {
await ensurePublishedMatch({
leagueId: wc.id,
homeTeamId: get(f.home).id,
awayTeamId: get(f.away).id,
startTime: f.start,
isHot: f.hot,
displayOrder: f.order,
});
}
console.log(` Sports demo: ${wcFixtures.length + 2} published matches`);
}
async function seedOutrightDemo() {
const wc = await prisma.league.findUnique({ where: { code: 'WC2026' } });
if (!wc) return;
const placeholder = await upsertTeam('OUT', { 'zh-CN': '冠军盘', 'en-US': 'Outright' });
const outrightOdds: Array<[string, Record<string, string>, number]> = [
['FRA', { 'zh-CN': '法国', 'en-US': 'France' }, 4.95],
['ESP', { 'zh-CN': '西班牙', 'en-US': 'Spain' }, 4.95],
['ENG', { 'zh-CN': '英格兰', 'en-US': 'England' }, 6.3],
['BRA', { 'zh-CN': '巴西', 'en-US': 'Brazil' }, 8.55],
['ARG', { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }, 8.55],
['POR', { 'zh-CN': '葡萄牙', 'en-US': 'Portugal' }, 9.0],
['GER', { 'zh-CN': '德国', 'en-US': 'Germany' }, 15.3],
['NED', { 'zh-CN': '荷兰', 'en-US': 'Netherlands' }, 18.9],
['NOR', { 'zh-CN': '挪威', 'en-US': 'Norway' }, 32.4],
['BEL', { 'zh-CN': '比利时', 'en-US': 'Belgium' }, 35.1],
['COL', { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia' }, 45.9],
['JPN', { 'zh-CN': '日本', 'en-US': 'Japan' }, 45.9],
['URU', { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay' }, 63.9],
['USA', { 'zh-CN': '美国', 'en-US': 'USA' }, 63.9],
['MAR', { 'zh-CN': '摩洛哥', 'en-US': 'Morocco' }, 63.9],
['CRO', { 'zh-CN': '克罗地亚', 'en-US': 'Croatia' }, 81.0],
['MEX', { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }, 85.0],
['SUI', { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }, 90.0],
['TUR', { 'zh-CN': '土耳其', 'en-US': 'Turkey' }, 95.0],
['SEN', { 'zh-CN': '塞内加尔', 'en-US': 'Senegal' }, 100.0],
];
for (const [code, names] of outrightOdds) {
await upsertTeam(code, names);
}
let match = await prisma.match.findFirst({
where: { leagueId: wc.id, isOutright: true },
});
if (!match) {
match = await prisma.match.create({
data: {
leagueId: wc.id,
homeTeamId: placeholder.id,
awayTeamId: placeholder.id,
isOutright: true,
startTime: new Date('2027-07-01T00:00:00Z'),
status: 'PUBLISHED',
publishTime: new Date(),
isHot: true,
displayOrder: 0,
},
});
}
const marketExists = await prisma.market.findFirst({
where: { matchId: match.id, marketType: 'OUTRIGHT_WINNER' },
});
if (!marketExists) {
await prisma.market.create({
data: {
matchId: match.id,
marketType: 'OUTRIGHT_WINNER',
period: 'OUTRIGHT',
allowSingle: true,
allowParlay: false,
sortOrder: 1,
status: 'OPEN',
selections: {
create: outrightOdds.map(([code, names, odds], i) => ({
selectionCode: code,
selectionName: names['zh-CN'],
odds,
sortOrder: i,
status: 'OPEN',
})),
},
},
});
}
console.log(' Outright demo: World Cup winner market');
}
async function seedPlayerDemo() {
const player = await prisma.user.findUnique({
where: { username: 'player1' },
include: { wallet: true },
});
if (!player?.wallet) return;
await prisma.wallet.update({
where: { id: player.wallet.id },
data: { availableBalance: 88888.88 },
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-001' },
create: {
transactionId: 'DEMO-DEP-001',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 50000,
balanceBefore: 1000,
balanceAfter: 51000,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值',
},
update: {},
});
await prisma.walletTransaction.upsert({
where: { transactionId: 'DEMO-DEP-002' },
create: {
transactionId: 'DEMO-DEP-002',
userId: player.id,
walletId: player.wallet.id,
transactionType: 'DEPOSIT',
amount: 37888.88,
balanceBefore: 51000,
balanceAfter: 88888.88,
frozenBefore: 0,
frozenAfter: 0,
remark: '演示充值(二笔)',
},
update: {},
});
const sampleSel = await prisma.marketSelection.findFirst({
where: {
status: 'OPEN',
market: { marketType: 'FT_1X2', match: { status: 'PUBLISHED' } },
},
include: { market: { include: { match: true } } },
});
if (sampleSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-001' } }))) {
const odds = Number(sampleSel.odds);
const stake = 200;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-001',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
status: 'PENDING',
requestId: 'seed-demo-bet-001',
selections: {
create: {
matchId: sampleSel.market.matchId,
marketId: sampleSel.marketId,
selectionId: sampleSel.id,
marketType: sampleSel.market.marketType,
period: sampleSel.market.period,
selectionNameSnapshot: sampleSel.selectionName,
odds: sampleSel.odds,
oddsVersion: sampleSel.oddsVersion,
},
},
},
});
}
const settledSel = await prisma.marketSelection.findFirst({
where: {
market: { marketType: 'FT_1X2' },
selectionCode: 'DRAW',
},
include: { market: true },
});
if (settledSel && !(await prisma.bet.findUnique({ where: { betNo: 'DEMO-BET-002' } }))) {
const odds = Number(settledSel.odds);
const stake = 50;
await prisma.bet.create({
data: {
betNo: 'DEMO-BET-002',
userId: player.id,
agentId: player.parentId,
betType: 'SINGLE',
stake,
totalOdds: odds,
potentialReturn: stake * odds,
actualReturn: stake * odds,
status: 'WON',
settlementStatus: 'SETTLED',
settledAt: new Date(Date.now() - 86400000),
requestId: 'seed-demo-bet-002',
selections: {
create: {
matchId: settledSel.market.matchId,
marketId: settledSel.marketId,
selectionId: settledSel.id,
marketType: settledSel.market.marketType,
period: settledSel.market.period,
selectionNameSnapshot: settledSel.selectionName,
odds: settledSel.odds,
oddsVersion: settledSel.oddsVersion,
resultStatus: 'WIN',
effectiveOdds: settledSel.odds,
},
},
},
});
}
console.log(' Player demo: wallet + transactions + sample bets');
}
async function main() {
console.log('Seeding database...');
@@ -107,70 +670,79 @@ async function main() {
}
}
const league = await prisma.league.upsert({
where: { code: 'EPL' },
create: { code: 'EPL' },
update: {},
});
await prisma.entityTranslation.upsert({
where: { entityType_entityId_locale_fieldName: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name' } },
create: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name', value: '英超' },
update: {},
});
for (const [code, name] of [['MUN', '曼联'], ['CHE', '切尔西']] as const) {
const team = await prisma.team.upsert({ where: { code }, create: { code }, update: {} });
await prisma.entityTranslation.upsert({
where: { entityType_entityId_locale_fieldName: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name' } },
create: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name', value: name },
update: { value: name },
});
}
const mun = await prisma.team.findUnique({ where: { code: 'MUN' } });
const che = await prisma.team.findUnique({ where: { code: 'CHE' } });
if (mun && che) {
const existing = await prisma.match.findFirst({ where: { homeTeamId: mun.id, awayTeamId: che.id } });
if (!existing) {
const match = await prisma.match.create({
data: {
leagueId: league.id,
homeTeamId: mun.id,
awayTeamId: che.id,
startTime: new Date(Date.now() + 86400000),
status: 'PUBLISHED',
isHot: true,
publishTime: new Date(),
},
});
await prisma.market.create({
data: {
matchId: match.id,
marketType: 'FT_1X2',
period: 'FT',
selections: {
create: [
{ selectionCode: 'HOME', selectionName: 'Home', odds: 2.5 },
{ selectionCode: 'DRAW', selectionName: 'Draw', odds: 3.2 },
{ selectionCode: 'AWAY', selectionName: 'Away', odds: 2.8 },
],
},
},
});
}
}
await seedSportsDemo();
await seedOutrightDemo();
await seedPlayerDemo();
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 1,
linkType: 'ROUTE',
linkTarget: '/football',
translations: {
create: [
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中' },
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available' },
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中', imageUrl: '/uploads/banners/welcome.svg' },
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available', imageUrl: '/uploads/banners/welcome.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 2,
translations: {
create: [
{ locale: 'zh-CN', title: '首存礼遇', body: '新会员专属优惠', imageUrl: '/uploads/banners/promo.svg' },
{ locale: 'en-US', title: 'First Deposit', body: 'New member offer', imageUrl: '/uploads/banners/promo.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'BANNER',
status: 'ACTIVE',
sortOrder: 3,
linkType: 'ROUTE',
linkTarget: '/football',
translations: {
create: [
{ locale: 'zh-CN', title: '热门赛事', body: '五大联赛天天有球', imageUrl: '/uploads/banners/hot-matches.svg' },
{ locale: 'en-US', title: 'Hot Matches', body: 'Top leagues daily', imageUrl: '/uploads/banners/hot-matches.svg' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'TICKER',
status: 'ACTIVE',
sortOrder: 1,
translations: {
create: [
{ locale: 'zh-CN', body: '欢迎来到 TheBet365 · 热门赛事每日更新 · 请理性投注' },
{ locale: 'en-US', body: 'Welcome to TheBet365 · Daily hot matches · Bet responsibly' },
],
},
},
}).catch(() => {});
await prisma.content.create({
data: {
contentType: 'NOTICE',
status: 'ACTIVE',
sortOrder: 1,
translations: {
create: [
{ locale: 'zh-CN', title: '系统维护通知:每周一 04:00-05:00 例行维护,敬请谅解' },
{ locale: 'en-US', title: 'Maintenance: Every Mon 04:00-05:00 UTC' },
],
},
},

View File

@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
@@ -62,6 +63,16 @@ class LocaleDto {
locale!: string;
}
class UpdateProfileDto {
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
}
@ApiTags('Player')
@Controller('player')
@UseGuards(JwtAuthGuard, PlayerGuard)
@@ -88,6 +99,12 @@ export class PlayerController {
return jsonResponse(result);
}
@Patch('profile')
async updateProfile(@CurrentUser('id') userId: bigint, @Body() dto: UpdateProfileDto) {
const user = await this.users.updateProfile(userId, dto);
return jsonResponse(user);
}
@Get('home')
async home(@CurrentUser('locale') locale: string) {
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
@@ -115,6 +132,12 @@ export class PlayerController {
return jsonResponse(items);
}
@Get('outrights')
async listOutrights(@CurrentUser('locale') locale: string) {
const items = await this.matches.listOutrights(locale);
return jsonResponse(items);
}
@Get('matches/:id')
async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) {
const match = await this.matches.getMatchDetail(BigInt(id), locale);
@@ -149,11 +172,13 @@ export class PlayerController {
@Get('bets')
async myBets(
@CurrentUser('id') userId: bigint,
@CurrentUser('locale') locale: string,
@Query('status') status?: string,
@Query('page') page?: string,
) {
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
return jsonResponse(result);
const items = await this.matches.enrichBetsForHistory(result.items, locale);
return jsonResponse({ ...result, items });
}
@Get('bets/:betNo')

View File

@@ -96,9 +96,8 @@ export class MatchesService {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
league?: unknown;
homeTeam?: unknown;
awayTeam?: unknown;
homeTeam?: { code: string };
awayTeam?: { code: string };
markets?: unknown[];
};
const [leagueName, homeName, awayName] = await Promise.all([
@@ -108,9 +107,13 @@ export class MatchesService {
]);
return {
...match,
id: m.id.toString(),
leagueId: m.leagueId.toString(),
leagueName,
homeTeamName: homeName,
awayTeamName: awayName,
homeTeamCode: m.homeTeam?.code ?? '',
awayTeamCode: m.awayTeam?.code ?? '',
};
}
@@ -118,9 +121,12 @@ export class MatchesService {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
isOutright: false,
...(leagueId ? { leagueId } : {}),
},
include: {
homeTeam: true,
awayTeam: true,
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
@@ -136,8 +142,11 @@ export class MatchesService {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
markets: {
include: { selections: true },
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' }, orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
score: true,
@@ -147,12 +156,180 @@ export class MatchesService {
return this.enrichMatch(match, locale);
}
async listOutrights(locale = 'en-US') {
const matches = await this.prisma.match.findMany({
where: { status: 'PUBLISHED', isOutright: true },
include: {
markets: {
where: { marketType: 'OUTRIGHT_WINNER', status: 'OPEN' },
include: {
selections: {
where: { status: 'OPEN' },
orderBy: { sortOrder: 'asc' },
},
},
},
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'asc' }],
});
const results = [];
for (const match of matches) {
const leagueName = await this.getTranslation('LEAGUE', match.leagueId, locale);
const market = match.markets[0];
if (!market) continue;
const selections = await Promise.all(
market.selections.map(async (sel) => {
const teamCode = sel.selectionCode.replace(/^TEAM_/, '');
const team = await this.prisma.team.findUnique({ where: { code: teamCode } });
const teamName = team
? await this.getTranslation('TEAM', team.id, locale)
: sel.selectionName;
return {
id: sel.id.toString(),
teamCode,
teamName,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
};
}),
);
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
leagueName,
title: `*${leagueName} 冠军`,
marketId: market.id.toString(),
selections,
});
}
return results;
}
private marketLabelKey(marketType: string): string {
const keys: Record<string, string> = {
FT_1X2: '全场独赢',
FT_HANDICAP: '全场让球',
FT_OVER_UNDER: '全场大小',
FT_ODD_EVEN: '全场单双',
HT_1X2: '半场独赢',
HT_HANDICAP: '半场让球',
HT_OVER_UNDER: '半场大小',
OUTRIGHT_WINNER: '冠军',
FT_CORRECT_SCORE: '波胆',
HT_CORRECT_SCORE: '上半场波胆',
SH_CORRECT_SCORE: '下半场波胆',
};
return keys[marketType] ?? marketType;
}
async enrichBetsForHistory(
bets: Array<{
betNo: string;
betType: string;
stake: unknown;
totalOdds: unknown;
potentialReturn: unknown;
actualReturn: unknown;
status: string;
placedAt: Date;
selections: Array<{
matchId: bigint | null;
marketType: string;
selectionNameSnapshot: string;
odds: unknown;
resultStatus?: string | null;
}>;
}>,
locale: string,
) {
const matchIds = [
...new Set(
bets.flatMap((b) =>
b.selections.map((s) => s.matchId).filter((id): id is bigint => id != null),
),
),
];
const matches =
matchIds.length > 0
? await this.prisma.match.findMany({
where: { id: { in: matchIds } },
include: { homeTeam: true, awayTeam: true },
})
: [];
const matchMeta = new Map<
string,
{ leagueName: string; matchTitle: string; isOutright: boolean }
>();
for (const m of matches) {
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
matchMeta.set(m.id.toString(), {
leagueName,
matchTitle: m.isOutright ? leagueName : `${homeName} vs ${awayName}`,
isOutright: m.isOutright,
});
}
return bets.map((bet) => {
const firstMatchId = bet.selections.find((s) => s.matchId)?.matchId?.toString();
const meta = firstMatchId ? matchMeta.get(firstMatchId) : undefined;
const isParlay = bet.betType === 'PARLAY' || bet.selections.length > 1;
const legs = bet.selections.map((sel) => {
const mid = sel.matchId?.toString();
const m = mid ? matchMeta.get(mid) : undefined;
return {
marketType: sel.marketType,
marketLabel: this.marketLabelKey(sel.marketType),
selectionName: sel.selectionNameSnapshot,
odds: sel.odds,
resultStatus: sel.resultStatus,
matchTitle: m?.matchTitle ?? sel.selectionNameSnapshot,
leagueName: m?.leagueName ?? '',
};
});
return {
betNo: bet.betNo,
betType: bet.betType,
stake: bet.stake,
totalOdds: bet.totalOdds,
potentialReturn: bet.potentialReturn,
actualReturn: bet.actualReturn,
status: bet.status,
placedAt: bet.placedAt,
leagueName: isParlay
? 'Parlay'
: meta?.leagueName ?? legs[0]?.leagueName ?? '',
legCount: bet.selections.length,
matchTitle: isParlay
? ''
: meta?.matchTitle ?? legs[0]?.matchTitle ?? bet.betNo,
pickLabel: isParlay
? ''
: `${legs[0]?.marketLabel ?? ''}: ${legs[0]?.selectionName ?? ''}`,
legs,
isParlay,
};
});
}
@Cron(CronExpression.EVERY_MINUTE)
async autoCloseMatches() {
const now = new Date();
await this.prisma.match.updateMany({
where: {
status: 'PUBLISHED',
isOutright: false,
startTime: { lte: now },
},
data: { status: 'CLOSED', closeTime: now },

View File

@@ -12,6 +12,17 @@ export class UsersService {
});
}
async updateProfile(userId: bigint, data: { phone?: string; email?: string }) {
const phone = data.phone?.trim() || null;
const email = data.email?.trim() || null;
await this.prisma.userPreference.upsert({
where: { userId },
create: { userId, phone, email },
update: { phone, email },
});
return this.findById(userId);
}
async updateLocale(userId: bigint, locale: string) {
await this.prisma.user.update({
where: { id: userId },

View File

@@ -1,10 +1,15 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const uploadDir = process.env.UPLOAD_DIR || join(__dirname, '..', '..', 'uploads');
app.useStaticAssets(uploadDir, { prefix: '/uploads/' });
app.setGlobalPrefix('api');
app.enableCors({ origin: true, credentials: true });

View File

@@ -32,10 +32,18 @@ export function generateBatchNo(prefix: string): string {
return `${prefix}${ts}`;
}
function isDecimalLike(obj: object): obj is { toJSON: () => string } {
return (
typeof (obj as { toJSON?: unknown }).toJSON === 'function' &&
typeof (obj as { toFixed?: unknown }).toFixed === 'function'
);
}
export function serializeBigInt(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'bigint') return obj.toString();
if (obj instanceof Date) return obj.toISOString();
if (typeof obj === 'object' && isDecimalLike(obj)) return obj.toJSON();
if (Array.isArray(obj)) return obj.map(serializeBigInt);
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};