feat: 世界杯48强夺冠盘、管理端调赔与项目文档
- 固定48强基准数据、同步种子与后台世界杯夺冠页 - 补全 user_preferences 迁移文件;新增启动指南与默认数据说明 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)**。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
|
||||
@@ -33,6 +33,7 @@ const zh: Record<string, string> = {
|
||||
'nav.users': '玩家管理',
|
||||
'nav.agents': '代理管理',
|
||||
'nav.matches': '赛事管理',
|
||||
'nav.outright': '世界杯夺冠',
|
||||
'nav.bets': '注单管理',
|
||||
'nav.cashback': '返水管理',
|
||||
'nav.audit': '操作日志',
|
||||
@@ -181,6 +182,7 @@ const en: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'nav.users': 'Pemain',
|
||||
'nav.agents': 'Ejen',
|
||||
'nav.matches': 'Perlawanan',
|
||||
'nav.outright': 'Juara Piala Dunia',
|
||||
'nav.bets': 'Pertaruhan',
|
||||
'nav.cashback': 'Rebat',
|
||||
'nav.audit': 'Log audit',
|
||||
|
||||
@@ -246,6 +246,21 @@ export const adminPagesMs: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -246,6 +246,21 @@ export const adminPagesZh: Record<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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'),
|
||||
|
||||
150
apps/admin/src/views/WorldCupOutright.vue
Normal file
150
apps/admin/src/views/WorldCupOutright.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useAdminLocale } from '../composables/useAdminLocale';
|
||||
import api from '../api';
|
||||
|
||||
const { t } = useAdminLocale();
|
||||
|
||||
interface SelectionRow {
|
||||
id: string;
|
||||
teamCode: string;
|
||||
rank: number;
|
||||
teamZh: string;
|
||||
teamEn: string;
|
||||
odds: string;
|
||||
oddsVersion: string;
|
||||
status: string;
|
||||
editOdds: number;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const leagueZh = ref('');
|
||||
const leagueEn = ref('');
|
||||
const selections = ref<SelectionRow[]>([]);
|
||||
const expectedCount = ref(48);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get('/admin/outrights/wc2026');
|
||||
const payload = data.data as {
|
||||
leagueZh: string;
|
||||
leagueEn: string;
|
||||
selections: SelectionRow[];
|
||||
expectedCount: number;
|
||||
};
|
||||
leagueZh.value = payload.leagueZh;
|
||||
leagueEn.value = payload.leagueEn;
|
||||
expectedCount.value = payload.expectedCount;
|
||||
selections.value = payload.selections.map((s) => ({
|
||||
...s,
|
||||
editOdds: Number(s.odds),
|
||||
}));
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCanonical() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await api.post('/admin/outrights/wc2026/apply-canonical');
|
||||
ElMessage.success(t('msg.outright_canonical_applied'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const invalid = selections.value.find((s) => !s.editOdds || s.editOdds <= 1);
|
||||
if (invalid) {
|
||||
ElMessage.warning(t('outright.err_odds_min'));
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.put('/admin/outrights/wc2026/odds', {
|
||||
updates: selections.value.map((s) => ({
|
||||
selectionId: s.id,
|
||||
odds: s.editOdds,
|
||||
})),
|
||||
});
|
||||
ElMessage.success(t('msg.outright_odds_saved'));
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { error?: string } } };
|
||||
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">{{ t('page.outright.title') }}</h2>
|
||||
<span class="page-desc">{{ t('page.outright.desc') }}</span>
|
||||
</div>
|
||||
|
||||
<el-card class="data-card" shadow="never" v-loading="loading">
|
||||
<div class="meta-row">
|
||||
<span>{{ leagueZh }} / {{ leagueEn }}</span>
|
||||
<span class="meta-count">
|
||||
{{ t('outright.team_count', { n: selections.length, total: expectedCount }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<el-table :data="selections" stripe>
|
||||
<el-table-column prop="rank" :label="t('outright.col.rank')" width="72" align="center" />
|
||||
<el-table-column prop="teamZh" :label="t('outright.col.team_zh')" min-width="120" />
|
||||
<el-table-column prop="teamEn" :label="t('outright.col.team_en')" min-width="140" />
|
||||
<el-table-column prop="teamCode" :label="t('outright.col.code')" width="88" />
|
||||
<el-table-column :label="t('outright.col.odds')" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.editOdds"
|
||||
:min="1.01"
|
||||
:step="0.05"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<el-button @click="load">{{ t('common.reset') }}</el-button>
|
||||
<el-button :loading="loading" @click="applyCanonical">
|
||||
{{ t('outright.btn.apply_canonical') }}
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveAll">
|
||||
{{ t('outright.btn.save_odds') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; }
|
||||
.page-desc { font-size: 13px; color: #888; }
|
||||
.data-card { border-radius: 12px; }
|
||||
.meta-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 13px; color: #aaa; }
|
||||
.meta-count { color: #666; }
|
||||
.footer-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
|
||||
</style>
|
||||
@@ -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);
|
||||
@@ -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<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 },
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
108
apps/api/src/domains/catalog/outright.service.ts
Normal file
108
apps/api/src/domains/catalog/outright.service.ts
Normal file
@@ -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<string> {
|
||||
const row = await this.prisma.entityTranslation.findFirst({
|
||||
where: { entityType, entityId, locale, fieldName: 'name' },
|
||||
});
|
||||
return row?.value ?? '';
|
||||
}
|
||||
}
|
||||
63
apps/api/src/domains/catalog/wc2026-outright-teams.ts
Normal file
63
apps/api/src/domains/catalog/wc2026-outright-teams.ts
Normal file
@@ -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 },
|
||||
];
|
||||
179
apps/api/src/domains/catalog/wc2026-outright.sync.ts
Normal file
179
apps/api/src/domains/catalog/wc2026-outright.sync.ts
Normal file
@@ -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 };
|
||||
}
|
||||
344
docs/项目启动指南.md
Normal file
344
docs/项目启动指南.md
Normal file
@@ -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) — 产品需求(若存在)
|
||||
|
||||
---
|
||||
|
||||
*文档随仓库脚本与端口变更时请同步更新。*
|
||||
178
docs/默认数据说明.md
Normal file
178
docs/默认数据说明.md
Normal file
@@ -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) — 项目概览
|
||||
Reference in New Issue
Block a user