From 19df9f2bfd9b380482839830a71cc727299edf99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Fri, 14 Aug 2026 17:50:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + build-all.ps1 | 133 +++++++++++++++ internal/handler/auth.go | 4 +- internal/handler/version.go | 315 ++++++++++++++++++++++++++++++++++++ internal/model/theme.go | 5 + internal/model/user.go | 2 +- internal/room/client.go | 26 ++- internal/room/room.go | 77 ++++++++- internal/router/router.go | 11 ++ 9 files changed, 566 insertions(+), 9 deletions(-) create mode 100644 build-all.ps1 create mode 100644 internal/handler/version.go diff --git a/.gitignore b/.gitignore index 46fe67d..73808f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ /run_server.log /server_test.log /server_test_err.log +/data/ +/发版/ diff --git a/build-all.ps1 b/build-all.ps1 new file mode 100644 index 0000000..f30afb5 --- /dev/null +++ b/build-all.ps1 @@ -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 前端 dist(Nginx 部署用) + [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)}} diff --git a/internal/handler/auth.go b/internal/handler/auth.go index b2e5b9b..722b474 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -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), diff --git a/internal/handler/version.go b/internal/handler/version.go new file mode 100644 index 0000000..ce84cc8 --- /dev/null +++ b/internal/handler/version.go @@ -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、b:a>b → 1,a==b → 0,a 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) +} diff --git a/internal/model/theme.go b/internal/model/theme.go index 0960503..b371996 100644 --- a/internal/model/theme.go +++ b/internal/model/theme.go @@ -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=不强制(强制时桌面端不允许跳过) ) diff --git a/internal/model/user.go b/internal/model/user.go index 11ba1bb..344afb4 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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"` // 历史累计积分(排行榜依据) diff --git a/internal/room/client.go b/internal/room/client.go index 80ec287..6fd624b 100644 --- a/internal/room/client.go +++ b/internal/room/client.go @@ -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 { diff --git a/internal/room/room.go b/internal/room/room.go index fa66d9b..3fcab93 100644 --- a/internal/room/room.go +++ b/internal/room/room.go @@ -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"` diff --git a/internal/router/router.go b/internal/router/router.go index 45231d0..883ce2b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) } } }