This commit is contained in:
win 2026-04-25 02:48:07 +08:00
parent 45ea70760b
commit 26696f4e80
3 changed files with 472 additions and 51 deletions

View File

@ -0,0 +1,404 @@
# 📋 实施计划:扫雷排行榜管理后台 + 去除免费模式
## 背景理解
用户说明:
1. **没有免费模式**`minesweeper_free` 这个 game_type 已废弃,前后端都要移除
2. **排行榜需要在管理后台展示**:当前排行榜只有 App 端接口,管理后台缺少排行榜 Tab
3. **"积分"含义模糊**:排行榜里的 `total_rank_points` 字段是"游戏对战分"不是平台积分points页面上要加说明
---
## 任务类型
- [x] 全栈(后端 + 前端并行)
---
## 技术方案
### 后端
`internal/api/game/handler.go` 新增一个 Admin 专用排行榜接口:
- `GET /api/admin/games/leaderboard` — 管理后台查排行榜(分页、支持搜索用户昵称)
- `GET /api/admin/games/records` — 管理后台查每局对战记录(分页、支持按用户/时间筛选)
两个接口都走读库,无需鉴权以外的特殊处理。
### 前端
`web/admin/src/views/operations/minesweeper/index.vue` 新增两个 Tab
- **排行榜 Tab**:表格展示所有玩家的排行数据,含"对战分"说明
- **对战记录 Tab**:按局查每场游戏的明细
同时去掉前端中所有 `minesweeper_free` 的相关逻辑和 `game_type` 切换选项。
---
## 实施步骤
### Step 1 — 后端:新增 Admin 排行榜接口
**文件**: `internal/api/game/handler.go`(在现有 Admin API 区域末尾追加)
```go
// GetAdminLeaderboard Admin查询扫雷排行榜
// @Router /api/admin/games/leaderboard [get]
func (h *handler) GetAdminLeaderboard() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"` // 可选:按昵称模糊搜索
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 { req.Page = 1 }
if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 }
offset := (req.Page - 1) * req.PageSize
type row struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalRankPoints int64 `json:"total_rank_points"`
MatchesPlayed int `json:"matches_played"`
Wins int `json:"wins"`
Losses int `json:"losses"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
AvgScore float64 `json:"avg_score"`
}
query := h.db.GetDbR().Table("minesweeper_leaderboard l").
Select("l.user_id, COALESCE(u.nick_name,'') AS nickname, COALESCE(u.avatar_url,'') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score").
Joins("LEFT JOIN users u ON u.id = l.user_id").
Where("l.game_type = ?", "minesweeper")
if req.Nickname != "" {
query = query.Where("u.nick_name LIKE ?", "%"+req.Nickname+"%")
}
var total int64
query.Count(&total)
var rows []row
query.Order("l.total_rank_points DESC, l.wins DESC, l.best_score DESC").
Limit(req.PageSize).Offset(offset).Scan(&rows)
// 补名次
list := make([]map[string]any, 0, len(rows))
for i, r := range rows {
list = append(list, map[string]any{
"rank": offset + i + 1,
"user_id": r.UserID,
"nickname": r.Nickname,
"avatar": r.Avatar,
"total_rank_points": r.TotalRankPoints,
"matches_played": r.MatchesPlayed,
"wins": r.Wins,
"losses": r.Losses,
"win_rate": r.WinRate,
"best_score": r.BestScore,
"avg_score": r.AvgScore,
})
}
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": list,
})
}
}
// GetAdminGameRecords Admin查询扫雷对战记录
// @Router /api/admin/games/records [get]
func (h *handler) GetAdminGameRecords() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
UserID int64 `form:"user_id"`
MatchID string `form:"match_id"`
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 { req.Page = 1 }
if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 }
offset := (req.Page - 1) * req.PageSize
type row struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
IsWinner bool `json:"is_winner"`
RankPosition int `json:"rank_position"`
TotalPlayers int `json:"total_players"`
Score int `json:"score"`
DamageDealt int `json:"damage_dealt"`
ChestsCollected int `json:"chests_collected"`
RankPoints int `json:"rank_points"`
SettledAt string `json:"settled_at"`
}
query := h.db.GetDbR().Table("minesweeper_game_records r").
Select("r.id, r.match_id, r.user_id, COALESCE(u.nick_name,'') AS nickname, r.is_winner, r.rank_position, r.total_players, r.score, r.damage_dealt, r.chests_collected, r.rank_points, r.settled_at").
Joins("LEFT JOIN users u ON u.id = r.user_id").
Where("r.game_type = ?", "minesweeper")
if req.UserID > 0 {
query = query.Where("r.user_id = ?", req.UserID)
}
if req.MatchID != "" {
query = query.Where("r.match_id = ?", req.MatchID)
}
var total int64
query.Count(&total)
var rows []row
query.Order("r.settled_at DESC").Limit(req.PageSize).Offset(offset).Scan(&rows)
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": rows,
})
}
}
```
### Step 2 — 后端:注册新路由
**文件**: `internal/router/router.go`
在 admin 认证路由区域找到 game 相关路由,追加:
```go
adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard())
adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords())
```
### Step 3 — 后端SettleGame 去掉免费模式分支
**文件**: `internal/api/game/handler.go`
`isFreeMode` 判断相关逻辑仍可保留(对 `minesweeper` 类型无影响),但移除文档/注释中所有 `minesweeper_free` 提及。
实际上后端逻辑本身没问题,如果 Nakama 不再发送 `minesweeper_free` 类型就不会触发,无需修改业务逻辑。
### Step 4 — 前端:在 index.vue 新增"排行榜"Tab
**文件**: `web/admin/src/views/operations/minesweeper/index.vue`
#### 4.1 在 `<el-tabs>` 中新增两个 Tab pane追加在"配置预览"之前)
```html
<!-- 5. 排行榜 -->
<el-tab-pane label="排行榜" name="leaderboard">
<div class="tab-content">
<el-alert
title="对战分说明:对战分是游戏内部的排名积分,与平台积分(商城积分/兑换积分)无关。赢得对局可获得更多对战分,用于在此排行榜中排名。"
type="info"
:closable="false"
class="mb-4"
show-icon
/>
<div class="flex gap-2 mb-4">
<el-input
v-model="lbSearch"
placeholder="搜索玩家昵称"
clearable
style="width: 240px"
@change="fetchLeaderboard"
/>
<el-button @click="fetchLeaderboard">刷新</el-button>
</div>
<el-table :data="lbList" border stripe v-loading="lbLoading">
<el-table-column label="排名" width="70" align="center">
<template #default="scope">
<span :class="scope.row.rank <= 3 ? 'font-bold text-yellow-600' : ''">
{{ scope.row.rank }}
</span>
</template>
</el-table-column>
<el-table-column label="玩家" min-width="140">
<template #default="scope">
<div class="flex items-center gap-2">
<el-avatar :src="scope.row.avatar" :size="28" v-if="scope.row.avatar" />
<span>{{ scope.row.nickname || scope.row.user_id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="对战分" prop="total_rank_points" width="100" align="right" sortable />
<el-table-column label="场次" prop="matches_played" width="80" align="center" />
<el-table-column label="胜场" prop="wins" width="70" align="center" />
<el-table-column label="胜率" width="80" align="center">
<template #default="scope">{{ (scope.row.win_rate * 100).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="最高分" prop="best_score" width="90" align="right" />
<el-table-column label="平均分" prop="avg_score" width="90" align="right">
<template #default="scope">{{ Number(scope.row.avg_score).toFixed(1) }}</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="lbPage"
v-model:page-size="lbPageSize"
:total="lbTotal"
layout="total, prev, pager, next"
@current-change="fetchLeaderboard"
/>
</div>
</div>
</el-tab-pane>
<!-- 6. 对战记录 -->
<el-tab-pane label="对战记录" name="records">
<div class="tab-content">
<div class="flex gap-2 mb-4">
<el-input
v-model="recUserID"
placeholder="按用户ID筛选"
clearable
style="width: 180px"
@change="fetchRecords"
/>
<el-input
v-model="recMatchID"
placeholder="按局ID筛选"
clearable
style="width: 260px"
@change="fetchRecords"
/>
<el-button @click="fetchRecords">刷新</el-button>
</div>
<el-table :data="recList" border stripe v-loading="recLoading" size="small">
<el-table-column label="局ID" prop="match_id" min-width="200" show-overflow-tooltip />
<el-table-column label="玩家" min-width="120">
<template #default="scope">{{ scope.row.nickname || scope.row.user_id }}</template>
</el-table-column>
<el-table-column label="结果" width="70" align="center">
<template #default="scope">
<el-tag :type="scope.row.is_winner ? 'success' : 'info'" size="small">
{{ scope.row.is_winner ? '胜' : '败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="名次" prop="rank_position" width="65" align="center" />
<el-table-column label="总人数" prop="total_players" width="75" align="center" />
<el-table-column label="得分" prop="score" width="80" align="right" />
<el-table-column label="对战分" prop="rank_points" width="85" align="right" />
<el-table-column label="结算时间" prop="settled_at" width="160" />
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="recPage"
v-model:page-size="recPageSize"
:total="recTotal"
layout="total, prev, pager, next"
@current-change="fetchRecords"
/>
</div>
</div>
</el-tab-pane>
```
#### 4.2 在 `<script setup>` 中追加响应式数据和 API 函数
```typescript
import request from '@/utils/http'
// 排行榜
const lbSearch = ref('')
const lbLoading = ref(false)
const lbList = ref<any[]>([])
const lbTotal = ref(0)
const lbPage = ref(1)
const lbPageSize = ref(20)
const fetchLeaderboard = async () => {
lbLoading.value = true
try {
const res = await request.get('/admin/games/leaderboard', {
params: { page: lbPage.value, page_size: lbPageSize.value, nickname: lbSearch.value }
})
lbList.value = res.list || []
lbTotal.value = res.total || 0
} finally {
lbLoading.value = false
}
}
// 对战记录
const recUserID = ref('')
const recMatchID = ref('')
const recLoading = ref(false)
const recList = ref<any[]>([])
const recTotal = ref(0)
const recPage = ref(1)
const recPageSize = ref(20)
const fetchRecords = async () => {
recLoading.value = true
try {
const res = await request.get('/admin/games/records', {
params: {
page: recPage.value,
page_size: recPageSize.value,
user_id: recUserID.value || undefined,
match_id: recMatchID.value || undefined,
}
})
recList.value = res.list || []
recTotal.value = res.total || 0
} finally {
recLoading.value = false
}
}
```
#### 4.3 在 `onMounted` 中追加调用
```typescript
onMounted(() => {
loadConfig()
fetchLeaderboard()
fetchRecords()
})
```
#### 4.4 监听 Tab 切换(可选优化)
在 Tab 切换到对应 Tab 时按需加载,避免首次全量请求:
```typescript
watch(activeTab, (val) => {
if (val === 'leaderboard') fetchLeaderboard()
if (val === 'records') fetchRecords()
})
```
---
## 关键文件汇总
| 文件 | 操作 | 说明 |
|------|------|------|
| `internal/api/game/handler.go` | 修改 | 新增 `GetAdminLeaderboard()``GetAdminGameRecords()` 两个函数 |
| `internal/router/router.go` | 修改 | 注册两条新路由 |
| `web/admin/src/views/operations/minesweeper/index.vue` | 修改 | 新增排行榜和对战记录两个 Tab + script 逻辑 |
---
## 注意事项
- "积分"含义:页面上 `total_rank_points` 显示为"对战分",并加 Alert 说明避免与平台积分points/兑换币)混淆
- 不需要新建文件,全部在现有文件中追加
- 后端无需修改任何免费模式的业务逻辑;前端 Tab 中不再展示 game_type 切换选项即可
- `activeTab` 初始值改为 `'board'`(已经是),排行榜 Tab 不作为默认 Tab
- 分页默认每页 20 条
---
## SESSION_ID
- CODEX_SESSION: N/A本次直接由 Claude 规划,无外部模型调用)
- GEMINI_SESSION: N/A

