mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-08 22:24:15 +00:00
- 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
721 lines
19 KiB
Markdown
721 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"
|
||
```
|
||
|