mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 22:24:15 +00:00
722 lines
19 KiB
Markdown
722 lines
19 KiB
Markdown
|
|
# 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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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"),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// web/service/setting.go
|
|||
|
|
"dbType": "mariadb",
|
|||
|
|
"nodeRole": "master",
|
|||
|
|
"nodeId": "",
|
|||
|
|
"syncInterval": "30",
|
|||
|
|
"trafficFlushInterval": "60",
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Add setting groups and getters/setters**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
"systemIntegration": {
|
|||
|
|
"nodeRole": "nodeRole",
|
|||
|
|
"nodeId": "nodeId",
|
|||
|
|
"syncInterval": "syncInterval",
|
|||
|
|
"trafficFlushInterval": "trafficFlushInterval",
|
|||
|
|
},
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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")
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
│ ${green}27.${plain} 数据库管理 │
|
|||
|
|
│ ${green}28.${plain} 节点管理 │
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
${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
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func (s *InboundService) AddInbound(inbound *model.Inbound) error {
|
|||
|
|
if err := RequireMaster(); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
// existing logic
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
type trafficTotals struct {
|
|||
|
|
Upload int64
|
|||
|
|
Download int64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (t trafficTotals) applyDelta(uploadDelta, downloadDelta int64) trafficTotals {
|
|||
|
|
t.Upload += uploadDelta
|
|||
|
|
t.Download += downloadDelta
|
|||
|
|
return t
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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:
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
./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**
|
|||
|
|
|
|||
|
|
```md
|
|||
|
|
- 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**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
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"
|
|||
|
|
```
|
|||
|
|
|