176 lines
5.9 KiB
Go
176 lines
5.9 KiB
Go
package main
|
||
|
||
// filestorage.go 全局「文件存储方式」配置:由管理员(云端账号 id=1)在设置页配置,
|
||
// 权威数据存远端 MySQL 的 sync_settings 表(user_id=1, name='file_storage' 行)。
|
||
// 保存时直接写库立即生效;各端上传图片时实时拉取该配置(短 TTL 缓存),
|
||
// 拉取失败(离线/未配置同步)回退本地缓存副本(由同步循环下发,见 syncFileStorageConfig)。
|
||
// mode=local 时一切维持本地行为;mode=server 时内容图与头像选图会上传到
|
||
// nl-pms-api 换取 http URL(远程/跨设备场景无法使用本地路径)。
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
fileStorageKey = "file_storage" // settings 表 KV 键(本地缓存,JSON)
|
||
fileStorageAtKey = "file_storage_updated_at" // LWW 时间戳,同步推拉的判定依据
|
||
fileStorageTTL = 30 * time.Second // 实时配置的内存缓存时长,避免连续上传反复查库
|
||
)
|
||
|
||
// normalizeFileStorage 收敛非法值:mode 只认 local/server,地址去尾斜杠。
|
||
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
|
||
}
|
||
|
||
// fileStorageConfig 读本地缓存的全局配置(未配置返回 local 零值)。
|
||
func (a *App) fileStorageConfig() FileStorageConfig {
|
||
var c FileStorageConfig
|
||
if raw := a.store.Meta(fileStorageKey); raw != "" {
|
||
_ = json.Unmarshal([]byte(raw), &c)
|
||
}
|
||
return normalizeFileStorage(c)
|
||
}
|
||
|
||
// fetchRemoteFileStorage 从远端 MySQL 读权威配置。第二个返回值表示"远端可用且结果权威"
|
||
// (无行记录视作权威的 local);成功时顺带刷新本地缓存副本,供离线兜底。
|
||
func (a *App) fetchRemoteFileStorage() (FileStorageConfig, bool) {
|
||
base := a.ctx
|
||
if base == nil {
|
||
base = context.Background()
|
||
}
|
||
ctx, cancel := context.WithTimeout(base, 5*time.Second)
|
||
defer cancel()
|
||
db, e := a.openRemote(ctx)
|
||
if e != nil {
|
||
return FileStorageConfig{}, false
|
||
}
|
||
defer db.Close()
|
||
var val, at string
|
||
e = db.QueryRowContext(ctx, `SELECT value,updated_at FROM sync_settings WHERE user_id=? AND name=?`,
|
||
festivalAdminID, fileStorageKey).Scan(&val, &at)
|
||
if e == sql.ErrNoRows {
|
||
return FileStorageConfig{}, true // 管理员尚未配置 → 权威的 local
|
||
}
|
||
if e != nil {
|
||
return FileStorageConfig{}, false
|
||
}
|
||
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
|
||
}
|
||
|
||
// currentFileStorage 返回上传时应采用的全局配置:优先远端实时值(TTL 内存缓存),
|
||
// 远端不可达时回退本地缓存副本。管理员改完配置后各端最迟 TTL 内生效,无需等同步轮。
|
||
// 失败结果同样缓存 TTL,避免离线时每次上传都白等一轮连接超时。
|
||
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
|
||
}
|
||
|
||
// invalidateFileStorageCache 让下一次读取强制回源(保存配置后调用)。
|
||
func (a *App) invalidateFileStorageCache() {
|
||
a.fsMu.Lock()
|
||
a.fsCfgAt = time.Time{}
|
||
a.fsMu.Unlock()
|
||
}
|
||
|
||
// GetFileStorageConfig 返回全局文件存储配置(远端优先,离线回本地缓存)。
|
||
// 所有账号可读:界面据此决定头像/内容图的存储走向与素材库可用性。
|
||
func (a *App) GetFileStorageConfig() (FileStorageConfig, error) {
|
||
if e := a.ready(); e != nil {
|
||
return FileStorageConfig{}, e
|
||
}
|
||
return a.currentFileStorage(), nil
|
||
}
|
||
|
||
// SaveFileStorageConfig 管理员(id=1)保存全局配置:直接写远端 MySQL 立即全员生效,
|
||
// 同时更新本地缓存副本与 LWW 时间戳(同步循环不会再把旧值推回)。要求在线。
|
||
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
|
||
}
|
||
ctx, cancel := context.WithTimeout(a.ctx, syncTimeout)
|
||
defer cancel()
|
||
db, e := a.openRemote(ctx)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer db.Close()
|
||
now := nowRFC()
|
||
if _, e = db.ExecContext(ctx, `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
|
||
ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)`,
|
||
festivalAdminID, fileStorageKey, string(b), now); e != nil {
|
||
return mapSyncErr(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
|
||
}
|