package gui import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "sync" "time" "huya-go/internal/huya" "huya-go/internal/sniper" ) const RoleStoreFileName = "roles.json" // RoleInfo 描述一个账号绑定的角色详情,供本地持久化及前端展示。 type RoleInfo struct { UID int64 `json:"uid"` RoleName string `json:"role_name"` AreaName string `json:"area_name"` PlatName string `json:"plat_name"` DisplayName string `json:"display_name"` GameName string `json:"game_name"` IsBound bool `json:"is_bound"` UpdatedAt int64 `json:"updated_at"` } // RoleResult 是批量查询角色时的单账号结果。 type RoleResult struct { UID int64 `json:"uid"` RoleName string `json:"role_name"` AreaName string `json:"area_name"` PlatName string `json:"plat_name"` DisplayName string `json:"display_name"` GameName string `json:"game_name"` IsBound bool `json:"is_bound"` Resp *huya.CheckUserBindGameAccountResp `json:"resp,omitempty"` Error string `json:"error,omitempty"` } // RoleStore 在各实例数据目录下持久化保存各账号的角色信息。 type RoleStore struct { path string mu sync.RWMutex roles map[int64]RoleInfo } func NewRoleStore(path string) *RoleStore { return &RoleStore{ path: path, roles: make(map[int64]RoleInfo), } } func (s *RoleStore) Load() error { s.mu.Lock() defer s.mu.Unlock() if s.path == "" { return nil } data, err := os.ReadFile(s.path) if err != nil { if os.IsNotExist(err) { return nil } return err } var raw map[string]RoleInfo if err := json.Unmarshal(data, &raw); err != nil { return err } s.roles = make(map[int64]RoleInfo, len(raw)) for k, v := range raw { if uid, err := strconv.ParseInt(k, 10, 64); err == nil { s.roles[uid] = v } } return nil } func (s *RoleStore) Save() error { s.mu.RLock() raw := make(map[string]RoleInfo, len(s.roles)) for k, v := range s.roles { raw[strconv.FormatInt(k, 10)] = v } s.mu.RUnlock() if s.path == "" { return nil } data, err := json.MarshalIndent(raw, "", " ") if err != nil { return err } dir := filepath.Dir(s.path) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmpFile := fmt.Sprintf("%s.tmp.%d", s.path, time.Now().UnixNano()) if err := os.WriteFile(tmpFile, data, 0o644); err != nil { return err } return os.Rename(tmpFile, s.path) } func (s *RoleStore) Get(uid int64) (RoleInfo, bool) { s.mu.RLock() defer s.mu.RUnlock() info, ok := s.roles[uid] return info, ok } func (s *RoleStore) Set(info RoleInfo) { s.mu.Lock() if info.UpdatedAt == 0 { info.UpdatedAt = time.Now().Unix() } s.roles[info.UID] = info s.mu.Unlock() } func (s *RoleStore) SetMany(list []RoleInfo) { s.mu.Lock() now := time.Now().Unix() for _, info := range list { if info.UpdatedAt == 0 { info.UpdatedAt = now } s.roles[info.UID] = info } s.mu.Unlock() } func (s *RoleStore) GetAll() map[string]RoleInfo { s.mu.RLock() defer s.mu.RUnlock() out := make(map[string]RoleInfo, len(s.roles)) for k, v := range s.roles { out[strconv.FormatInt(k, 10)] = v } return out } // GetCachedRoles 获取已持久化的角色缓存字典,供前端初始化加载。 func (a *App) GetCachedRoles() (map[string]RoleInfo, error) { a.ensureRoleStore() if a.roleStore == nil { return map[string]RoleInfo{}, nil } return a.roleStore.GetAll(), nil } func (a *App) ensureRoleStore() { if a.roleStore == nil && a.dataDir != "" { a.roleStore = NewRoleStore(filepath.Join(a.dataDir, RoleStoreFileName)) _ = a.roleStore.Load() } } // QueryAccountRoles 并发查询当前账号绑定的角色信息。 // 优先调用虎牙 webActUI.checkUserBindGameAccount; // QueryRoles 并发查询当前账号绑定的角色信息。 // 优先调用虎牙 webActUI.checkUserBindGameAccount; // 若未绑定角色但历史兑换记录中存在 UserPrizeInfo.Account,以此作为兜底展示; // 查询成功后自动持久化存盘到 roles.json。 func (a *App) QueryRoles(cfg Config) ([]RoleResult, error) { return a.QueryAccountRoles(cfg) } func (a *App) QueryAccountRoles(cfg Config) ([]RoleResult, error) { cfg = normalizeConfig(cfg) accounts, err := parseAccounts(cfg) if err != nil { return nil, err } if len(accounts) == 0 { return nil, errors.New("没有有效的账号凭据") } client, closeClient, err := a.newClient(cfg) if err != nil { return nil, err } defer closeClient() a.ensureRoleStore() ctx, cancel := requestContext(cfg.TimeoutSec) defer cancel() concurrency := cfg.Concurrency if concurrency <= 0 { concurrency = 3 } if concurrency > len(accounts) { concurrency = len(accounts) } results := make([]RoleResult, len(accounts)) sem := make(chan struct{}, concurrency) var wg sync.WaitGroup a.addLog("info", "开始查询绑定角色: accounts=%d 并发=%d", len(accounts), concurrency) for i, acct := range accounts { wg.Add(1) go func(idx int, targetAcct sniper.Account) { defer wg.Done() sem <- struct{}{} defer func() { <-sem }() res := RoleResult{UID: targetAcct.UID} // 1. 发起 checkUserBindGameAccount 查询 // bActID: 和平精英腾讯互联活动 ID,抓包确认为 9271 resp, callErr := client.CheckUserBindGameAccount(ctx, targetAcct.UID, targetAcct.Cookie, 9271) if callErr != nil { res.Error = callErr.Error() } else if resp != nil { res.Resp = resp res.DisplayName = resp.RoleDisplayName() res.RoleName = resp.AccountData.GameRole.RoleName res.AreaName = resp.AccountData.GameRole.AreaName res.PlatName = resp.AccountData.GameRole.PlatName res.GameName = resp.GameName res.IsBound = resp.AccountData.IsBindRole == 1 } // 2. 兜底策略:如果主接口未查到角色名,尝试用 getUserPrizeRecords 里的历史记录兜底 if res.RoleName == "" && cfg.SID > 0 { if recResp, recErr := client.GetUserPrizeRecords(ctx, targetAcct.UID, targetAcct.Cookie, cfg.SID); recErr == nil && recResp != nil { for _, rec := range recResp.Records { if strings.TrimSpace(rec.Account) != "" { res.RoleName = strings.TrimSpace(rec.Account) res.DisplayName = res.RoleName + " (历史)" res.IsBound = true break } } } } results[idx] = res }(i, acct) } wg.Wait() // 更新到 RoleStore 并持久化存盘 if a.roleStore != nil { toSave := make([]RoleInfo, 0, len(results)) for _, r := range results { if r.DisplayName != "" || r.RoleName != "" { toSave = append(toSave, RoleInfo{ UID: r.UID, RoleName: r.RoleName, AreaName: r.AreaName, PlatName: r.PlatName, DisplayName: r.DisplayName, GameName: r.GameName, IsBound: r.IsBound, }) } } if len(toSave) > 0 { a.roleStore.SetMany(toSave) if saveErr := a.roleStore.Save(); saveErr != nil { a.addLog("warn", "保存 roles.json 失败: %v", saveErr) } } } boundCount := 0 for _, r := range results { if r.IsBound && r.DisplayName != "" { boundCount++ } } a.addLog("info", "查询绑定角色完成: accounts=%d 已绑定=%d", len(accounts), boundCount) return results, nil }