From 637efd0fa245fd1a94a3a7db1a97178532120780 Mon Sep 17 00:00:00 2001 From: Blank Date: Sun, 25 Oct 2026 14:21:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(online):=20=E8=A1=A5=E9=BD=90=20Game=20Role?= =?UTF-8?q?=20=E5=AD=98=E5=82=A8=E9=85=8D=E7=BD=AE=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E7=9A=84=E6=97=A0=E5=A3=B0=E9=99=8D=E7=BA=A7=E9=98=B2=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Game Role 启用 Online 能力族但未声明 UseGameDbStores 时缺省走 InMemory,重启即失玩家身份/好友/资产/存储数据且无任何告警。 现将该开关三态化并在组合根对未声明形态发出启动期 Warning, 同时为 Game Role 配置显式声明落库;不 fail-fast 以保护既有无键部署。 BREAKING CHANGE: OnlineCapabilityOptions.UseGameDbStores 由 bool 改三态 bool?(null=未声明、true=落库、false=显式易失),消费方需按可空布尔判读。 Closes #639 Linear: GFX-1015 --- CHANGELOG.md | 2 + Configs/app_config.json | 1 + .../Online/OnlineCapabilityCompositionRoot.cs | 36 +++++- GameFrameX.Localization/Keys.Capability.cs | 11 ++ .../Localization/Messages/Resources.resx | 3 + .../Messages/Resources.zh-CN.resx | 3 + .../Setting/OnlineCapabilityOptions.cs | 20 ++-- .../OnlineCapabilityStoreModeWarningTests.cs | 111 ++++++++++++++++++ .../OnlineCapabilityConfigSectionTests.cs | 53 ++++++++- 9 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 Tests/GameFrameX.Tests/Online/OnlineCapabilityStoreModeWarningTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index dd7d378b7..0f8fd3adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### Added * Capability 架构护栏(C241):GFX0017 禁止 Apps/Hotfix 的直接及传递编译引用依赖实现包(保留 Online.Abstractions 及其合法 Hosting 契约闭包);GFX0018 以符号检查能力注册、builder 装配与三族桥 Publish/Retract 的所属边界,保留模块辅助注册、Launcher Wiring 与独立测试夹具。Capability 工程统一接入分析器;架构分析器 Roslyn 引用对齐 .NET 10 SDK 基线,防止 CS9057 跳过规则;Online.Persistence 针对 C249 域属行状态仅本工程豁免旧 GFX0001。静态检查不证明反射、动态加载、跨请求缓存或启停时序。 +* Online 存储模式无声降级防护(C302,GFX-1015):`Configs/app_config.json` Game Role 的 Online 段补 `UseGameDbStores: true`(原先缺省走 InMemory,重启丢玩家身份/好友/资产/存储数据);启用 Online 能力族(双闸任一开)但未显式声明存储模式时组合根发出启动期 Warning(新本地化键 `Capability.Online.UseGameDbStoresUndeclared`,判定收敛为纯谓词 `OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared` 供单测断言);不 fail-fast,保护既有无键部署。 * 两层会话协同与真实断线恢复(C204):Core 物理连接 / Online 逻辑会话 / Online 对局成员三层职责与标识映射冻结——身份恢复凭据 = Online 会话 Token(`RespPlayerLogin` 新增 `SessionToken` 字段断线前交付;桥不可用为空、登录主流程不挂),新连接携凭据经新增 220-18/19 `ReqSessionResume`/`RespSessionResume`(Auth 族)恢复:验证(指纹/终态/到期/作用域)→ 连接替换(`OnlineSessionManager.MarkResumedAsync` 新面,不放松既有状态机)→ 原子轮换(旧凭据即刻失效,重放显式 TokenRevoked)→ Core 绑定(`UpdateSession` 顶号编排复用)→ 在线簿记还原 → 对局恢复(`IOnlineMatchDomain` 增 `NotifyPlayerDisconnectedAsync`/`TryResumeForPlayerAsync`——按票据→分配登记→运行时成员表逐级核对,服务端留存 rt- 令牌回灌 `ResumeAsync` 三重校验原样生效,快照+增量/回退,不重新匹配、不另开对局、不重复结算;无对局 = 空回执、超窗 NetworkTimeout、已结束 StateEnded、已离局 ScopeDenied)。`Capability.Online.Abstractions` 新增 `IOnlineSessionReconnectContract` + `OnlineSessionDomainBridgeHolder/Lease`(C246 匹配域桥同型);Foundation `OnlineSessionReconnectService` 实现(不另建第二凭据权威——Token 签发/校验/轮换全部委托既有 Token 服务)+ `session-reconnect-window-sweep` 清扫 tick;`PlayerSessionManager.Remove` 带路由下线守卫(旧断连回调迟到不清新路由);`OnDisconnected` 接入两层协同(会话域开窗 + 对局域标记断线成员,旧回调迟到守卫跳过);KCP 首消息白名单补 `ReqSessionResume`(与角色登录同为鉴权完成信号);Bot `--scenario=match` 扩展断线恢复演练(断线→携凭据恢复同身份同对局→旧凭据重放被拒→幂等重入同一局证据链)。宿主重启 = InMemory 会话/票据/对局失忆,恢复承诺终止于进程存活期(不宣称跨重启无损恢复)。 @@ -68,6 +69,7 @@ ### Breaking +* `OnlineCapabilityOptions.UseGameDbStores` 由 `bool` 改三态 `bool?`(C302):null = 配置未声明(触发启动期告警后按 InMemory 处理)、true = 落库、false = 显式选择易失;消费方需按可空布尔判读(仓内调用点已同批迁移)。 * Capability 能力注入机制重设计(C294,GFX-998,破坏性一次性切换,能力未上线无兼容层): - 模块契约改为三段生命周期:`ICapabilityModule` 新增 `ActivateAsync(CapabilityActivationContext, CancellationToken)` / `DeactivateAsync(CancellationToken)`;激活按拓扑序执行、中途失败逆序回滚,停止为停调度 → 逆序 Deactivate → 机制自动撤销全部桥。四桥发布、榜单预建、撮合通知订阅、旧好友数据迁移迁入所属能力模块。 - 桥机制化:泛型 `CapabilityBridge.TryAcquire` / `CapabilityBridgeLease`(撤销 / 替换后抛 `CapabilityBridgeUnavailableException`,族层映射 8002),发布只经 `CapabilityActivationContext.Bridges.Publish`,同契约重复发布 fail-fast;删除 `Online.Abstractions/Bridges/` 四对 holder/lease 与 Launcher 四个 `*BridgeWiring`。 diff --git a/Configs/app_config.json b/Configs/app_config.json index d95a74043..162bffa4d 100644 --- a/Configs/app_config.json +++ b/Configs/app_config.json @@ -17,6 +17,7 @@ "EnableRuntime": true, "EnableAdminApi": true, "EnableRoles": ["Game"], + "UseGameDbStores": true, "Settings": {} }, "MinModuleId": 10, diff --git a/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs b/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs index a49c90ab4..3c8b60567 100644 --- a/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs +++ b/GameFrameX.Launcher/Online/OnlineCapabilityCompositionRoot.cs @@ -42,7 +42,9 @@ using GameFrameX.Capability.Online.Persistence.Social; using GameFrameX.Capability.Online.Persistence.Storage; using GameFrameX.Capability.Online.Social; +using GameFrameX.Foundation.Localization.Core; using GameFrameX.Foundation.Logger; +using GameFrameX.Localization; using GameFrameX.Utility.Setting; namespace GameFrameX.Launcher.Online; @@ -182,8 +184,9 @@ public static async Task ComposeIfEnabledAsync(AppSetting setting) options.Override(new HotfixManagerRollbackExecutor(setting)); options.Override(new HostPlayerDirectory()); - // GameDb 一级 store 覆盖(Online.UseGameDbStores=true 时预置五个持久化适配器;业务库已就绪由模板顺序保证)。 - if (online.UseGameDbStores) + // GameDb 一级 store 覆盖(Online.UseGameDbStores 三态,C302):true 预置五个持久化适配器(业务库已就绪由模板顺序保证); + // 未声明(null)= 无声降级防护位——启用族但未声明存储模式时告警(不 fail-fast,保护既有无键部署),随后保持 InMemory 默认。 + if (online.UseGameDbStores == true) { options.Override(new GameDbOnlineIdentityStore()); options.Override(new GameDbOnlinePlayerStorageStore()); @@ -191,6 +194,10 @@ public static async Task ComposeIfEnabledAsync(AppSetting setting) options.Override(new GameDbOnlineAssetTransactionStore()); options.Override(new GameDbOnlineFriendStore()); } + else if (ShouldWarnStoreModeUndeclared(online)) + { + LogHelper.Warning(LocalizationService.GetString(Localization.Keys.Capability.OnlineModule.UseGameDbStoresUndeclared, setting.ServerType)); + } _runtime = CapabilityComposition.Compose(new OnlineCapabilityFamily(), options); _composerServerType = setting.ServerType; @@ -206,6 +213,31 @@ public static async Task ComposeIfEnabledAsync(AppSetting setting) } } + /// + /// 判定是否需要发出「存储模式未声明」启动期告警(C302 无声降级防护): + /// 族被启用(双闸任一开)但 Online.UseGameDbStores 未在配置中显式声明(null)时为 true。 + /// 为什么不直接在组合点内联判定:三态口径(未声明 / 显式 true / 显式 false)是配置契约本身, + /// 收敛为纯谓词供单测直接断言,避免只能靠日志输出验证信号。 + /// 显式 false(有意选择 InMemory,如本地无库开发)与双闸全关(族未启用,零装配零日志)均不告警。 + /// + /// + /// Determines whether the "storage mode undeclared" startup warning must fire (the C302 + /// silent-degradation guard): true when the family is enabled (either gate on) yet + /// Online.UseGameDbStores was never explicitly declared in configuration (null). The + /// tri-state contract (undeclared / explicit true / explicit false) is collapsed into this pure + /// predicate so unit tests can assert it directly instead of scraping log output; an explicit + /// false (a deliberate in-memory choice, e.g. database-less local development) and both gates off + /// (family disabled: nothing assembled, nothing logged) never warn. + /// + /// 当前 Role 的 Online 配置段。 + /// 未声明存储模式且族已启用时为 true;否则为 false。 + public static bool ShouldWarnStoreModeUndeclared(OnlineCapabilityOptions online) + { + return online != null + && (online.EnableRuntime || online.EnableAdminApi) + && online.UseGameDbStores == null; + } + /// /// 组合失败路径的尽力停止:撤回已启动部分(桥 / 调度 / 管理面),停止自身的异常只记日志, /// 不得覆盖触发回滚的原异常。 diff --git a/GameFrameX.Localization/Keys.Capability.cs b/GameFrameX.Localization/Keys.Capability.cs index bc3646a77..3bf0d6e2e 100644 --- a/GameFrameX.Localization/Keys.Capability.cs +++ b/GameFrameX.Localization/Keys.Capability.cs @@ -656,6 +656,17 @@ public static class Assembly /// public static class OnlineModule { + + /// + /// Online 存储模式未声明告警 + /// + /// + /// 键名: Capability.Online.UseGameDbStoresUndeclared + /// 用途: 启用 Online 能力族(双闸任一开)但未显式声明 UseGameDbStores 时组合根发出的启动期告警(C302 无声降级防护) + /// 参数: {0} - 组合 Role 的 ServerType + /// + public const string UseGameDbStoresUndeclared = "Capability.Online.UseGameDbStoresUndeclared"; + /// /// online-match(对局)能力相关消息 /// diff --git a/GameFrameX.Localization/Localization/Messages/Resources.resx b/GameFrameX.Localization/Localization/Messages/Resources.resx index 9d7148c5e..9ecb4abe1 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.resx @@ -1484,6 +1484,9 @@ match settlement reward granting (settlement results are still recorded) + + Role {0} enables the Online capability family but never declares Online.UseGameDbStores: level-one stores will use the in-process in-memory implementations and data is lost on restart; declare true (persisted) or false (explicitly volatile) in configuration for production. + matchmaking domain (420 enqueue / cancel / status query) diff --git a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx index 4e8762e4b..3a79cd5a5 100644 --- a/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx +++ b/GameFrameX.Localization/Localization/Messages/Resources.zh-CN.resx @@ -1482,6 +1482,9 @@ 对局结算发奖(结算结果照常记录) + + 角色 {0} 已启用 Online 能力族但未声明 Online.UseGameDbStores:一级 store 将使用进程内 InMemory 实现,重启即失数据;生产环境请在配置中显式声明 true(落库)或 false(明确选择易失)。 + 匹配域(420 入队 / 取消 / 状态查询) diff --git a/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs b/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs index 0a4db6369..5b0cdc9e2 100644 --- a/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs +++ b/GameFrameX.Utility/Setting/OnlineCapabilityOptions.cs @@ -108,19 +108,23 @@ public sealed class OnlineCapabilityOptions public IReadOnlyList Disable { get; set; } /// - /// 是否预置 Online 一级 store 的 GameDb 持久化实现(C294 起语义为宿主组合根 Override 注入开关) - /// true = 宿主经 Override<T> 预置身份 / 玩家云存储 / 资产账本与交易 / 好友关系 store - /// (重启数据不丢);false(缺省)= 包内 InMemory 默认不变 + /// 是否预置 Online 一级 store 的 GameDb 持久化实现(C294 起语义为宿主组合根 Override 注入开关;C302 起为三态) + /// = 宿主经 Override<T> 预置身份 / 玩家云存储 / 资产账本与交易 / 好友关系 store + /// (重启数据不丢); = 显式选择包内 InMemory 默认;(缺省)= 配置未声明—— + /// 组合根对此形态发出启动期告警(无声降级防护,见 C302)后按 InMemory 处理 /// 前置约束:开启前数据库必须已完成初始化(GameDb 门面 Init);Session / Presence 保持易失(Redis 演进位) /// /// - /// Whether the host composition root pre-seeds the GameDb persistence adapters for the Online - /// level-one stores via Override<T> (C294 semantics): true overrides the identity / - /// player-storage / asset-ledger and transaction / friend stores (data survives restarts); false - /// (default) keeps the in-package in-memory defaults. The database must be initialized (GameDb + /// The tri-state host composition-root switch for the GameDb persistence adapters of the Online + /// level-one stores (C294 semantics; tri-state since C302): overrides the + /// identity / player-storage / asset-ledger and transaction / friend stores via + /// Override<T> (data survives restarts); explicitly keeps the + /// in-package in-memory defaults; (default) means the configuration never + /// declared the storage mode — the composition root warns at startup (the silent-degradation guard, + /// C302) and then falls back to the in-memory defaults. The database must be initialized (GameDb /// facade Init) before enabling; sessions and presence stay volatile. /// - public bool UseGameDbStores { get; set; } + public bool? UseGameDbStores { get; set; } /// /// 能力级参数原始 JSON 节点(键 = 能力 Id(如 online-social),值 = 该能力 Options 根类型的字段覆盖源; diff --git a/Tests/GameFrameX.Tests/Online/OnlineCapabilityStoreModeWarningTests.cs b/Tests/GameFrameX.Tests/Online/OnlineCapabilityStoreModeWarningTests.cs new file mode 100644 index 000000000..9d157ada3 --- /dev/null +++ b/Tests/GameFrameX.Tests/Online/OnlineCapabilityStoreModeWarningTests.cs @@ -0,0 +1,111 @@ +// ========================================================================================== +// GameFrameX 组织及其衍生项目的版权、商标、专利及其他相关权利 +// GameFrameX organization and its derivative projects' copyrights, trademarks, patents and other related rights +// 均受中华人民共和国及相关国际法律法规保护。 +// are protected by the laws of the People's Republic of China and relevant international regulations. +// 使用本项目须严格遵守相关法律法规及开源许可证之规定。 +// Usage of this project must strictly comply with applicable laws, regulations, and open-source licenses. +// 本项目采用 Apache License 2.0 单协议分发, +// This project is licensed solely under the Apache License 2.0, +// 完整许可证文本请参见源代码根目录的 LICENSE 文件。 +// please refer to the LICENSE file at the source code repository root for the full license text. +// 禁止利用本项目实施任何危害国家安全、破坏社会秩序、 +// It is prohibited to use this project to engage in any activities that endanger national security, disrupt social order, +// 侵犯他人合法权益等法律法规所禁止之行为! +// Any legal disputes and liabilities arising from secondary development based on this project +// shall be borne solely by the developer; the project organization and contributors assume no responsibility. +// GitHub 仓库:https://github.com/GameFrameX +// GitHub Repository: https://github.com/GameFrameX +// Gitee 仓库:https://gitee.com/GameFrameX +// Gitee Repository: https://gitee.com/cnblanblank +// CNB 仓库:https://cnb.cool +// CNB Repository: https://www.cnb.cool/GameFrameX +// 官方文档:https://gameframex.doc.alianblank.com/ +// Official Documentation: https://gameframex.doc.alianblank.com/ +// ========================================================================================== + + +using GameFrameX.Launcher.Online; +using GameFrameX.Utility.Setting; +using Xunit; + +namespace GameFrameX.Tests.Online; + +/// +/// Online 存储模式未声明告警判定测试(C302 无声降级防护):组合根纯谓词 +/// OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared 的三态口径—— +/// 族启用(双闸任一开)且 UseGameDbStores 未声明(null)才告警; +/// 显式 true / 显式 false / 双闸全关 / 配置段缺失均不告警。 +/// +public class OnlineCapabilityStoreModeWarningTests +{ + /// + /// 双闸开 + 未声明(null)→ 告警(原缺陷形态:Game Role 启用 Online 但配置无 UseGameDbStores 键)。 + /// + [Fact] + public void Warns_WhenFamilyEnabledAndUseGameDbStoresUndeclared() + { + var online = new OnlineCapabilityOptions + { + EnableRuntime = true, + EnableAdminApi = true, + }; + + Assert.True(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(online)); + } + + /// + /// 双闸只开其一(admin-only 形态)+ 未声明 → 同样告警。 + /// + [Fact] + public void Warns_WhenOnlyAdminGateEnabledAndUndeclared() + { + var online = new OnlineCapabilityOptions + { + EnableRuntime = false, + EnableAdminApi = true, + }; + + Assert.True(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(online)); + } + + /// + /// 显式声明(true 落库 / false 有意易失)→ 不告警:声明即消歧。 + /// + [Fact] + public void DoesNotWarn_WhenUseGameDbStoresExplicitlyDeclared() + { + var persisted = new OnlineCapabilityOptions + { + EnableRuntime = true, + EnableAdminApi = true, + UseGameDbStores = true, + }; + + var volatileExplicit = new OnlineCapabilityOptions + { + EnableRuntime = true, + EnableAdminApi = true, + UseGameDbStores = false, + }; + + Assert.False(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(persisted)); + Assert.False(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(volatileExplicit)); + } + + /// + /// 双闸全关(族未启用,零装配零日志)与配置段缺失 → 不告警。 + /// + [Fact] + public void DoesNotWarn_WhenFamilyDisabledOrSectionMissing() + { + var disabled = new OnlineCapabilityOptions + { + EnableRuntime = false, + EnableAdminApi = false, + }; + + Assert.False(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(disabled)); + Assert.False(OnlineCapabilityCompositionRoot.ShouldWarnStoreModeUndeclared(null)); + } +} diff --git a/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs b/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs index 96b440abc..c313b64c9 100644 --- a/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs +++ b/Tests/GameFrameX.Tests/Utility/OnlineCapabilityConfigSectionTests.cs @@ -155,7 +155,7 @@ public void Deserialize_SectionGatesAndFlatScopeFields_Coexist() Assert.NotNull(setting.Online); Assert.True(setting.Online.EnableRuntime); Assert.True(setting.Online.EnableAdminApi); - Assert.True(setting.Online.UseGameDbStores); + Assert.True(setting.Online.UseGameDbStores.Value); Assert.Equal(new[] { "Game", "Social" }, setting.Online.GetEffectiveEnableRoles().ToArray()); Assert.Null(setting.Online.Capabilities); Assert.Equal(new[] { "online-operations" }, setting.Online.Disable.ToArray()); @@ -186,11 +186,60 @@ public void Deserialize_SectionWithoutGates_DefaultsToDisabledAndGameRole() Assert.NotNull(section); Assert.False(section.EnableRuntime); Assert.False(section.EnableAdminApi); - Assert.False(section.UseGameDbStores); + Assert.Null(section.UseGameDbStores); Assert.Null(section.EnableRoles); Assert.Equal(new[] { "Game" }, section.GetEffectiveEnableRoles().ToArray()); } + /// + /// 存储模式三态绑定(C302):UseGameDbStores 缺省 = null(配置未声明,触发组合根告警); + /// 显式 false 如实绑定(有意选择 InMemory,不告警)。 + /// + [Fact] + public void Deserialize_UseGameDbStores_TriStateDefaultNullAndExplicitFalse() + { + const string json = """ + [ + { + "ServerType": "Game", + "ServerId": 1001, + "Online": { + "EnableRuntime": true, + "EnableAdminApi": true, + "UseGameDbStores": false + } + } + ] + """; + + var settings = JsonHelper.Deserialize>(json); + + Assert.NotNull(settings); + var section = Assert.Single(settings).Online; + Assert.NotNull(section); + Assert.False(section.UseGameDbStores.Value); + + const string jsonWithoutKey = """ + [ + { + "ServerType": "Game", + "ServerId": 1001, + "Online": { + "EnableRuntime": true, + "EnableAdminApi": true + } + } + ] + """; + + var settingsWithoutKey = JsonHelper.Deserialize>(jsonWithoutKey); + + Assert.NotNull(settingsWithoutKey); + var sectionWithoutKey = Assert.Single(settingsWithoutKey).Online; + Assert.NotNull(sectionWithoutKey); + Assert.Null(sectionWithoutKey.UseGameDbStores); + } + /// /// 能力级参数节点:Settings 按原始 JsonElement 绑定(键 = 能力 Id,值原样保留供装配期 Options 绑定器逐字段合并); /// 节点缺失时为默认 JsonElement(ValueKind = Undefined,机制按「无覆盖」处理)。