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 }