3x-ui/docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md
root f5862abc2e feat: add CodeMirror YAML editor for Clash template and fix settings save button bug
- Replace plain textarea with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent) for Clash subscription template
- Fix confAlerts crash when subClashURI/subURI/subJsonURI is null/undefined (prevented save button from enabling)
- Add yaml.js CodeMirror mode asset
- Include docs and .gitignore cleanup
2026-04-24 16:15:22 +08:00

19 KiB
Raw Blame History

Trojan-Go Style MariaDB Sync Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a minimal multi-VPS master / worker sync model on top of the existing MariaDB support, with install-time role selection, runtime role switching, MariaDB as the default for fresh installs, and SQLite compatibility preserved.

Architecture: Keep MariaDB as the shared source of truth and keep local Xray config generation unchanged. Add node-role and sync settings into the JSON config, expose them through CLI and shell scripts, enforce write restrictions in backend services, and add a polling plus traffic-delta sync loop that workers use against MariaDB-backed shared state.

Tech Stack: Go, GORM, Gin, shell scripts (install.sh, x-ui.sh), MariaDB, SQLite, Go tests, bash -n


File Map

Modify

  • config/config.go
  • config/config_test.go
  • web/service/setting.go
  • web/service/setting_test.go
  • web/entity/entity.go
  • main.go
  • x-ui.sh
  • install.sh
  • database/model/model.go
  • database/db.go
  • web/service/inbound.go
  • web/service/server.go
  • web/service/xray.go

Create

  • web/service/node_sync.go
  • web/service/node_sync_test.go

Reference

  • docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md

Task 1: Add node-role settings and MariaDB-first defaults

Files:

  • Modify: web/service/setting.go

  • Modify: config/config.go

  • Modify: config/config_test.go

  • Modify: web/service/setting_test.go

  • Step 1: Write the failing config tests

func TestGetNodeConfigFromJSONSupportsModulePurposeLayout(t *testing.T) {
	tmpDir := t.TempDir()
	t.Setenv("XUI_DB_FOLDER", tmpDir)

	settings := map[string]any{
		"_meta": map[string]any{"layout": "按模块-用途来归类"},
		"databaseConnection": map[string]any{
			"dbType": "mariadb",
		},
		"systemIntegration": map[string]any{
			"nodeRole":             "worker",
			"nodeId":               "vps-01",
			"syncInterval":         "30",
			"trafficFlushInterval": "60",
		},
	}
	data, _ := json.MarshalIndent(settings, "", "  ")
	if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil {
		t.Fatalf("WriteFile error: %v", err)
	}

	cfg := GetNodeConfigFromJSON()
	if cfg.Role != "worker" || cfg.ID != "vps-01" || cfg.SyncInterval != "30" || cfg.TrafficFlushInterval != "60" {
		t.Fatalf("unexpected node config: %+v", cfg)
	}
}

