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
This commit is contained in:
sagitchu
2026-03-03 22:10:33 +08:00
parent 1c596fae4b
commit 2e3d5c9249
7 changed files with 333 additions and 33 deletions
+17 -27
View File
@@ -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{} {
@@ -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)
@@ -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.
@@ -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.
@@ -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.
+38 -4
View File
@@ -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();
+48 -2
View File
@@ -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) {