Files
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00

304 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:<id>"。
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,
}
}