Files
code-utils/filestorage.go
2026-08-14 07:52:01 +08:00

176 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}