package main // filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1)在设置页配置, // 权威数据经 nl-pms-api settings(全局键 file_storage)。 // 保存时直接写 API 立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存), // 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 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 } // defaultFileStorage 未配置时的默认:服务器模式,地址与打包同步 API 基址一致。 func (a *App) defaultFileStorage() FileStorageConfig { return normalizeFileStorage(FileStorageConfig{ Mode: "server", BaseURL: a.apiBaseURL(), }) } func (a *App) fileStorageConfig() FileStorageConfig { var c FileStorageConfig if raw := a.store.Meta(fileStorageKey); raw != "" { _ = json.Unmarshal([]byte(raw), &c) return normalizeFileStorage(c) } return a.defaultFileStorage() } // fetchRemoteFileStorage 从 API 读权威配置。第二个返回值表示"远端可用且结果权威" // (无行记录时回落默认服务器配置);成功时顺带刷新本地缓存副本,供离线兜底。 func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) { if a.syncUserID() <= 0 || a.store.Meta("sync_access_token") == "" || a.apiBaseURL() == "" { return FileStorageConfig{}, false } val, at, ok, e := a.apiGetGlobalSetting(fileStorageKey) if e != nil { return FileStorageConfig{}, false } if !ok { return a.defaultFileStorage(), true } 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 } // SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写 API 立即全员生效。 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() if e := a.apiPutSettingWithStepUp(fileStorageKey, string(b), now, true); e != nil { return e } 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 } // TestFileStorage 探测服务器连通性(GET {baseURL}/healthz)。 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 }