diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index b872e76..2e4cf37 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -571,7 +571,7 @@ func (h *Handler) userTunnelList(w http.ResponseWriter, r *http.Request) { "userId": t.UserID, "tunnelId": t.TunnelID, "tunnelName": t.TunnelName, - "status": 1, + "status": t.Status, "flow": t.Flow, "num": t.Num, "expTime": t.ExpTime, diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 5fcddf1..9b4447c 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1279,13 +1279,13 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { if strategy == "" { strategy = forward.Strategy } - if actorRole != 0 { - if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { - response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则")) - return - } + rawSpeedID, hasSpeedID := req["speedId"] + requestedSpeedID := asAnyToInt64Ptr(rawSpeedID) + if actorRole != 0 && hasSpeedID && requestedSpeedID != nil && !sameSpeedLimitSelection(forward.SpeedID, requestedSpeedID) { + response.WriteJSON(w, response.Err(-1, "普通用户无法修改限速规则")) + return } - speedID := asAnyToInt64Ptr(req["speedId"]) + speedID := requestedSpeedID speedID, err = h.normalizeSpeedLimitReference(speedID) if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) @@ -3380,6 +3380,14 @@ func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) { return speedID, nil } +func sameSpeedLimitSelection(current sql.NullInt64, requested *int64) bool { + if requested == nil { + return !current.Valid + } + + return current.Valid && current.Int64 == *requested +} + func asAnySlice(v interface{}) []interface{} { if v == nil { return nil diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index e485683..8eb51a6 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -573,6 +573,7 @@ type UserTunnelDetail struct { UserID int64 TunnelID int64 TunnelName string + Status int TunnelFlow int Flow int64 InFlow int64 diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 71d3de0..e12674b 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -447,7 +447,7 @@ func (r *Repository) GetUserPackageTunnels(userID int64) ([]model.UserTunnelDeta } var items []model.UserTunnelDetail err := r.db.Model(&model.UserTunnel{}). - Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed"). + Select("user_tunnel.id, user_tunnel.user_id, user_tunnel.tunnel_id, tunnel.name AS tunnel_name, user_tunnel.status, tunnel.flow AS tunnel_flow, user_tunnel.flow, user_tunnel.in_flow, user_tunnel.out_flow, user_tunnel.num, user_tunnel.flow_reset_time, user_tunnel.exp_time, user_tunnel.speed_id, speed_limit.name AS speed_limit, speed_limit.speed"). Joins("LEFT JOIN tunnel ON tunnel.id = user_tunnel.tunnel_id"). Joins("LEFT JOIN speed_limit ON speed_limit.id = user_tunnel.speed_id"). Where("user_tunnel.user_id = ?", userID). diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index ac23b92..34d5199 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -1310,6 +1310,30 @@ func TestNonAdminCannotSetSpeedIdOrPort(t *testing.T) { assertCode(t, res, 0) }) + t.Run("non-admin can update when request keeps existing speedId", func(t *testing.T) { + if err := repo.DB().Exec(`UPDATE forward SET speed_id = ? WHERE id = ?`, speedID, forwardID).Error; err != nil { + t.Fatalf("assign forward speed limit: %v", err) + } + + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "perm-forward-keep-speed", + "tunnelId": tunnelID, + "remoteAddr": "9.10.11.12:443", + "speedId": speedID, + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + req.Header.Set("Authorization", userToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + assertCode(t, res, 0) + }) + t.Run("non-admin can create with speedId null and inPort 0", func(t *testing.T) { createPayload := map[string]interface{}{ "name": "perm-forward-null-values", diff --git a/go-backend/tests/contract/user_tunnel_status_contract_test.go b/go-backend/tests/contract/user_tunnel_status_contract_test.go new file mode 100644 index 0000000..d9687e0 --- /dev/null +++ b/go-backend/tests/contract/user_tunnel_status_contract_test.go @@ -0,0 +1,105 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go-backend/internal/auth" + "go-backend/internal/http/response" +) + +func TestUserTunnelListReturnsStoredStatusContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + now := time.Now().UnixMilli() + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status) + VALUES(201, 'user_tunnel_status_user', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(301, 'user-tunnel-status-enabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel enabled: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(302, 'user-tunnel-status-disabled', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert tunnel disabled: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(401, 201, 301, NULL, 10, 500, 0, 0, 1, 2727251700000, 1) + `).Error; err != nil { + t.Fatalf("insert enabled user_tunnel: %v", err) + } + if err := repo.DB().Exec(` + INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) + VALUES(402, 201, 302, NULL, 10, 500, 0, 0, 1, 2727251700000, 0) + `).Error; err != nil { + t.Fatalf("insert disabled user_tunnel: %v", err) + } + + body := bytes.NewBufferString(`{"userId":201}`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/list", body) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d (%s)", out.Code, out.Msg) + } + + items, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array data, got %T", out.Data) + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + statusByTunnelID := make(map[int64]int, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + t.Fatalf("expected object item, got %T", item) + } + tunnelID, ok := obj["tunnelId"].(float64) + if !ok { + t.Fatalf("expected tunnelId to be float64, got %T", obj["tunnelId"]) + } + status, ok := obj["status"].(float64) + if !ok { + t.Fatalf("expected status to be float64, got %T", obj["status"]) + } + statusByTunnelID[int64(tunnelID)] = int(status) + } + + if statusByTunnelID[301] != 1 { + t.Fatalf("expected enabled tunnel status 1, got %d", statusByTunnelID[301]) + } + if statusByTunnelID[302] != 0 { + t.Fatalf("expected disabled tunnel status 0, got %d", statusByTunnelID[302]) + } +} diff --git a/plans/018-user-tunnel-disable-status-sync.md b/plans/018-user-tunnel-disable-status-sync.md new file mode 100644 index 0000000..daf7d77 --- /dev/null +++ b/plans/018-user-tunnel-disable-status-sync.md @@ -0,0 +1,12 @@ +# 018 User Tunnel Disable Status Sync + +## Checklist + +- [x] Inspect the user tunnel permission edit flow and identify why disabling an assigned tunnel appears ineffective. +- [x] Return the real `user_tunnel.status` value from the admin permission list API instead of a hardcoded enabled state. +- [x] Add contract coverage for the user tunnel permission list status mapping and run focused backend verification. + +## Test Record + +- Command: `cd go-backend && go test ./tests/contract/...` +- Result: passed.