Files
opsx f3733f0bfc feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏
- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
2026-10-27 17:11:38 +08:00

86 lines
2.6 KiB
Go

package repository
import (
"encoding/json"
"os"
"sync"
"testing"
"lokistack/models"
)
// TestConcurrentSaveNoDataLoss 是并发写安全(B1/B2)的回归测试:
// N 个 goroutine 并发修改 db 并各自 RequestSave(经防抖 channel 序列化),
// 随后同步刷盘,直接读取磁盘文件校验写入数量与 ID 唯一性——
// 证明 channel 序列化 + 原子重命名写入不会发生"后写覆盖先写"的丢更新。
func TestConcurrentSaveNoDataLoss(t *testing.T) {
const N = 50
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db := GetDB()
// NextID 内部自行加锁;切勿在持有 db.Lock() 时调用(mu 不可重入,会自死锁)
id := db.NextID("hosts")
db.Lock()
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "concurrent-host"})
db.Unlock()
db.RequestSave()
}()
}
wg.Wait()
// 同步刷盘一次(绕过 500ms 防抖),确保最新内存状态落盘
if err := GetDB().RequestSaveSync(); err != nil {
t.Fatalf("RequestSaveSync: %v", err)
}
data, err := os.ReadFile(dbPath())
if err != nil {
t.Fatalf("read db file: %v", err)
}
var disk JsonDB
if err := json.Unmarshal(data, &disk); err != nil {
t.Fatalf("unmarshal db file: %v", err)
}
if len(disk.Hosts) != N {
t.Fatalf("DATA LOSS: disk has %d hosts, want %d (concurrent saves lost writes)", len(disk.Hosts), N)
}
// ID 唯一性:NextID 在 mutex 保护下递增,不应出现重复
seen := make(map[uint]bool, N)
for _, h := range disk.Hosts {
if seen[h.ID] {
t.Fatalf("duplicate host ID %d (NextID race)", h.ID)
}
seen[h.ID] = true
}
}
// TestRequestSaveDebounce 校验防抖行为:短时间内多次 RequestSave 不会撑爆 channel。
func TestRequestSaveDebounce(t *testing.T) {
db := GetDB()
startLen := len(db.Hosts)
// 连续触发 100 次防抖保存(channel 容量 10,防抖应合并而非阻塞)
for i := 0; i < 100; i++ {
id := db.NextID("hosts") // 自行加锁,勿在 db.Lock() 内调用
db.Lock()
db.Hosts = append(db.Hosts, models.Host{ID: id, Name: "debounce"})
db.Unlock()
db.RequestSave()
}
if err := db.RequestSaveSync(); err != nil {
t.Fatalf("RequestSaveSync after debounce: %v", err)
}
data, _ := os.ReadFile(dbPath())
var disk JsonDB
if err := json.Unmarshal(data, &disk); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 100 次追加 + 之前测试的 N 个 host,必须全部落盘
if len(disk.Hosts) != startLen+100 {
t.Fatalf("debounce lost writes: disk has %d hosts, want %d", len(disk.Hosts), startLen+100)
}
}