From 95abbcb4704eeda3a893174e81caa830ae0f4d58 Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Wed, 3 Jun 2026 16:19:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=96=E7=95=8C=E6=9D=AF48=E5=BC=BA?= =?UTF-8?q?=E5=A4=BA=E5=86=A0=E7=9B=98=E3=80=81=E7=AE=A1=E7=90=86=E7=AB=AF?= =?UTF-8?q?=E8=B0=83=E8=B5=94=E4=B8=8E=E9=A1=B9=E7=9B=AE=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固定48强基准数据、同步种子与后台世界杯夺冠页 - 补全 user_preferences 迁移文件;新增启动指南与默认数据说明 Co-authored-by: Cursor --- README.md | 4 + apps/admin/src/i18n/admin-messages.ts | 3 + apps/admin/src/i18n/admin-pages-ms.ts | 15 + apps/admin/src/i18n/admin-pages.ts | 30 ++ apps/admin/src/layouts/ManageLayout.vue | 1 + apps/admin/src/router/index.ts | 5 + apps/admin/src/views/WorldCupOutright.vue | 150 ++++++++ .../migration.sql | 3 + apps/api/prisma/seed.ts | 79 +--- .../applications/admin/admin.controller.ts | 37 ++ .../api/src/domains/catalog/matches.module.ts | 7 +- .../src/domains/catalog/matches.service.ts | 43 ++- .../src/domains/catalog/outright.service.ts | 108 ++++++ .../domains/catalog/wc2026-outright-teams.ts | 63 ++++ .../domains/catalog/wc2026-outright.sync.ts | 179 +++++++++ docs/项目启动指南.md | 344 ++++++++++++++++++ docs/默认数据说明.md | 178 +++++++++ 17 files changed, 1157 insertions(+), 92 deletions(-) create mode 100644 apps/admin/src/views/WorldCupOutright.vue create mode 100644 apps/api/prisma/migrations/20260602120000_user_preference_contact/migration.sql create mode 100644 apps/api/src/domains/catalog/outright.service.ts create mode 100644 apps/api/src/domains/catalog/wc2026-outright-teams.ts create mode 100644 apps/api/src/domains/catalog/wc2026-outright.sync.ts create mode 100644 docs/项目启动指南.md create mode 100644 docs/默认数据说明.md diff --git a/README.md b/README.md index 48d6b30..fd7c5cd 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Monorepo 项目,包含 NestJS 后端与 Vue 3 三端前端。 ## 快速开始 +> 完整步骤、环境变量、端口、演示账号与排错见 **[docs/项目启动指南.md](docs/项目启动指南.md)**。 + ### 1. 启动基础设施 ```bash @@ -58,6 +60,8 @@ API 文档:http://localhost:3000/api/docs | 二级代理 | agent2 | Agent@123 | | 玩家 | player1 | Player@123 | +更多默认数据(赛事、48 强夺冠、余额、Banner 等)见 **[docs/默认数据说明.md](docs/默认数据说明.md)**。 + ## 项目结构 ``` diff --git a/apps/admin/src/i18n/admin-messages.ts b/apps/admin/src/i18n/admin-messages.ts index 9352b03..30f704e 100644 --- a/apps/admin/src/i18n/admin-messages.ts +++ b/apps/admin/src/i18n/admin-messages.ts @@ -33,6 +33,7 @@ const zh: Record = { 'nav.users': '玩家管理', 'nav.agents': '代理管理', 'nav.matches': '赛事管理', + 'nav.outright': '世界杯夺冠', 'nav.bets': '注单管理', 'nav.cashback': '返水管理', 'nav.audit': '操作日志', @@ -181,6 +182,7 @@ const en: Record = { 'nav.users': 'Players', 'nav.agents': 'Agents', 'nav.matches': 'Matches', + 'nav.outright': 'WC Winner', 'nav.bets': 'Bets', 'nav.cashback': 'Cashback', 'nav.audit': 'Audit Log', @@ -329,6 +331,7 @@ const ms: Record = { 'nav.users': 'Pemain', 'nav.agents': 'Ejen', 'nav.matches': 'Perlawanan', + 'nav.outright': 'Juara Piala Dunia', 'nav.bets': 'Pertaruhan', 'nav.cashback': 'Rebat', 'nav.audit': 'Log audit', diff --git a/apps/admin/src/i18n/admin-pages-ms.ts b/apps/admin/src/i18n/admin-pages-ms.ts index c8c7d86..aac71b3 100644 --- a/apps/admin/src/i18n/admin-pages-ms.ts +++ b/apps/admin/src/i18n/admin-pages-ms.ts @@ -246,6 +246,21 @@ export const adminPagesMs: Record = { 'msg.credit_adjusted': 'Kredit dikemas kini', 'msg.credit_adjust_failed': 'Pelarasan gagal', 'msg.outright_no_edit': 'Outright tidak boleh diedit di sini', + 'msg.outright_odds_saved': 'Odds juara disimpan', + 'msg.load_failed': 'Gagal memuatkan', + + 'page.outright.title': 'Juara Piala Dunia 48', + 'page.outright.desc': '48 pasukan tetap; laraskan odds juara', + 'outright.col.rank': 'Kedudukan', + 'outright.col.team_zh': 'Pasukan (ZH)', + 'outright.col.team_en': 'Pasukan (EN)', + 'outright.col.code': 'Kod', + 'outright.col.odds': 'Odds juara', + 'outright.btn.save_odds': 'Simpan semua odds', + 'outright.btn.apply_canonical': 'Guna data jadual asas', + 'msg.outright_canonical_applied': 'Odds 48 pasukan telah dikemas kini', + 'outright.team_count': '{n} / {total} pasukan', + 'outright.err_odds_min': 'Odds mesti lebih 1.00', 'msg.load_matches_failed': 'Gagal memuatkan perlawanan', 'msg.cashback_issued': 'Rebat telah dikeluarkan', 'msg.freeze_confirm_title': '{action} akaun', diff --git a/apps/admin/src/i18n/admin-pages.ts b/apps/admin/src/i18n/admin-pages.ts index e870684..466a063 100644 --- a/apps/admin/src/i18n/admin-pages.ts +++ b/apps/admin/src/i18n/admin-pages.ts @@ -246,6 +246,21 @@ export const adminPagesZh: Record = { 'msg.credit_adjusted': '授信已调整', 'msg.credit_adjust_failed': '调整失败', 'msg.outright_no_edit': '冠军盘不支持在此编辑', + 'msg.outright_odds_saved': '夺冠赔率已保存', + 'msg.load_failed': '加载失败', + + 'page.outright.title': '世界杯 48 强夺冠', + 'page.outright.desc': '固定 48 支球队,可调整夺冠赔率(玩家端「优胜冠军」)', + 'outright.col.rank': '排名', + 'outright.col.team_zh': '队伍(中文)', + 'outright.col.team_en': '队伍(英文)', + 'outright.col.code': '代码', + 'outright.col.odds': '夺冠赔率', + 'outright.btn.save_odds': '保存全部赔率', + 'outright.btn.apply_canonical': '应用表格基准数据', + 'msg.outright_canonical_applied': '已按基准表写入 48 强夺冠赔率', + 'outright.team_count': '已配置 {n} / {total} 队', + 'outright.err_odds_min': '赔率须大于 1.00', 'msg.load_matches_failed': '加载赛事失败', 'msg.cashback_issued': '返水已发放', 'msg.freeze_confirm_title': '{action}账号', @@ -502,6 +517,21 @@ export const adminPagesEn: Record = { 'msg.credit_adjusted': 'Credit updated', 'msg.credit_adjust_failed': 'Adjustment failed', 'msg.outright_no_edit': 'Outright cannot be edited here', + 'msg.outright_odds_saved': 'Outright odds saved', + 'msg.load_failed': 'Load failed', + + 'page.outright.title': 'World Cup 48 — Winner', + 'page.outright.desc': 'Fixed 48 teams; adjust winner odds (player Outright tab)', + 'outright.col.rank': 'Rank', + 'outright.col.team_zh': 'Team (ZH)', + 'outright.col.team_en': 'Team (EN)', + 'outright.col.code': 'Code', + 'outright.col.odds': 'Winner odds', + 'outright.btn.save_odds': 'Save all odds', + 'outright.btn.apply_canonical': 'Apply baseline table', + 'msg.outright_canonical_applied': '48-team winner odds applied from baseline', + 'outright.team_count': '{n} / {total} teams', + 'outright.err_odds_min': 'Odds must be greater than 1.00', 'msg.load_matches_failed': 'Failed to load matches', 'msg.cashback_issued': 'Cashback issued', 'msg.freeze_confirm_title': '{action} account', diff --git a/apps/admin/src/layouts/ManageLayout.vue b/apps/admin/src/layouts/ManageLayout.vue index 9172197..9bb4f86 100644 --- a/apps/admin/src/layouts/ManageLayout.vue +++ b/apps/admin/src/layouts/ManageLayout.vue @@ -15,6 +15,7 @@ const adminMenus = computed(() => [ { path: '/users', label: t('nav.users') }, { path: '/agents', label: t('nav.agents') }, { path: '/matches', label: t('nav.matches') }, + { path: '/world-cup-outright', label: t('nav.outright') }, { path: '/bets', label: t('nav.bets') }, { path: '/cashback', label: t('nav.cashback') }, { path: '/audit', label: t('nav.audit') }, diff --git a/apps/admin/src/router/index.ts b/apps/admin/src/router/index.ts index ac9c872..912cba9 100644 --- a/apps/admin/src/router/index.ts +++ b/apps/admin/src/router/index.ts @@ -26,6 +26,11 @@ const router = createRouter({ component: () => import('../views/Matches.vue'), meta: { adminOnly: true }, }, + { + path: 'world-cup-outright', + component: () => import('../views/WorldCupOutright.vue'), + meta: { adminOnly: true }, + }, { path: 'bets', component: () => import('../views/Bets.vue'), diff --git a/apps/admin/src/views/WorldCupOutright.vue b/apps/admin/src/views/WorldCupOutright.vue new file mode 100644 index 0000000..f965830 --- /dev/null +++ b/apps/admin/src/views/WorldCupOutright.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/apps/api/prisma/migrations/20260602120000_user_preference_contact/migration.sql b/apps/api/prisma/migrations/20260602120000_user_preference_contact/migration.sql new file mode 100644 index 0000000..aed10fc --- /dev/null +++ b/apps/api/prisma/migrations/20260602120000_user_preference_contact/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable: user_preferences 增加手机、邮箱(玩家资料) +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "phone" VARCHAR(32); +ALTER TABLE "user_preferences" ADD COLUMN IF NOT EXISTS "email" VARCHAR(128); diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 675f6de..8533514 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; +import { syncWc2026OutrightMarket } from '../src/domains/catalog/wc2026-outright.sync'; const prisma = new PrismaClient(); @@ -359,81 +360,11 @@ 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, 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 }, + const { matchId, marketId } = await syncWc2026OutrightMarket(prisma, { + forceCanonical: 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'); + const count = await prisma.marketSelection.count({ where: { marketId } }); + console.log(` WC2026 outright: match ${matchId}, ${count} selections`); } async function seedPlayerDemo() { diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index f9f8757..27ba58c 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -19,6 +19,7 @@ import { UsersService } from '../../domains/identity/users.service'; import { AgentsService } from '../../domains/agent/agents.service'; import { WalletService } from '../../domains/ledger/wallet.service'; import { MatchesService } from '../../domains/catalog/matches.service'; +import { OutrightService } from '../../domains/catalog/outright.service'; import { MarketsService } from '../../domains/odds/markets.service'; import { SettlementService } from '../../domains/settlement/settlement.service'; import { CashbackService } from '../../domains/operations/cashback/cashback.service'; @@ -245,6 +246,20 @@ class UpdateOddsDto { odds!: number; } +class OutrightOddsUpdateItemDto { + @IsString() + selectionId!: string; + + @IsNumber() + @Min(1.01) + odds!: number; +} + +class BatchOutrightOddsDto { + @IsArray() + updates!: OutrightOddsUpdateItemDto[]; +} + class CashbackPreviewDto { @IsString() periodStart!: string; @@ -263,6 +278,7 @@ export class AdminController { private agents: AgentsService, private wallet: WalletService, private matches: MatchesService, + private outright: OutrightService, private markets: MarketsService, private settlement: SettlementService, private cashback: CashbackService, @@ -632,6 +648,27 @@ export class AdminController { return jsonResponse(selection); } + @Get('outrights/wc2026') + async getWc2026Outright() { + const data = await this.outright.getWc2026ForAdmin(); + return jsonResponse(data); + } + + @Put('outrights/wc2026/odds') + async updateWc2026OutrightOdds( + @CurrentUser('id') operatorId: bigint, + @Body() dto: BatchOutrightOddsDto, + ) { + const data = await this.outright.updateWc2026Odds(dto.updates, operatorId); + return jsonResponse(data); + } + + @Post('outrights/wc2026/apply-canonical') + async applyWc2026Canonical() { + const data = await this.outright.applyWc2026Canonical(); + return jsonResponse(data); + } + @Post('matches/:id/settlement/score') async recordScore( @CurrentUser('id') operatorId: bigint, diff --git a/apps/api/src/domains/catalog/matches.module.ts b/apps/api/src/domains/catalog/matches.module.ts index 68d9672..fe326cd 100644 --- a/apps/api/src/domains/catalog/matches.module.ts +++ b/apps/api/src/domains/catalog/matches.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; +import { MarketsModule } from '../odds/markets.module'; import { MatchesService } from './matches.service'; +import { OutrightService } from './outright.service'; @Module({ - providers: [MatchesService], - exports: [MatchesService], + imports: [MarketsModule], + providers: [MatchesService, OutrightService], + exports: [MatchesService, OutrightService], }) export class MatchesModule {} diff --git a/apps/api/src/domains/catalog/matches.service.ts b/apps/api/src/domains/catalog/matches.service.ts index f7d7f76..c8e2a6c 100644 --- a/apps/api/src/domains/catalog/matches.service.ts +++ b/apps/api/src/domains/catalog/matches.service.ts @@ -14,6 +14,12 @@ import { toVenueJson, translationsFromZhiboNames, } from './zhibo-match.mapper'; +import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; + +const WC2026_OUTRIGHT_RANK = new Map( + WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t.rank]), +); +const WC2026_OUTRIGHT_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code)); @Injectable() export class MatchesService { @@ -586,22 +592,27 @@ export class MatchesService { 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(), - }; - }), - ); + const selections = ( + await Promise.all( + market.selections + .filter((sel) => WC2026_OUTRIGHT_CODES.has(sel.selectionCode)) + .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, + rank: WC2026_OUTRIGHT_RANK.get(teamCode) ?? 999, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + }; + }), + ) + ).sort((a, b) => a.rank - b.rank); results.push({ id: match.id.toString(), diff --git a/apps/api/src/domains/catalog/outright.service.ts b/apps/api/src/domains/catalog/outright.service.ts new file mode 100644 index 0000000..36f8c4e --- /dev/null +++ b/apps/api/src/domains/catalog/outright.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/prisma/prisma.service'; +import { MarketsService } from '../odds/markets.service'; +import { WC2026_OUTRIGHT_TEAMS } from './wc2026-outright-teams'; +import { syncWc2026OutrightMarket } from './wc2026-outright.sync'; + +const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code)); + +@Injectable() +export class OutrightService { + constructor( + private prisma: PrismaService, + private markets: MarketsService, + ) {} + + async getWc2026ForAdmin() { + const { matchId, marketId } = await syncWc2026OutrightMarket(this.prisma, { + forceCanonical: false, + }); + + const match = await this.prisma.match.findUniqueOrThrow({ where: { id: matchId } }); + const [leagueZh, leagueEn, market] = await Promise.all([ + this.getTranslation('LEAGUE', match.leagueId, 'zh-CN'), + this.getTranslation('LEAGUE', match.leagueId, 'en-US'), + this.prisma.market.findUniqueOrThrow({ + where: { id: marketId }, + include: { + selections: { orderBy: { sortOrder: 'asc' } }, + }, + }), + ]); + + const teamByCode = new Map( + WC2026_OUTRIGHT_TEAMS.map((t) => [t.code, t]), + ); + + const canonicalSelections = market.selections.filter((sel) => + CANONICAL_CODES.has(sel.selectionCode), + ); + + const selections = await Promise.all( + canonicalSelections.map(async (sel) => { + const meta = teamByCode.get(sel.selectionCode); + const team = await this.prisma.team.findUnique({ where: { code: sel.selectionCode } }); + const teamZh = team + ? await this.getTranslation('TEAM', team.id, 'zh-CN') + : sel.selectionName; + const teamEn = team + ? await this.getTranslation('TEAM', team.id, 'en-US') + : sel.selectionName; + return { + id: sel.id.toString(), + teamCode: sel.selectionCode, + rank: meta?.rank ?? sel.sortOrder + 1, + teamZh, + teamEn, + odds: sel.odds.toString(), + oddsVersion: sel.oddsVersion.toString(), + status: sel.status, + }; + }), + ); + + selections.sort((a, b) => a.rank - b.rank); + + return { + matchId: matchId.toString(), + marketId: marketId.toString(), + matchStatus: match.status, + marketStatus: market.status, + leagueZh, + leagueEn, + selections, + expectedCount: WC2026_OUTRIGHT_TEAMS.length, + }; + } + + async applyWc2026Canonical() { + await syncWc2026OutrightMarket(this.prisma, { forceCanonical: true }); + return this.getWc2026ForAdmin(); + } + + async updateWc2026Odds( + updates: Array<{ selectionId: string; odds: number }>, + operatorId: bigint, + ) { + const results = await this.markets.batchUpdateOdds( + updates.map((u) => ({ selectionId: BigInt(u.selectionId), odds: u.odds })), + operatorId, + ); + return results.map((r) => ({ + id: r.id.toString(), + odds: r.odds.toString(), + oddsVersion: r.oddsVersion.toString(), + })); + } + + private async getTranslation( + entityType: string, + entityId: bigint, + locale: string, + ): Promise { + const row = await this.prisma.entityTranslation.findFirst({ + where: { entityType, entityId, locale, fieldName: 'name' }, + }); + return row?.value ?? ''; + } +} diff --git a/apps/api/src/domains/catalog/wc2026-outright-teams.ts b/apps/api/src/domains/catalog/wc2026-outright-teams.ts new file mode 100644 index 0000000..e38255c --- /dev/null +++ b/apps/api/src/domains/catalog/wc2026-outright-teams.ts @@ -0,0 +1,63 @@ +/** + * 2026 世界杯 48 强固定夺冠盘口(唯一基准数据源) + * 排名 / 中英文名 / 夺冠赔率 — 与运营表格一致;改此处后执行 seed 或后台「应用表格基准数据」 + */ +export const WC2026_LEAGUE_CODE = 'WC2026'; + +export type Wc2026OutrightTeam = { + rank: number; + code: string; + names: { 'zh-CN': string; 'en-US': string }; + defaultOdds: number; +}; + +export const WC2026_OUTRIGHT_TEAMS: Wc2026OutrightTeam[] = [ + { rank: 1, code: 'FRA', names: { 'zh-CN': '法国', 'en-US': 'France' }, defaultOdds: 4.95 }, + { rank: 2, code: 'ESP', names: { 'zh-CN': '西班牙', 'en-US': 'Spain' }, defaultOdds: 4.95 }, + { rank: 3, code: 'ENG', names: { 'zh-CN': '英格兰', 'en-US': 'England' }, defaultOdds: 6.3 }, + { rank: 4, code: 'BRA', names: { 'zh-CN': '巴西', 'en-US': 'Brazil' }, defaultOdds: 8.55 }, + { rank: 5, code: 'ARG', names: { 'zh-CN': '阿根廷', 'en-US': 'Argentina' }, defaultOdds: 8.55 }, + { rank: 6, code: 'POR', names: { 'zh-CN': '葡萄牙', 'en-US': 'Portugal' }, defaultOdds: 9 }, + { rank: 7, code: 'GER', names: { 'zh-CN': '德国', 'en-US': 'Germany' }, defaultOdds: 15.3 }, + { rank: 8, code: 'NED', names: { 'zh-CN': '荷兰', 'en-US': 'Netherlands' }, defaultOdds: 18.9 }, + { rank: 9, code: 'NOR', names: { 'zh-CN': '挪威', 'en-US': 'Norway' }, defaultOdds: 32.4 }, + { rank: 10, code: 'BEL', names: { 'zh-CN': '比利时', 'en-US': 'Belgium' }, defaultOdds: 35.1 }, + { rank: 11, code: 'COL', names: { 'zh-CN': '哥伦比亚', 'en-US': 'Colombia' }, defaultOdds: 45.9 }, + { rank: 12, code: 'JPN', names: { 'zh-CN': '日本', 'en-US': 'Japan' }, defaultOdds: 45.9 }, + { rank: 13, code: 'URU', names: { 'zh-CN': '乌拉圭', 'en-US': 'Uruguay' }, defaultOdds: 63.9 }, + { rank: 14, code: 'USA', names: { 'zh-CN': '美国', 'en-US': 'USA' }, defaultOdds: 63.9 }, + { rank: 15, code: 'MAR', names: { 'zh-CN': '摩洛哥', 'en-US': 'Morocco' }, defaultOdds: 63.9 }, + { rank: 16, code: 'CRO', names: { 'zh-CN': '克罗地亚', 'en-US': 'Croatia' }, defaultOdds: 81 }, + { rank: 17, code: 'MEX', names: { 'zh-CN': '墨西哥', 'en-US': 'Mexico' }, defaultOdds: 85 }, + { rank: 18, code: 'SUI', names: { 'zh-CN': '瑞士', 'en-US': 'Switzerland' }, defaultOdds: 90 }, + { rank: 19, code: 'TUR', names: { 'zh-CN': '土耳其', 'en-US': 'Turkey' }, defaultOdds: 95 }, + { rank: 20, code: 'SEN', names: { 'zh-CN': '塞内加尔', 'en-US': 'Senegal' }, defaultOdds: 100 }, + { rank: 21, code: 'KOR', names: { 'zh-CN': '韩国', 'en-US': 'South Korea' }, defaultOdds: 120 }, + { rank: 22, code: 'AUT', names: { 'zh-CN': '奥地利', 'en-US': 'Austria' }, defaultOdds: 125 }, + { rank: 23, code: 'ECU', names: { 'zh-CN': '厄瓜多尔', 'en-US': 'Ecuador' }, defaultOdds: 130 }, + { rank: 24, code: 'SWE', names: { 'zh-CN': '瑞典', 'en-US': 'Sweden' }, defaultOdds: 140 }, + { rank: 25, code: 'IRN', names: { 'zh-CN': '伊朗', 'en-US': 'Iran' }, defaultOdds: 150 }, + { rank: 26, code: 'GHA', names: { 'zh-CN': '加纳', 'en-US': 'Ghana' }, defaultOdds: 160 }, + { rank: 27, code: 'ALG', names: { 'zh-CN': '阿尔及利亚', 'en-US': 'Algeria' }, defaultOdds: 180 }, + { rank: 28, code: 'BIH', names: { 'zh-CN': '波黑', 'en-US': 'Bosnia' }, defaultOdds: 200 }, + { rank: 29, code: 'EGY', names: { 'zh-CN': '埃及', 'en-US': 'Egypt' }, defaultOdds: 220 }, + { rank: 30, code: 'TUN', names: { 'zh-CN': '突尼斯', 'en-US': 'Tunisia' }, defaultOdds: 250 }, + { rank: 31, code: 'CAN', names: { 'zh-CN': '加拿大', 'en-US': 'Canada' }, defaultOdds: 280 }, + { rank: 32, code: 'PAN', names: { 'zh-CN': '巴拿马', 'en-US': 'Panama' }, defaultOdds: 300 }, + { rank: 33, code: 'AUS', names: { 'zh-CN': '澳大利亚', 'en-US': 'Australia' }, defaultOdds: 350 }, + { rank: 34, code: 'CZE', names: { 'zh-CN': '捷克', 'en-US': 'Czech' }, defaultOdds: 400 }, + { rank: 35, code: 'KSA', names: { 'zh-CN': '沙特阿拉伯', 'en-US': 'Saudi Arabia' }, defaultOdds: 450 }, + { rank: 36, code: 'NZL', names: { 'zh-CN': '新西兰', 'en-US': 'New Zealand' }, defaultOdds: 500 }, + { rank: 37, code: 'COD', names: { 'zh-CN': '刚果(金)', 'en-US': 'DR Congo' }, defaultOdds: 600 }, + { rank: 38, code: 'UZB', names: { 'zh-CN': '乌兹别克斯坦', 'en-US': 'Uzbekistan' }, defaultOdds: 700 }, + { rank: 39, code: 'IRQ', names: { 'zh-CN': '伊拉克', 'en-US': 'Iraq' }, defaultOdds: 750 }, + { rank: 40, code: 'RSA', names: { 'zh-CN': '南非', 'en-US': 'South Africa' }, defaultOdds: 800 }, + { rank: 41, code: 'CIV', names: { 'zh-CN': '科特迪瓦', 'en-US': 'Ivory Coast' }, defaultOdds: 850 }, + { rank: 42, code: 'JOR', names: { 'zh-CN': '约旦', 'en-US': 'Jordan' }, defaultOdds: 900 }, + { rank: 43, code: 'PAR', names: { 'zh-CN': '巴拉圭', 'en-US': 'Paraguay' }, defaultOdds: 950 }, + { rank: 44, code: 'HAI', names: { 'zh-CN': '海地', 'en-US': 'Haiti' }, defaultOdds: 1000 }, + { rank: 45, code: 'QAT', names: { 'zh-CN': '卡塔尔', 'en-US': 'Qatar' }, defaultOdds: 1200 }, + { rank: 46, code: 'CPV', names: { 'zh-CN': '佛得角', 'en-US': 'Cape Verde' }, defaultOdds: 1500 }, + { rank: 47, code: 'CUW', names: { 'zh-CN': '库拉索', 'en-US': 'Curacao' }, defaultOdds: 2000 }, + { rank: 48, code: 'SCO', names: { 'zh-CN': '苏格兰', 'en-US': 'Scotland' }, defaultOdds: 2500 }, +]; diff --git a/apps/api/src/domains/catalog/wc2026-outright.sync.ts b/apps/api/src/domains/catalog/wc2026-outright.sync.ts new file mode 100644 index 0000000..f11752d --- /dev/null +++ b/apps/api/src/domains/catalog/wc2026-outright.sync.ts @@ -0,0 +1,179 @@ +import type { PrismaClient } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; +import { + WC2026_LEAGUE_CODE, + WC2026_OUTRIGHT_TEAMS, + type Wc2026OutrightTeam, +} from './wc2026-outright-teams'; + +const PLACEHOLDER_TEAM_CODE = 'OUT'; + +const CANONICAL_CODES = new Set(WC2026_OUTRIGHT_TEAMS.map((t) => t.code)); + +export type Wc2026OutrightSyncOptions = { + /** true:赔率/队名/排序与 wc2026-outright-teams.ts 完全一致,并关闭不在表内的选项 */ + forceCanonical?: boolean; +}; + +function oddsEqual(a: Decimal | number, b: number) { + return Number(a) === b; +} + +async function upsertTeamTranslations( + prisma: PrismaClient, + teamId: bigint, + names: Wc2026OutrightTeam['names'], +) { + for (const [locale, value] of Object.entries(names)) { + await prisma.entityTranslation.upsert({ + where: { + entityType_entityId_locale_fieldName: { + entityType: 'TEAM', + entityId: teamId, + locale, + fieldName: 'name', + }, + }, + create: { + entityType: 'TEAM', + entityId: teamId, + locale, + fieldName: 'name', + value, + }, + update: { value }, + }); + } +} + +async function upsertTeam(prisma: PrismaClient, entry: Wc2026OutrightTeam) { + const team = await prisma.team.upsert({ + where: { code: entry.code }, + create: { code: entry.code }, + update: {}, + }); + await upsertTeamTranslations(prisma, team.id, entry.names); + return team; +} + +/** 确保 WC2026 夺冠盘存在;forceCanonical 时与基准表完全一致 */ +export async function syncWc2026OutrightMarket( + prisma: PrismaClient, + options: Wc2026OutrightSyncOptions = {}, +) { + const forceCanonical = options.forceCanonical ?? false; + const league = await prisma.league.findUnique({ where: { code: WC2026_LEAGUE_CODE } }); + if (!league) { + throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedSportsDemo first`); + } + + const placeholder = await upsertTeam(prisma, { + rank: 0, + code: PLACEHOLDER_TEAM_CODE, + names: { 'zh-CN': '冠军盘', 'en-US': 'Outright' }, + defaultOdds: 1, + }); + + for (const entry of WC2026_OUTRIGHT_TEAMS) { + await upsertTeam(prisma, entry); + } + + let match = await prisma.match.findFirst({ + where: { leagueId: league.id, isOutright: true, deletedAt: null }, + }); + if (!match) { + match = await prisma.match.create({ + data: { + leagueId: league.id, + homeTeamId: placeholder.id, + awayTeamId: placeholder.id, + isOutright: true, + matchName: '2026 FIFA World Cup Winner', + startTime: new Date('2027-07-01T00:00:00Z'), + status: 'PUBLISHED', + publishTime: new Date(), + isHot: true, + displayOrder: 0, + }, + }); + } else if (match.status === 'DRAFT') { + match = await prisma.match.update({ + where: { id: match.id }, + data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() }, + }); + } + + let market = await prisma.market.findFirst({ + where: { matchId: match.id, marketType: 'OUTRIGHT_WINNER' }, + include: { selections: true }, + }); + if (!market) { + market = await prisma.market.create({ + data: { + matchId: match.id, + marketType: 'OUTRIGHT_WINNER', + period: 'OUTRIGHT', + allowSingle: true, + allowParlay: false, + sortOrder: 1, + status: 'OPEN', + }, + include: { selections: true }, + }); + } + + const existingByCode = new Map(market.selections.map((s) => [s.selectionCode, s])); + + for (const entry of WC2026_OUTRIGHT_TEAMS) { + const sortOrder = entry.rank - 1; + const existing = existingByCode.get(entry.code); + if (!existing) { + await prisma.marketSelection.create({ + data: { + marketId: market.id, + selectionCode: entry.code, + selectionName: entry.names['zh-CN'], + odds: entry.defaultOdds, + sortOrder, + status: 'OPEN', + }, + }); + continue; + } + + const updateData: { + selectionName: string; + sortOrder: number; + status: string; + odds?: number; + oddsVersion?: bigint; + } = { + selectionName: entry.names['zh-CN'], + sortOrder, + status: 'OPEN', + }; + + if (forceCanonical && !oddsEqual(existing.odds, entry.defaultOdds)) { + updateData.odds = entry.defaultOdds; + updateData.oddsVersion = existing.oddsVersion + BigInt(1); + } + + await prisma.marketSelection.update({ + where: { id: existing.id }, + data: updateData, + }); + } + + if (forceCanonical) { + for (const sel of market.selections) { + if (CANONICAL_CODES.has(sel.selectionCode)) continue; + if (sel.selectionCode === PLACEHOLDER_TEAM_CODE) continue; + await prisma.marketSelection.update({ + where: { id: sel.id }, + data: { status: 'CLOSED' }, + }); + } + } + + return { matchId: match.id, marketId: market.id }; +} diff --git a/docs/项目启动指南.md b/docs/项目启动指南.md new file mode 100644 index 0000000..a62a4d6 --- /dev/null +++ b/docs/项目启动指南.md @@ -0,0 +1,344 @@ +# TheBet365 项目启动指南 + +本文档汇总本地开发、首次部署与日常启动所需的全部步骤与配置说明。 + +--- + +## 一、环境要求 + +| 工具 | 版本要求 | 说明 | +|------|----------|------| +| **Node.js** | ≥ 20 | 见根目录 `package.json` → `engines` | +| **pnpm** | 8+(推荐最新) | Monorepo 包管理,需先 `corepack enable` 或全局安装 | +| **Docker** | 最新稳定版(可选) | 推荐:一键起 PostgreSQL;也可用本机安装的 Postgres | +| **PostgreSQL** | 14+(推荐 16) | **必需**(API / Prisma) | +| **Git** | 任意 | 拉取代码 | + +> 当前 API **仅依赖 PostgreSQL**;`docker-compose` 里的 Redis 为预留,未安装 Redis 一般不影响本地开发。 + +可选: + +- **Docker Compose** v2(`docker compose`,非旧版 `docker-compose`) +- Windows 用户建议使用 PowerShell 或 Windows Terminal + +--- + +## 二、仓库结构速览 + +``` +thebet365/ +├── apps/ +│ ├── api/ NestJS 后端(Prisma + PostgreSQL) +│ ├── admin/ 管理后台(平台管理员 + 代理,:5174) +│ └── player/ 玩家 H5 前台(:5173) +├── packages/ +│ └── shared/ 共享类型与静态资源(public) +├── uploads/ 上传文件目录(Banner 等,API 静态托管) +├── docker-compose.yml +├── .env.example 环境变量模板 +└── package.json 根脚本(dev / db:*) +``` + +--- + +## 三、首次启动(完整流程) + +按顺序执行,**只需做一次**(或换机器 / 清空数据库后重做)。 + +### 1. 克隆并进入项目 + +```bash +cd thebet365 +``` + +### 2. 启动数据库(二选一) + +#### 方案 A:Docker(已安装 Docker Desktop 时) + +```bash +docker compose up -d +docker compose ps +``` + +| 服务 | 容器名 | 端口 | 默认账号 | +|------|--------|------|----------| +| PostgreSQL 16 | `thebet365-postgres` | `5432` | 用户/密码/库:`thebet365` | +| Redis 7 | `thebet365-redis` | `6379` | 无密码(可选) | + +`apps/api/.env` 使用: + +```env +DATABASE_URL=postgresql://thebet365:thebet365@localhost:5432/thebet365 +``` + +停止:`docker compose down` · 清空数据卷(慎用):`docker compose down -v` + +**Windows 未识别 `docker` 命令时**:安装 [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/),安装后重启 PowerShell,并确保设置里已勾选 **Use WSL 2**(推荐)。也可用 winget: + +```powershell +winget install Docker.DockerDesktop +``` + +#### 方案 B:本机 PostgreSQL(无 Docker) + +1. 安装 PostgreSQL 16(任选其一): + - https://www.postgresql.org/download/windows/ + - 或 `winget install PostgreSQL.PostgreSQL.16` +2. 安装时记住 **超级用户密码**(默认用户常为 `postgres`)。 +3. 用 **pgAdmin** 或 `psql` 创建与项目一致的数据库(也可改用你自己的账号,同步改 `DATABASE_URL`): + +```sql +CREATE USER thebet365 WITH PASSWORD 'thebet365'; +CREATE DATABASE thebet365 OWNER thebet365; +GRANT ALL PRIVILEGES ON DATABASE thebet365 TO thebet365; +``` + +4. 编辑 `apps/api/.env`: + +```env +DATABASE_URL=postgresql://thebet365:thebet365@localhost:5432/thebet365 +``` + +若只用默认 `postgres` 用户: + +```env +DATABASE_URL=postgresql://postgres:你的密码@localhost:5432/thebet365 +``` + +(需已 `CREATE DATABASE thebet365;`) + +5. 确认本机 **5432** 端口 Postgres 服务已启动(Windows「服务」里 `postgresql-x64-16` 为「正在运行」)。 + +### 3. 安装依赖 + +在**仓库根目录**执行: + +```bash +pnpm install +``` + +### 4. 配置环境变量 + +将根目录模板复制到 API 目录(Nest 从 `apps/api` 工作目录读取 `.env`): + +```bash +cp .env.example apps/api/.env +``` + +`apps/api/.env` 主要项说明: + +| 变量 | 示例值 | 说明 | +|------|--------|------| +| `DATABASE_URL` | `postgresql://thebet365:thebet365@localhost:5432/thebet365` | 须与 docker-compose 一致 | +| `REDIS_URL` | `redis://localhost:6379` | 预留,与 compose 中 Redis 对应 | +| `JWT_SECRET` | 长随机字符串 | **生产环境务必修改** | +| `JWT_PLAYER_EXPIRES` | `24h` | 玩家 Token 有效期 | +| `JWT_ADMIN_EXPIRES` | `2h` | 管理员 Token | +| `JWT_AGENT_EXPIRES` | `8h` | 代理 Token | +| `PORT` | `3000` | API 监听端口 | +| `NODE_ENV` | `development` | 开发环境 | +| `UPLOAD_DIR` | 留空 | 默认使用 `apps/api/uploads`;可填绝对路径 | + +### 5. 数据库迁移与种子数据 + +仍在**仓库根目录**: + +```bash +pnpm db:generate # 生成 Prisma Client(改 schema 后需要) +pnpm db:migrate # 执行迁移(开发环境,可交互命名) +pnpm db:seed # 演示账号、赛事、世界杯 48 强夺冠盘等 +``` + +生产环境部署迁移使用: + +```bash +pnpm --filter @thebet365/api db:migrate:deploy +``` + +可视化查看数据(可选): + +```bash +pnpm db:studio +``` + +### 6. 启动开发服务 + +**方式 A:一键启动全部**(API + admin + player + shared 编译监听) + +```bash +pnpm dev +``` + +**方式 B:分终端启动**(推荐调试时) + +```bash +# 终端 1 — 必须先起 API +pnpm dev:api + +# 终端 2 — 管理后台 +pnpm dev:admin + +# 终端 3 — 玩家前台 +pnpm dev:player +``` + +> `dev:manage` 与 `dev:admin` 相同,均为管理后台。 + +--- + +## 四、访问地址 + +| 应用 | 地址 | 说明 | +|------|------|------| +| **API** | http://localhost:3000/api | 全局前缀 `api` | +| **Swagger** | http://localhost:3000/api/docs | 接口文档 | +| **玩家前台** | http://localhost:5173 | Vite 代理 `/api` → `:3000` | +| **管理后台** | http://localhost:5174 | Vite 代理 `/api` → `:3000` | +| **静态上传** | http://localhost:3000/uploads/... | 玩家端也可经 `:5173/uploads` 代理 | + +前端开发时**无需单独配置 CORS**,Vite 已将 `/api`(玩家端含 `/uploads`)反向代理到 API。 + +--- + +## 五、演示账号与默认数据 + +`pnpm db:seed` 写入账号、赛事、48 强夺冠、Banner 等。**完整清单**见 **[默认数据说明.md](./默认数据说明.md)**。 + +| 角色 | 用户名 | 密码 | 登录入口 | +|------|--------|------|----------| +| 平台管理员 | `admin` | `Admin@123` | 管理后台 http://localhost:5174 | +| 一级代理 | `agent1` | `Agent@123` | 同上(菜单为代理端) | +| 二级代理 | `agent2` | `Agent@123` | 同上 | +| 玩家 | `player1` | `Player@123` | 玩家前台 http://localhost:5173 | + +管理端:`POST /api/manage/auth/login` · 玩家端:`POST /api/player/auth/login` + +--- + +## 六、根目录常用命令 + +| 命令 | 作用 | +|------|------| +| `pnpm dev` | 并行启动各 app 的 `dev` 脚本 | +| `pnpm dev:api` | 仅 API(watch) | +| `pnpm dev:admin` | 仅管理后台 | +| `pnpm dev:player` | 仅玩家前台 | +| `pnpm build` | 构建所有 workspace 包 | +| `pnpm test` | 运行各包测试 | +| `pnpm db:generate` | Prisma Client 生成 | +| `pnpm db:migrate` | 开发迁移 | +| `pnpm db:seed` | 种子数据(含 WC2026 48 强夺冠) | +| `pnpm db:studio` | Prisma Studio | + +--- + +## 七、业务数据与上传目录 + +详见 **[默认数据说明.md](./默认数据说明.md)**(赛事、盘口、48 强夺冠、player1 余额与注单、Banner 等)。 + +- 上传目录默认:`apps/api/uploads/`(可由 `UPLOAD_DIR` 覆盖) + +--- + +## 八、生产构建(简述) + +```bash +pnpm install +pnpm build +# API +pnpm --filter @thebet365/api start +# 前端产物在各自 apps/*/dist,由 Nginx 等托管,并反向代理 /api 到后端 +``` + +生产务必: + +1. 修改 `JWT_SECRET` 与数据库密码 +2. 使用 `db:migrate:deploy` 而非 `migrate dev` +3. 配置 `NODE_ENV=production` 与真实 `DATABASE_URL` + +--- + +## 九、常见问题 + +### 1. PowerShell 提示「无法将 docker 识别为 cmdlet」 + +- 未安装 Docker,或未加入 PATH → 用上文 **方案 B 本机 PostgreSQL**,或安装 Docker Desktop 后重启终端。 + +### 2. `P3015` / `Could not find the migration file at migration.sql` + +- 原因:`apps/api/prisma/migrations/` 下某个迁移目录缺少 `migration.sql`(例如空的 `20260602120000_user_preference_contact`)。 +- 处理:拉取最新代码后重试 `pnpm db:migrate`;若本地曾误删该文件,需从仓库恢复或删除空目录后重新 `prisma migrate dev`。 +- 说明:若 `pnpm db:seed` 已成功,说明表结构多半已可用,可先 `pnpm dev:api` 开发;仍建议修好 migrate 以便团队协作。 + +**若修复文件后提示 Drift detected(库里有字段但迁移历史未记录)**,在 `apps/api` 下将已存在的迁移标记为已应用(不删数据): + +```bash +cd apps/api +pnpm exec prisma migrate resolve --applied 20260602120000_user_preference_contact +pnpm exec prisma migrate resolve --applied 20260603102323_zhibo_match_fields +pnpm db:migrate +``` + +仍无法收敛且可接受清空库时:`pnpm exec prisma migrate reset`(会删库),再 `pnpm db:seed`。 + +### 3. `pnpm db:migrate` 连接数据库失败 + +- Docker 方案:确认 `docker compose up -d` 且 Postgres 健康 +- 本机方案:确认 PostgreSQL 服务已启动、库 `thebet365` 已创建 +- 检查 `apps/api/.env` 中 `DATABASE_URL` 主机/端口/账号/密码 + +### 4. 管理后台文案显示为 key(如 `user.field.xxx`) + +- 勿在 `apps/admin/src` 下保留误生成的 `*.js`(会抢先于 `.ts` 被 Vite 加载) +- 清理缓存后重启:`apps/admin/node_modules/.vite` +- 硬刷新浏览器(Ctrl+Shift+R) + +### 5. 前端接口 502 / 代理错误 + +- 必须先启动 `pnpm dev:api`,再开 admin/player + +### 6. `agent-form` 等模块导出报错 + +- 同上,删除 `apps/admin/src` 内过期 `.js`,重启 dev server + +### 7. 夺冠盘不足 48 队 + +```bash +pnpm db:seed +``` + +或在管理后台 **世界杯夺冠 → 应用表格基准数据**。 + +### 8. Windows 下 Prisma / ts-node 权限问题 + +- 以管理员运行终端,或关闭占用 `5432` 端口的其他 Postgres 实例 + +--- + +## 十、推荐日常开发顺序 + +```text +1. docker compose up -d +2. pnpm dev:api # 等 API 打印 running +3. pnpm dev:admin # 或 pnpm dev:player +4. 浏览器打开对应端口 +``` + +改 Prisma schema 后: + +```text +pnpm db:generate && pnpm db:migrate +``` + +--- + +## 十一、相关文档 + +- [README.md](../README.md) — 项目概览与技术栈 +- [默认数据说明.md](./默认数据说明.md) — seed 后的账号、赛事、夺冠盘、运营内容 +- [apps/admin/README.md](../apps/admin/README.md) — 管理后台结构 +- [足球投注平台产品需求与MVP实施计划_PRD_v1.2.md](../足球投注平台产品需求与MVP实施计划_PRD_v1.2.md) — 产品需求(若存在) + +--- + +*文档随仓库脚本与端口变更时请同步更新。* diff --git a/docs/默认数据说明.md b/docs/默认数据说明.md new file mode 100644 index 0000000..fb20359 --- /dev/null +++ b/docs/默认数据说明.md @@ -0,0 +1,178 @@ +# 默认数据说明 + +执行 `pnpm db:migrate` 后数据库仅有**表结构**;执行 **`pnpm db:seed`** 后会写入下文演示数据。 +种子脚本位置:`apps/api/prisma/seed.ts`。 + +> **注意**:以下为开发演示用途。生产环境务必修改密码、`JWT_SECRET`,勿直接使用默认账号。 + +--- + +## migrate 与 seed 的区别 + +| 命令 | 作用 | +|------|------| +| `pnpm db:migrate` | 按 Prisma 迁移**建表 / 改表**(用户、赛事、盘口、注单等) | +| `pnpm db:seed` | 在已有表结构中**写入演示账号、赛事、赔率、内容等** | + +推荐顺序:先 `db:migrate`,再 `db:seed`。 +种子多为 **upsert**:重复执行一般不会重复创建用户,但会补全赛事、48 强夺冠盘等。 + +--- + +## 一、默认账号 + +| 用户名 | 密码 | 角色 | 说明 | +|--------|------|------|------| +| `admin` | `Admin@123` | 平台管理员 | 绑定 `SUPER_ADMIN` 角色 | +| `agent1` | `Agent@123` | 一级代理 | 授信额度 **100,000** | +| `agent2` | `Agent@123` | 二级代理 | 上级为 agent1,授信 **30,000** | +| `player1` | `Player@123` | 玩家 | 挂靠 agent1 | + +| 入口 | 地址 | +|------|------| +| 管理后台(admin / agent1 / agent2) | http://localhost:5174 | +| 玩家前台(player1) | http://localhost:5173 | + +| 接口 | 路径 | +|------|------| +| 管理端登录 | `POST /api/manage/auth/login` | +| 玩家端登录 | `POST /api/player/auth/login` | + +--- + +## 二、默认环境与端口 + +| 项 | 默认值 | +|----|--------| +| API | http://localhost:3000 ,全局前缀 `/api` | +| Swagger | http://localhost:3000/api/docs | +| 管理后台 | http://localhost:5174 | +| 玩家前台 | http://localhost:5173 | +| Docker PostgreSQL | 用户/密码/库:`thebet365` @ `localhost:5432` | +| Docker Redis | `localhost:6379`(当前 API 逻辑以 Postgres 为主) | + +`apps/api/.env` 模板见根目录 `.env.example`,主要项: + +| 变量 | 示例 | 说明 | +|------|------|------| +| `DATABASE_URL` | `postgresql://thebet365:thebet365@localhost:5432/thebet365` | 数据库连接 | +| `JWT_SECRET` | 长随机串 | **生产必须修改** | +| `JWT_PLAYER_EXPIRES` | `24h` | 玩家 Token | +| `JWT_ADMIN_EXPIRES` | `2h` | 管理员 Token | +| `JWT_AGENT_EXPIRES` | `8h` | 代理 Token | +| `PORT` | `3000` | API 端口 | +| `UPLOAD_DIR` | 空 | 默认 `apps/api/uploads` | + +--- + +## 三、默认赛事与盘口 + +### 联赛 + +| 代码 | 名称 | +|------|------| +| `EPL` | 英超 / Premier League | +| `WC2026` | 2026 世界杯(加拿大、墨西哥、美国) | + +### 已发布场次(约 9 场) + +- **英超 2 场**:如曼联 vs 切尔西(开球时间为相对当前的演示时间) +- **世界杯 7 场**:如墨西哥-南非、美国-巴拉圭、法国-阿根廷等(2026-06 固定日期) + +每场在尚无盘口时会自动创建演示玩法,包括但不限于: + +- 全场 / 半场:独赢、让球、大小、单双 +- 全场 / 半场 / 下半场:波胆(多档比分选项) + +赔率均为种子脚本中的**示例数值**,可在管理后台赛事相关流程中调整(非冠军盘)。 + +### 世界杯 48 强夺冠盘 + +| 项 | 说明 | +|----|------| +| 数据来源 | `apps/api/src/domains/catalog/wc2026-outright-teams.ts` | +| 队伍数 | **48** 支,含排名与中英文名 | +| 默认赔率 | 如法国 4.95、英格兰 6.3、苏格兰 2500 等 | +| 玩家端 | 足球页 → **「优胜冠军」** | +| 管理端 | 菜单 **「世界杯夺冠」** → 可改赔率 | +| 恢复基准 | 点击 **「应用表格基准数据」** 与代码表对齐 | + +`pnpm db:seed` 会以 `forceCanonical: true` 同步 48 强;已有选项的赔率仅在「应用基准」或重新 seed 时按文件覆盖。 + +--- + +## 四、玩家演示数据(player1) + +| 项 | 默认值 | +|----|--------| +| 可用余额 | **88,888.88** | +| 账变流水 | 2 笔演示充值(`DEMO-DEP-001` / `DEMO-DEP-002`) | +| 语言偏好 | `zh-CN` | +| 示例注单 | `DEMO-BET-001`:待结算单关;`DEMO-BET-002`:已赢且已结算单关 | + +--- + +## 五、运营内容(玩家首页) + +| 类型 | 数量 | 说明 | +|------|------|------| +| Banner | 3 | 欢迎投注、首存礼遇、热门赛事 | +| 走马灯(TICKER) | 1 | 欢迎语 + 理性投注提示 | +| 公告(NOTICE) | 1 | 每周一 04:00–05:00 维护通知 | + +图片路径示例:`/uploads/banners/welcome.svg` 等。 +重复执行 seed 时,已存在的同类内容可能因 `catch` 跳过,不会无限重复插入。 + +--- + +## 六、权限与库内多语言 + +### 角色与权限 + +- 角色:`SUPER_ADMIN`(超级管理员) +- 权限示例:`users.create`、`users.view`、`agents.create`、`agents.view`、`wallet.deposit`、`wallet.withdraw`、`matches.manage`、`settlement.confirm`、`cashback.confirm`、`content.manage`、`reports.view` + +### i18n_messages 表 + +种子仅写入少量玩家端 key(中 / 英 / 马来),例如 `nav.home`、`bet.place_bet`。 +**管理后台与玩家端大部分文案在代码内**(`admin-messages`、`player` 内 vue-i18n),不全部存数据库。 + +--- + +## 七、管理后台默认行为 + +| 项 | 默认 | +|----|------| +| 界面语言 | 中文 `zh-CN`(`localStorage` 键 `admin_locale`) | +| 可选语言 | 中文、English、Bahasa Melayu | +| 登录页 | 若开启「快速登录(调试)」可一键 admin / agent | + +--- + +## 八、如何查看 / 重置 + +```bash +# 可视化浏览所有表 +pnpm db:studio + +# 重新写入种子(不删表,多为 upsert) +pnpm db:seed +``` + +仅想恢复 **48 强夺冠赔率** 为代码基准:管理后台 → **世界杯夺冠** → **应用表格基准数据**。 + +清空数据库后重来: + +```bash +docker compose down -v # 会删除 Docker 数据卷,慎用 +docker compose up -d +pnpm db:migrate +pnpm db:seed +``` + +--- + +## 相关文档 + +- [项目启动指南.md](./项目启动指南.md) — 安装、启动、排错 +- [README.md](../README.md) — 项目概览