package main // apiclient.go 是桌面端访问 nl-pms-api 的 HTTP+JWT 客户端: // 基址来自打包/本地 sync_base_url;带鉴权请求在 401 时用 refresh token 续期一次后重试。 import ( "bytes" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "time" ) const apiBodyLimit = 16 << 20 var apiHTTPClient = &http.Client{Timeout: 30 * time.Second} // apiBaseURL 返回当前 API 基址(无尾斜杠)。优先本地 meta,再回退打包默认。 func (a *App) apiBaseURL() string { if a.store != nil { if u := strings.TrimRight(strings.TrimSpace(a.store.Meta("sync_base_url")), "/"); u != "" { return u } } return strings.TrimRight(strings.TrimSpace(packagedSyncDefaults().BaseURL), "/") } // apiDo 发 JSON HTTP 请求。auth=true 时带 Bearer access token,遇 401 尝试刷新后重试一次。 // 网络失败 → SYNC_OFFLINE;业务错误体 {"error":"CODE"} → errors.New(CODE)。 func (a *App) apiDo(method, path string, body any, auth bool) (int, []byte, error) { return a.apiDoOnce(method, path, body, auth, true, nil) } // apiDoWithHeaders 同 apiDo,可附加额外请求头(如 X-Admin-StepUp)。 func (a *App) apiDoWithHeaders(method, path string, body any, auth bool, headers map[string]string) (int, []byte, error) { return a.apiDoOnce(method, path, body, auth, true, headers) } func (a *App) apiDoOnce(method, path string, body any, auth, allowRefresh bool, headers map[string]string) (int, []byte, error) { base := a.apiBaseURL() if base == "" { return 0, nil, errors.New("SYNC_NOT_CONFIGURED") } var rdr io.Reader if body != nil { b, e := json.Marshal(body) if e != nil { return 0, nil, e } rdr = bytes.NewReader(b) } req, e := http.NewRequest(method, base+path, rdr) if e != nil { return 0, nil, errors.New("SYNC_OFFLINE") } if body != nil { req.Header.Set("Content-Type", "application/json") } for k, v := range headers { if strings.TrimSpace(k) != "" && strings.TrimSpace(v) != "" { req.Header.Set(k, v) } } if auth { tok := "" if a.store != nil { tok = strings.TrimSpace(a.store.Meta("sync_access_token")) } if tok == "" { return 0, nil, errors.New("SYNC_NOT_LOGGED_IN") } req.Header.Set("Authorization", "Bearer "+tok) } resp, e := apiHTTPClient.Do(req) if e != nil { return 0, nil, errors.New("SYNC_OFFLINE") } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, apiBodyLimit)) if resp.StatusCode == http.StatusUnauthorized && auth && allowRefresh { if a.apiTryRefresh() == nil { return a.apiDoOnce(method, path, body, auth, false, headers) } } if resp.StatusCode >= 400 { var er struct { Error string `json:"error"` } if json.Unmarshal(raw, &er) == nil && er.Error != "" { return resp.StatusCode, raw, errors.New(er.Error) } return resp.StatusCode, raw, errors.New("SYNC_HTTP_" + strconv.Itoa(resp.StatusCode)) } return resp.StatusCode, raw, nil } // apiTryRefresh 用 refresh token 换新双令牌并写入 meta;失败返回错误码。 func (a *App) apiTryRefresh() error { if a.store == nil { return errors.New("SYNC_NOT_LOGGED_IN") } rt := strings.TrimSpace(a.store.Meta("sync_refresh_token")) if rt == "" { return errors.New("SYNC_NOT_LOGGED_IN") } status, raw, e := a.apiDoOnce(http.MethodPost, "/api/v1/auth/refresh", map[string]string{ "refreshToken": rt, }, false, false, nil) if e != nil { return e } if status != http.StatusOK { return errors.New("UNAUTHORIZED") } var out struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` UserID int64 `json:"userId"` Username string `json:"username"` } if json.Unmarshal(raw, &out) != nil || out.AccessToken == "" { return errors.New("UNAUTHORIZED") } _ = a.store.SetMeta("sync_access_token", out.AccessToken) if out.RefreshToken != "" { _ = a.store.SetMeta("sync_refresh_token", out.RefreshToken) } if out.UserID > 0 { _ = a.store.SetMeta("sync_user_id", strconv.FormatInt(out.UserID, 10)) } if out.Username != "" { _ = a.store.SetMeta("sync_username", out.Username) } return nil } // apiDecode 调用 apiDo 并把 2xx JSON 解到 out(out 可为 nil)。 func (a *App) apiDecode(method, path string, body, out any, auth bool) error { return a.apiDecodeHeaders(method, path, body, out, auth, nil) } func (a *App) apiDecodeHeaders(method, path string, body, out any, auth bool, headers map[string]string) error { _, raw, e := a.apiDoWithHeaders(method, path, body, auth, headers) if e != nil { return e } if out == nil { return nil } if e := json.Unmarshal(raw, out); e != nil { return errors.New("SYNC_BAD_RESPONSE") } return nil } // apiPutSetting 写入(或 LWW 更新)单条设置。 func (a *App) apiPutSetting(name, value, updatedAt string) error { return a.apiPutSettingWithStepUp(name, value, updatedAt, false) } func (a *App) apiPutSettingWithStepUp(name, value, updatedAt string, withStepUp bool) error { headers := map[string]string{} if withStepUp { tok := a.currentStepUpToken() if tok == "" { return errors.New("ADMIN_STEPUP_REQUIRED") } headers["X-Admin-StepUp"] = tok } return a.apiDecodeHeaders(http.MethodPut, "/api/v1/settings/"+pathEscape(name), map[string]string{ "value": value, "updatedAt": updatedAt, }, nil, true, headers) } // apiGetSetting 读单条设置;NOT_FOUND 时 ok=false 且 err=nil。 func (a *App) apiGetSetting(name string) (value, updatedAt string, ok bool, err error) { var row struct { Value string `json:"value"` UpdatedAt string `json:"updatedAt"` } e := a.apiDecode(http.MethodGet, "/api/v1/settings/"+pathEscape(name), nil, &row, true) if e != nil { if e.Error() == "NOT_FOUND" { return "", "", false, nil } return "", "", false, e } return row.Value, row.UpdatedAt, true, nil } // apiGetGlobalSetting 读挂在管理员名下的全局设置。 func (a *App) apiGetGlobalSetting(name string) (value, updatedAt string, ok bool, err error) { var row struct { Value string `json:"value"` UpdatedAt string `json:"updatedAt"` } e := a.apiDecode(http.MethodGet, "/api/v1/settings/global/"+pathEscape(name), nil, &row, true) if e != nil { if e.Error() == "NOT_FOUND" { return "", "", false, nil } return "", "", false, e } return row.Value, row.UpdatedAt, true, nil } // apiListSettings 按前缀批量拉取(如 fest_img:)。 func (a *App) apiListSettings(prefix string) ([]struct { Name string `json:"name"` Value string `json:"value"` UpdatedAt string `json:"updatedAt"` }, error) { var resp struct { Items []struct { Name string `json:"name"` Value string `json:"value"` UpdatedAt string `json:"updatedAt"` } `json:"items"` } q := "/api/v1/settings?prefix=" + pathEscape(prefix) if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil { return nil, e } if resp.Items == nil { return []struct { Name string `json:"name"` Value string `json:"value"` UpdatedAt string `json:"updatedAt"` }{}, nil } return resp.Items, nil } // pathEscape 对路径段做 URL 转义(保留常见安全字符)。 func pathEscape(s string) string { var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == '~', r == ':': b.WriteRune(r) default: for _, c := range []byte(string(r)) { b.WriteByte('%') const hex = "0123456789ABCDEF" b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0xf]) } } } return b.String() }