File size: 30,990 Bytes
6d60378 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 | // 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/<ver>` 与归属头
// X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。
ClientVersion string `json:"client_version"`
// CliVersion 出站 UA 中 `CLI/<ver>` 段的版本。空 = 内置默认(官方内置 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:<token>@<host>: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
}
|