File size: 3,671 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
package syncer

import (
	"encoding/json"
	"strings"
)

// credential 是从一个 auth 文件里抽出来的、同步对账需要的**最小字段集**。
//
// 为什么只留这几个:冲突裁决只需要回答"哪一端的 accessToken 更新",
// 判据就是 expiresAt(外加 refreshToken 有无)。没必要把整份凭证建模一遍——
// 少建模就少一处会被上游改动作废的地方。
type credential struct {
	UID        string
	Nickname   string
	ExpiresAt  int64
	HasRefresh bool
	Realm      string
	Domain     string
}

// parseCredential 解析 auth 文件的两种磁盘形态,返回 (凭证, 是否为可用凭证)。
//
// 形态(与网关 auth.Parse 同口径):
//
//	嵌套形 {"auth":{...},"account":{...}}   —— 插件 OAuth 输出
//	扁平形 {"accessToken":...,"uid":...}    —— 手写 / 旧版
//
// ⚠️ 这里**刻意不复用 internal/auth**,尽管那是网关自己的解析器。
// 原因:本工具要做成**独立 exe**(用户换掉上游新版的 wb2api.exe 时,启动器不受影响),
// 一旦 import 网关内部包,启动器和网关的源码目录就绑死了——上游重构 internal/auth
// 会让启动器连编译都过不去。自带一份容错解析,代价是几十行,换来的是两边解耦。
//
// 容错原则:解析不出来**不是错误**。返回 false 表示"这只是一堆字节",对账会退化成
// 按内容哈希直接比较(仍能正确同步,只是失去了 expiresAt 这档更精细的裁决能力)。
func parseCredential(raw []byte) (credential, bool) {
	var probe map[string]json.RawMessage
	if len(raw) == 0 || json.Unmarshal(raw, &probe) != nil {
		return credential{}, false
	}

	var c credential
	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"`
				Nickname string `json:"nickname"`
			} `json:"account"`
		}
		if json.Unmarshal(raw, &n) != nil {
			return credential{}, false
		}
		if strings.TrimSpace(n.Auth.AccessToken) == "" {
			return credential{}, false
		}
		c = credential{
			UID:        n.Account.UID,
			Nickname:   n.Account.Nickname,
			ExpiresAt:  n.Auth.ExpiresAt,
			HasRefresh: strings.TrimSpace(n.Auth.RefreshToken) != "",
			Realm:      n.Auth.Realm,
			Domain:     n.Auth.Domain,
		}
	} 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"`
			Nickname     string `json:"nickname"`
		}
		if json.Unmarshal(raw, &f) != nil {
			return credential{}, false
		}
		if strings.TrimSpace(f.AccessToken) == "" {
			return credential{}, false
		}
		c = credential{
			UID:        f.UID,
			Nickname:   f.Nickname,
			ExpiresAt:  f.ExpiresAt,
			HasRefresh: strings.TrimSpace(f.RefreshToken) != "",
			Realm:      f.Realm,
			Domain:     f.Domain,
		}
	}
	c.Realm = resolveRealm(c.Realm, c.Domain)
	return c, true
}

// resolveRealm 归一化 realm:显式非空优先,否则按 domain 后缀推断,最后回落 "cn"。
func resolveRealm(explicit, domain string) string {
	if r := strings.TrimSpace(explicit); r != "" {
		return r
	}
	d := strings.ToLower(strings.TrimSpace(domain))
	if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") {
		return "global"
	}
	return "cn"
}