// config.go 加载 JSON 配置 + 环境变量覆盖。 package main import ( "crypto/rand" "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" ) // Config 顶层配置。 type Config struct { Listen string `json:"listen"` // ":7863" APIKey string `json:"api_key"` // 空 = 不鉴权 AuthDir string `json:"auth_dir"` // ./auths StateFile string `json:"state_file"` // ./data/state.json Panel struct { // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。 PackageDetailLimit int `json:"package_detail_limit"` } `json:"panel"` Logging struct { // RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。 RequestArchiveEnabled bool `json:"request_archive_enabled"` // RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。 RequestRetentionDays int `json:"request_retention_days"` // RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。 RequestArchiveMaxMB int `json:"request_archive_max_mb"` // RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行 // 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。 // // 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索, // 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要 // 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。 // 热生效(经 livecfg 快照),无需重启。 RequestClientInfo bool `json:"request_client_info"` } `json:"logging"` Server struct { // ReadTimeout 入站请求读取(含 body 上传)总时长上限(issue #100)。 // http.Server 的 ReadTimeout 覆盖整个请求读取:大上下文/文件块请求经 // 反代链转发时上传可超过旧固定值 60s,被掐后客户端拿到 // 400 "read body: ... i/o timeout"。缺省 "300s";"0" = 不限制 //(慢速 body 可无限占用连接,自担风险);改动需重启进程。 ReadTimeout string `json:"read_timeout"` // "300s";"0" = 不限制 } `json:"server"` Cooldown struct { // hard_credit / err_threshold / err_cooldown 三个历史键已退役: // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。 // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。 SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数 // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。 // 空值回落默认,非法值报错(处理风格同 soft_rate)。 SoftRateMax string `json:"soft_rate_max"` // "2h" } `json:"cooldown"` Schedule struct { CheckinHours []int `json:"checkin_hours"` // [9,21] TravelHours []int `json:"travel_hours"` // [9,21] ActivityHours []int `json:"activity_hours"` // [10] KeepaliveHours []int `json:"keepalive_hours"` // [22] BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数) GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行) // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。 // // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用": // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转 // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true // 则对老配置零影响,向后完全兼容。 // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。 // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。 // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。 CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到 TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行 ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报 KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活 BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子 GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程 // IncludeDisabledInTasks 让「保号类」定时任务(签到 / 活跃上报 / token 保活 / // 余额刷新)对**已禁用(disabled)**的账号也执行。 // // 为什么需要它:面板「禁用」的语义是「不再参与选号」(见面板确认文案),但这四类 // 任务此前一律 `if st.Disabled { continue }`,等于把「停用流量」放大成「停止一切 // 上游保号行为」——被禁用的号拿不到签到积分、不续 token、余额也不再刷新;而 // ReenableIfCredits 明确不复活 disabled 账号(见 pool.state.go),于是签到这条唯一 // 的自动回血路径也断了,账号只能靠人工「解冻」回来。 // // 对「一次只放开一个号、用禁用做流量开关」的轮换用法(同 IP 多号防风控),闲置 // 待命的号恰恰是最需要签到的那批——本开关即为该用法提供出口。 // // 缺省 false = 保持既有行为,对老配置零影响。打开后禁用号仍会签到 / 保活,但 // **依旧不参与选号**:pool 选号侧的 disabled 过滤不受本开关影响。 IncludeDisabledInTasks bool `json:"include_disabled_in_tasks"` // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。 // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 } `json:"schedule"` Global struct { // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/ // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。 // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号 // 恒判 cn,global base 只在 realm=global 的账号上被使用。 Enabled bool `json:"enabled"` // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认 // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。 ChatBase string `json:"chat_base"` BillingBase string `json:"billing_base"` } `json:"global"` Upstream struct { // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 TimeoutSeconds int `json:"timeout_seconds"` // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。 HeaderTimeoutSeconds int `json:"header_timeout_seconds"` // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。 IdleTimeoutSeconds int `json:"idle_timeout_seconds"` // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。 // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。 // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。 UserAgent string `json:"user_agent"` // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/` 与归属头 // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。 ClientVersion string `json:"client_version"` // CliVersion 出站 UA 中 `CLI/` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。 CliVersion string `json:"cli_version"` // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。 // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。 ClientName string `json:"client_name"` // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。 // 每号 auth 文件的 device_token 键优先于本项。 DeviceToken string `json:"device_token"` // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。 DeviceTokenFile string `json:"device_token_file"` // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。 PassthroughIP bool `json:"passthrough_ip"` } `json:"upstream"` Features struct { // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。 SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` } `json:"features"` Prompt struct { // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); // custom = 网关用自有系统提示词替换客户端 system/developer; // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。 Mode string `json:"mode"` // "passthrough" / "custom" / "append" // File 提示词文件路径;空 = 内置默认 defaultprompt.md; // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。 File string `json:"file"` } `json:"prompt"` // PromptText 解析后的系统提示词文本(custom/append 模式使用)。 PromptText string `json:"-"` Upstash struct { URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:@:6379 } `json:"upstash"` Pool struct { MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m" DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制) IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重 IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0 // PreferExpiring 快过期积分加权开关,默认 true。开启且 expiring_soon 窗口内 // 存在有效批次时,该账号选号权重 ×3(虚拟实例,见 pool 路由加权); // 不按到期时间排序、与批次金额无关(issue #101 对齐实现口径)。 // 关闭后完全不使用到期信息选号。 PreferExpiring bool `json:"prefer_expiring"` // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在 // 此窗口内的批次令账号命中上述 ×3 加权;窗口开大 → 命中账号变多、 // 偏好被稀释。空/0 = 禁用该加权门槛。 ExpiringSoon string `json:"expiring_soon"` // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0 // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切 // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有 // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为); // 空值回落默认。 CostExploreInterval string `json:"cost_explore_interval"` // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再 // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。 // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 // 默认 0 = 关闭;负值钳 0。 CreditFloor int64 `json:"credit_floor"` } `json:"pool"` SessionSticky struct { Enabled bool `json:"enabled"` // 默认 true TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m" GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m" } `json:"session_sticky"` // 解析后 SoftRateDur time.Duration `json:"-"` SoftRateMaxDur time.Duration `json:"-"` BreakerCooldownDur time.Duration `json:"-"` BreakerCooldownMaxD time.Duration `json:"-"` DegradeCooldownDur time.Duration `json:"-"` DegradeCooldownMaxD time.Duration `json:"-"` SessionTTL time.Duration `json:"-"` SessionGCInterval time.Duration `json:"-"` BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) ExpiringSoonDur time.Duration `json:"-"` // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 CostExploreIntervalDur time.Duration `json:"-"` // ServerReadTimeoutDur 解析后的入站请求读取上限(issue #100);0 = 不限制。 ServerReadTimeoutDur time.Duration `json:"-"` } // Default 默认配置。 func Default() *Config { c := &Config{ Listen: ":7863", APIKey: "", AuthDir: "./auths", StateFile: "./data/state.json", } c.Cooldown.SoftRate = "600s" c.Cooldown.SoftRateMax = "2h" c.Server.ReadTimeout = "300s" c.Panel.PackageDetailLimit = 5 c.Logging.RequestArchiveEnabled = true c.Logging.RequestRetentionDays = 7 c.Logging.RequestArchiveMaxMB = 100 // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值, // 只有显式 false 才关闭来源记录。 c.Logging.RequestClientInfo = true c.Schedule.CheckinHours = []int{9, 21} c.Schedule.TravelHours = []int{9, 21} c.Schedule.ActivityHours = []int{10} c.Schedule.KeepaliveHours = []int{22} c.Schedule.BlackcatHours = []int{23} c.Schedule.GrowthHours = []int{1} // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖, // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。 c.Schedule.CheckinEnabled = true c.Schedule.GrowthEnabled = true c.Schedule.TravelEnabled = true c.Schedule.ActivityEnabled = true c.Schedule.KeepaliveEnabled = true c.Schedule.BlackcatEnabled = true c.Schedule.BalanceRefreshEnabled = true c.Schedule.BalanceRefreshMinutes = 5 c.Upstream.TimeoutSeconds = 120 // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 c.Upstream.HeaderTimeoutSeconds = 0 c.Upstream.IdleTimeoutSeconds = 0 // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用); // ChatBase/BillingBase 缺省空(回落内置默认)。 c.Global.Enabled = true c.Features.SanitizeBlacklistFingerprints = true c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) c.Pool.MaxInFlight = 3 // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 // 的 0 没有合理语义,回退分档默认最稳)。 c.Pool.MaxInFlightGlobal = 2 c.Pool.BreakerThreshold = 3 c.Pool.BreakerCooldown = "30m" c.Pool.BreakerCooldownMax = "6h" c.Pool.DegradeThreshold = 5 c.Pool.DegradeCooldown = "10m" c.Pool.DegradeCooldownMax = "2h" c.Pool.IdleWeightPerHour = 0.5 c.Pool.IdleWeightMax = 5.0 c.Pool.PreferExpiring = true c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期 // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。 c.Pool.CostExploreInterval = "30m" c.SessionSticky.Enabled = true c.SessionSticky.TTL = "30m" c.SessionSticky.GCInterval = "5m" return c } // Load 从文件读,再用 WB2A_* env 覆盖。 func Load(path string) (*Config, error) { c := Default() if path != "" { // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录, // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。 if st, statErr := os.Stat(path); statErr == nil && st.IsDir() { return nil, fmt.Errorf("config %s 是目录而非文件——"+ "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+ "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path) } raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config: %w", err) } if _, err := ParseConfigInto(raw, c); err != nil { return nil, err } } applyEnv(c) if err := c.normalize(); err != nil { return nil, err } return c, nil } // ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。 // 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。 func ParseConfigInto(raw []byte, c *Config) (*Config, error) { if err := json.Unmarshal(raw, c); err != nil { return nil, fmt.Errorf("parse config: %w", err) } if err := c.normalize(); err != nil { return nil, err } return c, nil } // ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。 func ParseConfig(raw []byte) (*Config, error) { return ParseConfigInto(raw, Default()) } // WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。 // 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成: // 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。 // 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。 func WriteDefault(path string) (string, error) { raw := make([]byte, 18) if _, err := rand.Read(raw); err != nil { return "", fmt.Errorf("gen api_key: %w", err) } key := "sk-" + base64.RawURLEncoding.EncodeToString(raw) c := Default() c.APIKey = key _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值 out, err := json.MarshalIndent(c, "", " ") if err != nil { return "", fmt.Errorf("marshal config: %w", err) } if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("mkdir config dir: %w", err) } } // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。 f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { return "", fmt.Errorf("write config: %w", err) } defer f.Close() if _, err := f.Write(out); err != nil { return "", fmt.Errorf("write config: %w", err) } return key, nil } func applyEnv(c *Config) { if v := os.Getenv("WB2A_LISTEN"); v != "" { c.Listen = v } if v := os.Getenv("WB2A_API_KEY"); v != "" { c.APIKey = v } if v := os.Getenv("WB2A_AUTH_DIR"); v != "" { c.AuthDir = v } if v := os.Getenv("WB2A_STATE_FILE"); v != "" { c.StateFile = v } if v := os.Getenv("WB2A_SOFT_RATE"); v != "" { c.Cooldown.SoftRate = v } if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" { c.Cooldown.SoftRateMax = v } if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" { if n, err := strconv.Atoi(v); err == nil { c.Upstream.TimeoutSeconds = n } } if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" { if n, err := strconv.Atoi(v); err == nil { c.Upstream.HeaderTimeoutSeconds = n } } if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" { if n, err := strconv.Atoi(v); err == nil { c.Upstream.IdleTimeoutSeconds = n } } if v := os.Getenv("WB2A_USER_AGENT"); v != "" { c.Upstream.UserAgent = v } if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" { c.Upstream.ClientVersion = v } if v := os.Getenv("WB2A_CLI_VERSION"); v != "" { c.Upstream.CliVersion = v } if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" { c.Upstream.ClientName = v } if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" { c.Upstream.DeviceToken = v } if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" { c.Upstream.DeviceTokenFile = v } if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" { if b, err := strconv.ParseBool(v); err == nil { c.Upstream.PassthroughIP = b } } if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" { if b, err := strconv.ParseBool(v); err == nil { c.Features.SanitizeBlacklistFingerprints = b } } if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" { c.Prompt.Mode = v } if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" { c.Prompt.File = v } if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" { c.Pool.ExpiringSoon = v } if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" { if b, err := strconv.ParseBool(v); err == nil { c.Pool.PreferExpiring = b } } } func (c *Config) normalize() error { var err error if c.Panel.PackageDetailLimit <= 0 { c.Panel.PackageDetailLimit = 5 } if c.Logging.RequestRetentionDays <= 0 { c.Logging.RequestRetentionDays = 7 } if c.Logging.RequestArchiveMaxMB <= 0 { c.Logging.RequestArchiveMaxMB = 100 } // 入站读取上限(issue #100):空值回落默认 300s;"0" 合法(不限制); // 负值无语义,fail fast(静默钳 0 会把保护悄悄关掉)。 if c.Server.ReadTimeout == "" { c.Server.ReadTimeout = "300s" } if c.ServerReadTimeoutDur, err = time.ParseDuration(c.Server.ReadTimeout); err != nil { return fmt.Errorf("server.read_timeout: %w", err) } if c.ServerReadTimeoutDur < 0 { return fmt.Errorf("server.read_timeout: 负时长 %q 无意义", c.Server.ReadTimeout) } if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil { return fmt.Errorf("cooldown.soft_rate: %w", err) } // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。 if c.Cooldown.SoftRateMax == "" { c.Cooldown.SoftRateMax = "2h" } if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil { return fmt.Errorf("cooldown.soft_rate_max: %w", err) } if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil { return fmt.Errorf("pool.breaker_cooldown: %w", err) } if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil { return fmt.Errorf("pool.breaker_cooldown_max: %w", err) } if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil { return fmt.Errorf("pool.degrade_cooldown: %w", err) } if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil { return fmt.Errorf("pool.degrade_cooldown_max: %w", err) } if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil { return fmt.Errorf("session_sticky.ttl: %w", err) } if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil { return fmt.Errorf("session_sticky.gc_interval: %w", err) } // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。 if c.Pool.ExpiringSoon != "" { if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil { return fmt.Errorf("pool.expiring_soon: %w", err) } } if c.ExpiringSoonDur < 0 { c.ExpiringSoonDur = 0 c.Pool.ExpiringSoon = "0" } // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖 // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停 //("−5m" 无合理语义)。 if c.Pool.CostExploreInterval == "" { c.Pool.CostExploreInterval = "30m" } if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil { return fmt.Errorf("pool.cost_explore_interval: %w", err) } if c.CostExploreIntervalDur < 0 { c.CostExploreIntervalDur = 0 } // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。 if c.Pool.CreditFloor < 0 { c.Pool.CreditFloor = 0 } if c.Pool.BreakerThreshold <= 0 { c.Pool.BreakerThreshold = 3 } // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。 if c.Pool.DegradeThreshold <= 0 { c.Pool.DegradeThreshold = 5 } if c.Pool.DegradeCooldown == "" { c.Pool.DegradeCooldown = "10m" } if c.Pool.DegradeCooldownMax == "" { c.Pool.DegradeCooldownMax = "2h" } // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。 if c.Pool.MaxInFlightGlobal <= 0 { c.Pool.MaxInFlightGlobal = 2 } if c.Pool.IdleWeightPerHour <= 0 { c.Pool.IdleWeightPerHour = 0.5 } if c.Pool.IdleWeightMax <= 0 { c.Pool.IdleWeightMax = 5.0 } if c.Upstream.TimeoutSeconds <= 0 { c.Upstream.TimeoutSeconds = 120 } // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。 // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。 if c.Upstream.HeaderTimeoutSeconds <= 0 { c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds } if c.Upstream.IdleTimeoutSeconds <= 0 { c.Upstream.IdleTimeoutSeconds = 300 } if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") { c.Listen = ":" + c.Listen } // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。 // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。 if len(c.Schedule.CheckinHours) == 0 { c.Schedule.CheckinHours = []int{9, 21} } if len(c.Schedule.TravelHours) == 0 { c.Schedule.TravelHours = []int{9, 21} } if len(c.Schedule.ActivityHours) == 0 { c.Schedule.ActivityHours = []int{10} } if len(c.Schedule.KeepaliveHours) == 0 { c.Schedule.KeepaliveHours = []int{22} } if len(c.Schedule.BlackcatHours) == 0 { c.Schedule.BlackcatHours = []int{23} } if len(c.Schedule.GrowthHours) == 0 { c.Schedule.GrowthHours = []int{1} } // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。 if c.Schedule.BalanceRefreshEnabled { if c.Schedule.BalanceRefreshMinutes <= 0 { c.Schedule.BalanceRefreshMinutes = 5 } c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute } if err := c.validateScheduleHours(); err != nil { return err } return c.normalizePrompt() } // normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。 // // mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支; // custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认 // (两模式共用同一加载路径,PromptText 均非空)。 // passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。 func (c *Config) normalizePrompt() error { switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m { case "", "passthrough": c.Prompt.Mode = "passthrough" case "custom": c.Prompt.Mode = "custom" case "append": c.Prompt.Mode = "append" default: return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode) } if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" { text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File) if err != nil { return err } c.PromptText = text } return nil } // validateScheduleHours 校验排程小时落在 0-23。 // // 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到, // 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关 // (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。 func (c *Config) validateScheduleHours() error { if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil { return err } if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil { return err } if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil { return err } if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil { return err } if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil { return err } return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours) } func checkHourRange(field, switchKey string, hours []int) error { for _, h := range hours { if h < 0 || h > 23 { return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey) } } return nil }