更新功能

This commit is contained in:
李琦
2026-08-14 17:50:37 +08:00
parent 2f5ce02608
commit 19df9f2bfd
9 changed files with 566 additions and 9 deletions

2
.gitignore vendored
View File

@@ -7,3 +7,5 @@
/run_server.log
/server_test.log
/server_test_err.log
/data/
/发版/

133
build-all.ps1 Normal file
View File

@@ -0,0 +1,133 @@
# 三端一键打包脚本PowerShell
# 产物:后端 Linux amd64 + 后端 Windows + 桌面端 Windows NSIS 安装包
# 输出:后端根目录/发版/yyyyMMdd-HHmm/<平台>/
#
# 注意:本脚本必须是 UTF-8 无 BOM + PowerShell 5.1 兼容写法。
# 中文目录名「发版」用码位构造0x53D1 0x7248避免 PS5.1 按
# ANSI 读取脚本导致字面量变成乱码目录名。
#
# 用法:在 nl-game-api-gin 目录下执行
# .\build-all.ps1 # 打全部三端
# .\build-all.ps1 -SkipDesktop # 只打后端两端
# .\build-all.ps1 -SkipLinux -SkipWindows # 只打桌面端
param(
[switch]$SkipLinux, # 跳过后端 Linux 二进制
[switch]$SkipWindows, # 跳过后端 Windows 二进制
[switch]$SkipWeb, # 跳过 Web 前端 distNginx 部署用)
[switch]$SkipDesktop # 跳过桌面端 NSIS 安装包
)
$ErrorActionPreference = 'Stop'
# 路径定位:本脚本位于 nl-game-api-gin/,前端在 ../nl-game
$repoRoot = Split-Path -Parent $PSScriptRoot
$backendDir = $PSScriptRoot
$frontendDir = Join-Path $repoRoot 'nl-game'
# 中文目录名「发版」:码位构造,免疫脚本编码问题
$releaseDirName = -join @([char]0x53D1, [char]0x7248)
# 输出目录:后端根目录/发版/yyyyMMdd-HHmm精确到分钟多次打包不覆盖
$stamp = Get-Date -Format 'yyyyMMdd-HHmm'
$outDir = Join-Path $backendDir (Join-Path $releaseDirName $stamp)
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
Write-Host ("==> Output dir: {0}" -f $outDir) -ForegroundColor Cyan
function Write-Section($name) {
Write-Host ""
Write-Host ("====[{0}]====" -f $name) -ForegroundColor Green
}
# ----------------------------------------------------------------------
# 1. Backend: Linux amd64 (production, CGO_ENABLED=0 static build)
# ----------------------------------------------------------------------
if (-not $SkipLinux) {
Write-Section 'Backend Linux amd64'
Push-Location $backendDir
try {
$env:GOOS = 'linux'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0'
go build -ldflags="-s -w" -o nl-game-api-gin .
Remove-Item Env:\GOOS, Env:\GOARCH, Env:\CGO_ENABLED -ErrorAction SilentlyContinue
$dest = Join-Path $outDir 'linux-amd64'
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Copy-Item 'nl-game-api-gin' -Destination $dest -Force
Copy-Item 'config.yaml' -Destination $dest -Force
Write-Host (" OK {0}" -f (Join-Path $dest 'nl-game-api-gin'))
} finally { Pop-Location }
}
# ----------------------------------------------------------------------
# 2. Backend: Windows amd64 (local debug)
# ----------------------------------------------------------------------
if (-not $SkipWindows) {
Write-Section 'Backend Windows amd64'
Push-Location $backendDir
try {
$env:GOOS = 'windows'; $env:GOARCH = 'amd64'; $env:CGO_ENABLED = '0'
go build -ldflags="-s -w" -o nl-game-api-gin.exe .
Remove-Item Env:\GOOS, Env:\GOARCH, Env:\CGO_ENABLED -ErrorAction SilentlyContinue
$dest = Join-Path $outDir 'windows-amd64'
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Copy-Item 'nl-game-api-gin.exe' -Destination $dest -Force
Copy-Item 'config.yaml' -Destination $dest -Force
Write-Host (" OK {0}" -f (Join-Path $dest 'nl-game-api-gin.exe'))
} finally { Pop-Location }
}
# ----------------------------------------------------------------------
# 3. Frontend web (for Nginx static hosting, base=./)
# ----------------------------------------------------------------------
if (-not $SkipWeb) {
Write-Section 'Frontend Web (Nginx dist)'
if (-not (Test-Path (Join-Path $frontendDir 'node_modules'))) {
Write-Host ' First run: npm install ...' -ForegroundColor Yellow
Push-Location $frontendDir
try { npm install } finally { Pop-Location }
}
Push-Location $frontendDir
try {
# 注意web 版部署在 Nginx 子路径/根路径都可以用相对 base
# 若部署在域名根路径,建议改为 npm run build绝对 base
npm run build | Out-Null
$dest = Join-Path $outDir 'web-dist'
if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
Copy-Item 'dist' -Destination $dest -Recurse -Force
$n = (Get-ChildItem $dest -Recurse -File | Measure-Object).Count
Write-Host (" OK {0} ({1} files)" -f $dest, $n)
} finally { Pop-Location }
}
# ----------------------------------------------------------------------
# 4. Desktop: Windows NSIS installer (for players)
# ----------------------------------------------------------------------
if (-not $SkipDesktop) {
Write-Section 'Desktop Windows NSIS'
if (-not (Test-Path (Join-Path $frontendDir 'node_modules'))) {
Write-Host ' First run: npm install ...' -ForegroundColor Yellow
Push-Location $frontendDir
try { npm install } finally { Pop-Location }
}
Push-Location $frontendDir
try {
npm run electron:build | Out-Null
$src = Join-Path $frontendDir 'release\PixelArcade-Setup-*.exe'
$dest = Join-Path $outDir 'desktop-windows'
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Get-ChildItem $src | Copy-Item -Destination $dest -Force
Get-ChildItem (Join-Path $frontendDir 'release\*.blockmap') -ErrorAction SilentlyContinue |
Copy-Item -Destination $dest -Force
Get-ChildItem "$dest\PixelArcade-Setup-*.exe" | ForEach-Object {
Write-Host (" OK {0}" -f $_.FullName)
}
} finally { Pop-Location }
}
# ----------------------------------------------------------------------
# Summary
# ----------------------------------------------------------------------
Write-Host ""
Write-Section 'DONE'
Write-Host ("All artifacts: {0}" -f $outDir)
Get-ChildItem -Recurse $outDir -File |
Select-Object @{n='Path';e={$_.FullName.Replace($outDir, '.').TrimStart('\')}},
@{n='MB';e={[math]::Round($_.Length/1MB, 2)}}

View File

@@ -19,7 +19,7 @@ type registerReq struct {
Username string `json:"username" binding:"required,min=3,max=20"` // 用户名3-20位
Password string `json:"password" binding:"required,min=6,max=32"` // 密码6-32位
Nickname string `json:"nickname" binding:"max=20"` // 昵称:可选,默认同用户名
Avatar string `json:"avatar"` // emoji 头像:可选
Avatar string `json:"avatar"` // 头像:像素编码 px:01~px:32 emoji 兼容)
}
// Register 用户注册:校验重名、加密密码、发注册礼积分
@@ -47,7 +47,7 @@ func Register(c *gin.Context) {
req.Nickname = req.Username
}
if req.Avatar == "" {
req.Avatar = "🙂"
req.Avatar = "px:01"
}
user := model.User{
Username: req.Username, Password: string(hash),

315
internal/handler/version.go Normal file
View File

@@ -0,0 +1,315 @@
package handler
import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"nl-game-api-gin/internal/model"
"nl-game-api-gin/internal/service"
"nl-game-api-gin/pkg/resp"
)
// 桌面安装包存放目录相对进程工作目录Nginx 可 alias 同目录或反代 /download/
const releaseDir = "data/releases"
// 最多保留的安装包数量(按语义化版本从新到旧)
const maxReleaseKeep = 3
var (
// 从文件名解析版本号,如 PixelArcade-Setup-1.0.1.exe → 1.0.1
releaseVerRe = regexp.MustCompile(`(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?)`)
// 允许的安装包后缀
releaseExtOK = map[string]bool{".exe": true, ".msi": true, ".dmg": true, ".zip": true, ".7z": true}
)
// releaseItem 单个安装包在列表接口中的展示结构
type releaseItem struct {
Filename string `json:"filename"`
Version string `json:"version"`
Size int64 `json:"size"`
URL string `json:"url"` // 相对路径 /download/xxx前端/客户端拼域名
ModTime int64 `json:"mod_time"`
IsCurrent bool `json:"is_current"`
}
// ensureReleaseDir 确保安装包目录存在
func ensureReleaseDir() error {
return os.MkdirAll(releaseDir, 0o755)
}
// ReleaseDir 对外暴露目录路径(路由挂静态文件用)
func ReleaseDir() string {
_ = ensureReleaseDir()
return releaseDir
}
// parseVersionFromName 从安装包文件名提取语义化版本;失败返回空串
func parseVersionFromName(name string) string {
base := filepath.Base(name)
m := releaseVerRe.FindStringSubmatch(base)
if len(m) < 2 {
return ""
}
return m[1]
}
// compareSemver 比较 a、ba>b → 1a==b → 0a<b → -1
func compareSemver(a, b string) int {
pa := strings.Split(strings.Split(a, "-")[0], ".")
pb := strings.Split(strings.Split(b, "-")[0], ".")
n := len(pa)
if len(pb) > n {
n = len(pb)
}
for i := 0; i < n; i++ {
var va, vb int
if i < len(pa) {
va, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
vb, _ = strconv.Atoi(pb[i])
}
if va > vb {
return 1
}
if va < vb {
return -1
}
}
return 0
}
// listReleaseFiles 扫描目录内允许后缀的安装包,按版本降序
func listReleaseFiles() ([]releaseItem, error) {
if err := ensureReleaseDir(); err != nil {
return nil, err
}
entries, err := os.ReadDir(releaseDir)
if err != nil {
return nil, err
}
curVer := service.GetConfig(model.ConfKeyAppVersion, "")
var items []releaseItem
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if !releaseExtOK[ext] {
continue
}
info, err := e.Info()
if err != nil {
continue
}
ver := parseVersionFromName(name)
items = append(items, releaseItem{
Filename: name,
Version: ver,
Size: info.Size(),
URL: "/download/" + name,
ModTime: info.ModTime().Unix(),
IsCurrent: ver != "" && ver == curVer,
})
}
sort.Slice(items, func(i, j int) bool {
c := compareSemver(items[i].Version, items[j].Version)
if c != 0 {
return c > 0
}
return items[i].ModTime > items[j].ModTime
})
return items, nil
}
// pruneReleases 只保留最新 maxReleaseKeep 个安装包,删除其余
func pruneReleases() error {
items, err := listReleaseFiles()
if err != nil {
return err
}
if len(items) <= maxReleaseKeep {
return nil
}
for _, it := range items[maxReleaseKeep:] {
_ = os.Remove(filepath.Join(releaseDir, it.Filename))
}
return nil
}
// publicOrigin 根据请求拼对外可访问的站点根(含协议),用于把相对下载路径补成绝对 URL
func publicOrigin(c *gin.Context) string {
scheme := c.GetHeader("X-Forwarded-Proto")
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := c.GetHeader("X-Forwarded-Host")
if host == "" {
host = c.Request.Host
}
if host == "" {
return ""
}
return scheme + "://" + host
}
// absDownloadURL 相对路径 → 绝对 URL已是 http(s) 则原样返回
func absDownloadURL(c *gin.Context, u string) string {
u = strings.TrimSpace(u)
if u == "" {
return ""
}
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") {
return u
}
origin := publicOrigin(c)
if origin == "" {
return u
}
if !strings.HasPrefix(u, "/") {
u = "/" + u
}
return origin + u
}
// VersionLatest 公开的桌面端最新版本信息,无需登录
// 桌面端启动时与「检查更新」调用,返回 version / download_url / release_notes / force
func VersionLatest(c *gin.Context) {
rawURL := service.GetConfig(model.ConfKeyAppDownloadURL, "")
resp.OK(c, gin.H{
"version": service.GetConfig(model.ConfKeyAppVersion, "1.0.0"),
"download_url": absDownloadURL(c, rawURL),
"release_notes": service.GetConfig(model.ConfKeyAppReleaseNotes, ""),
// 1=强制更新,桌面端会拦截跳过按钮;其他值或空都视为不强制
"force": service.GetConfigInt(model.ConfKeyAppForceUpdate, 0),
})
}
// AdminVersionGet 后台:当前版本配置 + 已上传安装包列表(最多 3 个)
func AdminVersionGet(c *gin.Context) {
items, err := listReleaseFiles()
if err != nil {
resp.Fail(c, "读取安装包目录失败:"+err.Error())
return
}
rawURL := service.GetConfig(model.ConfKeyAppDownloadURL, "")
resp.OK(c, gin.H{
"version": service.GetConfig(model.ConfKeyAppVersion, "1.0.0"),
"download_url": absDownloadURL(c, rawURL),
"release_notes": service.GetConfig(model.ConfKeyAppReleaseNotes, ""),
"force": service.GetConfigInt(model.ConfKeyAppForceUpdate, 0),
"keep": maxReleaseKeep,
"releases": items,
})
}
// AdminVersionSave 后台:仅更新说明 / 强制更新开关(不改安装包)
func AdminVersionSave(c *gin.Context) {
var req struct {
ReleaseNotes *string `json:"release_notes"`
Force *int `json:"force"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, "参数错误")
return
}
if req.ReleaseNotes != nil {
_ = service.SetConfig(model.ConfKeyAppReleaseNotes, *req.ReleaseNotes)
}
if req.Force != nil {
v := "0"
if *req.Force == 1 {
v = "1"
}
_ = service.SetConfig(model.ConfKeyAppForceUpdate, v)
}
AdminVersionGet(c)
}
// AdminVersionUpload 后台:上传桌面安装包,从文件名解析版本,写 site_configs并只保留最近 3 个
// 表单字段file必填、release_notes可选、force可选 0/1
func AdminVersionUpload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
resp.Fail(c, "请选择安装包文件")
return
}
name := filepath.Base(fh.Filename)
ext := strings.ToLower(filepath.Ext(name))
if !releaseExtOK[ext] {
resp.Fail(c, "仅支持 .exe / .msi / .dmg / .zip / .7z")
return
}
ver := parseVersionFromName(name)
if ver == "" {
resp.Fail(c, "无法从文件名解析版本号,请使用类似 PixelArcade-Setup-1.0.1.exe 的命名")
return
}
if err := ensureReleaseDir(); err != nil {
resp.Fail(c, "创建目录失败:"+err.Error())
return
}
// 同名覆盖;不同文件同版本也允许(后上传覆盖目录内旧文件靠 prune + 文件名)
dst := filepath.Join(releaseDir, name)
if err := c.SaveUploadedFile(fh, dst); err != nil {
resp.Fail(c, "保存失败:"+err.Error())
return
}
_ = service.SetConfig(model.ConfKeyAppVersion, ver)
_ = service.SetConfig(model.ConfKeyAppDownloadURL, "/download/"+name)
if notes := strings.TrimSpace(c.PostForm("release_notes")); notes != "" {
_ = service.SetConfig(model.ConfKeyAppReleaseNotes, notes)
}
if force := c.PostForm("force"); force == "1" || force == "true" {
_ = service.SetConfig(model.ConfKeyAppForceUpdate, "1")
} else if force == "0" || force == "false" {
_ = service.SetConfig(model.ConfKeyAppForceUpdate, "0")
}
if err := pruneReleases(); err != nil {
resp.Fail(c, "清理旧版本失败:"+err.Error())
return
}
// 若刚上传的文件被 prune 误删(不应发生,它是最新),再检查一下
if _, err := os.Stat(dst); err != nil {
resp.Fail(c, "上传后文件丢失,请重试")
return
}
AdminVersionGet(c)
}
// AdminVersionDelete 后台:删除指定安装包;若删的是当前版则回退到剩余最新版
func AdminVersionDelete(c *gin.Context) {
name := filepath.Base(c.Param("filename"))
if name == "" || name == "." || name == ".." {
resp.Fail(c, "文件名无效")
return
}
path := filepath.Join(releaseDir, name)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
resp.Fail(c, "删除失败:"+err.Error())
return
}
// 当前指向被删文件时,回退到仍存在的最新包
curURL := service.GetConfig(model.ConfKeyAppDownloadURL, "")
if strings.HasSuffix(curURL, "/"+name) || strings.HasSuffix(curURL, name) {
items, _ := listReleaseFiles()
if len(items) > 0 {
_ = service.SetConfig(model.ConfKeyAppVersion, items[0].Version)
_ = service.SetConfig(model.ConfKeyAppDownloadURL, items[0].URL)
} else {
_ = service.SetConfig(model.ConfKeyAppDownloadURL, "")
}
}
AdminVersionGet(c)
}

View File

@@ -54,4 +54,9 @@ const (
ConfKeyAIDeepSeekKey = "ai_deepseek_key" // DeepSeek API Key
ConfKeyAIDeepSeekBase = "ai_deepseek_base" // DeepSeek 接口地址
ConfKeyAIDeepSeekModel = "ai_deepseek_model" // DeepSeek 模型名
// 桌面端版本升级配置(后台可改,桌面端启动时与菜单「检查更新…」会拉取)
ConfKeyAppVersion = "app_version" // 桌面端最新版本号(语义化,如 1.0.1
ConfKeyAppDownloadURL = "app_download_url" // 最新安装包下载地址https://...
ConfKeyAppReleaseNotes = "app_release_notes" // 更新说明(一行简短文案,桌面端弹窗展示)
ConfKeyAppForceUpdate = "app_force_update" // 是否强制更新1=强制 0=不强制(强制时桌面端不允许跳过)
)

View File

@@ -23,7 +23,7 @@ type User struct {
Username string `gorm:"size:32;uniqueIndex" json:"username"` // 登录用户名(唯一)
Password string `gorm:"size:100" json:"-"` // bcrypt 密文(不下发给前端)
Nickname string `gorm:"size:32" json:"nickname"` // 昵称
Avatar string `gorm:"size:16" json:"avatar"` // emoji 头像
Avatar string `gorm:"size:16" json:"avatar"` // 头像(像素编码 px:01~px:32
Role int `gorm:"default:2" json:"role"` // 角色1超管 2普通
Points int `gorm:"default:0" json:"points"` // 当前积分余额
TotalPoints int `gorm:"default:0" json:"total_points"` // 历史累计积分(排行榜依据)

View File

@@ -25,8 +25,30 @@ type wsMessage struct {
}
// push 向该客户端推送一条消息(满队列时丢弃,防止慢客户端拖垮全局)
// 注意data 若是 json.RawMessage[]byte必须走 wsMessage 结构体嵌入,
// 否则 encoding/json 会把 []byte 编成 base64 字符串,饥荒世界快照会损坏。
func (c *Client) push(msgType string, data any) {
payload, _ := json.Marshal(map[string]any{"type": msgType, "data": data})
var dataRaw json.RawMessage
switch v := data.(type) {
case nil:
dataRaw = json.RawMessage("null")
case json.RawMessage:
if len(v) == 0 {
dataRaw = json.RawMessage("null")
} else {
dataRaw = v
}
default:
b, err := json.Marshal(v)
if err != nil {
return
}
dataRaw = b
}
payload, err := json.Marshal(wsMessage{Type: msgType, Data: dataRaw})
if err != nil {
return
}
select {
case c.send <- payload:
default:
@@ -44,7 +66,7 @@ func (c *Client) readPump() {
c.hub.onDisconnect(c)
c.conn.Close()
}()
c.conn.SetReadLimit(8 * 1024)
c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB饥荒全量世界快照tiles+ents远超默认 8KB
// 心跳60秒收不到任何数据判定断线前端每30秒发 ping
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {

View File

@@ -463,7 +463,7 @@ func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) {
seat.Skin = req.Skin
r.broadcastStateLocked()
case "starve_input":
// 饥荒队友输入:解析内容,附上座位号转发给房主连接(主机权威模拟)
// 饥荒队友输入:解析后附上权威座位号转发给房主(主机权威模拟)
if r.Game != "starve" || r.status != "playing" {
return
}
@@ -471,14 +471,27 @@ func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) {
if host == nil || host.client == nil || seat.UserID == r.hostUserID {
return
}
host.client.push("starve_input", map[string]any{"seat": seat.Index, "data": raw})
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil || payload == nil {
return
}
payload["seat"] = seat.Index // 以服务端座位为准,防伪造
host.client.push("starve_input", payload)
case "starve_state":
// 饥荒主机快照:仅房主可发,原样广播给其余座位(间隔 <80ms 丢弃防刷)
// 饥荒主机快照:仅房主可发,原样广播给其余座位
// 全量世界full:1不限流增量快照间隔 <80ms 丢弃防刷
if r.Game != "starve" || r.status != "playing" || seat.UserID != r.hostUserID {
return
}
isFull := false
var peek struct {
Full int `json:"full"`
}
if json.Unmarshal(raw, &peek) == nil && peek.Full != 0 {
isFull = true
}
now := time.Now().UnixMilli()
if now-r.starveMs < 80 {
if !isFull && now-r.starveMs < 80 {
return
}
r.starveMs = now
@@ -495,6 +508,62 @@ func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) {
r.broadcastChatLocked(-1, "系统", "", "全员阵亡,冒险结束", false)
r.endStarveLocked("全员阵亡,冒险结束")
r.broadcastStateLocked()
case "invite":
// 邀请好友进房:仅房主可发,目标必须是自己的已通过好友且不在房内
// 好友在线则实时推弹窗邀请;离线落一条私聊兜底(上线可见)
var req struct {
UserID int `json:"user_id"`
}
json.Unmarshal(raw, &req)
if r.status != "waiting" {
c.pushError("对局进行中,无法邀请")
return
}
if seat.UserID != r.hostUserID {
c.pushError("只有房主可以邀请好友")
return
}
if req.UserID <= 0 || req.UserID == c.userID {
c.pushError("参数有误")
return
}
// 必须是已通过的好友关系
var cnt int64
database.DB.Model(&model.Friend{}).
Where("(user_id = ? AND friend_id = ? OR user_id = ? AND friend_id = ?) AND status = ?",
c.userID, req.UserID, req.UserID, c.userID, model.FriendStatusAccepted).
Count(&cnt)
if cnt == 0 {
c.pushError("只能邀请好友")
return
}
// 不能已在房内
for _, s := range r.seats {
if s.UserID == req.UserID {
c.pushError("对方已在房间内")
return
}
}
gameName := r.Game
var g model.Game
if err := database.DB.Where("code = ?", r.Game).First(&g).Error; err == nil && g.Name != "" {
gameName = g.Name
}
inviteData := map[string]any{
"code": r.Code, "game": r.Game, "game_name": gameName,
"host_id": c.userID, "host_name": seat.Name, "host_avatar": seat.Avatar,
}
// 在线实时弹窗;离线降级为私聊消息兜底(下次上线可见)
if !PushToUser(req.UserID, "room_invite", inviteData) {
var peer model.User
if err := database.DB.First(&peer, req.UserID).Error; err == nil {
database.DB.Create(&model.ChatMessage{
FromID: c.userID, ToID: req.UserID,
Content: fmt.Sprintf("邀请你加入房间【%s】邀请码 %s", gameName, r.Code),
})
}
}
c.push("invite_sent", map[string]any{"user_id": req.UserID})
case "chat":
var req struct {
Text string `json:"text"`

View File

@@ -13,6 +13,10 @@ import (
func Setup() *gin.Engine {
r := gin.Default()
r.Use(middleware.CORS())
// 安装包可达 ~100MB+,提高 multipart 内存阈值(超出部分写临时文件)
r.MaxMultipartMemory = 256 << 20
// 桌面安装包静态下载:后台上传到 data/releases对外路径 /download/xxx.exe
r.Static("/download", handler.ReleaseDir())
// WebSocket 对战入口(?token= 鉴权,内部自行校验)
hub := room.NewHub()
r.GET("/ws", hub.HandleWS)
@@ -22,6 +26,8 @@ func Setup() *gin.Engine {
api.POST("/auth/register", handler.Register)
api.POST("/auth/login", handler.Login)
api.GET("/config", handler.SiteConfigPublic)
// 公开接口:桌面端检查最新版本(无需登录)
api.GET("/version/latest", handler.VersionLatest)
// 登录后接口
auth := api.Group("", middleware.Auth())
{
@@ -101,6 +107,11 @@ func Setup() *gin.Engine {
admin.GET("/ai", handler.AdminAIConfig)
admin.PUT("/ai", handler.AdminSaveAIConfig)
admin.POST("/ai/test", handler.AdminTestAI)
// 桌面端版本:列表 / 上传安装包 / 改说明 / 删除
admin.GET("/version", handler.AdminVersionGet)
admin.PUT("/version", handler.AdminVersionSave)
admin.POST("/version/upload", handler.AdminVersionUpload)
admin.DELETE("/version/:filename", handler.AdminVersionDelete)
}
}
}