Files
code-utils/apiclient.go

257 lines
7.4 KiB
Go
Raw Normal View History

2026-08-15 17:18:00 +08:00
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()
}