From 63d0a0caf8fa781a9c3ba9bbaa1a079170848b7a Mon Sep 17 00:00:00 2001 From: 4566704 <4566704@qq.com> Date: Sun, 20 Sep 2026 12:37:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(cafegame):=20=E8=87=AA=20go-hua=20?= =?UTF-8?q?=E8=BF=81=E5=85=A5=E7=BD=91=E5=92=96=E6=B8=B8=E6=88=8F=E8=AF=86?= =?UTF-8?q?=E5=88=AB=E4=B8=8E=E8=BF=90=E8=A1=8C=E8=AE=B0=E5=BD=95=E6=A1=86?= =?UTF-8?q?=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 游戏来源常量/按目录特征识别(云更新/网维大师/易乐游)/RunAppRecord 上报框架 - 网吧业务专用包, 附测试、examples/cafegame 例程与包 README --- cafegame/README.md | 35 ++ cafegame/constants.go | 63 ++++ cafegame/constants_test.go | 53 +++ cafegame/game.go | 678 +++++++++++++++++++++++++++++++++++++ cafegame/gameMonitor.go | 117 +++++++ cafegame/gameRecord.go | 597 ++++++++++++++++++++++++++++++++ cafegame/game_test.go | 104 ++++++ cafegame/ignoreProcess.go | 63 ++++ cafegame/types.go | 90 +++++ examples/cafegame/main.go | 43 +++ 10 files changed, 1843 insertions(+) create mode 100644 cafegame/README.md create mode 100644 cafegame/constants.go create mode 100644 cafegame/constants_test.go create mode 100644 cafegame/game.go create mode 100644 cafegame/gameMonitor.go create mode 100644 cafegame/gameRecord.go create mode 100644 cafegame/game_test.go create mode 100644 cafegame/ignoreProcess.go create mode 100644 cafegame/types.go create mode 100644 examples/cafegame/main.go diff --git a/cafegame/README.md b/cafegame/README.md new file mode 100644 index 0000000..a17b30c --- /dev/null +++ b/cafegame/README.md @@ -0,0 +1,35 @@ +# cafegame + +网咖游戏业务包:游戏来源常量(云更新/网维大师/易乐游)、按目录特征识别游戏 +(ID/名称/来源)、前台进程监控与运行记录上报回调框架。 + +> 迁移自 go-hua/cafegame,代码保持原样。**网吧业务专用包**(目录特征与各游戏 +> 平台的落盘规则强耦合)。 + +## 用法 + +```go +import "git.zeroonesoft.cn/golib/zogo/cafegame" + +// 游戏来源常量 +cafegame.SourceYGX // 1 云更新 +cafegame.SourceWWDS // 2 网维大师 +cafegame.SourceYLY // 3 易乐游 +cafegame.SourceToName(cafegame.SourceYGX) + +// 按目录特征识别游戏(各平台一个判定函数) +ok, info, err := cafegame.IsGameDirByYGX(`D:\40001示例游戏`) +// info: AppData{AppId, AppName, AppSource, AppGuid...} + +// 运行记录上报框架:启动后台监控,游戏开始/停止触发 recordCallback, +// 周期性触发 countCallback(在线游戏数+前台进程),事件触发 eventCallback +cafegame.RunAppRecord(recordCallback, countCallback, eventCallback, + []int{cafegame.SourceYGX}, 60 /* 上报间隔秒 */) +``` + +完整可运行例程:[examples/cafegame/main.go](../examples/cafegame/main.go) + +## 注意 + +- 目录识别规则与对应游戏平台版本耦合,平台更新落盘规则后需同步维护本包。 +- `RunAppRecord` 内部启动 goroutine,服务进程常驻场景使用;回调里不要阻塞。 diff --git a/cafegame/constants.go b/cafegame/constants.go new file mode 100644 index 0000000..03ffa82 --- /dev/null +++ b/cafegame/constants.go @@ -0,0 +1,63 @@ +package cafegame + +// 游戏运行状态 +const GameStatusStart = 1 // 开始运行 +const GameStatusStop = 0 // 停止运行 + +const SourceYGX = 1 // 云更新 +const SourceWWDS = 2 // 网维大师 +const SourceYLY = 3 // 易乐游 +const SourceMTGX = 4 // 马蹄更新 +const SourceStory = 11 // 蘑菇 +const SourceGoodGame = 12 // 盖伦 +const SourceHaoSiJi = 13 // 好司机 +const SourceFast = 14 // 火箭 +const SourceWfGame = 15 // WF游戏 +// GetSourceAll 返回所有已支持的游戏来源 ID 列表 +func GetSourceAll() []int { + return []int{SourceYGX, SourceWWDS, SourceYLY, SourceMTGX, SourceStory, SourceGoodGame, SourceHaoSiJi, SourceFast, SourceWfGame} +} + +// SourceToName 返回来源 ID 对应的名称,未知来源返回空字符串 +func SourceToName(id int) string { + switch id { + case SourceYGX: + return "云更新" + case SourceWWDS: + return "网维大师" + case SourceYLY: + return "易乐游" + case SourceMTGX: + return "马蹄更新" + case SourceStory: + return "蘑菇" + case SourceGoodGame: + return "盖伦" + case SourceHaoSiJi: + return "好司机" + case SourceFast: + return "火箭" + case SourceWfGame: + return "WF游戏" + } + return "" +} + +// 游戏监控事件类型 +const ( + Error = iota + GameStart + GameStop + GameAdd + GameDelete + GameUpdate + GameExist + GameStartIntervalSmall + ProcessExit + ProcessIgnore + ProcessSourceNotMatch + ProcessExist + ProcessChange + ProcessPathDifferent + ForegroundWindow +) diff --git a/cafegame/constants_test.go b/cafegame/constants_test.go new file mode 100644 index 0000000..9cc2793 --- /dev/null +++ b/cafegame/constants_test.go @@ -0,0 +1,53 @@ +package cafegame + +import "testing" + +func TestSourceToName(t *testing.T) { + cases := map[int]string{ + SourceYGX: "云更新", + SourceWWDS: "网维大师", + SourceYLY: "易乐游", + SourceMTGX: "马蹄更新", + SourceStory: "蘑菇", + SourceGoodGame: "盖伦", + SourceHaoSiJi: "好司机", + SourceFast: "火箭", + SourceWfGame: "WF游戏", + 999: "", // 未知来源 + } + for id, want := range cases { + if got := SourceToName(id); got != want { + t.Errorf("SourceToName(%d) = %q, want %q", id, got, want) + } + } +} + +func TestGetSourceAll(t *testing.T) { + all := GetSourceAll() + if len(all) == 0 { + t.Fatal("GetSourceAll 不应为空") + } + for _, id := range all { + if SourceToName(id) == "" { + t.Errorf("来源 %d 无对应名称", id) + } + } +} + +func TestGameStatusConstants(t *testing.T) { + if GameStatusStart != 1 || GameStatusStop != 0 { + t.Errorf("游戏状态常量错误: start=%d stop=%d", GameStatusStart, GameStatusStop) + } +} + +func TestIsAllNumberHandle(t *testing.T) { + if !IsAllNumberHandle("12345") { + t.Error("纯数字应通过") + } + if IsAllNumberHandle("12a45") { + t.Error("含字母应不通过") + } + if IsAllNumberHandle("") { + t.Error("空串应不通过") + } +} diff --git a/cafegame/game.go b/cafegame/game.go new file mode 100644 index 0000000..603c7f8 --- /dev/null +++ b/cafegame/game.go @@ -0,0 +1,678 @@ +//go:build windows + +package cafegame + +import ( + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// IsGameDirByYGX 云更新 id从40000开始 +func IsGameDirByYGX(dir string) (bool, AppData, error) { + info := AppData{} + gamePatch := filepath.Join(dir, "gamepatch") + ok, err := pathExists(gamePatch) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + gconfig := filepath.Join(gamePatch, "gconfig.ini") + ok, err = pathExists(gconfig) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdByNumberIco(dir) + if id < 40000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceYGX + + return true, info, nil +} + +// IsGameDirByStory 蘑菇 id从8000开始 +func IsGameDirByStory(dir string) (bool, AppData, error) { + info := AppData{} + gamePatch := filepath.Join(dir, "gamepatch") + ok, err := pathExists(gamePatch) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + gconfig := filepath.Join(gamePatch, "gconfig.ini") + ok, err = pathExists(gconfig) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdByNumberIco(dir) + if id < 10000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceStory + + return true, info, nil +} + +// IsGameDirByWWDS 网维大师 ID 从 10001 开始 +func IsGameDirByWWDS(dir string) (bool, AppData, error) { + info := AppData{} + SunwardPkg := filepath.Join(dir, "SunwardPkg.idx") + ok, err := pathExists(SunwardPkg) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + SunwardPkgCks := filepath.Join(dir, "SunwardPkg.cks") + ok, err = pathExists(SunwardPkgCks) + if err != nil { + return false, info, err + } + if !ok { + fmt.Printf("没有cks文件 dir:%s\n", dir) + return false, info, nil + } + + // 不能有这文件 这是盖伦文件 + GameIndex := filepath.Join(dir, "GameIndex.txt") + ok, err = pathExists(GameIndex) + if err != nil { + return false, info, err + } + if ok { + return false, info, nil + } + + // 不能有这文件,这是易乐游文件 + GameInfodb := filepath.Join(dir, "GameInfodb.gz") + ok, err = pathExists(GameInfodb) + if err != nil { + return false, info, err + } + if ok { + return false, info, nil + } + + // 不能有这文件 这是好司机文件 + hsjGame := filepath.Join(dir, "hsjGame.exe") + ok, err = pathExists(hsjGame) + if err != nil { + return false, info, err + } + if ok { + return false, info, nil + } + + id, _ := getIdBySunwardPkg(dir) + if id < 10000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceWWDS + + return true, info, nil +} + +// IsGameDirByGoodGame 盖伦 id应该是从 1001开始 steam是第一个游戏 1011 +func IsGameDirByGoodGame(dir string) (bool, AppData, error) { + info := AppData{} + GameIndex := filepath.Join(dir, "GameIndex.txt") + ok, err := pathExists(GameIndex) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + GameCksum := filepath.Join(dir, "GameCksum.txt") + ok, err = pathExists(GameCksum) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdBySunwardPkg(dir) + if id < 1000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceGoodGame + + return true, info, nil +} + +// IsGameDirByYLY 易乐游 +func IsGameDirByYLY(dir string) (bool, AppData, error) { + info := AppData{} + GameInfodb := filepath.Join(dir, "GameInfodb.gz") + ok, err := pathExists(GameInfodb) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + Gamever := filepath.Join(dir, "Gamever.ver") + ok, err = pathExists(Gamever) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + // TODO: 没有游戏ID,只有GUID + guid, _ := GetGuidByYLY(dir) + if guid == "{00000000-0000-0000-0000-000000000000}" { + return false, info, nil + } + info.AppId = 0 + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceYLY + info.AppGuid = guid + + return true, info, nil +} + +// IsGameDirByHaoSiJi 好司机 +func IsGameDirByHaoSiJi(dir string) (bool, AppData, error) { + info := AppData{} + hsjGame := filepath.Join(dir, "hsjGame.exe") + ok, err := pathExists(hsjGame) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + hsjLoader := filepath.Join(dir, "hsjLoader.exe") + ok, err = pathExists(hsjLoader) + if !ok { + return false, info, nil + } + if err != nil { + return false, info, err + } + + SunwardPkg := filepath.Join(dir, "SunwardPkg.idx") + ok, err = pathExists(SunwardPkg) + if !ok { + return false, info, nil + } + if err != nil { + return false, info, err + } + + id, _ := getIdBySunwardPkg(dir) + if id == 0 { + return false, info, nil + } + + guid, _ := GetGuidByYLY(dir) + if guid == "{00000000-0000-0000-0000-000000000000}" { + return false, info, nil + } + + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceHaoSiJi + info.AppGuid = guid + return true, info, nil +} + +// IsGameDirByMTGX 马蹄更新 id从10000开始 +func IsGameDirByMTGX(dir string) (bool, AppData, error) { + info := AppData{} + patchdata := filepath.Join(dir, "patchdata") + ok, err := pathExists(patchdata) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + config := filepath.Join(patchdata, "config.ini") + ok, err = pathExists(config) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + ndstorrent := filepath.Join(dir, "ndstorrent.idx") + ok, err = pathExists(ndstorrent) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdByNumberIco(dir) + if id < 10000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceMTGX + + return true, info, nil +} + +// IsGameDirByFast 火箭 id从8000开始 +func IsGameDirByFast(dir string) (bool, AppData, error) { + info := AppData{} + patchdata := filepath.Join(dir, "patchdata") + ok, err := pathExists(patchdata) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + rkttorrent := filepath.Join(dir, "rkttorrent.idx") + ok, err = pathExists(rkttorrent) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + gconfig := filepath.Join(patchdata, "config.ini") + ok, err = pathExists(gconfig) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdByNumberIco(dir) + if id < 8000 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceFast + + return true, info, nil +} + +// IsGameDirByWfGame WF游戏 +func IsGameDirByWfGame(dir string) (bool, AppData, error) { + info := AppData{} + ndsgameres := filepath.Join(dir, "ndsgameres.index") + ok, err := pathExists(ndsgameres) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + dd_launcher := filepath.Join(dir, "dd_launcher.json") + ok, err = pathExists(dd_launcher) + if err != nil { + return false, info, err + } + if !ok { + return false, info, nil + } + + id, _ := getIdByDdLauncher(dir) + if id == 0 { + return false, info, nil + } + info.AppId = id + info.AppName = filepath.Base(dir) + info.AppDir = dir + info.AppSource = SourceWfGame + + return true, info, nil +} + +// 网维大师 盖伦 好司机 +func getIdBySunwardPkg(dir string) (int, error) { + path := filepath.Join(dir, "SunwardPkg.idx") + file, err := os.Open(path) + if err != nil { + fmt.Println("Error opening file:", err) + return 0, err + } + defer file.Close() + + // Read a 4-byte integer + var num int32 + err = binary.Read(file, binary.BigEndian, &num) + if err != nil { + fmt.Println("Error reading integer:", err) + return 0, err + } + // fmt.Println("Read integer:", num) + return int(num), nil +} + +// 云更新 蘑菇 火箭使用 +func getIdByNumberIco(dir string) (int, error) { + // 获该目录下所有文件 + fileList, _ := getFileList(dir) + for _, s := range fileList { + ext := filepath.Ext(s) + // 判断是否ico图标 + if strings.ToLower(ext) == ".ico" { + // fmt.Printf("ico: %s\n", s) + // 通过ico图标的数字确定游戏ID + fileName := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s)) + if IsAllNumberHandle(fileName) { + gameId, _ := strconv.Atoi(fileName) + if gameId >= 8000 { + return gameId, nil + } + } + } else { + continue + } + } + + return 0, nil +} + +// DdLauncherJson DDL Launcher 平台游戏信息(JSON 配置) +type DdLauncherJson struct { + Gameid int `json:"gameid"` +} + +// getIdByDdLauncher 从 DDL Launcher 配置文件中读取游戏 ID +func getIdByDdLauncher(dir string) (int, error) { + path := filepath.Join(dir, "dd_launcher.json") + + ok, err := pathExists(path) + + if err != nil { + return 0, err + } + if !ok { + return 0, errors.New("文件不存在") + } + + buf, err := os.ReadFile(path) + if err != nil { + fmt.Println(err) + return 0, err + } + + data := DdLauncherJson{} + err = json.Unmarshal(buf, &data) + if err != nil { + fmt.Println(err) + return 0, err + } + + if data.Gameid == 0 { + return 0, errors.New("游戏ID为空") + } + + return data.Gameid, nil +} + +// GetGuidByYLY 从易乐游目录配置中读取游戏 GUID +func GetGuidByYLY(dir string) (string, error) { + path := filepath.Join(dir, "$ResMetadata$", "ResAppData.dat") + + ok, err := pathExists(path) + + if err != nil { + return "", err + } + if !ok { + return "", errors.New("文件不存在") + } + + file, err := os.Open(path) + if err != nil { + fmt.Println(err) + return "", err + } + defer file.Close() + // 设置读取起始位置为第25个字节 + _, err = file.Seek(24, 0) + if err != nil { + fmt.Println(err) + return "", err + } + // 创建一个字节切片来存储GUID + buf := make([]byte, 16) + // 读取GUID到字节切片中 + _, err = file.Read(buf) + if err != nil { + fmt.Println(err) + return "", err + } + guidInt32 := binary.LittleEndian.Uint32(buf[:4]) + guidInt16_1 := binary.LittleEndian.Uint16(buf[4:6]) + guidInt16_2 := binary.LittleEndian.Uint16(buf[6:8]) + guidBytes_1 := buf[8:10] + guidBytes_2 := buf[10:] + guid := fmt.Sprintf("{%08x-%04x-%04x-%s-%s}", guidInt32, guidInt16_1, guidInt16_2, hex.EncodeToString(guidBytes_1), hex.EncodeToString(guidBytes_2)) + guid = strings.ToTitle(guid) + return guid, err +} + +// GetGameInfoByDir 获取游戏信息 从目录获取 +func GetGameInfoByDir(root string) (AppData, error) { + // fmt.Println(root) + data := AppData{} + + // 云更新 + ok, data, err := IsGameDirByYGX(root) + if ok { + return data, err + } + + // 网维大师 + ok, data, err = IsGameDirByWWDS(root) + if ok { + return data, err + } + + // 易乐游 + ok, data, err = IsGameDirByYLY(root) + if ok { + return data, err + } + + // 马蹄更新 + ok, data, err = IsGameDirByMTGX(root) + if ok { + return data, err + } + + // 蘑菇 + ok, data, err = IsGameDirByStory(root) + if ok { + return data, err + } + + // 盖伦 + ok, data, err = IsGameDirByGoodGame(root) + if ok { + return data, err + } + + // 好司机 + ok, data, err = IsGameDirByHaoSiJi(root) + if ok { + return data, err + } + + // 火箭 + ok, data, err = IsGameDirByFast(root) + if ok { + return data, err + } + + // WF游戏 + ok, data, err = IsGameDirByWfGame(root) + if ok { + return data, err + } + + return data, nil + +} + +// GetGameInfoByPath 获取游戏信息 从路径获取 +func GetGameInfoByPath(exePath string) (AppData, error) { + + path := filepath.ToSlash(exePath) + pathArr := strings.Split(path, "/") + // 3个/ 比如 D:/网络游戏/英雄联盟/xxx.exe + + // 长度3 是 D:/英雄联盟/xxx.exe + if len(pathArr) < 3 { + return AppData{}, errors.New("不支持1级目录") + } + + dir := "" + // 连接二级目录 + for i, s := range pathArr { + + // 最后是文件名,不检测 + if i >= len(pathArr)-1 { + break + } + // 最多支持3级目录 如 D:/网络游戏/腾讯游戏/英雄联盟/xxx.exe + if i > 4 { + break + } + if i == 0 { + dir = s + } else { + dir = filepath.Join(dir, string(filepath.Separator), s) + } + + // 获取游戏信息 + info, err := GetGameInfoByDir(dir) + if err != nil { + return info, err + } + + if info.AppId > 0 || info.AppGuid != "" { + return info, nil + } + + } + + return AppData{}, nil + +} + +// 获取文件列表 +func getFileList(dir string) ([]string, error) { + list := make([]string, 0) + //fmt.Println(dir) + f, err := os.Open(dir) + if err != nil { + fmt.Println(err) + } + files, err := f.Readdir(-1) + f.Close() + if err != nil { + fmt.Println(err) + return nil, err + } + + for _, file := range files { + if !file.IsDir() && file.Name() != "$RECYCLE.BIN" && file.Name() != "System Volume Information" { + list = append(list, filepath.Join(dir, file.Name())) + } + } + + return list, nil +} + +// IsAllNumberHandle /** 是否全部是数字 */ +func IsAllNumberHandle(str string) bool { + // 空串不认为是数字 + if str == "" { + return false + } + anyReplaceList := []int32{'.', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0'} + for strIndex, strInt := range str { + if strIndex == 0 { + if strInt == '-' { + continue + } + } + if IsHaveReplaceList(strInt, anyReplaceList) { + continue + } + return false + } + return true +} + +// IsHaveReplaceList 是否在过滤列表中 +func IsHaveReplaceList(strInt int32, replaceList []int32) bool { + for _, chStrInt := range replaceList { + if chStrInt == strInt { + return true + } + } + return false +} + +// pathExists 判断一个文件或文件夹是否存在 +// 输入文件路径,根据返回的bool值来判断文件或文件夹是否存在 +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} diff --git a/cafegame/gameMonitor.go b/cafegame/gameMonitor.go new file mode 100644 index 0000000..647bc95 --- /dev/null +++ b/cafegame/gameMonitor.go @@ -0,0 +1,117 @@ +//go:build windows + +package cafegame + +import ( + "os" + "path/filepath" + "sync" + "time" +) + +// ProcessMonitor 单个游戏进程的监视器:等待进程退出后向 waitChan 发送结果 +type ProcessMonitor struct { + pid int + exeName string + exePath string + AppData + status int + waitChan chan AppWaitData +} + +// NewProcessMonitor 创建进程监视器 +func NewProcessMonitor(pid int, exePath string, app AppData, waitChan chan AppWaitData) *ProcessMonitor { + process := new(ProcessMonitor) + process.pid = pid + process.exeName = filepath.Base(exePath) + process.exePath = exePath + process.waitChan = waitChan + process.AppData = app + return process +} + +// monitor 阻塞等待进程退出 +func (receiver *ProcessMonitor) monitor() error { + defer func() { + receiver.waitChan <- AppWaitData{Pid: receiver.pid, AppData: receiver.AppData} + }() + process, err := os.FindProcess(receiver.pid) + if err != nil { + // 查找进程失败 + return err + } + _, err = process.Wait() + if err != nil { + //"进程运行错误 + return err + } + // 进程已经结束 + return nil +} + +// Start 在后台协程中开始监视进程 +func (receiver *ProcessMonitor) Start() { + go receiver.monitor() +} + +// AppMonitor 单个游戏的监控器:聚合该游戏的所有进程 +type AppMonitor struct { + AppData + processList []*ProcessMonitor + mux sync.Mutex + waitChan chan AppWaitData + recordId int + startTime int64 +} + +// NewAppMonitor 创建游戏监控器 +func NewAppMonitor(app AppData, path string, waitChan chan AppWaitData) *AppMonitor { + monitor := new(AppMonitor) + monitor.AppData = app + monitor.waitChan = waitChan + monitor.startTime = time.Now().Unix() + return monitor +} + +// AddProcess 添加该游戏的一个进程(之前无进程时重置开始时间) +func (receiver *AppMonitor) AddProcess(pid int, exePath string) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + // 没有进程,重新设置开始时间为当前时间 + if len(receiver.processList) == 0 { + receiver.startTime = time.Now().Unix() + } + process := NewProcessMonitor(pid, exePath, receiver.AppData, receiver.waitChan) + process.Start() + receiver.processList = append(receiver.processList, process) +} + +// RemoveProcess 移除指定 PID 的进程 +func (receiver *AppMonitor) RemoveProcess(pid int) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for i := 0; i < len(receiver.processList); i++ { + if pid == receiver.processList[i].pid { + receiver.processList = append(receiver.processList[0:i], receiver.processList[i+1:]...) + i-- + } + } +} + +// AppIsRun 该游戏是否仍有进程在运行 +func (receiver *AppMonitor) AppIsRun() bool { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + return len(receiver.processList) > 0 +} + +// GetStartInterval 返回游戏开始运行至今的秒数 +func (receiver *AppMonitor) GetStartInterval() int64 { + receiver.mux.Lock() + defer receiver.mux.Unlock() + now := time.Now().Unix() + interval := now - receiver.startTime + return interval +} diff --git a/cafegame/gameRecord.go b/cafegame/gameRecord.go new file mode 100644 index 0000000..725f976 --- /dev/null +++ b/cafegame/gameRecord.go @@ -0,0 +1,597 @@ +//go:build windows + +package cafegame + +import ( + "fmt" + "github.com/lxn/win" + "github.com/shirou/gopsutil/v3/process" + "path/filepath" + "strings" + "sync" + "time" + "unicode" +) + +// SimpleProcessData 简化版进程数据(用于已存在进程列表) +type SimpleProcessData struct { + pid int + name string + exeFile string + status int + game bool +} + +// RecordCallback 游戏记录回调:游戏开始/停止时提交,返回记录 ID +type RecordCallback func(app AppData, status int) (int, error) + +// CountCallback 游戏统计回调:周期性提交统计信息 +type CountCallback func(apps []AppData, interval int, foreground []ForegroundApp) error + +// EventCallback 游戏事件回调 +type EventCallback func(eventData AppEventData) + +// AppRecord 游戏运行记录/监控主对象 +type AppRecord struct { + existProcessList []SimpleProcessData + appMonitorList []*AppMonitor + mux sync.Mutex + waitChan chan AppWaitData + postRecord RecordCallback // 提交记录 回调方法 + postCount CountCallback // 提交统计 回调方法 + eventCallback EventCallback // 事件回调方法 + sourceList []int // 来源列表 表示需要监控哪个来源的游戏 + reportInterval int +} + +// AppWaitData 进程退出等待结果 +type AppWaitData struct { + AppData + Pid int + Status int +} + +var appRecord *AppRecord +var foregroundGame []ForegroundApp + +// RunAppRecord 运行游戏记录/监控 入口函数 +func RunAppRecord(recordCallback RecordCallback, countCallback CountCallback, eventCallback EventCallback, sourceList []int, reportInterval int) { + time.Sleep(time.Second * 10) + foregroundGame = make([]ForegroundApp, 0) + appRecord = new(AppRecord) + appRecord.waitChan = make(chan AppWaitData, 100) + appRecord.postRecord = recordCallback + appRecord.postCount = countCallback + appRecord.eventCallback = eventCallback + appRecord.sourceList = sourceList + appRecord.reportInterval = reportInterval + // 检查上报间隔范围 + if appRecord.reportInterval < 60 { + appRecord.reportInterval = 60 + } else if appRecord.reportInterval > 600 { + appRecord.reportInterval = 600 + } + appRecord.Start() +} + +// 应用事件 +func (receiver *AppRecord) appEvent(eventData AppEventData) { + if appRecord.eventCallback != nil { + appRecord.eventCallback(eventData) + } +} + +// AppMonitorIsRun 指定游戏监控否运行 +func (receiver *AppRecord) AppMonitorIsRun(gameId int, name string) bool { + receiver.mux.Lock() + defer receiver.mux.Unlock() + for _, game := range receiver.appMonitorList { + if game.AppId == gameId { + return true + } + } + return false +} + +// ProcessIsExist 进程是否存在 +func (receiver *AppRecord) ProcessIsExist(pid int, path string) bool { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for _, data := range receiver.existProcessList { + if pid == data.pid { + if strings.ToLower(path) == strings.ToLower(data.exeFile) { + return true + } + // 进程路径不一致 + receiver.appEvent(AppEventData{ + ProcessPid: data.pid, + ProcessPath: path, + ProcessOldPath: data.exeFile, + EventType: ProcessPathDifferent, + EventSource: "AddProcess", + }) + return true + } + } + return false +} + +// RemoveProcess 移除进程 +func (receiver *AppRecord) RemoveProcess(pid int) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for i := 0; i < len(receiver.existProcessList); i++ { + if pid == receiver.existProcessList[i].pid { + receiver.existProcessList = append(receiver.existProcessList[:i], receiver.existProcessList[i+1:]...) + i-- + } + } +} + +// AddProcess 添加进程到已经存在列表 +func (receiver *AppRecord) AddProcess(pid int, path string) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + processData := SimpleProcessData{ + pid: pid, + name: filepath.Base(path), + exeFile: path, + status: 1, + } + + for i, data := range receiver.existProcessList { + if pid == data.pid { + if strings.ToLower(path) == strings.ToLower(data.exeFile) { + // 进程已存在 + receiver.appEvent(AppEventData{ + ProcessPid: data.pid, + ProcessPath: data.exeFile, + EventType: ProcessExist, + EventSource: "AddProcess", + }) + return + } else { + // 进程信息变更 + receiver.appEvent(AppEventData{ + ProcessPid: data.pid, + ProcessPath: path, + ProcessOldPath: data.exeFile, + EventType: ProcessChange, + EventSource: "AddProcess", + }) + receiver.existProcessList[i] = processData + } + return + } + } + receiver.existProcessList = append(receiver.existProcessList, processData) +} + +// AddAppMonitor 添加监控 +func (receiver *AppRecord) AddAppMonitor(info AppData, pid int, path string) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + // 检查游戏是否已监控 + for _, game := range receiver.appMonitorList { + if game.AppId == info.AppId { + // 已经监控,添加进程 + if !game.AppIsRun() { + // 两次启动间隔 要大于60秒,防止多次运行 重复统计。如:游戏进程不断重新启动,崩溃重启等。 + if game.GetStartInterval() > 60 { + if receiver.postRecord != nil { + id, _ := receiver.postRecord(game.AppData, GameStatusStart) + game.recordId = id + } + } else { + // 游戏已存在 但启动间隔小于60秒,可能是游戏进程崩溃重启,忽略统计 + receiver.appEvent(AppEventData{ + AppId: info.AppId, + AppName: info.AppName, + ProcessPid: pid, + ProcessPath: path, + EventType: GameStartIntervalSmall, + EventSource: "AddAppMonitor", + }) + return + } + } + game.AddProcess(pid, path) + // 游戏已存在 + receiver.appEvent(AppEventData{ + AppId: info.AppId, + AppName: info.AppName, + ProcessPid: pid, + ProcessPath: path, + EventType: GameExist, + EventSource: "AddAppMonitor", + }) + return + } + } + + // 没有监控,添加游戏 + game := NewAppMonitor(info, path, receiver.waitChan) + game.AddProcess(pid, path) + receiver.appMonitorList = append(receiver.appMonitorList, game) + // 新增游戏 + receiver.appEvent(AppEventData{ + AppId: info.AppId, + AppName: info.AppName, + ProcessPid: pid, + ProcessPath: path, + EventType: GameAdd, + EventSource: "AddAppMonitor", + }) + if receiver.postRecord != nil { + id, _ := receiver.postRecord(game.AppData, GameStatusStart) + game.recordId = id + } +} + +// Start 启动定时检查与进程退出监听协程 +func (receiver *AppRecord) Start() { + go receiver.timer() + go receiver.readWaitChan() +} + +// 定时器 每隔10秒检查一次所有系统中所有进程 +func (receiver *AppRecord) timer() { + i := 0 + interval := 10 + for { + err := receiver.checkProcess() + if err != nil { + receiver.appEvent(AppEventData{ + EventType: Error, + EventSource: "checkProcess", + Message: fmt.Sprintf("检查进程错误:%s", err.Error()), + }) + } + receiver.checkForegroundApp(interval) + time.Sleep(time.Second * time.Duration(interval)) + i += interval + // 60秒提交一次游戏统计信息 + if i >= appRecord.reportInterval { + i = 0 + err = receiver.count() + if err != nil { + receiver.appEvent(AppEventData{ + EventType: Error, + EventSource: "checkProcess", + Message: fmt.Sprintf("提交游戏统计错误:%s", err.Error()), + }) + } + } + } +} + +// checkForegroundApp 检测前端窗口关联游戏 +func (receiver *AppRecord) checkForegroundApp(interval int) { + hWnd := win.GetForegroundWindow() + if hWnd == 0 { + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: "无前端窗口", + }) + return + } + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: fmt.Sprintf("前端窗口句柄:%d", hWnd), + }) + pid := uint32(0) + ret := win.GetWindowThreadProcessId(hWnd, &pid) + if ret == 0 { + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: "获取前端窗口关联进程失败", + }) + return + } + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: fmt.Sprintf("前端窗口关联进程ID:%d", pid), + }) + game, ok := receiver.getGameByPid(int(pid)) + if !ok { + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: "未能获取前端窗口关联游戏", + }) + return + } + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: fmt.Sprintf("前端窗口关联游戏 id:%d name:%s", game.AppId, game.AppName), + }) + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for i, info := range foregroundGame { + if info.AppId == game.AppId && info.AppSource == game.AppSource { + foregroundGame[i].Duration += interval + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: fmt.Sprintf("找到游戏 id:%d name:%s duration:%d", game.AppId, game.AppName, foregroundGame[i].Duration), + }) + return + } + } + receiver.appEvent(AppEventData{ + EventType: ForegroundWindow, + EventSource: "checkForegroundApp", + Message: fmt.Sprintf("新增游戏 id:%d name:%s duration:%d", game.AppId, game.AppName, interval), + }) + foregroundGame = append(foregroundGame, ForegroundApp{ + AppData: game, + Duration: interval, + }) +} + +// getGameByPid 根据pid获取游戏信息 +func (receiver *AppRecord) getGameByPid(pid int) (AppData, bool) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for _, game := range receiver.appMonitorList { + for _, processMonitor := range game.processList { + if processMonitor.pid == pid { + return game.AppData, true + } + } + } + + return AppData{}, false +} + +// count 提交游戏统计信息 +func (receiver *AppRecord) count() error { + receiver.mux.Lock() + list := make([]AppData, 0) + for _, game := range receiver.appMonitorList { + if game.AppIsRun() { + list = append(list, game.AppData) + } + } + + foreground := make([]ForegroundApp, 0) + + for i, info := range foregroundGame { + if info.Duration > 0 { + foreground = append(foreground, info) + foregroundGame[i].Duration = 0 + } + } + + receiver.mux.Unlock() + if receiver.postCount != nil { + err := receiver.postCount(list, 60, foreground) + if err != nil { + return err + } + } + return nil +} + +// 检查系统中所有进程 +func (receiver *AppRecord) checkProcess() error { + + processList, err := getProcessInfo() + + if err != nil { + return err + } + + for _, info := range processList { + driveLetter := int32(0) + for _, char := range info.ExE { + driveLetter = char + break + } + + // 盘符是否为字母 + if !unicode.IsLetter(driveLetter) { + continue + } + // 跳过B和C盘 + caps := strings.ToTitle(info.ExE[0:1]) + if caps == "B" || caps == "C" { + continue + } + + // 进程是否已经存在 已经在监控中 + if appRecord.ProcessIsExist(int(info.Pid), info.ExE) { + continue + } + // 检测是否游戏进程 + appRecord.GameCheck(int(info.Pid), info.ExE) + + // 添加到已存在进程,防止重复检测浪费CPU资源 + appRecord.AddProcess(int(info.Pid), info.ExE) + } + return nil +} + +// GameCheck 检测进程是否游戏进程 +func (receiver *AppRecord) GameCheck(pid int, exePath string) error { + info, err := GetGameInfoByPath(exePath) + if err != nil { + return err + } + if info.AppId > 0 { + + if receiver.inSource(info.AppSource) { + // 检查进程是否被忽略 + if !checkGameProcessIsIgnore(info.AppId, filepath.Base(exePath)) { + // 没有被忽略 开始监控 + appRecord.AddAppMonitor(info, pid, exePath) + } else { + // 进程被忽略 + receiver.appEvent(AppEventData{ + AppId: info.AppId, + AppName: info.AppName, + ProcessPid: pid, + ProcessPath: exePath, + EventType: ProcessIgnore, + EventSource: "GameCheck", + }) + } + } else { + // 进程来源不在列表中 + receiver.appEvent(AppEventData{ + AppId: info.AppId, + AppName: info.AppName, + ProcessPid: pid, + ProcessPath: exePath, + EventType: ProcessSourceNotMatch, + EventSource: "GameCheck", + }) + } + } + + return nil +} + +// inSource 判断来源是否在监控列表中 +func (receiver *AppRecord) inSource(source int) bool { + for _, id := range receiver.sourceList { + if source == id { + return true + } + } + + return false +} + +// readWaitChan 消费进程退出结果:移除进程并延迟检查游戏是否整体退出 +func (receiver *AppRecord) readWaitChan() { + // 进程结束通道 + for waitData := range receiver.waitChan { + // 进程结束事件 + receiver.appEvent(AppEventData{ + AppId: waitData.AppId, + AppName: waitData.AppName, + ProcessPid: waitData.Pid, + EventType: ProcessExit, + EventSource: "readWaitChan", + }) + // 从进程列表中移除 + receiver.RemoveProcess(waitData.Pid) + + // 检查游戏是否还有进程在运行 + go func(data AppWaitData) { + time.Sleep(time.Second * 15) + receiver.checkGameIsRun(data) + }(waitData) + + } +} + +// checkGameIsRun 移除已退出的进程,游戏全部进程退出时结束统计 +func (receiver *AppRecord) checkGameIsRun(data AppWaitData) { + receiver.mux.Lock() + defer receiver.mux.Unlock() + + for _, game := range receiver.appMonitorList { + if game.AppId == data.AppId { + game.RemoveProcess(data.Pid) + if !game.AppIsRun() { + // 游戏停止运行 + receiver.appEvent(AppEventData{ + AppId: game.AppId, + AppName: game.AppName, + EventType: GameStop, + EventSource: "checkGameIsRun", + }) + if receiver.postRecord != nil { + _, _ = receiver.postRecord(game.AppData, GameStatusStop) + } + } + } + } +} + +// MemoryInfoStat 进程内存信息(RSS/虚拟内存) +type MemoryInfoStat struct { + Rss uint64 `json:"res"` + Vms uint64 `json:"vms"` +} + +// ProcessInfo 进程信息(PID/名称/路径/CPU/内存/启动时间) +type ProcessInfo struct { + Pid int32 `json:"pid"` + Name string `json:"name"` + ExE string `json:"exe"` + Cmd string `json:"cmd"` + Cpu float64 `json:"cpu"` + Mem MemoryInfoStat `json:"mem"` + CreateTime int64 `json:"createTime"` +} + +// 获取系统中所有进程信息 +func getProcessInfo() ([]ProcessInfo, error) { + + v, err := process.Processes() + if err != nil { + return nil, err + } + info := make([]ProcessInfo, len(v)) + + for i, p := range v { + + name := "" + exe, _ := p.Exe() + cmd, _ := p.Cmdline() + // 先从执行文件中获取进程名 + // 如果执行文件为空,再调用 Name() 方式,Name()方式很占用CPU + if exe == "" { + name, _ = p.Name() + } else { + name = filepath.Base(exe) + } + + mem, _ := p.MemoryInfo() + cpu, _ := p.CPUPercent() + createTime, _ := p.CreateTime() + p.Times() + // 不获取线程数 太占用用CPU + // p.NumThreads() + + info[i].Pid = p.Pid + info[i].Name = name + info[i].ExE = exe + info[i].Cmd = cmd + info[i].Cpu = cpu + if mem != nil { + info[i].Mem = MemoryInfoStat{ + Rss: mem.RSS, + Vms: mem.VMS, + } + } + info[i].CreateTime = createTime + + if name == "QQ.exe" { + //fmt.Printf("Pid:%d name:%s exe:%s\n", p.Pid, name, exe) + //fmt.Printf("工作集:%d 提交大小:%d\n", mem.RSS, mem.VMS) + //fmt.Printf("CPU:%.2f%% \n", cpu) + } + + } + + if err != nil { + return nil, err + } + + return info, nil +} diff --git a/cafegame/game_test.go b/cafegame/game_test.go new file mode 100644 index 0000000..c51f2ce --- /dev/null +++ b/cafegame/game_test.go @@ -0,0 +1,104 @@ +//go:build windows + +package cafegame + +import ( + "encoding/binary" + "os" + "path/filepath" + "strconv" + "testing" +) + +// newYGXGameDir 构造一个云更新游戏目录(gamepatch/gconfig.ini + 数字 ico) +func newYGXGameDir(t *testing.T, id int) string { + dir := filepath.Join(t.TempDir(), "测试游戏") + if err := os.MkdirAll(filepath.Join(dir, "gamepatch"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "gamepatch", "gconfig.ini"), nil, 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, strconv.Itoa(id)+".ico"), nil, 0644); err != nil { + t.Fatal(err) + } + return dir +} + +func TestIsGameDirByYGX(t *testing.T) { + dir := newYGXGameDir(t, 40001) + + ok, info, err := IsGameDirByYGX(dir) + if err != nil { + t.Fatalf("IsGameDirByYGX: %v", err) + } + if !ok { + t.Fatal("云更新目录应识别成功") + } + if info.AppId != 40001 { + t.Errorf("AppId = %d, want 40001", info.AppId) + } + if info.AppSource != SourceYGX { + t.Errorf("AppSource = %d, want SourceYGX(%d)", info.AppSource, SourceYGX) + } + if info.AppName != "测试游戏" { + t.Errorf("AppName = %q", info.AppName) + } +} + +func TestIsGameDirByYGXNegative(t *testing.T) { + // 空目录(无 gamepatch) + if ok, _, _ := IsGameDirByYGX(t.TempDir()); ok { + t.Error("空目录不应识别为云更新") + } + + // 有 gamepatch/gconfig.ini 但 ico 编号过小 + dir := filepath.Join(t.TempDir(), "small") + _ = os.MkdirAll(filepath.Join(dir, "gamepatch"), 0755) + _ = os.WriteFile(filepath.Join(dir, "gamepatch", "gconfig.ini"), nil, 0644) + _ = os.WriteFile(filepath.Join(dir, "12345.ico"), nil, 0644) + if ok, _, _ := IsGameDirByYGX(dir); ok { + t.Error("ico 编号小于 40000 不应识别为云更新") + } +} + +func TestIsGameDirByStory(t *testing.T) { + dir := newYGXGameDir(t, 10001) + ok, info, err := IsGameDirByStory(dir) + if err != nil { + t.Fatalf("IsGameDirByStory: %v", err) + } + if !ok { + t.Fatal("蘑菇目录应识别成功") + } + if info.AppSource != SourceStory { + t.Errorf("AppSource = %d, want SourceStory(%d)", info.AppSource, SourceStory) + } +} + +func TestGetGuidByYLY(t *testing.T) { + // 构造易乐游 $ResMetadata$/ResAppData.dat:24 字节头 + GUID 结构 + dir := t.TempDir() + data := make([]byte, 40) + binary.LittleEndian.PutUint32(data[24:28], 0x12345678) + binary.LittleEndian.PutUint16(data[28:30], 0xABCD) + binary.LittleEndian.PutUint16(data[30:32], 0xEF01) + copy(data[32:36], []byte{1, 2, 3, 4}) + copy(data[36:40], []byte{5, 6, 7, 8}) + + if err := os.MkdirAll(filepath.Join(dir, "$ResMetadata$"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "$ResMetadata$", "ResAppData.dat"), data, 0644); err != nil { + t.Fatal(err) + } + + guid, err := GetGuidByYLY(dir) + if err != nil { + t.Fatalf("GetGuidByYLY: %v", err) + } + want := "{12345678-ABCD-EF01-0102-030405060708}" + if guid != want { + t.Errorf("GUID = %q, want %q", guid, want) + } +} diff --git a/cafegame/ignoreProcess.go b/cafegame/ignoreProcess.go new file mode 100644 index 0000000..9c1f5c6 --- /dev/null +++ b/cafegame/ignoreProcess.go @@ -0,0 +1,63 @@ +//go:build windows + +package cafegame + +import ( + "strings" + "sync" +) + +var mux sync.Mutex +var appIgnoreProcess []AppIgnoreProcess + +// AppIgnoreProcess 需要忽略的游戏进程配置(不参与监控统计) +type AppIgnoreProcess struct { + AppId int `json:"appId" gorm:"column:app_id"` + AppName string `json:"appName" gorm:"column:app_name"` + AppSource int `json:"appSource" gorm:"column:app_source"` + AppGuid string `json:"appGuid" gorm:"column:app_guid"` + ProcessName []string `json:"processName" gorm:"column:process_name"` + Status int `json:"status" gorm:"column:status"` +} + +func init() { + mux.Lock() + appIgnoreProcess = make([]AppIgnoreProcess, 0) + mux.Unlock() +} + +// AddIgnoreProcessItem 添加单条忽略配置 +func AddIgnoreProcessItem(item AppIgnoreProcess) { + mux.Lock() + defer mux.Unlock() + appIgnoreProcess = append(appIgnoreProcess, item) +} + +// AddIgnoreProcessList 批量添加忽略配置 +func AddIgnoreProcessList(list []AppIgnoreProcess) { + mux.Lock() + defer mux.Unlock() + appIgnoreProcess = append(appIgnoreProcess, list...) +} + +// checkGameProcessIsIgnore 判断指定游戏的进程名是否在忽略列表中(大小写不敏感) +func checkGameProcessIsIgnore(appId int, processName string) bool { + if appId == 0 || processName == "" { + return false + } + + mux.Lock() + defer mux.Unlock() + + for _, game := range appIgnoreProcess { + if appId == game.AppId && appId != 0 { + for _, s := range game.ProcessName { + if strings.ToLower(s) == strings.ToLower(processName) && processName != "" { + return true + } + } + } + } + + return false +} diff --git a/cafegame/types.go b/cafegame/types.go new file mode 100644 index 0000000..8ad5577 --- /dev/null +++ b/cafegame/types.go @@ -0,0 +1,90 @@ +// Package cafegame 提供网咖游戏目录识别与游戏运行监控/统计(Windows)。 +package cafegame + +// AppData 单个游戏信息(目录识别结果) +type AppData struct { + AppSource int `json:"appSource"` + AppId int `json:"appId"` + AppName string `json:"appName"` + AppAlias string `json:"appAlias"` + AppDir string `json:"appDir"` + AppGuid string `json:"appGuid"` +} + +// CountItem 单条游戏统计记录 +type CountItem struct { + Runs int64 `json:"runs"` // 运行次数 + RunTime int64 `json:"runTime"` // 运行时间 + Duration int64 `json:"duration"` // 运行时长 + Foreground int64 `json:"foreground"` // 前台时长 +} + +// AppCountInfo 单个游戏的统计汇总 +type AppCountInfo struct { + Source int `json:"source"` + Id int `json:"id"` + Name string `json:"name"` +} + +// AppCountItem 上报用的游戏统计项(游戏信息 + 统计) +type AppCountItem struct { + AppCountInfo + CountItem +} + +// AppCountReqData 游戏统计请求数据 +type AppCountReqData struct { + HostName string `json:"hostName"` + HostIp string `json:"hostIp"` + GatewayMac string `json:"gatewayMac"` + Apps []AppData `json:"game"` + BootTime uint64 `json:"bootTime"` + Duration int `json:"duration"` + Foreground []ForegroundApp `json:"foreground"` + Time int64 `json:"time"` +} + +// ForegroundApp 前台窗口关联的游戏及持续时间(秒) +type ForegroundApp struct { + AppData + Duration int +} + +// AppDownloadReqData 游戏下载上报数据 +type AppDownloadReqData struct { + AppId int `json:"appId"` + AppName string `json:"appName"` + AppSource int `json:"appSource"` + Status int `json:"status"` + Progress float32 `json:"progress"` + UpdateQuantity float64 `json:"updateQuantity"` + Residue float64 `json:"residue"` + Speed float64 `json:"speed"` + ConnNumber int `json:"connNumber"` + Remainder int `json:"remainder"` +} + +// AppRunReqData 游戏运行上报数据 +type AppRunReqData struct { + HostName string `json:"hostName"` + HostIp string `json:"hostIp"` + GatewayMac string `json:"gatewayMac"` + App AppData `json:"app"` + BootTime uint64 `json:"bootTime"` + Status int `json:"status"` + Time int64 `json:"time"` + RecordId int `json:"recordId"` +} + +// AppEventData 游戏事件上报数据 +type AppEventData struct { + AppId int `json:"appId"` // 游戏ID + AppName string `json:"appName"` // 游戏名称 + AppSource int `json:"appSource"` // 游戏来源 + ProcessPid int `json:"processPid"` // 进程ID + ProcessPath string `json:"processPath"` // 进程路径 + ProcessOldPath string `json:"processOldPath"` // 进程原路径 + EventType int `json:"eventType"` // 事件类型 + EventSource string `json:"eventSource"` // 事件来源 + Message string `json:"message"` // 事件信息 +} diff --git a/examples/cafegame/main.go b/examples/cafegame/main.go new file mode 100644 index 0000000..c94f5ab --- /dev/null +++ b/examples/cafegame/main.go @@ -0,0 +1,43 @@ +// cafegame 包示例:网咖游戏识别(按各游戏平台目录特征判断游戏来源与 ID) +package main + +import ( + "fmt" + "os" + "path/filepath" + + "git.zeroonesoft.cn/golib/zogo/cafegame" +) + +func main() { + // 游戏来源常量与名称 + for _, id := range cafegame.GetSourceAll() { + fmt.Printf("来源 %2d: %s\n", id, cafegame.SourceToName(id)) + } + + // 构造一个模拟的"云更新"游戏目录,演示目录识别 + // 云更新特征:目录下有 gamepatch/gconfig.ini,且有数字命名的 .ico 图标(ID≥40000) + root := filepath.Join(os.TempDir(), "go-hua-cafegame-demo", "40001示例游戏") + _ = os.MkdirAll(filepath.Join(root, "gamepatch"), 0755) + _ = os.WriteFile(filepath.Join(root, "gamepatch", "gconfig.ini"), nil, 0644) + _ = os.WriteFile(filepath.Join(root, "40001.ico"), nil, 0644) + defer os.RemoveAll(filepath.Dir(root)) + + ok, info, err := cafegame.IsGameDirByYGX(root) + fmt.Println("是否云更新游戏目录:", ok, "错误:", err) + if ok { + fmt.Printf("识别结果: Id=%d Name=%s Source=%d(%s)\n", + info.AppId, info.AppName, info.AppSource, cafegame.SourceToName(info.AppSource)) + } + + // 实际使用中扫描游戏盘根目录,自动识别所有游戏: + // entries, _ := os.ReadDir("D:\\") + // for _, e := range entries { + // if info, err := cafegame.GetGameInfoByDir(filepath.Join("D:\\", e.Name())); err == nil && (info.AppId > 0 || info.AppGuid != "") { + // fmt.Printf("%+v\n", info) + // } + // } + // + // 游戏运行记录/监控入口(Windows): + // cafegame.RunAppRecord(recordCallback, countCallback, eventCallback, sourceList, reportInterval) +}