// Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态), // 提供 refresh 后的原子写回。 package auth import ( "encoding/json" "errors" "fmt" "io/fs" "log" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" ) // Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。 type Auth struct { // mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。 mu sync.Mutex AccessToken string RefreshToken string ExpiresAt int64 // Unix 秒 Domain string // realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。 // 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。 // // 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用 // 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。 realm string UID string EnterpriseID string Nickname string FilePath string // 来源文件;refresh 后原子写回此处 // DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。 // 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。 // 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形 // 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。 DeviceToken string } // Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。 func (a *Auth) Lock() { a.mu.Lock() } // Unlock 释放 a.Lock 获取的锁。 func (a *Auth) Unlock() { a.mu.Unlock() } // AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。 // // 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt // (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造 // (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models / // global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发: // Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关), // 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的 // 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。 func (a *Auth) AccessTokenValue() string { if a == nil { return "" } a.mu.Lock() defer a.mu.Unlock() return a.AccessToken } // DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。 func (a *Auth) DomainValue() string { if a == nil { return "" } a.mu.Lock() defer a.mu.Unlock() return a.Domain } // RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内 // 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的 // `a.RefreshToken == ""`)必须经此取值,勿直读字段。 func (a *Auth) RefreshTokenValue() string { if a == nil { return "" } a.mu.Lock() defer a.mu.Unlock() return a.RefreshToken } // globalEnabled 全局开关:global realm 是否路由(D5 双保险)。 // 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/ // domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭 // → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai // 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。 var globalEnabled atomic.Bool func init() { globalEnabled.Store(true) } // SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。 func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) } // GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。 func GlobalEnabled() bool { return globalEnabled.Load() } // Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global", // 否则 "cn"。显式 global 优先于 domain 回落(D1)。 // 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。 // 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。 func (a *Auth) Realm() string { a.mu.Lock() defer a.mu.Unlock() return a.realmLocked() } // realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入, // 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内 // 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。 func (a *Auth) realmLocked() string { if !globalEnabled.Load() { return "cn" } if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) { return "global" } return "cn" } // ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断 // (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定); // domain 也为空 → "cn"(老 CN 凭证零回归)。 func ResolveRealm(explicit, domain string) string { if r := strings.TrimSpace(explicit); r != "" { return r } if isGlobalDomain(domain) { return "global" } return "cn" } // BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」 // 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。 // // 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门 // (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证 // (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。 func (a *Auth) BackfillRealm() (bool, string) { a.mu.Lock() defer a.mu.Unlock() if strings.TrimSpace(a.realm) != "" { return false, a.realm } r := ResolveRealm("", a.Domain) a.realm = r return true, r } // RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。 func (a *Auth) RealmStored() string { a.mu.Lock() defer a.mu.Unlock() return a.realm } // BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了 // global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global, // 非法值报错(防写脏)。返回是否发生变更。 func BackfillRealmFor(a *Auth, realm string) (bool, error) { if a == nil { return false, fmt.Errorf("nil auth") } switch strings.TrimSpace(realm) { case "cn", "global": default: return false, fmt.Errorf("realm must be cn/global, got %q", realm) } a.mu.Lock() defer a.mu.Unlock() if a.realm == realm { return false, nil } a.realm = realm return true, nil } // IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } // isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 // 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 func isGlobalDomain(d string) bool { d = strings.ToLower(strings.TrimSpace(d)) return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") } // NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。 func (a *Auth) NeedsRefresh(within time.Duration) bool { a.mu.Lock() defer a.mu.Unlock() if a.ExpiresAt <= 0 { return true } return time.Now().Add(within).Unix() >= a.ExpiresAt } // Parse 兼容两种磁盘形态: // // 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出) // 扁平形 {"accessToken":...,"uid":...} (手写/旧版) func Parse(raw []byte) (*Auth, error) { if len(raw) == 0 { return nil, fmt.Errorf("empty auth storage") } var probe map[string]json.RawMessage if err := json.Unmarshal(raw, &probe); err != nil { return nil, fmt.Errorf("storage_parse_error: %w", err) } var a Auth if _, nested := probe["auth"]; nested { var n struct { Auth struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt int64 `json:"expiresAt"` Domain string `json:"domain"` Realm string `json:"realm"` } `json:"auth"` Account struct { UID string `json:"uid"` EnterpriseID string `json:"enterpriseId"` Nickname string `json:"nickname"` } `json:"account"` // DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。 DeviceToken string `json:"device_token"` } if err := json.Unmarshal(raw, &n); err != nil { return nil, fmt.Errorf("storage_parse_error: %w", err) } a = Auth{ AccessToken: n.Auth.AccessToken, RefreshToken: n.Auth.RefreshToken, ExpiresAt: n.Auth.ExpiresAt, Domain: n.Auth.Domain, realm: n.Auth.Realm, UID: n.Account.UID, EnterpriseID: n.Account.EnterpriseID, Nickname: n.Account.Nickname, DeviceToken: n.DeviceToken, } } else { var f struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt int64 `json:"expiresAt"` Domain string `json:"domain"` Realm string `json:"realm"` UID string `json:"uid"` EnterpriseID string `json:"enterpriseId"` Nickname string `json:"nickname"` DeviceToken string `json:"device_token"` } if err := json.Unmarshal(raw, &f); err != nil { return nil, fmt.Errorf("storage_parse_error: %w", err) } a = Auth{ AccessToken: f.AccessToken, RefreshToken: f.RefreshToken, ExpiresAt: f.ExpiresAt, Domain: f.Domain, realm: f.Realm, UID: f.UID, EnterpriseID: f.EnterpriseID, Nickname: f.Nickname, DeviceToken: f.DeviceToken, } } if strings.TrimSpace(a.AccessToken) == "" { return nil, fmt.Errorf("parse_error: missing accessToken") } return &a, nil } // SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。 // 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。 // 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。 func (a *Auth) SaveAtomic() error { a.mu.Lock() defer a.mu.Unlock() if strings.TrimSpace(a.AccessToken) == "" { return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID) } if a.FilePath == "" { return fmt.Errorf("no FilePath set") } doc := map[string]any{ "auth": map[string]any{ "accessToken": a.AccessToken, "refreshToken": a.RefreshToken, "expiresAt": a.ExpiresAt, "domain": a.Domain, "realm": a.realm, }, "account": map[string]any{ "uid": a.UID, "enterpriseId": a.EnterpriseID, "nickname": a.Nickname, }, } // DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键 // (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。 if a.DeviceToken != "" { doc["device_token"] = a.DeviceToken } raw, err := json.MarshalIndent(doc, "", " ") if err != nil { return err } tmp := a.FilePath + ".tmp" if err := os.WriteFile(tmp, raw, 0o600); err != nil { // Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001) // 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。 msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err) if errors.Is(err, fs.ErrPermission) { msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" + "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" + "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" + "3) compose 设 user: \"0:0\" 以 root 运行)" } return errors.New(msg) } return os.Rename(tmp, a.FilePath) } // LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。 // 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic // 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续), // 避免历史 auth 目录个别文件不可写时整个服务起不来。 func LoadDir(dir string) ([]*Auth, error) { files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json")) if err != nil { return nil, err } // seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零) // 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。 seenUID := make(map[string]string, len(files)) var out []*Auth for _, f := range files { raw, err := os.ReadFile(f) if err != nil { continue } a, err := Parse(raw) if err != nil { continue } a.FilePath = f if prev, ok := seenUID[a.UID]; ok { log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)", logfmt.Label(a.UID, a.Nickname), prev, f) } seenUID[a.UID] = f if a.RealmStored() == "" { if changed, r := a.BackfillRealm(); changed { if err := a.SaveAtomic(); err != nil { log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err) } else if r == "global" { log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain) } } } out = append(out, a) } return out, nil }