refactor(framework): 移除 resolveBootstrap 下游兜底,立第二准则
- CLAUDE.md 新增「第二准则:数据源权威、唯一,下游不兜底」 - bootstrap 删除 fallbackServers 选项与 ?? DEFAULT_GAMESERVER 猜默认 - 无 server/gameserver、远程失败、解析不出地址 → 一律抛 ConfigFetchError 显式暴露 - 同步 guide + 两篇 spec(删 fallback/降级措辞) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- 遇到协议层有疑问时,**先读 `docs/protocol/` 对应章节核对,不要臆测**;宁可前端多做适配,也绝不要求服务器配合改动。
|
||||
- 新的 Cocos 前端(`YouleNexus`)的目标,就是在不改服务器的前提下复刻该协议契约、实现联调兼容。
|
||||
|
||||
## 第二准则:数据源权威、唯一,下游不兜底
|
||||
|
||||
**每一类数据只能有一个权威且唯一的来源(single source of truth);下游消费方不得猜测、修补、兜底。**
|
||||
|
||||
- **唯一来源**:同一份配置/数据(如服务器地址、渠道身份、协议常量)只在一个地方定义,其它地方一律引用,不得各处复制或并存第二份。
|
||||
- **下游零兜底**:消费方拿到的数据缺失或非法时,**直接显式报错/抛出,把问题暴露到来源**,不得用默认值「猜一个」、不得静默 `?? 兜底值`、不得 try/catch 后塞个 fallback 蒙混。错误要早暴露、可定位,而不是被下游悄悄掩盖。
|
||||
- **配置即契约**:来源(如 `profiles.ts` 的服务器地址)必须把该提供的字段显式提供齐全;缺了就是配置错误,应在来源处修正,而不是让下游补。
|
||||
- 例外只有一种:来源本身明确定义了「可选 + 缺省语义」,且该缺省**写在来源处**(而非散落在各下游)。
|
||||
|
||||
> 这条与「服务器零改动」并列:前者保边界契约不变,后者保内部数据流可信、单源、可追溯。新增/修改任何带默认值、回退、容错分支的代码前,先问:这是不是在替上游兜底?若是,改为在来源处修正 + 在下游暴露。
|
||||
|
||||
## 仓库总览
|
||||
|
||||
这是友乐(Youle)棋牌游戏平台的前端工作区,核心是「**一套前端模板 + 多个子游戏**」的架构,并正在迁移到 Cocos Creator。三个顶层目录:
|
||||
|
||||
@@ -3,7 +3,7 @@ import { resolveIdentity } from './identity.ts';
|
||||
import { BUILD_IDENTITY } from './sources/defaults.ts';
|
||||
import { queryStringSource } from './sources/query-string.ts';
|
||||
import { nativeSettingsSource, type NativeHost } from './sources/native-settings.ts';
|
||||
import { resolveActiveProfile, PROFILES, DEFAULT_GAMESERVER, type DebugProfile } from './profiles.ts';
|
||||
import { resolveActiveProfile, PROFILES, type DebugProfile } from './profiles.ts';
|
||||
import { resolveServers, type ConfigFetcher, type RemoteConfig, ConfigFetchError } from './remote-config.ts';
|
||||
import type { RuntimeMode } from './runtime-mode.ts';
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface BootstrapOptions {
|
||||
search: string; // location.search
|
||||
fetcher: ConfigFetcher; // 远程配置抓取
|
||||
isNative: boolean; // 环境判定(URL 含 index.html → true,由调用方算)
|
||||
fallbackServers?: string[]; // 远程失败/无地址时降级
|
||||
cacheBust?: () => string; // 防缓存串注入(默认 Date.now)
|
||||
}
|
||||
|
||||
@@ -26,7 +25,11 @@ export interface BootstrapResult {
|
||||
profile: DebugProfile;
|
||||
}
|
||||
|
||||
/** 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。 */
|
||||
/**
|
||||
* 启动编排:按运行模式解析身份 + 决定服务器,产出可喂 NetClient 的结果。
|
||||
* 数据源权威唯一、下游不兜底(CLAUDE.md 第二准则):服务器地址只来自 profile.server(直连)
|
||||
* 或远程配置解析;取不到一律显式抛错暴露,不猜默认、不降级。
|
||||
*/
|
||||
export async function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
|
||||
const isDebug = opts.mode === 'debug';
|
||||
|
||||
@@ -46,29 +49,29 @@ export async function resolveBootstrap(opts: BootstrapOptions): Promise<Bootstra
|
||||
|
||||
const resultBase = { mode: opts.mode, isDebugger: isDebug, identity, profile };
|
||||
|
||||
// 服务器决策:profile 显式 server 直连捷径(仅 debug 的 local/lab 等档位会有)
|
||||
// 服务器决策:profile 显式 server 直连捷径
|
||||
if (profile.server) {
|
||||
return { ...resultBase, servers: [profile.server], rawConfig: null };
|
||||
}
|
||||
|
||||
// 否则远程配置
|
||||
const gameserver = profile.gameserver ?? DEFAULT_GAMESERVER;
|
||||
// profile 是服务器地址的权威来源:无 server 则必须有 gameserver,缺则配置错误,显式暴露。
|
||||
if (!profile.gameserver) {
|
||||
throw new ConfigFetchError(`profile '${profile.name}' 未配置 server 或 gameserver`);
|
||||
}
|
||||
|
||||
const bust = opts.cacheBust ? opts.cacheBust() : String(Date.now());
|
||||
const url = gameserver + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random
|
||||
const url = profile.gameserver + '?' + bust; // 防缓存,等价原 min_timestamp/ifast_random
|
||||
|
||||
let config: RemoteConfig;
|
||||
try {
|
||||
config = await opts.fetcher.fetch(url);
|
||||
} catch {
|
||||
if (opts.fallbackServers && opts.fallbackServers.length) {
|
||||
return { ...resultBase, servers: opts.fallbackServers, rawConfig: null };
|
||||
}
|
||||
throw new ConfigFetchError(`远程配置抓取失败: ${url}`);
|
||||
}
|
||||
|
||||
let servers = resolveServers(config, identity);
|
||||
if (!servers.length && opts.fallbackServers && opts.fallbackServers.length) {
|
||||
servers = opts.fallbackServers;
|
||||
const servers = resolveServers(config, identity);
|
||||
if (!servers.length) {
|
||||
throw new ConfigFetchError(`远程配置未给出可用服务器地址: ${url}`);
|
||||
}
|
||||
return { ...resultBase, servers, rawConfig: config };
|
||||
}
|
||||
|
||||
@@ -47,18 +47,21 @@ test('debug + 原生模式:identity 来自 window.settings', async () => {
|
||||
assert.equal(r.identity.marketid, 3);
|
||||
});
|
||||
|
||||
test('debug + 远程失败 + 有 fallbackServers → 降级使用', async () => {
|
||||
const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: throwingFetcher, isNative: false, fallbackServers: ['ws://fb:1'] });
|
||||
assert.deepEqual(r.servers, ['ws://fb:1']);
|
||||
});
|
||||
|
||||
test('debug + 远程失败 + 无 fallback → 抛 ConfigFetchError', async () => {
|
||||
test('远程抓取失败 → 抛 ConfigFetchError(不兜底)', async () => {
|
||||
await assert.rejects(
|
||||
resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: throwingFetcher, isNative: false }),
|
||||
/远程配置抓取失败/,
|
||||
);
|
||||
});
|
||||
|
||||
test('远程配置未给出可用服务器地址 → 抛错(不兜底)', async () => {
|
||||
const emptyFetcher: ConfigFetcher = { fetch: async () => ({ data: {} }) };
|
||||
await assert.rejects(
|
||||
resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher: emptyFetcher, isNative: false }),
|
||||
/未给出可用服务器/,
|
||||
);
|
||||
});
|
||||
|
||||
test('debug 结果含 mode/isDebugger', async () => {
|
||||
const fetcher = fetcherReturning(REMOTE);
|
||||
const r = await resolveBootstrap({ mode: 'debug', win: {}, search: '', fetcher, isNative: false });
|
||||
|
||||
@@ -232,8 +232,8 @@ export const PROFILES: Record<string, DebugProfile> = {
|
||||
`resolveBootstrap` 决定 `servers`(一个 `ws://` 地址数组,交给 `NetClient` 轮询)的顺序:
|
||||
|
||||
1. **当前 profile 有 `server`** → 直接用它,**跳过远程配置抓取**(`local`/`lab` 这类直连档位走这条)。
|
||||
2. **否则**抓远程配置文件(profile 的 `gameserver`,没有就用 `DEFAULT_GAMESERVER`),用 `getParam` 取出连接参数,`resolveServers` 组装候选列表。
|
||||
3. 远程抓取失败或解析不出地址时:若调用方传了 `fallbackServers` 则降级使用;否则抛 `ConfigFetchError`(交由上层重试,重连本身归 `NetClient`)。
|
||||
2. **否则**抓 profile 的 `gameserver` 远程配置文件,用 `getParam` 取出连接参数,`resolveServers` 组装候选列表。
|
||||
3. **不兜底**(CLAUDE.md 第二准则):profile 既无 `server` 又无 `gameserver`、远程抓取失败、或解析不出任何地址——一律抛 `ConfigFetchError` **显式暴露**,不猜默认、不降级。重试/重连归 `NetClient`,不在本层造兜底轮子。
|
||||
|
||||
`resolveServers` 取的连接参数键(**逐字一致**原工程,按此优先级):
|
||||
|
||||
@@ -308,7 +308,6 @@ interface BootstrapOptions {
|
||||
search: string; // location.search(如 '?profile=local')
|
||||
fetcher: ConfigFetcher; // 远程配置抓取;生产传 new HttpConfigFetcher()
|
||||
isNative: boolean; // 环境判定:原生 WebView=true,H5/浏览器=false(由调用方算)
|
||||
fallbackServers?: string[];// 远程失败/无地址时降级
|
||||
cacheBust?: () => string; // 防缓存串注入(默认 String(Date.now()))
|
||||
}
|
||||
|
||||
@@ -318,7 +317,7 @@ interface BootstrapResult {
|
||||
rawConfig: RemoteConfig | null; // 直连档位为 null
|
||||
profile: DebugProfile;
|
||||
mode: 'debug' | 'release'; // 透传入参 mode
|
||||
isDebugger: boolean; // 是否开启收发包日志(debug 模式 + profile.isDebugger)
|
||||
isDebugger: boolean; // 是否开启收发包日志(= mode === 'debug')
|
||||
}
|
||||
|
||||
function resolveBootstrap(opts: BootstrapOptions): Promise<BootstrapResult>;
|
||||
@@ -344,7 +343,6 @@ async function startup() {
|
||||
search: location.search,
|
||||
fetcher: new HttpConfigFetcher(),
|
||||
isNative: href.indexOf('index.html') > -1, // 等价旧 Logic.isH5Version() 反向
|
||||
fallbackServers: [], // 可选:填应急地址
|
||||
});
|
||||
|
||||
const net = new NetClient({
|
||||
@@ -435,9 +433,9 @@ npm run typecheck:framework # 类型检查(tsc --noEmit)
|
||||
|
||||
在 `PROFILES` 里加一个档位(见 [§5](#5-调试-profiles) 的 `lab` 例子),用 `?profile=lab`。
|
||||
|
||||
### E. 远程配置服挂了也要能调
|
||||
### E. 想绕开远程配置服直连某地址
|
||||
|
||||
给 `resolveBootstrap` 传 `fallbackServers: ['ws://<应急地址>']`,抓取失败时自动降级。
|
||||
加一个带 `server` 的 profile(同 D 的 `lab` 例子)用 `?profile=lab` 直连——这是把"应急地址"写进**权威来源**(profile),而不是让 `resolveBootstrap` 兜底。远程配置服真的挂了,就是显式 `ConfigFetchError`,去修配置服或临时切到直连 profile,不在代码里降级(CLAUDE.md 第二准则)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export interface BootstrapOptions {
|
||||
search: string; // location.search(测试可注入)
|
||||
fetcher: ConfigFetcher; // 远程配置抓取(测试注入 fake)
|
||||
isNative: boolean; // 环境判定结果(URL 含 index.html → true)
|
||||
fallbackServers?: string[]; // 远程失败时降级
|
||||
}
|
||||
export interface BootstrapResult {
|
||||
identity: ChannelIdentity;
|
||||
@@ -134,7 +133,7 @@ export function resolveActiveProfile(search: string): DebugProfile;
|
||||
|
||||
## 6. 错误处理
|
||||
|
||||
- 远程抓取失败:无 `fallbackServers` 时抛 `ConfigFetchError`,交调用方重试(重连归 net,不在本层重复造轮子);有 fallback 则降级使用;
|
||||
- 远程抓取失败 / 解析不出任何地址 / profile 既无 server 又无 gameserver:一律抛 `ConfigFetchError` **显式暴露**,不猜默认、不降级(CLAUDE.md 第二准则)。重试/重连归 net,不在本层造兜底;
|
||||
- `getParam`:某层匹配不到 → 返回已找到的最具体值或顶层 `data[key]`,最终可能 `null`(忠实原语义);
|
||||
- 原生 settings 缺失:`try/catch` → source 返回 `{}`(不覆盖),等价原 `try/catch return ""`;
|
||||
- 远程 txt JSON 解析失败:抛 `ConfigParseError`。
|
||||
|
||||
@@ -57,7 +57,6 @@ export interface BootstrapOptions {
|
||||
search: string;
|
||||
fetcher: ConfigFetcher;
|
||||
isNative: boolean;
|
||||
fallbackServers?: string[];
|
||||
cacheBust?: () => string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user