启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 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 灵狐小竹整合单机版」
This commit is contained in:
1 parent
977a4a1196
commit
0ced464168
15 files changed
+1022
-14
No files matched your search
@@ -0,0 +1,303 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user