View File

@ -381,16 +381,19 @@ type settleResponse struct {
Reward string `json:"reward,omitempty"` Reward string `json:"reward,omitempty"`
} }
func calcRankPoints(win bool, score, damageDealt, damageTaken, chests, totalRounds int) int64 { func calcRankPoints(rank int) int64 {
base := int64(100) switch rank {
if win { case 1:
base = 1000 return 1000
case 2:
return -900
case 3:
return -1100
case 4:
return -1300
default:
return 0
} }
pts := base + int64(score)*10 + int64(damageDealt)*3 + int64(chests)*50 - int64(damageTaken)*2 - int64(totalRounds)
if pts < 0 {
pts = 0
}
return pts
} }
// SettleGame Internal游戏结算批量全员 // SettleGame Internal游戏结算批量全员
@ -411,10 +414,15 @@ func (h *handler) SettleGame() core.HandlerFunc {
if len(req.Players) == 0 && req.UserID != "" { if len(req.Players) == 0 && req.UserID != "" {
uid, _ := strconv.ParseInt(req.UserID, 10, 64) uid, _ := strconv.ParseInt(req.UserID, 10, 64)
if uid > 0 { if uid > 0 {
rank := 2
if req.Win {
rank = 1
}
req.Players = []settlePlayerRecord{{ req.Players = []settlePlayerRecord{{
UserID: uid, UserID: uid,
Ticket: req.Ticket, Ticket: req.Ticket,
Win: req.Win, Win: req.Win,
Rank: rank,
Score: req.Score, Score: req.Score,
}} }}
if req.GameType == "" { if req.GameType == "" {
@ -457,7 +465,17 @@ func (h *handler) SettleGame() core.HandlerFunc {
} }
for _, p := range req.Players { for _, p := range req.Players {
rankPoints := calcRankPoints(p.Win, p.Score, p.DamageDealt, p.DamageTaken, p.ChestsCollected, req.TotalRounds) // 兜底:旧客户端漏传 Rank 时按 Win 推断
rank := p.Rank
if rank == 0 {
rank = 2
if p.Win {
rank = 1
}
}
p.Rank = rank
p.Win = p.Rank == 1
rankPoints := calcRankPoints(p.Rank)
rawJSON, _ := json.Marshal(p) rawJSON, _ := json.Marshal(p)

View File

@ -51,64 +51,59 @@ type settleResponse struct {
// ---- calcRankPoints 本地副本(保持与 handler.go 一致) ---- // ---- calcRankPoints 本地副本(保持与 handler.go 一致) ----
func calcRankPoints(win bool, score, damageDealt, damageTaken, chests, totalRounds int) int64 { func calcRankPoints(rank int) int64 {
base := int64(100) switch rank {
if win { case 1:
base = 1000 return 1000
case 2:
return -900
case 3:
return -1100
case 4:
return -1300
default:
return 0
} }
pts := base + int64(score)*10 + int64(damageDealt)*3 + int64(chests)*50 - int64(damageTaken)*2 - int64(totalRounds)
if pts < 0 {
pts = 0
}
return pts
} }
// ---- 积分公式单元测试 ---- // ---- 名次积分单元测试 ----
func TestCalcRankPoints(t *testing.T) { func TestCalcRankPoints(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
win bool rank int
score int expected int64
damageDealt int
damageTaken int
chests int
rounds int
expected int64
}{ }{
{ {
name: "赢家基础积分", name: "第1名固定加分",
win: true, score: 0, damageDealt: 0, damageTaken: 0, chests: 0, rounds: 0, rank: 1,
expected: 1000, expected: 1000,
}, },
{ {
name: "输家基础积分", name: "第2名固定扣分",
win: false, score: 0, damageDealt: 0, damageTaken: 0, chests: 0, rounds: 0, rank: 2,
expected: 100, expected: -900,
}, },
{ {
name: "赢家满加成", name: "第3名固定扣分",
win: true, score: 50, damageDealt: 10, damageTaken: 5, chests: 3, rounds: 12, rank: 3,
// 1000 + 50*10 + 10*3 + 3*50 - 5*2 - 12 = 1000+500+30+150-10-12 = 1658 expected: -1100,
expected: 1658,
}, },
{ {
name: "不能为负数", name: "第4名固定扣分",
win: false, score: 0, damageDealt: 0, damageTaken: 100, chests: 0, rounds: 1000, rank: 4,
// 100 - 200 - 1000 < 0 → 0 expected: -1300,
},
{
name: "未知名次兜底为0",
rank: 0,
expected: 0, expected: 0,
}, },
{
name: "宝箱加成显著",
win: true, score: 0, damageDealt: 0, damageTaken: 0, chests: 5, rounds: 0,
// 1000 + 5*50 = 1250
expected: 1250,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := calcRankPoints(tt.win, tt.score, tt.damageDealt, tt.damageTaken, tt.chests, tt.rounds) got := calcRankPoints(tt.rank)
assert.Equal(t, tt.expected, got) assert.Equal(t, tt.expected, got)
}) })
} }
@ -282,7 +277,7 @@ func TestSettleGame_Integration(t *testing.T) {
// 兼容旧版 // 兼容旧版
if len(req.Players) == 0 && req.UserID != "" { if len(req.Players) == 0 && req.UserID != "" {
req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Score: req.Score}} req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Rank: 1, Score: req.Score}}
} }
if len(req.Players) == 0 { if len(req.Players) == 0 {
@ -291,9 +286,13 @@ func TestSettleGame_Integration(t *testing.T) {
} }
// 计算积分(验证公式被调用) // 计算积分(验证公式被调用)
// 计算积分(验证名次映射)
expectedRankPoints := map[int]int64{1: 1000, 2: -900, 3: -1100, 4: -1300}
for _, p := range req.Players { for _, p := range req.Players {
pts := calcRankPoints(p.Win, p.Score, p.DamageDealt, p.DamageTaken, p.ChestsCollected, req.TotalRounds) pts := calcRankPoints(p.Rank)
assert.GreaterOrEqual(t, pts, int64(0)) if expected, ok := expectedRankPoints[p.Rank]; ok {
assert.Equal(t, expected, pts)
}
} }
c.JSON(http.StatusOK, settleResponse{Success: true}) c.JSON(http.StatusOK, settleResponse{Success: true})
@ -342,6 +341,6 @@ func TestSettleGame_OldBugScenario(t *testing.T) {
func BenchmarkCalcRankPoints(b *testing.B) { func BenchmarkCalcRankPoints(b *testing.B) {
for i := 0; i < b.N; i++ { for i := 0; i < b.N; i++ {
calcRankPoints(true, 50, 10, 5, 3, 12) calcRankPoints(1)
} }
} }