更新若干功能

This commit is contained in:
李琦
2026-08-15 17:18:00 +08:00
parent 4954295961
commit 6a81e479ef
77 changed files with 8165 additions and 2372 deletions

View File

@@ -2,8 +2,8 @@ package main
// fileapi.go 调 nl-pms-api 的最小客户端:上传图片换取公开访问 URL
// 以及素材库的列表/删除透传(权限由服务端按 用户/团队角色 判定)。
// 服务器地址与密钥来自管理员下发的全局文件存储配置(见 filestorage.go
// 请求由 Go 端发起(不走前端 fetch天然绕开 webview 的 CORS 限制
// 服务器地址来自管理员下发的全局文件存储配置(见 filestorage.go
// 鉴权使用同步登录的 JWTsync_access_token不再使用 apiKey / 表单 userId
import (
"bytes"
@@ -20,7 +20,6 @@ import (
var fileAPIClient = &http.Client{Timeout: 30 * time.Second}
// currentTeamID 返回客户端当前所在团队 id未加入团队为 0
func (a *App) currentTeamID() int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(a.store.Meta("current_team_id")), 10, 64)
if n < 0 {
@@ -29,8 +28,6 @@ func (a *App) currentTeamID() int64 {
return n
}
// fileAPIBase 返回已启用的服务器存储地址(实时读全局配置,离线回本地缓存);
// 未启用返回 FILE_API_UNCONFIGURED。
func (a *App) fileAPIBase() (FileStorageConfig, error) {
cfg := a.currentFileStorage()
if cfg.Mode != "server" || cfg.BaseURL == "" {
@@ -39,7 +36,44 @@ func (a *App) fileAPIBase() (FileStorageConfig, error) {
return cfg, nil
}
// fileAPIStatusErr 把 nl-pms-api 的非 200 响应映射为客户端错误码。
func (a *App) syncBearerToken() (string, error) {
tok := ""
if a.store != nil {
tok = strings.TrimSpace(a.store.Meta("sync_access_token"))
}
if tok == "" {
return "", errors.New("SYNC_NOT_LOGGED_IN")
}
return tok, nil
}
// fileAPIDo 对文件服务发请求401 时尝试 refresh 后重试一次。
func (a *App) fileAPIDo(req *http.Request) (*http.Response, error) {
tok, e := a.syncBearerToken()
if e != nil {
return nil, e
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, e := fileAPIClient.Do(req)
if e != nil {
return nil, e
}
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close()
if a.apiTryRefresh() != nil {
return nil, errors.New("UNAUTHORIZED")
}
tok, e = a.syncBearerToken()
if e != nil {
return nil, e
}
// 重建请求体不可行时由调用方重试;此处仅对无 body 的 GET/DELETE 重试。
req.Header.Set("Authorization", "Bearer "+tok)
return fileAPIClient.Do(req)
}
return resp, nil
}
func fileAPIStatusErr(status int) error {
switch status {
case http.StatusForbidden:
@@ -51,14 +85,14 @@ func fileAPIStatusErr(status int) error {
}
}
// uploadImageToServer 把编码好的图片字节上传到 nl-pms-api返回可公开访问的 http URL。
// 归属头像记个人teamId=0内容图记当前团队素材库据此划定管理范围。
// 未启用服务器存储时返回 FILE_API_UNCONFIGURED失败不静默降级由调用方向用户报错。
func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error) {
cfg, e := a.fileAPIBase()
if e != nil {
return "", e
}
if _, e := a.syncBearerToken(); e != nil {
return "", e
}
ext := ".jpg"
switch mime {
case "image/png":
@@ -68,51 +102,60 @@ func (a *App) uploadImageToServer(data []byte, mime, kind string) (string, error
case "image/webp":
ext = ".webp"
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, e := w.CreateFormFile("file", "img"+ext)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
doUpload := func() (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, e := w.CreateFormFile("file", "img"+ext)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
if _, e = fw.Write(data); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
_ = w.WriteField("kind", kind)
if kind == "content" {
_ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10))
}
if e = w.Close(); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req.Header.Set("Content-Type", w.FormDataContentType())
tok, _ := a.syncBearerToken()
req.Header.Set("Authorization", "Bearer "+tok)
resp, e := fileAPIClient.Do(req)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusUnauthorized {
if a.apiTryRefresh() == nil {
return "", errors.New("RETRY")
}
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
if resp.StatusCode != http.StatusOK {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
var out struct {
URL string `json:"url"`
}
if json.Unmarshal(body, &out) != nil || out.URL == "" {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
return out.URL, nil
}
if _, e = fw.Write(data); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
url, e := doUpload()
if e != nil && e.Error() == "RETRY" {
return doUpload()
}
_ = w.WriteField("kind", kind)
if id := a.syncUserID(); id > 0 {
_ = w.WriteField("userId", strconv.FormatInt(id, 10))
}
if kind == "content" {
_ = w.WriteField("teamId", strconv.FormatInt(a.currentTeamID(), 10))
}
if e = w.Close(); e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req, e := http.NewRequest(http.MethodPost, cfg.BaseURL+"/api/v1/files", &buf)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
if e != nil {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
var out struct {
URL string `json:"url"`
}
if json.Unmarshal(body, &out) != nil || out.URL == "" {
return "", errors.New("IMAGE_UPLOAD_FAILED")
}
return out.URL, nil
return url, e
}
// ListServerFiles 素材库分页列表。scope=mine 看自己scope=team 看指定团队
// (需为该团队 owner/adminscope=all 看全部(仅云端账号 id=1
func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileList, error) {
if e := a.ready(); e != nil {
return ServerFileList{}, e
@@ -121,23 +164,24 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL
if e != nil {
return ServerFileList{}, e
}
uid := a.syncUserID()
if uid <= 0 {
if a.syncUserID() <= 0 {
return ServerFileList{}, errors.New("SYNC_NOT_LOGGED_IN")
}
if page < 1 {
page = 1
}
u := fmt.Sprintf("%s/api/v1/files?scope=%s&userId=%d&teamId=%d&page=%d&pageSize=24",
cfg.BaseURL, scope, uid, teamID, page)
u := fmt.Sprintf("%s/api/v1/files?scope=%s&teamId=%d&page=%d&pageSize=24",
cfg.BaseURL, scope, teamID, page)
req, e := http.NewRequest(http.MethodGet, u, nil)
if e != nil {
return ServerFileList{}, errors.New("FILE_API_REQUEST_FAILED")
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
resp, e := a.fileAPIDo(req)
if e != nil {
return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE")
if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" {
return ServerFileList{}, errors.New("FILE_STORAGE_UNREACHABLE")
}
return ServerFileList{}, e
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
@@ -151,8 +195,6 @@ func (a *App) ListServerFiles(scope string, teamID int64, page int) (ServerFileL
return out, nil
}
// DeleteServerFile 删除素材(记录+服务器磁盘文件)。服务端校验:本人、
// 超管 id=1、或该文件归属团队的 owner/admin越权返回 FILE_PERMISSION_DENIED。
func (a *App) DeleteServerFile(id int64) error {
if e := a.ready(); e != nil {
return e
@@ -161,19 +203,20 @@ func (a *App) DeleteServerFile(id int64) error {
if e != nil {
return e
}
uid := a.syncUserID()
if uid <= 0 {
if a.syncUserID() <= 0 {
return errors.New("SYNC_NOT_LOGGED_IN")
}
u := fmt.Sprintf("%s/api/v1/files/%d?userId=%d", cfg.BaseURL, id, uid)
u := fmt.Sprintf("%s/api/v1/files/%d", cfg.BaseURL, id)
req, e := http.NewRequest(http.MethodDelete, u, nil)
if e != nil {
return errors.New("FILE_API_REQUEST_FAILED")
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, e := fileAPIClient.Do(req)
resp, e := a.fileAPIDo(req)
if e != nil {
return errors.New("FILE_STORAGE_UNREACHABLE")
if e.Error() == "SYNC_OFFLINE" || e.Error() == "UNAUTHORIZED" {
return errors.New("FILE_STORAGE_UNREACHABLE")
}
return e
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {