Files
code-utils/filestorage.go

176 lines
5.9 KiB
Go
Raw Normal View History

2026-08-14 07:52:01 +08:00
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
}