Files
Cyrene_v2/cmd/cyrene/websearch_projection_test.go
T
AskaEth bc1dee3f56
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
refactor(search): provider 配置面去 env 化 —— 加新家不再需要动 .env
动因:`.env` 已冻结(基础键不加/不改/不删),而「加一家 provider」原本会逼出
新的 env 键(一家一个 struct 字段 + searchProviderVector 逐家读 + 逐家写死的
switch)⇒ 想接腾讯云元宝/秘塔就只能破红线。面板本来就该是插件配置的一等来源
($DATA_DIR/config/plugins/web-search.json),故让**文件成为权威**。

- plugins/tools/io/search/registry.go:接入位表加 defaultBaseURL(「没配 baseUrl
  回落代码默认值」从此只有一个出处);新增 Descriptor(s)/DefaultBaseURL。
- cmd/cyrene/websearch_providers.go(新):按接入位表逐家算出生效投影 ——
  加一家时这里**零改动**,这正是本片的核心;顺手把 wire_io_tools.go 从 430 行
  降到 342(它逼近 §10 的 500 行上限)。
- cmd/cyrene/wire_plugin_actions.go / websearch_action_data.go /
  websearch_selection.go:基地址判定统一走「文件值 → 接入位默认值」的进位链;
  checkPrimaryUsable 判据不放松(真没配齐仍 400 primary_not_configured)。

🔴 **一处对我任务书的纠正(子代理提出、我采纳)**:我要求「既有 6 家在文件里
没有该条目时仍回落 .env」。仓库现状**没有**这层回落,且有回归用例把它钉死
(TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv:「文件里没有
的家不得从 .env 复活」)。本片按「不改变既有行为」实现 —— 我的要求才是错的:
文件一旦存在就是权威,env 只在文件缺失/为空时播种。

验收(主会话复跑 + 亲自变异):go build ./... + 两包 vet 绿;search 包与
cmd/cyrene 全绿;我用 -overlay 让文件里的 apiKey 被忽略 ⇒ 4 条用例变红
(含「无 env 字段的家只认文件也能进注册表」这条核心判据)。

⚠️ 新家仍未接:腾讯云元宝(tencent_wsa)/ 秘塔(metaso)两片随后串行落地。
2026-10-07 10:18:58 +08:00

