Files
code-utils/apiclient.go
2026-08-15 17:18:00 +08:00

257 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 解到 outout 可为 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()
}