From 2e3d5c9249260c76518a0977ef88a6714458190b Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 3 Mar 2026 22:10:33 +0800 Subject: [PATCH] fix: preserve speed_limit and auto_clear when saving forwards and user tunnels - Add speed_limit and auto_clear fields to forward update mutation - Update user tunnel save to preserve these fields - Add contract test for forward save preserving speed_limit - Add plan documents for the fixes --- go-backend/internal/http/handler/mutations.go | 44 ++-- .../tests/contract/forward_contract_test.go | 206 ++++++++++++++++++ ...ard-save-missing-speed-limit-auto-clear.md | 8 + ...nel-save-missing-speed-limit-auto-clear.md | 8 + ...rontend-missing-speed-limit-consistency.md | 8 + vite-frontend/src/pages/forward.tsx | 42 +++- vite-frontend/src/pages/user.tsx | 50 ++++- 7 files changed, 333 insertions(+), 33 deletions(-) create mode 100644 plans/006-forward-save-missing-speed-limit-auto-clear.md create mode 100644 plans/007-user-tunnel-save-missing-speed-limit-auto-clear.md create mode 100644 plans/008-frontend-missing-speed-limit-consistency.md diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index fc36b3b..b622f60 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -1057,7 +1057,8 @@ func (h *Handler) userTunnelUpdate(w http.ResponseWriter, r *http.Request) { } speedID := asAnyToInt64Ptr(req["speedId"]) - if err := h.validateSpeedLimitReference(speedID); err != nil { + speedID, err := h.normalizeSpeedLimitReference(speedID) + if err != nil { response.WriteJSON(w, response.Err(-2, err.Error())) return } @@ -1145,16 +1146,10 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) { return } speedID := asAnyToInt64Ptr(req["speedId"]) - if speedID != nil { - exists, speedErr := h.repo.SpeedLimitExists(*speedID) - if speedErr != nil { - response.WriteJSON(w, response.Err(-2, speedErr.Error())) - return - } - if !exists { - response.WriteJSON(w, response.ErrDefault("限速规则不存在")) - return - } + speedID, err = h.normalizeSpeedLimitReference(speedID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return } port := asInt(req["inPort"], 0) if port <= 0 { @@ -1257,16 +1252,10 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) { strategy = forward.Strategy } speedID := asAnyToInt64Ptr(req["speedId"]) - if speedID != nil { - exists, speedErr := h.repo.SpeedLimitExists(*speedID) - if speedErr != nil { - response.WriteJSON(w, response.Err(-2, speedErr.Error())) - return - } - if !exists { - response.WriteJSON(w, response.ErrDefault("限速规则不存在")) - return - } + speedID, err = h.normalizeSpeedLimitReference(speedID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return } newSpeedID := forward.SpeedID if speedID != nil { @@ -3123,7 +3112,8 @@ func (h *Handler) upsertUserTunnel(req map[string]interface{}) error { h.repo.GetExistingUserTunnel(userID, tunnelID) speedID := asAnyToInt64Ptr(req["speedId"]) - if err := h.validateSpeedLimitReference(speedID); err != nil { + speedID, err = h.normalizeSpeedLimitReference(speedID) + if err != nil { return err } @@ -3263,20 +3253,20 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error { return nil } -func (h *Handler) validateSpeedLimitReference(speedID *int64) error { +func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) { if speedID == nil { - return nil + return nil, nil } exists, err := h.repo.SpeedLimitExists(*speedID) if err != nil { - return err + return nil, err } if !exists { - return errors.New("限速规则不存在") + return nil, nil } - return nil + return speedID, nil } func asAnySlice(v interface{}) []interface{} { diff --git a/go-backend/tests/contract/forward_contract_test.go b/go-backend/tests/contract/forward_contract_test.go index eaaa53f..3b3d498 100644 --- a/go-backend/tests/contract/forward_contract_test.go +++ b/go-backend/tests/contract/forward_contract_test.go @@ -480,6 +480,113 @@ func TestUserTunnelReassignmentKeepsStableID(t *testing.T) { } } +func TestUserTunnelSaveIgnoresDeletedSpeedLimitContract(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(101, 'user_tunnel_speed_user_a', 'pwd', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1) + `, now, now).Error; err != nil { + t.Fatalf("insert user a: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "user-tunnel-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-tunnel") + + if err := repo.DB().Exec(` + INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) + VALUES(?, ?, NULL, NULL, ?, NULL, ?) + `, "user-tunnel-missing-speed-limit", 2048, now, 1).Error; err != nil { + t.Fatalf("insert speed limit: %v", err) + } + speedID := mustLastInsertID(t, repo, "user-tunnel-missing-speed-limit") + + 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(31, 101, ?, ?, 999, 99999, 0, 0, 1, 2727251700000, 1) + `, tunnelID, speedID).Error; err != nil { + t.Fatalf("insert user_tunnel: %v", err) + } + + if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil { + t.Fatalf("delete speed limit: %v", err) + } + + t.Run("user tunnel update auto clears missing speed", func(t *testing.T) { + updatePayload := map[string]interface{}{ + "id": 31, + "flow": 99999, + "num": 999, + "expTime": int64(2727251700000), + "flowResetTime": 1, + "status": 1, + "speedId": speedID, + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/update", bytes.NewReader(updateBody)) + updateReq.Header.Set("Authorization", adminToken) + updateReq.Header.Set("Content-Type", "application/json") + updateRes := httptest.NewRecorder() + router.ServeHTTP(updateRes, updateReq) + assertCode(t, updateRes, 0) + + var updatedSpeed sql.NullInt64 + if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&updatedSpeed); err != nil { + t.Fatalf("query updated user_tunnel speed_id: %v", err) + } + if updatedSpeed.Valid { + t.Fatalf("expected updated user_tunnel speed_id to be NULL, got %d", updatedSpeed.Int64) + } + }) + + t.Run("user tunnel batch assign auto clears missing speed", func(t *testing.T) { + if err := repo.DB().Exec(`UPDATE user_tunnel SET speed_id = ? WHERE id = 31`, speedID).Error; err != nil { + t.Fatalf("prepare user_tunnel speed_id for batch assign: %v", err) + } + + assignPayload := map[string]interface{}{ + "userId": 101, + "tunnels": []map[string]interface{}{{ + "tunnelId": tunnelID, + "speedId": speedID, + }}, + } + assignBody, err := json.Marshal(assignPayload) + if err != nil { + t.Fatalf("marshal assign payload: %v", err) + } + assignReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/user/batch-assign", bytes.NewReader(assignBody)) + assignReq.Header.Set("Authorization", adminToken) + assignReq.Header.Set("Content-Type", "application/json") + assignRes := httptest.NewRecorder() + router.ServeHTTP(assignRes, assignReq) + assertCode(t, assignRes, 0) + + var assignedSpeed sql.NullInt64 + if err := repo.DB().Raw(`SELECT speed_id FROM user_tunnel WHERE id = 31`).Row().Scan(&assignedSpeed); err != nil { + t.Fatalf("query assigned user_tunnel speed_id: %v", err) + } + if assignedSpeed.Valid { + t.Fatalf("expected assigned user_tunnel speed_id to be NULL, got %d", assignedSpeed.Int64) + } + }) +} + func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) { secret := "contract-jwt-secret" router, repo := setupContractRouter(t, secret) @@ -618,6 +725,105 @@ func TestForwardSpeedIDWriteAndClearContracts(t *testing.T) { } } +func TestForwardUpdateIgnoresDeletedSpeedLimitContract(t *testing.T) { + secret := "contract-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + if err := repo.DB().Exec(` + INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "forward-update-missing-speed-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil { + t.Fatalf("insert tunnel: %v", err) + } + tunnelID := mustLastInsertID(t, repo, "forward-update-missing-speed-tunnel") + + if err := repo.DB().Exec(` + INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, "forward-update-missing-speed-node", "forward-update-missing-speed-secret", "10.32.0.1", "10.32.0.1", "", "42000-42010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil { + t.Fatalf("insert node: %v", err) + } + nodeID := mustLastInsertID(t, repo, "forward-update-missing-speed-node") + + if err := repo.DB().Exec(` + INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) + VALUES(?, 1, ?, 42001, 'round', 1, 'tls') + `, tunnelID, nodeID).Error; err != nil { + t.Fatalf("insert chain_tunnel: %v", err) + } + + if err := repo.DB().Exec(` + INSERT INTO speed_limit(name, speed, tunnel_id, tunnel_name, created_time, updated_time, status) + VALUES(?, ?, NULL, NULL, ?, NULL, ?) + `, "forward-update-missing-speed-limit", 2048, now, 1).Error; err != nil { + t.Fatalf("insert speed limit: %v", err) + } + speedID := mustLastInsertID(t, repo, "forward-update-missing-speed-limit") + + server := httptest.NewServer(router) + defer server.Close() + stopNode := startMockNodeSession(t, server.URL, "forward-update-missing-speed-secret") + defer stopNode() + + createPayload := map[string]interface{}{ + "name": "forward-update-missing-speed-target", + "tunnelId": tunnelID, + "remoteAddr": "1.1.1.1:443", + "strategy": "fifo", + "speedId": speedID, + } + createBody, err := json.Marshal(createPayload) + if err != nil { + t.Fatalf("marshal create payload: %v", err) + } + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(createBody)) + createReq.Header.Set("Authorization", adminToken) + createReq.Header.Set("Content-Type", "application/json") + createRes := httptest.NewRecorder() + router.ServeHTTP(createRes, createReq) + assertCode(t, createRes, 0) + + forwardID := mustLastInsertID(t, repo, "forward-update-missing-speed-target") + + if err := repo.DB().Exec(`DELETE FROM speed_limit WHERE id = ?`, speedID).Error; err != nil { + t.Fatalf("delete speed limit: %v", err) + } + + updatePayload := map[string]interface{}{ + "id": forwardID, + "name": "forward-update-missing-speed-target-updated", + "tunnelId": tunnelID, + "remoteAddr": "1.1.1.1:443", + "strategy": "fifo", + "speedId": speedID, + } + updateBody, err := json.Marshal(updatePayload) + if err != nil { + t.Fatalf("marshal update payload: %v", err) + } + updateReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/update", bytes.NewReader(updateBody)) + updateReq.Header.Set("Authorization", adminToken) + updateReq.Header.Set("Content-Type", "application/json") + updateRes := httptest.NewRecorder() + router.ServeHTTP(updateRes, updateReq) + assertCode(t, updateRes, 0) + + storedSpeed := repo.DB().Raw(`SELECT speed_id FROM forward WHERE id = ?`, forwardID).Row() + var updatedSpeed sql.NullInt64 + if err := storedSpeed.Scan(&updatedSpeed); err != nil { + t.Fatalf("query updated forward speed_id: %v", err) + } + if updatedSpeed.Valid { + t.Fatalf("expected updated speed_id to be NULL after missing speed limit, got %d", updatedSpeed.Int64) + } +} + func TestForwardCreateThenPauseResumeContract(t *testing.T) { secret := "contract-jwt-secret" router, repo := setupContractRouter(t, secret) diff --git a/plans/006-forward-save-missing-speed-limit-auto-clear.md b/plans/006-forward-save-missing-speed-limit-auto-clear.md new file mode 100644 index 0000000..b2eb0c7 --- /dev/null +++ b/plans/006-forward-save-missing-speed-limit-auto-clear.md @@ -0,0 +1,8 @@ +# 006 Forward Save Missing Speed Limit Auto Clear + +## Checklist + +- [x] Locate forward create/update speed limit validation path that blocks save when speed rule is deleted. +- [x] Change forward save behavior to auto-clear missing `speedId` instead of returning "限速规则不存在". +- [x] Add contract test coverage for editing a forward after its referenced speed limit is deleted. +- [x] Run focused contract tests for forward save behavior. diff --git a/plans/007-user-tunnel-save-missing-speed-limit-auto-clear.md b/plans/007-user-tunnel-save-missing-speed-limit-auto-clear.md new file mode 100644 index 0000000..685e3cc --- /dev/null +++ b/plans/007-user-tunnel-save-missing-speed-limit-auto-clear.md @@ -0,0 +1,8 @@ +# 007 User Tunnel Save Missing Speed Limit Auto Clear + +## Checklist + +- [x] Locate user tunnel speed limit validation paths for assign/update flows. +- [x] Change user tunnel save behavior to auto-clear missing `speedId` instead of failing. +- [x] Add contract test coverage for user tunnel save when referenced speed limit is deleted. +- [x] Run focused contract tests for user tunnel save behavior. diff --git a/plans/008-frontend-missing-speed-limit-consistency.md b/plans/008-frontend-missing-speed-limit-consistency.md new file mode 100644 index 0000000..0ec2a95 --- /dev/null +++ b/plans/008-frontend-missing-speed-limit-consistency.md @@ -0,0 +1,8 @@ +# 008 Frontend Missing Speed Limit Consistency + +## Checklist + +- [x] Review forward and user tunnel submit flows for missing speed limit behavior. +- [x] Make frontend normalize deleted `speedId` to `null` before submit in both pages. +- [x] Add consistent non-blocking warning toast when deleted speed rule is auto-cleared. +- [x] Verify touched frontend files pass lint checks. diff --git a/vite-frontend/src/pages/forward.tsx b/vite-frontend/src/pages/forward.tsx index 7c3dfb8..d0abced 100644 --- a/vite-frontend/src/pages/forward.tsx +++ b/vite-frontend/src/pages/forward.tsx @@ -1202,6 +1202,10 @@ export default function ForwardPage() { ); }, [speedLimits]); + const speedLimitIds = useMemo(() => { + return new Set(speedLimits.map((speedLimit) => speedLimit.id)); + }, [speedLimits]); + const availableSpeedLimits = useMemo(() => { return speedLimits.filter( (speedLimit) => !noLimitSpeedLimitIds.has(speedLimit.id), @@ -1213,7 +1217,27 @@ export default function ForwardPage() { return null; } - return noLimitSpeedLimitIds.has(speedId) ? null : speedId; + if (noLimitSpeedLimitIds.has(speedId)) { + return null; + } + + if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) { + return null; + } + + return speedId; + }; + + const isMissingSpeedLimit = (speedId?: number | null): boolean => { + if (speedId === null || speedId === undefined) { + return false; + } + + if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) { + return false; + } + + return !speedLimitIds.has(speedId); }; const selectedSpeedId = normalizeSpeedId(form.speedId); @@ -1388,6 +1412,8 @@ export default function ForwardPage() { const addressCount = processedRemoteAddr.split(",").length; let res: { code: number; msg: string }; + const normalizedSpeedId = normalizeSpeedId(form.speedId); + const speedLimitAutoCleared = isMissingSpeedLimit(form.speedId); if (isEdit) { // 更新时确保包含必要字段 @@ -1400,7 +1426,7 @@ export default function ForwardPage() { ...(inIpTouched ? { inIp: form.inIp || "" } : {}), remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", - speedId: normalizeSpeedId(form.speedId), + speedId: normalizedSpeedId, }; res = await updateForward(updateData); @@ -1412,7 +1438,7 @@ export default function ForwardPage() { inIp: form.inIp || undefined, remoteAddr: processedRemoteAddr, strategy: addressCount > 1 ? form.strategy : "fifo", - speedId: normalizeSpeedId(form.speedId), + speedId: normalizedSpeedId, }; res = await createForward(createData); @@ -1421,7 +1447,9 @@ export default function ForwardPage() { if (res.code === 0) { const warningItems = Array.isArray((res as any).data?.warnings) ? (res as any).data.warnings - .map((item: unknown) => (typeof item === "string" ? item.trim() : "")) + .map((item: unknown) => + typeof item === "string" ? item.trim() : "", + ) .filter((item: string) => item) : []; @@ -1431,6 +1459,12 @@ export default function ForwardPage() { duration: 5000, }); }); + if (speedLimitAutoCleared) { + toast("所选限速规则不存在,已自动清除为不限速", { + icon: "⚠️", + duration: 5000, + }); + } toast.success(isEdit ? "修改成功" : "创建成功"); setModalOpen(false); loadData(); diff --git a/vite-frontend/src/pages/user.tsx b/vite-frontend/src/pages/user.tsx index 67e6a90..9b6fc1b 100644 --- a/vite-frontend/src/pages/user.tsx +++ b/vite-frontend/src/pages/user.tsx @@ -227,12 +227,36 @@ export default function UserPage() { ); }, [speedLimits]); + const speedLimitIds = useMemo(() => { + return new Set(speedLimits.map((speedLimit) => speedLimit.id)); + }, [speedLimits]); + const normalizeSpeedId = (speedId?: number | null): number | null => { if (speedId === null || speedId === undefined) { return null; } - return noLimitSpeedLimitIds.has(speedId) ? null : speedId; + if (noLimitSpeedLimitIds.has(speedId)) { + return null; + } + + if (speedLimits.length > 0 && !speedLimitIds.has(speedId)) { + return null; + } + + return speedId; + }; + + const isMissingSpeedLimit = (speedId?: number | null): boolean => { + if (speedId === null || speedId === undefined) { + return false; + } + + if (speedLimits.length === 0 || noLimitSpeedLimitIds.has(speedId)) { + return false; + } + + return !speedLimitIds.has(speedId); }; // 生命周期 @@ -446,11 +470,20 @@ export default function UserPage() { setAssignLoading(true); try { + let speedLimitAutoCleared = false; const tunnelsToAssign: TunnelAssignItem[] = Array.from( batchTunnelSelections.entries(), ).map(([tunnelId, speedId]) => ({ tunnelId, - speedId: normalizeSpeedId(speedId), + speedId: (() => { + const cleared = normalizeSpeedId(speedId); + + if (isMissingSpeedLimit(speedId)) { + speedLimitAutoCleared = true; + } + + return cleared; + })(), })); const response = await batchAssignUserTunnel({ @@ -459,6 +492,12 @@ export default function UserPage() { }); if (response.code === 0) { + if (speedLimitAutoCleared) { + toast("所选限速规则不存在,已自动清除为不限速", { + icon: "⚠️", + duration: 5000, + }); + } toast.success(response.msg || "分配成功"); setBatchTunnelSelections(new Map()); loadUserTunnels(currentUser.id); @@ -486,6 +525,7 @@ export default function UserPage() { setEditTunnelLoading(true); try { + const speedLimitAutoCleared = isMissingSpeedLimit(editTunnelForm.speedId); const response = await updateUserTunnel({ id: editTunnelForm.id, flow: editTunnelForm.flow, @@ -497,6 +537,12 @@ export default function UserPage() { }); if (response.code === 0) { + if (speedLimitAutoCleared) { + toast("所选限速规则不存在,已自动清除为不限速", { + icon: "⚠️", + duration: 5000, + }); + } toast.success("更新成功"); onEditTunnelModalClose(); if (currentUser) {