432 lines
19 KiB
Go
Raw 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 main
// 本文件钉住 web-search「各家 provider 配置投影」的新形状(切片 WS-P2 的「去 env 化」)。
//
// 为什么要有这一片:旧形状是**一家一个 `.env` 字段**(`config.WebSearchConfig` 的
// `ZhipuAPIKey` / `BochaAPIKey` / …)+ 组合根里一个逐家写死的 switch。那个形状的后果是
// 「加一家 provider 就必须先加一个 env 键」—— 直接破主人红线(`.env` 已冻结)。
// 新形状把名字表收回到插件自己的接入位表(`plugins/tools/io/search`),组合根只按名字
// 从**面板文件**里取值;`.env` 退居**播种层**(只在文件缺失/为空时落盘一次)。
//
// 四条判据(任务书 ①②③④)与一条边界(文件权威 vs `.env` 字段表)都在这里:
// ① 文件里有 zhipu 的 apiKey ⇒ 该家进注册表;
// ② 文件里没有 zhipu、但 `.env` 有(播种层)⇒ 与改动前逐字一致;
// ③ **没有 env 字段的家**在文件里有 key ⇒ 也能生效(本片的核心判据,用假接入位驱动);
// ④ 文件里的 baseUrl 缺位 ⇒ 回落该接入位的代码默认基地址。
//
// 夹具(假上游、日志字段读取、`wsSurfaceApp`)在 wire_web_search_test.go 与
// websearch_testenv_test.go;`.env` 与面板文件的既有语义在各 wire_web_search_*_test.go。
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// wsFakeNewProviderDef 造一个**测试专用的假接入位**:模拟「下一片要接的新家」。
//
// 它在 search 包里**没有**登记(`search.Known` 为 false),也没有对应的 `.env` 字段 ——
// 于是它正好是判据 ③ 的载体:「没有 env 字段的家,只认文件也能被投影出生效配置」。
func wsFakeNewProviderDef() search.Descriptor {
return search.Descriptor{
Name: "tencent_wsa",
NeedKey: true,
DefaultBaseURL: "https://wsa.tencentcloudapi.com",
}
}
// wsSavePanel 写一份面板文件(临时 DataDir 里的真实落点,与面板保存走同一个写口)。
func wsSavePanel(t *testing.T, cfg *config.Config, f runtimeconfig.WebSearchFile) {
t.Helper()
if f.Version == 0 {
f.Version = runtimeconfig.WebSearchVersion
}
if err := runtimeconfig.SaveWebSearch(wsPanelPath(cfg), f); err != nil {
t.Fatalf("写面板配置: %v", err)
}
}
// wsEffectiveProviders 走组合根的**同一条解析路**(effectiveWebSearch),返回生效配置与来源标签。
func wsEffectiveProviders(t *testing.T, cfg *config.Config) (effectiveWebSearchConfig, string) {
t.Helper()
a := &app{cfg: cfg, log: testLog()}
return a.effectiveWebSearch(a.log)
}
// wsFindWire 在投影里按名字取一家(名字大小写与空白不敏感,与注册表同口径)。
func wsFindWire(providers []searchProviderWire, name string) (searchProviderWire, bool) {
for _, p := range providers {
if strings.EqualFold(p.Name, name) {
return p, true
}
}
return searchProviderWire{}, false
}
// wsZhipuSearchBody 造一份智谱官方形状的应答(`search_result[]`,见 search 包的 zhipuSpec)。
func wsZhipuSearchBody(title string) string {
return `{"search_result":[{"title":"` + title + `","link":"https://z.example/1","content":"` +
title + ` 正文","publish_date":"2025-05-23"}]}`
}
// wsExecuteSearch 从注册面取出 web-search 真打一次(返回线格式结果)。
func wsExecuteSearch(t *testing.T, a *app, query string) map[string]any {
t.Helper()
tl, err := a.caps.Get(webSearchToolName)
if err != nil {
t.Fatalf("web-search 不在注册面里: %v(工具面 = %v)", err, toolNames(a))
}
exec, ok := tl.Value.(interface {
Execute(context.Context, any) (any, error)
})
if !ok {
t.Fatalf("注册表里的值类型 = %T, 不是可执行工具", tl.Value)
}
got, err := exec.Execute(context.Background(), map[string]any{"query": query})
if err != nil {
t.Fatalf("Execute(%q): %v", query, err)
}
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("返回值类型 = %T", got)
}
return m
}
// --- ④ 默认基地址只有一个出处:接入位表 vs `.env` 标签默认值,逐字对齐 ----------
// TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults:接入位表里的默认基地址必须与
// `.env` 解析出的**标签默认值**逐字一致。
//
// 只钉一侧不够:`.env` 的那几个键仍然存在(冻结的基础键,仍然参与播种),接入位表是本片
// 新增的出处。两侧漂移时,同一家 provider 会因「值从哪来」指向两个不同的上游。
func TestWebSearchAccessSlotDefaultsAlignWithEnvDefaults(t *testing.T) {
t.Parallel()
// 一个 WEB_SEARCH_*_BASE_URL 都不写 ⇒ 下面每项取的都是标签默认值。
cfg := wsLoadConfig(t)
cases := []struct {
name string
envDefault string
}{
{search.NameZhipu, cfg.WebSearch.ZhipuBaseURL},
{search.NameAliyunIQS, cfg.WebSearch.AliyunIQSBaseURL},
{search.NameBocha, cfg.WebSearch.BochaBaseURL},
{search.NameVolcengine, cfg.WebSearch.VolcengineBaseURL},
{search.NameBaiduQianfan, cfg.WebSearch.BaiduQianfanBaseURL},
}
for _, tc := range cases {
// 进程环境里的同名覆盖会让「标签默认值」这一侧变成用户值 —— 那不是本表要钉的事实
// (缺省值),如实跳过而不是报假红。
envKey := "WEB_SEARCH_" + strings.ToUpper(tc.name) + "_BASE_URL"
if os.Getenv(envKey) != "" {
t.Skipf("%s 在进程环境里有覆盖值,跳过默认值对齐(本表只钉缺省值)", envKey)
}
if tc.envDefault == "" {
t.Fatalf("%s 的标签默认值解析出来是空的(.env 冻结键不许被清空)", tc.name)
}
if got := search.DefaultBaseURL(tc.name); got != tc.envDefault {
t.Errorf("接入位表 %s 的默认基地址 = %q,.env 的标签默认值 = %q(必须逐字一致)",
tc.name, got, tc.envDefault)
}
}
// searxng 的「默认地址」是 WEB_SEARCH_ENDPOINT 的标签默认值,且**不进**接入位表的默认表
// ——它的地址为空是「关掉这家」的开关语义。
if got := search.DefaultBaseURL(search.NameSearXNG); got != "" || cfg.WebSearch.Endpoint == "" {
t.Errorf("searxng: 接入位默认地址 = %q(want 空),.env 端点默认值 = %q(want 非空)",
got, cfg.WebSearch.Endpoint)
}
}
// --- ① 文件里有 key ⇒ 进注册表 ----------------------------------------------
// TestWebSearchFileProviderEntersRegistry 是判据 ①:**文件里有 zhipu 的 apiKey** ⇒ 该家进
// 注册表(`.env` 一个 zhipu 凭据键都没写),且请求真的带着文件里那把 key 打到文件里那个地址。
func TestWebSearchFileProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-zhipu-key"
up := newWSFakeUpstream(t, http.StatusOK, wsZhipuSearchBody("文件里的智谱"))
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameZhipu)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: key, BaseURL: up.url, Enabled: runtimeconfig.EnableFlag(true)},
},
})
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourcePanel {
t.Fatalf("configSource = %q, want %q(文件存在时它是唯一来源)", source, webSearchSourcePanel)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 1 || got[0] != search.NameZhipu {
t.Fatalf("可用清单 = %v, want [%s](凭据来自面板文件)", got, search.NameZhipu)
}
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的主家应让工具进注册面: %v", toolNames(a))
}
if got := wsExecuteSearch(t, a, "昔涟"); got["provider"] != search.NameZhipu {
t.Fatalf("provider = %v, want %q", got["provider"], search.NameZhipu)
}
select {
case r := <-up.hits:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key(凭据必须以文件为准)", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ② `.env` 是播种层:文件缺失时与改动前逐字一致 ----------------------------
// TestWebSearchProviderFromEnvSeedLayer 是判据 ②:**文件里没有 zhipu、但 `.env` 有**
// ⇒ 与改动前逐字一致:文件缺失/为空时由 `.env` 播种落盘,本次就按 `.env` 运行。
//
// 「与改动前一致」的落点有三处:播种内容、来源标签(env)、生效凭据。
func TestWebSearchProviderFromEnvSeedLayer(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-001",
)
// 第一次解析发生在文件缺失时 ⇒ 它就是「播种那一次」。
eff, source := wsEffectiveProviders(t, cfg)
if source != webSearchSourceEnv {
t.Fatalf("configSource = %q, want %q(文件缺失时按 .env 运行)", source, webSearchSourceEnv)
}
z, ok := wsFindWire(eff.Providers, search.NameZhipu)
if !ok || z.APIKey != "sk-env-001" || z.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("播种层的 zhipu 投影 = %+v, want 凭据来自 .env、基地址回落默认值", z)
}
if got := availableProviderNames(searchProviderSpecs(eff.Scalar, eff.Providers)); len(got) != 2 {
t.Fatalf("可用清单 = %v, want searxng + zhipu 两家", got)
}
// 播种真的落了盘:条目里带 `.env` 的凭据(`.env` 是播种层的输入)。
f, found, err := runtimeconfig.LoadWebSearch(wsPanelPath(cfg))
if err != nil || !found {
t.Fatalf("文件缺失时必须按 .env 播种落盘: found=%v err=%v", found, err)
}
entry, ok := findProviderEntry(f, search.NameZhipu)
if !ok || entry.APIKey != "sk-env-001" {
t.Fatalf("播种的 zhipu 条目 = %+v, want 带 .env 的凭据", entry)
}
if f.Provider != search.NameZhipu {
t.Fatalf("播种的主 provider = %q, want %q", f.Provider, search.NameZhipu)
}
// 播种之后工具照常在注册面里(`.env` 走的是与面板键同一条装配路)。
if !wsHasTool(wsSurfaceApp(t, cfg, nil), webSearchToolName) {
t.Fatal("按 .env 播种后 web-search 应在注册面里")
}
}
// --- ③ 没有 env 字段的家:只认文件(本片的**核心判据**) ----------------------
// TestWebSearchProviderWithoutEnvFieldIsProjected 是判据 ③ 的投影层:**没有 env 字段的家**
// (测试专用的假接入位 `tencent_wsa`)在文件里有 key ⇒ 照样被投影出生效配置。
//
// 这条判据就是本片存在的理由:旧形状的逐家 switch 只认 `.env` 字段表里的 6 家,第 7 家
// 想进来只能先加 env 键(破红线)。新形状按**接入位表**逐家投影,因此第 7 家只需在
// `plugins/tools/io/search` 登记(加一个接入位文件 + 表里一行),组合根零改动。
//
// 最后一跳(进注册表)由同文件的 TestWebSearchFileOnlyProviderEntersRegistry 用真实接入位
// 走完:注册表按名字查的正是同一张接入位表。
func TestWebSearchProviderWithoutEnvFieldIsProjected(t *testing.T) {
t.Parallel()
fake := wsFakeNewProviderDef()
cfg := wsLoadConfig(t)
// 前提一:它确实不在 `.env` 的字段表里(否则这条用例会退化成「既有 6 家」的回归)。
for _, p := range searchProviderVector(cfg.WebSearch) {
if strings.EqualFold(p.Name, fake.Name) {
t.Fatalf("前提被破坏:%s 不该出现在 .env 的字段表里", fake.Name)
}
}
// 前提二:它确实还没登记(登记工作是下一片的:加接入位文件 + 表里一行)。
if search.Known(fake.Name) {
t.Fatalf("前提被破坏:假接入位 %s 不该已经登记", fake.Name)
}
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Providers: []runtimeconfig.ProviderEntry{
// 连 baseUrl 都不给:顺带钉住判据 ④ 在「没有 env 字段」的家上同样成立。
{Provider: fake.Name, APIKey: "sk-wsa-001"},
},
}
got := resolveSearchProviders(append(search.Descriptors(), fake), f)
w, ok := wsFindWire(got, fake.Name)
if !ok {
t.Fatalf("没有 env 字段的家在文件里有 key 时也必须被投影出来,实际 = %+v", got)
}
if w.APIKey != "sk-wsa-001" {
t.Fatalf("投影的凭据 = %q, want 文件里的 sk-wsa-001", w.APIKey)
}
if w.BaseURL != fake.DefaultBaseURL {
t.Fatalf("投影的基地址 = %q, want 接入位默认值 %q", w.BaseURL, fake.DefaultBaseURL)
}
// 没配的家照样占位(它带着默认基地址进 SSRF 白名单)。
if _, ok := wsFindWire(got, search.NameBaiduQianfan); !ok {
t.Fatalf("未配置的家也要出现在投影里(否则 SSRF 白名单会少一家): %+v", got)
}
}
// TestWebSearchFileOnlyProviderEntersRegistry 是判据 ③ 的端到端:文件里有 key、
// `.env` 里没有任何该家的值 ⇒ 该家真的进注册表,且请求带着**文件里那把** key 打出去。
//
// 落在真实接入位(bocha)上,是因为注册表按名字查的正是那张接入位表:这条路径一旦通了,
// 「没有 env 字段的家」登记之后走的就是同一条路。
func TestWebSearchFileOnlyProviderEntersRegistry(t *testing.T) {
t.Parallel()
const key = "test-file-only-key"
got := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got <- r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`))
}))
defer srv.Close()
// `.env` 只给「谁当主家」,凭据与地址一个都不给。
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameBocha)
wsSavePanel(t, cfg, runtimeconfig.WebSearchFile{
Provider: search.NameBocha,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameBocha, APIKey: key, BaseURL: srv.URL, Enabled: runtimeconfig.EnableFlag(true)},
},
})
a := wsSurfaceApp(t, cfg, nil)
if !wsHasTool(a, webSearchToolName) {
t.Fatalf("文件里配好的家应让工具进注册面: %v", toolNames(a))
}
if m := wsExecuteSearch(t, a, "昔涟"); m["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q", m["provider"], search.NameBocha)
}
select {
case r := <-got:
if r.Header.Get("Authorization") != "Bearer "+key {
t.Errorf("Authorization = %q,want 文件里那把 key", r.Header.Get("Authorization"))
}
default:
t.Fatal("文件里配好的家一个请求都没收到")
}
}
// --- ④ 基地址的进位链:文件值 → 接入位默认值 ---------------------------------
// TestWebSearchBaseURLFallbackChain 是判据 ④ 的表驱动:
// 文件里的 baseUrl 缺位 ⇒ 回落**该接入位的代码默认基地址**(逐字对上 registry 里的值)。
func TestWebSearchBaseURLFallbackChain(t *testing.T) {
t.Parallel()
cases := []struct {
name string
entries []runtimeconfig.ProviderEntry
wantKey string
wantURL string
}{
{
"文件没给地址 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1"}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"文件给了地址 ⇒ 用文件的(进位链第一跳)",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: "https://proxy.example"}},
"sk-1", "https://proxy.example",
},
{
"空串地址等同于没给 ⇒ 回落接入位默认值",
[]runtimeconfig.ProviderEntry{{Provider: search.NameZhipu, APIKey: "sk-1", BaseURL: " "}},
"sk-1", search.DefaultBaseURL(search.NameZhipu),
},
{
"被停用的家仍带默认地址(白名单口径),但没有凭据",
[]runtimeconfig.ProviderEntry{
{Provider: search.NameZhipu, APIKey: "sk-1", Enabled: runtimeconfig.EnableFlag(false)},
},
"", search.DefaultBaseURL(search.NameZhipu),
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
f := runtimeconfig.WebSearchFile{Version: runtimeconfig.WebSearchVersion, Providers: tc.entries}
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("zhipu 必须出现在投影里: %+v", got)
}
if w.BaseURL != tc.wantURL {
t.Errorf("基地址 = %q, want %q", w.BaseURL, tc.wantURL)
}
if w.APIKey != tc.wantKey {
t.Errorf("凭据 = %q, want %q", w.APIKey, tc.wantKey)
}
})
}
// 逐字对齐:这个「接入位默认值」必须就是 registry 里那张表的值(不是从 `.env` 抄来的)。
if got := search.DefaultBaseURL(search.NameZhipu); got != "https://open.bigmodel.cn" {
t.Fatalf("registry 的默认基地址 = %q, want 逐字 https://open.bigmodel.cn", got)
}
}
// --- 边界:文件存在时它**就是**权威(`.env` 不复活任何条目) ------------------
// TestWebSearchFileAuthorityOverEnvFieldTable 钉住投影层的权威边界:文件里**没有** zhipu
// 条目 ⇒ 即使 `.env` 里有 zhipu 的 key,这家也不生效(凭据不复活)。`.env` 只在**文件
// 缺失/为空**时经 seedWebSearchFile 播种(判据 ②)—— 文件一旦存在,权威就在文件那边。
//
// 这是工具层既有回归用例(`wire_web_search_panel_test.go` 的
// `TestWireIOTools_PanelConfigClearedProviderDoesNotReviveFromEnv`:主家因此没配齐 ⇒ 启动
// 报错)在**投影层**的同一条口径,也是与本体形状契约(runtimeconfig:providers 为空
// 数组 ⇒ 一家都没配)一致的行为。
func TestWebSearchFileAuthorityOverEnvFieldTable(t *testing.T) {
t.Parallel()
cfg := wsLoadConfig(t,
"WEB_SEARCH_PROVIDER="+search.NameZhipu,
"WEB_SEARCH_ZHIPU_API_KEY=sk-env-stale",
)
f := runtimeconfig.WebSearchFile{
Version: runtimeconfig.WebSearchVersion,
Provider: search.NameZhipu,
Providers: []runtimeconfig.ProviderEntry{
{Provider: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471"},
},
}
// 文件真的落到盘上(否则「文件里没有这家」这条用例会退化成「文件缺失 ⇒ 播种」)。
wsSavePanel(t, cfg, f)
got := resolveSearchProviders(search.Descriptors(), f)
w, ok := wsFindWire(got, search.NameZhipu)
if !ok {
t.Fatalf("未配置的家也要在投影里占位(它带着默认基地址进 SSRF 白名单): %+v", got)
}
if w.APIKey != "" {
t.Fatalf("文件里没有的家不得从 .env 复活凭据,得到 %q(.env 只是播种层)", w.APIKey)
}
if w.BaseURL != search.DefaultBaseURL(search.NameZhipu) {
t.Fatalf("基地址 = %q, want 接入位默认值 %q", w.BaseURL, search.DefaultBaseURL(search.NameZhipu))
}
// 与 `.env` 无关的旁证:那份 `.env` 里确实有 key(否则这条用例是空转)。
if cfg.WebSearch.ZhipuAPIKey != "sk-env-stale" {
t.Fatalf(".env 里的 zhipu key = %q(用例前提不成立)", cfg.WebSearch.ZhipuAPIKey)
}
// 工具层:主家没配齐 ⇒ 启动报错(既不复活,也不静默换一家)。
if err := wsNewTools(t, cfg); !errors.Is(err, search.ErrNotConfigured) {
t.Fatalf("err = %v, want errors.Is(..., ErrNotConfigured):文件里没有的家不得从 .env 复活", err)
}
}