2026-08-14 07:52:01 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
// filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1)在设置页配置,
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// 权威数据经 nl-pms-api settings(全局键 file_storage)。
|
|
|
|
|
|
// 保存时直接写 API 立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存),
|
2026-08-14 07:52:01 +08:00
|
|
|
|
// 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 syncFileStorageConfig)。
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
fileStorageKey = "file_storage" // settings 表 KV 键(本地缓存,JSON)
|
|
|
|
|
|
fileStorageAtKey = "file_storage_updated_at" // LWW 时间戳,同步推拉的判定依据
|
|
|
|
|
|
fileStorageTTL = 30 * time.Second // 实时配置的内存缓存时长,避免连续上传反复查库
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
func normalizeFileStorage(c FileStorageConfig) FileStorageConfig {
|
|
|
|
|
|
if c.Mode != "server" {
|
|
|
|
|
|
c.Mode = "local"
|
|
|
|
|
|
}
|
|
|
|
|
|
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
|
|
|
|
|
|
c.APIKey = strings.TrimSpace(c.APIKey)
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// defaultFileStorage 未配置时的默认:服务器模式,地址与打包同步 API 基址一致。
|
|
|
|
|
|
func (a *App) defaultFileStorage() FileStorageConfig {
|
|
|
|
|
|
return normalizeFileStorage(FileStorageConfig{
|
|
|
|
|
|
Mode: "server",
|
|
|
|
|
|
BaseURL: a.apiBaseURL(),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
func (a *App) fileStorageConfig() FileStorageConfig {
|
|
|
|
|
|
var c FileStorageConfig
|
|
|
|
|
|
if raw := a.store.Meta(fileStorageKey); raw != "" {
|
|
|
|
|
|
_ = json.Unmarshal([]byte(raw), &c)
|
2026-08-15 17:18:00 +08:00
|
|
|
|
return normalizeFileStorage(c)
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
2026-08-15 17:18:00 +08:00
|
|
|
|
return a.defaultFileStorage()
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// fetchRemoteFileStorage 从 API 读权威配置。第二个返回值表示"远端可用且结果权威"
|
|
|
|
|
|
// (无行记录时回落默认服务器配置);成功时顺带刷新本地缓存副本,供离线兜底。
|
2026-08-14 07:52:01 +08:00
|
|
|
|
func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) {
|
2026-08-15 17:18:00 +08:00
|
|
|
|
if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" || a.apiBaseURL() == "" {
|
2026-08-14 07:52:01 +08:00
|
|
|
|
return FileStorageConfig{}, false
|
|
|
|
|
|
}
|
2026-08-15 17:18:00 +08:00
|
|
|
|
val, at, ok, e := a.apiGetGlobalSetting(fileStorageKey)
|
2026-08-14 07:52:01 +08:00
|
|
|
|
if e != nil {
|
|
|
|
|
|
return FileStorageConfig{}, false
|
|
|
|
|
|
}
|
2026-08-15 17:18:00 +08:00
|
|
|
|
if !ok {
|
|
|
|
|
|
return a.defaultFileStorage(), true
|
|
|
|
|
|
}
|
2026-08-14 07:52:01 +08:00
|
|
|
|
var c FileStorageConfig
|
|
|
|
|
|
if json.Unmarshal([]byte(val), &c) != nil {
|
|
|
|
|
|
return FileStorageConfig{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
c = normalizeFileStorage(c)
|
|
|
|
|
|
if b, e := json.Marshal(c); e == nil {
|
|
|
|
|
|
_ = a.store.SetMeta(fileStorageKey, string(b))
|
|
|
|
|
|
_ = a.store.SetMeta(fileStorageAtKey, at)
|
|
|
|
|
|
}
|
|
|
|
|
|
return c, true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) currentFileStorage() FileStorageConfig {
|
|
|
|
|
|
a.fsMu.Lock()
|
|
|
|
|
|
if !a.fsCfgAt.IsZero() && time.Since(a.fsCfgAt) < fileStorageTTL {
|
|
|
|
|
|
c := a.fsCfg
|
|
|
|
|
|
a.fsMu.Unlock()
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
a.fsMu.Unlock()
|
|
|
|
|
|
c, ok := a.fetchRemoteFileStorage()
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
c = a.fileStorageConfig()
|
|
|
|
|
|
}
|
|
|
|
|
|
a.fsMu.Lock()
|
|
|
|
|
|
a.fsCfg, a.fsCfgAt = c, time.Now()
|
|
|
|
|
|
a.fsMu.Unlock()
|
|
|
|
|
|
return c
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) invalidateFileStorageCache() {
|
|
|
|
|
|
a.fsMu.Lock()
|
|
|
|
|
|
a.fsCfgAt = time.Time{}
|
|
|
|
|
|
a.fsMu.Unlock()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetFileStorageConfig() (FileStorageConfig, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return FileStorageConfig{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.currentFileStorage(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写 API 立即全员生效。
|
2026-08-14 07:52:01 +08:00
|
|
|
|
func (a *App) SaveFileStorageConfig(c FileStorageConfig) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.syncUserID() != festivalAdminID {
|
|
|
|
|
|
return errors.New("FILE_STORAGE_ADMIN_ONLY")
|
|
|
|
|
|
}
|
|
|
|
|
|
c = normalizeFileStorage(c)
|
|
|
|
|
|
if c.Mode == "server" && !strings.HasPrefix(c.BaseURL, "http") {
|
|
|
|
|
|
return errors.New("FILE_STORAGE_BAD_URL")
|
|
|
|
|
|
}
|
|
|
|
|
|
b, e := json.Marshal(c)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
now := nowRFC()
|
2026-08-15 17:18:00 +08:00
|
|
|
|
if e := a.apiPutSettingWithStepUp(fileStorageKey, string(b), now, true); e != nil {
|
|
|
|
|
|
return e
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
if e := a.store.SetMeta(fileStorageKey, string(b)); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = a.store.SetMeta(fileStorageAtKey, now)
|
|
|
|
|
|
a.invalidateFileStorageCache()
|
|
|
|
|
|
a.store.Log("info", "系统", "文件存储配置已保存", "mode="+c.Mode)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// TestFileStorage 探测服务器连通性(GET {baseURL}/healthz)。
|
2026-08-14 07:52:01 +08:00
|
|
|
|
func (a *App) TestFileStorage(c FileStorageConfig) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
c = normalizeFileStorage(c)
|
|
|
|
|
|
if !strings.HasPrefix(c.BaseURL, "http") {
|
|
|
|
|
|
return errors.New("FILE_STORAGE_BAD_URL")
|
|
|
|
|
|
}
|
|
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
|
|
|
|
resp, e := client.Get(c.BaseURL + "/healthz")
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return errors.New("FILE_STORAGE_UNREACHABLE")
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
|
|
|
|
return errors.New("FILE_STORAGE_UNREACHABLE")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|