257 lines
7.4 KiB
Go
257 lines
7.4 KiB
Go
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()
|
||
}
|