func TestLoadSettingsUsesMariaDBAsFreshInstallDefault(t *testing.T) {
	setupTestSettings(t)

	settings, err := loadSettings()
	if err != nil {
		t.Fatalf("loadSettings() error: %v", err)
	}
	if settings["dbType"] != "mariadb" {
		t.Fatalf("expected default dbType=mariadb, got %s", settings["dbType"])
	}
	if settings["nodeRole"] != "master" {
		t.Fatalf("expected default nodeRole=master, got %s", settings["nodeRole"])
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v

Expected: FAIL because GetNodeConfigFromJSON, nodeRole, and the new defaults do not exist yet.

  • Step 3: Add defaults and JSON readers
type NodeConfig struct {
	Role                 string
	ID                   string
	SyncInterval         string
	TrafficFlushInterval string
}

func GetNodeConfigFromJSON() NodeConfig {
	data, err := os.ReadFile(GetSettingPath())
	if err != nil {
		return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
	}

	var settings map[string]any
	if err := json.Unmarshal(data, &settings); err != nil {
		return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
	}

	return NodeConfig{
		Role:                 readGroupedString(settings, "nodeRole"),
		ID:                   readGroupedString(settings, "nodeId"),
		SyncInterval:         readGroupedString(settings, "syncInterval"),
		TrafficFlushInterval: readGroupedString(settings, "trafficFlushInterval"),
	}
}
// web/service/setting.go
"dbType":               "mariadb",
"nodeRole":             "master",
"nodeId":               "",
"syncInterval":         "30",
"trafficFlushInterval": "60",
  • Step 4: Add setting groups and getters/setters
"systemIntegration": {
	"nodeRole":             "nodeRole",
	"nodeId":               "nodeId",
	"syncInterval":         "syncInterval",
	"trafficFlushInterval": "trafficFlushInterval",
},
func (s *SettingService) GetNodeRole() (string, error) { return s.getString("nodeRole") }
func (s *SettingService) SetNodeRole(value string) error { return s.setString("nodeRole", value) }
func (s *SettingService) GetNodeID() (string, error) { return s.getString("nodeId") }
func (s *SettingService) SetNodeID(value string) error { return s.setString("nodeId", value) }
  • Step 5: Run tests to verify they pass

Run: go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v

Expected: PASS

  • Step 6: Commit
git add config/config.go config/config_test.go web/service/setting.go web/service/setting_test.go
git commit -m "feat: add node sync settings and mariadb defaults"

Task 2: Expose node-role configuration through the Go CLI

Files:

  • Modify: main.go

  • Modify: web/entity/entity.go

  • Modify: web/service/setting_test.go

  • Step 1: Write the failing validation and CLI tests

func TestSettingEntityAcceptsNodeRoleValues(t *testing.T) {
	s := AllSetting{DBType: "mariadb", NodeRole: "worker"}
	if err := s.Check(); err != nil {
		t.Fatalf("expected worker nodeRole to be accepted: %v", err)
	}
}

func TestSettingServiceSetAndGetNodeRole(t *testing.T) {
	setupTestSettings(t)
	svc := &SettingService{}
	if err := svc.SetNodeRole("worker"); err != nil {
		t.Fatalf("SetNodeRole error: %v", err)
	}
	role, err := svc.GetNodeRole()
	if err != nil {
		t.Fatalf("GetNodeRole error: %v", err)
	}
	if role != "worker" {
		t.Fatalf("expected worker, got %s", role)
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v

Expected: FAIL because NodeRole is not part of the validated setting payload and service API yet.

  • Step 3: Extend the CLI flags and JSON write path
var nodeRole string
var nodeID string
var syncInterval string
var trafficFlushInterval string

settingCmd.StringVar(&nodeRole, "nodeRole", "", "Set node role (master or worker)")
settingCmd.StringVar(&nodeID, "nodeId", "", "Set node identifier")
settingCmd.StringVar(&syncInterval, "syncInterval", "", "Set account sync interval in seconds")
settingCmd.StringVar(&trafficFlushInterval, "trafficFlushInterval", "", "Set traffic flush interval in seconds")
if nodeRole != "" {
	if err := config.WriteSettingToJSON("nodeRole", nodeRole); err != nil {
		fmt.Println("Failed to set nodeRole:", err)
	} else {
		fmt.Println("nodeRole set to:", nodeRole)
	}
}
  • Step 4: Validate nodeRole in the setting entity
if s.NodeRole != "" && s.NodeRole != "master" && s.NodeRole != "worker" {
	return common.NewError("node role must be master or worker, got:", s.NodeRole)
}
  • Step 5: Run tests to verify they pass

Run: go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v

Expected: PASS

  • Step 6: Commit
git add main.go web/entity/entity.go web/service/setting_test.go
git commit -m "feat: expose node role settings in cli"

Task 3: Add node management to x-ui.sh

Files:

  • Modify: x-ui.sh

  • Step 1: Write a shell syntax safety checkpoint

Run: bash -n x-ui.sh

Expected: PASS before changes, establishing a clean syntax baseline.

  • Step 2: Add helpers for reading and writing node settings
read_json_noderole() {
    local node_role
    node_role=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep '^nodeRole:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
    if [ -z "$node_role" ]; then
        echo "master"
    else
        echo "$node_role"
    fi
}

switch_node_role() {
    local role="$1"
    ${xui_folder}/x-ui setting -nodeRole "$role" >/dev/null 2>&1
}
  • Step 3: Add a node management menu
node_menu() {
    local current_role=$(read_json_noderole)

    echo -e "
╔────────────────────────────────────────────────╗
${green}节点管理${plain}│────────────────────────────────────────────────│
${green}0.${plain} 返回主菜单                                │
${green}1.${plain} 查看当前节点角色(当前: ${current_role}${green}2.${plain} 切换到 master                             │
${green}3.${plain} 切换到 worker                             │
${green}4.${plain} 设置 nodeId                               │
╚════════════════════════════════════════════════╝
"
}
  • Step 4: Wire the menu into show_menu and preserve existing database menu
${green}27.${plain} 数据库管理                                │
│  ${green}28.${plain} 节点管理                                  │
27)
    check_install && db_menu
    ;;
28)
    check_install && node_menu
    ;;
  • Step 5: Re-run shell syntax verification

Run: bash -n x-ui.sh

Expected: PASS

  • Step 6: Commit
git add x-ui.sh
git commit -m "feat: add node role management to x-ui shell"

Task 4: Add install-time role selection and MariaDB-first bootstrap

Files:

  • Modify: install.sh

  • Step 1: Write a shell syntax safety checkpoint

Run: bash -n install.sh

Expected: PASS before changes.

  • Step 2: Add fresh-install prompts for database type and node role
read -rp "请选择数据库类型 [默认 mariadb可选 sqlite/mariadb]" install_db_type
install_db_type="${install_db_type// /}"
install_db_type="${install_db_type,,}"
if [[ -z "$install_db_type" ]]; then
    install_db_type="mariadb"
fi

read -rp "请选择节点角色 [默认 master可选 master/worker]" install_node_role
install_node_role="${install_node_role// /}"
install_node_role="${install_node_role,,}"
if [[ -z "$install_node_role" ]]; then
    install_node_role="master"
fi
  • Step 3: Persist fresh-install settings through the existing CLI
${xui_folder}/x-ui setting -dbType "${install_db_type}" -nodeRole "${install_node_role}"
if [[ -n "${install_node_id}" ]]; then
    ${xui_folder}/x-ui setting -nodeId "${install_node_id}"
fi
if [[ "${install_db_type}" == "mariadb" ]]; then
    XUI_DB_PASSWORD="${db_pass}" ${xui_folder}/x-ui setting \
        -dbHost "${db_host}" \
        -dbPort "${db_port}" \
        -dbUser "${db_user}" \
        -dbName "${db_name}"
fi
  • Step 4: Preserve SQLite compatibility for existing installs
if [[ "$is_fresh_install" != "true" ]]; then
    echo -e "${green}检测到现有安装,保留当前数据库类型与节点角色。${plain}"
else
    # prompt for install_db_type and install_node_role only on fresh install
fi
  • Step 5: Re-run shell syntax verification

Run: bash -n install.sh

Expected: PASS

  • Step 6: Commit
git add install.sh
git commit -m "feat: prompt for node role and mariadb on install"

Task 5: Enforce master / worker write boundaries in Go services

Files:

  • Create: web/service/node_sync.go

  • Create: web/service/node_sync_test.go

  • Modify: web/service/inbound.go

  • Modify: web/service/server.go

  • Modify: web/service/user_test.go

  • Step 1: Write the failing role-enforcement tests

func TestRequireMasterAllowsMaster(t *testing.T) {
	setupTestSettings(t)
	if err := config.WriteSettingToJSON("nodeRole", "master"); err != nil {
		t.Fatalf("WriteSettingToJSON error: %v", err)
	}
	if err := RequireMaster(); err != nil {
		t.Fatalf("expected master to pass: %v", err)
	}
}

func TestRequireMasterRejectsWorker(t *testing.T) {
	setupTestSettings(t)
	if err := config.WriteSettingToJSON("nodeRole", "worker"); err != nil {
		t.Fatalf("WriteSettingToJSON error: %v", err)
	}
	if err := RequireMaster(); err == nil {
		t.Fatal("expected worker role to be rejected")
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v

Expected: FAIL because no role gate exists yet.

  • Step 3: Implement a shared guard helper
func CurrentNodeRole() string {
	cfg := config.GetNodeConfigFromJSON()
	if cfg.Role == "" {
		return "master"
	}
	return cfg.Role
}

func RequireMaster() error {
	if CurrentNodeRole() != "master" {
		return common.NewError("write operations are only allowed on master nodes")
	}
	return nil
}
  • Step 4: Call the guard in shared-state write paths
func (s *InboundService) AddInbound(inbound *model.Inbound) error {
	if err := RequireMaster(); err != nil {
		return err
	}
	// existing logic
}
func (s *InboundService) DelInbound(id int) error {
	if err := RequireMaster(); err != nil {
		return err
	}
	// existing logic
}
  • Step 5: Re-run the tests and a focused service package pass

Run: go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v

Expected: PASS

Run: go test ./web/service/...

Expected: PASS

  • Step 6: Commit
git add web/service/node_sync.go web/service/node_sync_test.go web/service/inbound.go web/service/server.go web/service/user_test.go
git commit -m "feat: enforce master-only shared writes"

Task 6: Add account polling and local cache refresh

Files:

  • Create: web/service/node_sync.go

  • Create: web/service/node_sync_test.go

  • Modify: web/service/xray.go

  • Modify: main.go

  • Step 1: Write the failing sync-interval and version tests

func TestShouldRefreshAccountsWhenVersionChanges(t *testing.T) {
	state := syncState{lastVersion: 2}
	if !state.shouldRefresh(3) {
		t.Fatal("expected newer version to trigger refresh")
	}
}

func TestShouldNotRefreshAccountsWhenVersionMatches(t *testing.T) {
	state := syncState{lastVersion: 3}
	if state.shouldRefresh(3) {
		t.Fatal("expected same version to skip refresh")
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v

Expected: FAIL because the sync state object and refresh logic do not exist.

  • Step 3: Implement the sync state and poller skeleton
type syncState struct {
	lastVersion int64
}

func (s syncState) shouldRefresh(version int64) bool {
	return version > s.lastVersion
}

func StartNodeSyncLoop() {
	cfg := config.GetNodeConfigFromJSON()
	if config.GetDBTypeFromJSON() != "mariadb" {
		return
	}
	if cfg.Role != "worker" && cfg.Role != "master" {
		return
	}
	go runAccountSyncLoop(cfg)
}
  • Step 4: Hook the poller into process startup without changing local Xray ownership
func runWebServer() {
	if err := database.InitDB(); err != nil {
		log.Fatal(err)
	}
	service.StartNodeSyncLoop()
	// existing startup
}
  • Step 5: Re-run focused tests

Run: go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v

Expected: PASS

  • Step 6: Commit
git add web/service/node_sync.go web/service/node_sync_test.go web/service/xray.go main.go
git commit -m "feat: add account sync polling skeleton"

Task 7: Add traffic delta writeback and worker-safe accounting

Files:

  • Modify: database/model/model.go

  • Modify: database/db.go

  • Modify: web/service/node_sync.go

  • Modify: web/service/node_sync_test.go

  • Step 1: Write the failing delta accounting tests

func TestApplyTrafficDeltaAccumulatesUsage(t *testing.T) {
	current := trafficTotals{Upload: 100, Download: 200}
	next := current.applyDelta(20, 30)
	if next.Upload != 120 || next.Download != 230 {
		t.Fatalf("unexpected totals: %+v", next)
	}
}
  • Step 2: Run tests to verify they fail

Run: go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v

Expected: FAIL because the delta helper does not exist.

  • Step 3: Implement atomic delta write helpers
type trafficTotals struct {
	Upload   int64
	Download int64
}

func (t trafficTotals) applyDelta(uploadDelta, downloadDelta int64) trafficTotals {
	t.Upload += uploadDelta
	t.Download += downloadDelta
	return t
}
func ApplyTrafficDelta(clientID int64, uploadDelta, downloadDelta int64) error {
	return GetDB().Model(&model.ClientTraffic{}).
		Where("client_id = ?", clientID).
		Updates(map[string]any{
			"up":   gorm.Expr("up + ?", uploadDelta),
			"down": gorm.Expr("down + ?", downloadDelta),
		}).Error
}
  • Step 4: Flush only deltas from worker nodes
func flushTrafficDeltas(deltas map[int64]trafficTotals) error {
	for clientID, delta := range deltas {
		if err := database.ApplyTrafficDelta(clientID, delta.Upload, delta.Download); err != nil {
			return err
		}
	}
	return nil
}
  • Step 5: Re-run focused tests and database package tests

Run: go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v

Expected: PASS

Run: go test ./database/...

Expected: PASS

  • Step 6: Commit
git add database/model/model.go database/db.go web/service/node_sync.go web/service/node_sync_test.go
git commit -m "feat: add worker traffic delta writeback"

Task 8: Final verification and docs alignment

Files:

  • Modify: docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md

  • Modify: docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md

  • Step 1: Run the targeted Go test suites

Run: go test ./config ./database/... ./web/service/...

Expected: PASS

  • Step 2: Run shell syntax checks

Run: bash -n x-ui.sh

Expected: PASS

Run: bash -n install.sh

Expected: PASS

  • Step 3: Manually verify fresh-install and runtime flows

Run:

./x-ui setting -showDbType
./x-ui setting -nodeRole worker -nodeId vps-02
./x-ui setting -dbType sqlite

Expected:

  • first command prints the configured backend

  • second command updates the JSON config without touching the DB schema

  • third command remains accepted for compatibility

  • Step 4: Update docs if the final implementation diverges from the spec

- document the final JSON keys
- document the exact `x-ui.sh` menu entries
- document the fresh-install MariaDB default and SQLite compatibility rule
  • Step 5: Commit
git add docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md
git commit -m "docs: finalize node sync implementation notes"