package dnfbridge // gm_channel_switches.go 提供「频道开关」的运行时配置:服主在启动器的 // 「游戏设置 → 频道开关」页里关掉某个频道,这里持久化禁用频道集合, // 并在频道目录(channelcatalog.Catalog)与客户端频道脚本(channelScript) // 构建时过滤掉被禁用的频道,随后对账游戏端口监听(关掉的频道不再开端口)。 // // 与 gm_channel_reload.go 的热重载区别: // // 热重载是"重读 channel_info.etc 这个文件",适合运维在文件里改完后一键生效; // 频道开关是"运行时按频道 ID 记录禁用状态",不要求改文件,开关即改内存目录 + // 落盘,重启后依然生效(从 Settings 读回)。 // // 持久化与 activity/gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。 import ( "context" "fmt" "strconv" "strings" "time" "longheng.io/server/internal/modules/dnf/channelcatalog" "longheng.io/server/internal/modules/dnf/channelinfo" "longheng.io/server/internal/modules/dnf/dnfenum" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) const gmChannelSwitchesScope = "global:dnf90:gm-channel-switches" // gmChannelDisabledKeyPrefix 是持久化里"禁用频道"键的前缀,键形如 "disabled:"。 const gmChannelDisabledKeyPrefix = "disabled:" // GMChannelSwitch 单个频道的开关状态。 type GMChannelSwitch struct { ID int `json:"id"` Name string `json:"name"` Group string `json:"group,omitempty"` Type uint8 `json:"type"` Port int `json:"port"` Enabled bool `json:"enabled"` } // GMChannelSwitches 频道开关总览(供 GM 接口 / 启动器展示)。 type GMChannelSwitches struct { TotalChannels int `json:"total_channels"` EnabledChannels int `json:"enabled_channels"` DisabledChannels int `json:"disabled_channels"` Channels []GMChannelSwitch `json:"channels"` UpdatedAt time.Time `json:"updated_at,omitempty"` } // gmDisabledChannelIDsFromRecord 从设置记录解析禁用频道 ID 集合。 func gmDisabledChannelIDsFromRecord(record dnfrepo.SettingsRecord, found bool) map[int]bool { out := make(map[int]bool) if !found { return out } for key, value := range record.Values { if !strings.HasPrefix(key, gmChannelDisabledKeyPrefix) { continue } if strings.TrimSpace(value) != "1" && !strings.EqualFold(strings.TrimSpace(value), "true") { continue } idText := strings.TrimPrefix(key, gmChannelDisabledKeyPrefix) id, err := strconv.Atoi(idText) if err != nil || id <= 0 { continue } out[id] = true } return out } // gmDisabledChannelIDsLocked 返回当前禁用的频道 ID 集合(调用方须持 gmChannelSwitchesMu)。 func (s *Service) gmDisabledChannelIDsLocked() map[int]bool { if s == nil || s.gmChannelSwitches == nil { return nil } return s.gmChannelSwitches } // GMChannelSwitches 返回当前频道开关总览(带缓存)。 func (s *Service) GMChannelSwitches(ctx context.Context) (GMChannelSwitches, error) { if s == nil { return GMChannelSwitches{}, ErrGMUnavailable } s.gmChannelSwitchesMu.Lock() defer s.gmChannelSwitchesMu.Unlock() if s.gmChannelSwitchesReady { return s.gmChannelSwitchesSnapshotLocked(), nil } repositories, ok := s.repositoryGroup() if !ok || repositories.Settings == nil { return GMChannelSwitches{}, ErrGMUnavailable } record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope) if err != nil { return GMChannelSwitches{}, err } s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found) s.gmChannelSwitchesAt = record.UpdatedAt s.gmChannelSwitchesReady = true return s.gmChannelSwitchesSnapshotLocked(), nil } // GMSetChannelSwitch 切换某频道的启用状态,返回更新后的总览。 // // 生效机制:更新持久化的禁用集合 → 重建频道目录与脚本(应用禁用过滤)→ // 对账游戏端口监听。与 GMReloadChannels 一样,失败时保留原运行态。 func (s *Service) GMSetChannelSwitch(ctx context.Context, channelID int, enabled bool) (GMChannelSwitches, error) { if s == nil { return GMChannelSwitches{}, ErrGMUnavailable } if channelID <= 0 { return GMChannelSwitches{}, fmt.Errorf("%w: channel id must be positive", ErrGMInvalidRequest) } s.gmChannelSwitchesMu.Lock() defer s.gmChannelSwitchesMu.Unlock() repositories, ok := s.repositoryGroup() if !ok || repositories.Settings == nil { return GMChannelSwitches{}, ErrGMUnavailable } if !s.gmChannelSwitchesReady { record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope) if err != nil { return GMChannelSwitches{}, err } s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found) s.gmChannelSwitchesAt = record.UpdatedAt s.gmChannelSwitchesReady = true } // 确认该频道确实存在于当前目录(不能凭空开关一个不存在的频道)。 active := s.currentCatalog() if active == nil { return GMChannelSwitches{}, ErrGMUnavailable } if _, exists := active.Channel(channelID); !exists { return GMChannelSwitches{}, fmt.Errorf("%w: channel %d not found", ErrGMInvalidRequest, channelID) } // 读最新记录落盘。 record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope) if err != nil { return GMChannelSwitches{}, err } if !found { record = dnfrepo.SettingsRecord{Scope: gmChannelSwitchesScope} } else { record = dnfrepo.CloneSettings(record) } if record.Values == nil { record.Values = make(map[string]string) } key := gmChannelDisabledKeyPrefix + strconv.Itoa(channelID) if enabled { delete(record.Values, key) } else { record.Values[key] = "1" } record.UpdatedAt = time.Now().UTC() if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil { return GMChannelSwitches{}, err } // 更新内存缓存。 newDisabled := gmDisabledChannelIDsFromRecord(record, true) s.gmChannelSwitches = newDisabled s.gmChannelSwitchesAt = record.UpdatedAt // 应用过滤:重建目录与脚本并热替换(复用热重载的对账逻辑,但源是内存禁用集合)。 if err := s.applyChannelSwitchesLocked(newDisabled); err != nil { // 目录应用失败不覆盖已落盘的开关,下次读回仍会尝试应用。 return GMChannelSwitches{}, fmt.Errorf("%w: 应用频道开关失败: %v", ErrGMUnavailable, err) } return s.gmChannelSwitchesSnapshotLocked(), nil } // applyChannelSwitchesLocked 用禁用集合重建频道目录与脚本并热替换(调用方须持 gmChannelSwitchesMu)。 // 与 GMReloadChannels 同源,但过滤来源是内存禁用集合而非 etc 文件。 func (s *Service) applyChannelSwitchesLocked(disabled map[int]bool) error { raw, err := osReadFile(s.options.channelInfoFile) if err != nil { return fmt.Errorf("load dnf channel info: %w", err) } index, err := channelinfo.Parse(raw) if err != nil { return fmt.Errorf("parse dnf channel info: %w", err) } catalog, err := channelcatalog.New(index, channelcatalog.Options{ ServerID: s.channelServerID(), DisabledIDs: disabled, }) if err != nil { return err } script, err := s.buildChannelScriptFiltered(raw, disabled) if err != nil { return fmt.Errorf("build 90CN online channel script: %w", err) } s.mu.Lock() oldCatalog := s.catalog runCtx := s.runCtx if s.cancel == nil || runCtx == nil { s.mu.Unlock() return fmt.Errorf("服务未启动,无法应用频道开关") } s.catalog = catalog s.channelScript = append([]byte(nil), script...) s.mu.Unlock() // 对账游戏端口监听(与 GMReloadChannels 同逻辑)。 oldPorts := []int(nil) if oldCatalog != nil { oldPorts = oldCatalog.GamePorts() } newPorts := catalog.GamePorts() for _, port := range oldPorts { if containsIntValue(newPorts, port) { continue } _ = s.closeGamePort(port) } for _, port := range newPorts { if containsIntValue(oldPorts, port) { continue } if err := s.listenGamePort(runCtx, port); err != nil { s.logWarn("dnfbridge channel switch open port failed", "port", port, "error", err) } } s.logInfo("dnfbridge channel switches applied", "disabled", len(disabled), "total_channels", len(catalog.Channels())) return nil } // buildChannelScriptFiltered 用禁用集合生成客户端频道脚本。 func (s *Service) buildChannelScriptFiltered(raw []byte, disabled map[int]bool) ([]byte, error) { if s.channelTableStrict() { return channelinfo.Build90CNOnlineScriptFiltered( raw, s.channelServerID(), s.channelAdvertiseID(), dnfenum.BootstrapChannelID, disabled, ) } script, bootstrapPresent, err := channelinfo.Build90CNOnlineScriptAllowingMissingBootstrapFiltered( raw, s.channelServerID(), s.channelAdvertiseID(), dnfenum.BootstrapChannelID, disabled, ) if err != nil { return nil, err } if !bootstrapPresent { s.logWarn("dnfbridge channel table has no bootstrap channel; applying switch anyway", "bootstrap_channel", dnfenum.BootstrapChannelID) } return script, nil } // gmChannelSwitchesSnapshotLocked 组装总览(调用方须持 gmChannelSwitchesMu)。 func (s *Service) gmChannelSwitchesSnapshotLocked() GMChannelSwitches { active := s.currentCatalog() disabled := s.gmDisabledChannelIDsLocked() if active == nil { return GMChannelSwitches{ Channels: []GMChannelSwitch{}, UpdatedAt: s.gmChannelSwitchesAt, } } channels := active.Channels() out := make([]GMChannelSwitch, 0, len(channels)) enabledCount := 0 for _, channel := range channels { isDisabled := disabled != nil && disabled[channel.ID] enabled := !isDisabled if enabled { enabledCount++ } out = append(out, GMChannelSwitch{ ID: channel.ID, Name: channel.Name, Group: channel.Group, Type: channel.Type, Port: channel.Port, Enabled: enabled, }) } return GMChannelSwitches{ TotalChannels: len(channels), EnabledChannels: enabledCount, DisabledChannels: len(channels) - enabledCount, Channels: out, UpdatedAt: s.gmChannelSwitchesAt, } }